merge: resolve dev conflicts
This commit is contained in:
commit
28c3f81521
2
.github/agents/copilot-instructions.md
vendored
2
.github/agents/copilot-instructions.md
vendored
@ -5,6 +5,7 @@ # TenantAtlas Development Guidelines
|
||||
## Active Technologies
|
||||
- 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 (Sail locally) (feat/032-backup-scheduling-mvp)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -24,6 +25,7 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## 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
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@ -13,6 +13,9 @@
|
||||
/.zed
|
||||
/auth.json
|
||||
/node_modules
|
||||
dist/
|
||||
build/
|
||||
coverage/
|
||||
/public/build
|
||||
/public/hot
|
||||
/public/storage
|
||||
@ -22,4 +25,6 @@
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
/references
|
||||
/references
|
||||
*.tmp
|
||||
*.swp
|
||||
|
||||
@ -1,50 +1,35 @@
|
||||
# [PROJECT_NAME] Constitution
|
||||
<!-- Example: Spec Constitution, TaskFlow Constitution, etc. -->
|
||||
# TenantPilot Constitution
|
||||
|
||||
## Core Principles
|
||||
|
||||
### [PRINCIPLE_1_NAME]
|
||||
<!-- Example: I. Library-First -->
|
||||
[PRINCIPLE_1_DESCRIPTION]
|
||||
<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries -->
|
||||
### Safety-First Restore
|
||||
- Any destructive action MUST support preview/dry-run, explicit confirmation, and a clear pre-execution summary.
|
||||
- High-risk policy types default to `preview-only` restore unless explicitly enabled by a feature spec + tests + checklist.
|
||||
- Restore must be defensive: validate inputs, detect conflicts, allow selective restore, and record outcomes per item.
|
||||
|
||||
### [PRINCIPLE_2_NAME]
|
||||
<!-- Example: II. CLI Interface -->
|
||||
[PRINCIPLE_2_DESCRIPTION]
|
||||
<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats -->
|
||||
### Auditability & Tenant Isolation
|
||||
- Every operation is tenant-scoped and MUST write an audit log entry (no secrets, no tokens).
|
||||
- Snapshots are immutable JSONB and MUST remain reproducible (who/when/what/source tenant).
|
||||
|
||||
### [PRINCIPLE_3_NAME]
|
||||
<!-- Example: III. Test-First (NON-NEGOTIABLE) -->
|
||||
[PRINCIPLE_3_DESCRIPTION]
|
||||
<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced -->
|
||||
### Graph Abstraction & Contracts
|
||||
- All Microsoft Graph calls MUST go through `GraphClientInterface`.
|
||||
- Contract assumptions are config-driven (`config/graph_contracts.php`); do not hardcode endpoints in feature code.
|
||||
- Unknown/missing policy types MUST fail safe (preview-only / no Graph calls) rather than calling `deviceManagement/{type}`.
|
||||
|
||||
### [PRINCIPLE_4_NAME]
|
||||
<!-- Example: IV. Integration Testing -->
|
||||
[PRINCIPLE_4_DESCRIPTION]
|
||||
<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas -->
|
||||
### Least Privilege
|
||||
- Prefer least-privilege roles/scopes; surface warnings when higher privileges are selected.
|
||||
- Never store secrets in code/config; never log credentials or tokens.
|
||||
|
||||
### [PRINCIPLE_5_NAME]
|
||||
<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity -->
|
||||
[PRINCIPLE_5_DESCRIPTION]
|
||||
<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles -->
|
||||
### Spec-First Workflow
|
||||
- For any feature that changes runtime behavior, include or update `specs/<NNN>-<slug>/` with `spec.md`, `plan.md`, `tasks.md`, and `checklists/requirements.md`.
|
||||
- New work branches from `dev` using `feat/<NNN>-<slug>` (spec + code in the same PR).
|
||||
|
||||
## [SECTION_2_NAME]
|
||||
<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. -->
|
||||
|
||||
[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. -->
|
||||
## Quality Gates
|
||||
- Changes MUST be programmatically tested (Pest) and run via targeted `php artisan test ...`.
|
||||
- Run `./vendor/bin/pint --dirty` before finalizing.
|
||||
|
||||
## 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]
|
||||
<!-- 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 -->
|
||||
**Version**: 1.0.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-01-03
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
# Implementation Plan: TenantPilot v1
|
||||
|
||||
**Branch**: `tenantpilot-v1`
|
||||
**Date**: 2025-12-12
|
||||
**Spec Source**: `.specify/spec.md` (scope/restore matrix unchanged)
|
||||
**Branch**: `dev`
|
||||
**Date**: 2026-01-03
|
||||
**Spec Source**: `.specify/spec.md` (scope/restore matrix is config-driven)
|
||||
|
||||
## 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)
|
||||
- **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.
|
||||
- **Next up**: **US7** Intune RBAC onboarding wizard (delegated, synchronous Filament flow).
|
||||
- **Upcoming**: **US8** Graph Contract Registry & Drift Guard (contract registry, type-family handling, verification command, fallback strategies).
|
||||
- **Done**: Phases 1–15 (US1–US8, Settings Catalog hydration/display, restore rerun, Highlander, permissions/health, housekeeping/UX, ops).
|
||||
- **Open**: T167 (optional) CLI/Job for CHECK/REPORT only (no grant).
|
||||
- **Next up**: Feature 018 (Driver Updates / WUfB add-on) in `specs/018-driver-updates-wufb/`.
|
||||
|
||||
## Technical Baseline
|
||||
- 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 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.
|
||||
- **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 10–12)**: Soft/force deletes for tenants/backups/versions/restore runs with guards; table actions in ActionGroup per UX guideline.
|
||||
- **Ops (Phase 7)**: Sail runbook and Dokploy staging→prod guidance captured.
|
||||
|
||||
## 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.
|
||||
- Scope alignment: FR-023–FR-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.
|
||||
- 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.
|
||||
- Scope alignment: FR-031–FR-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 & 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.
|
||||
- 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.
|
||||
|
||||
## 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.
|
||||
- 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).
|
||||
|
||||
164
.specify/spec.md
164
.specify/spec.md
@ -1,20 +1,50 @@
|
||||
# Feature Specification: TenantPilot v1
|
||||
|
||||
**Feature Branch**: `tenantpilot-v1`
|
||||
**Feature Branch**: `dev`
|
||||
**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.
|
||||
|
||||
## Scope
|
||||
|
||||
```yaml
|
||||
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:
|
||||
- key: deviceConfiguration
|
||||
name: "Device Configuration"
|
||||
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
|
||||
name: "Device Compliance"
|
||||
@ -25,6 +55,16 @@ ## Scope
|
||||
graph_resource: "deviceAppManagement/managedAppPolicies"
|
||||
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
|
||||
name: "Conditional Access"
|
||||
graph_resource: "identity/conditionalAccess/policies"
|
||||
@ -35,6 +75,14 @@ ## Scope
|
||||
graph_resource: "deviceManagement/deviceManagementScripts"
|
||||
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
|
||||
name: "Enrollment Restrictions"
|
||||
graph_resource: "deviceManagement/deviceEnrollmentConfigurations"
|
||||
@ -46,22 +94,40 @@ ## Scope
|
||||
- key: windowsEnrollmentStatusPage
|
||||
name: "Enrollment Status Page (ESP)"
|
||||
graph_resource: "deviceManagement/deviceEnrollmentConfigurations"
|
||||
filter: "odata.type eq '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration'"
|
||||
notes: "Filtered to #microsoft.graph.windows10EnrollmentCompletionPageConfiguration."
|
||||
|
||||
- key: endpointSecurityIntent
|
||||
name: "Endpoint Security Intents"
|
||||
graph_resource: "deviceManagement/intents"
|
||||
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
|
||||
name: "Applications (Metadata only)"
|
||||
graph_resource: "deviceAppManagement/mobileApps"
|
||||
notes: "Backup nur von Metadaten/Zuweisungen (kein Binary-Download in v1)."
|
||||
|
||||
- key: settingsCatalogPolicy
|
||||
name: "Settings Catalog Policy"
|
||||
graph_resource: "deviceManagement/configurationPolicies"
|
||||
notes: "Intune Settings Catalog Policies liegen NICHT unter deviceConfigurations, sondern unter configurationPolicies. v1 behandelt sie als eigenen Typ."
|
||||
foundation_types:
|
||||
- key: assignmentFilter
|
||||
name: "Assignment Filter"
|
||||
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:
|
||||
deviceConfiguration:
|
||||
@ -70,6 +136,37 @@ ## Scope
|
||||
risk: medium
|
||||
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:
|
||||
backup: full
|
||||
restore: enabled
|
||||
@ -82,6 +179,16 @@ ## Scope
|
||||
risk: medium-high
|
||||
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:
|
||||
backup: full
|
||||
restore: preview-only
|
||||
@ -94,6 +201,16 @@ ## Scope
|
||||
risk: medium
|
||||
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:
|
||||
backup: full
|
||||
restore: preview-only
|
||||
@ -118,17 +235,38 @@ ## Scope
|
||||
risk: high
|
||||
notes: "Security-relevante Einstellungen (z. B. Credential Guard); Preview + klare Konflikt-Warnungen nötig."
|
||||
|
||||
settingsCatalogPolicy:
|
||||
endpointSecurityPolicy:
|
||||
backup: full
|
||||
restore: enableds
|
||||
risk: medium
|
||||
notes: "Settings Catalog Policies sind Standard-Config-Policies (Settings Catalog). Preview/Audit Pflicht; Restore automatisierbar."
|
||||
restore: enabled
|
||||
risk: high
|
||||
notes: "Enabled with template validation (Feature 023)."
|
||||
|
||||
securityBaselinePolicy:
|
||||
backup: full
|
||||
restore: preview-only
|
||||
risk: high
|
||||
notes: "High risk; preview-only by default."
|
||||
|
||||
mobileApp:
|
||||
backup: metadata-only
|
||||
restore: enabled
|
||||
risk: low-medium
|
||||
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)*
|
||||
|
||||
@ -8,9 +8,9 @@ # Tasks: TenantPilot v1
|
||||
**Prerequisites**: plan.md (complete), spec.md (complete)
|
||||
|
||||
**Status snapshot**
|
||||
- Done: Phases 1–13 (US1–US4, Settings normalization/display, Highlander, US6 permissions/health, housekeeping/UX, ops)
|
||||
- Next up: Phase 14 (US7) delegated Intune RBAC onboarding wizard (synchronous)
|
||||
- Upcoming: Phase 15 (US8) Graph Contract Registry & Drift Guard
|
||||
- Done: Phases 1–15 (US1–US8, Settings Catalog hydration/display, restore rerun, Highlander, US6 permissions/health, housekeeping/UX, ops)
|
||||
- Open: T167 (optional) CLI/Job for CHECK/REPORT only (no grant)
|
||||
- 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`.
|
||||
- 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:
|
||||
- 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.
|
||||
- **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”.
|
||||
- Tabelle zeigt **sprechende Bezeichnung** + **kompakte Werte**
|
||||
@ -699,7 +699,7 @@ ## Acceptance Criteria
|
||||
- **Readable Setting name** (not a cut-off vendor string)
|
||||
- **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`.
|
||||
|
||||
@ -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] 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`.
|
||||
- [ ] 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)
|
||||
|
||||
@ -35,6 +35,13 @@ ## Bulk operations (Feature 005)
|
||||
- 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.
|
||||
|
||||
### 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
|
||||
|
||||
- `TENANTPILOT_BULK_CHUNK_SIZE` (default `10`): job refresh/progress chunk size.
|
||||
|
||||
162
app/Console/Commands/ReclassifyEnrollmentConfigurations.php
Normal file
162
app/Console/Commands/ReclassifyEnrollmentConfigurations.php
Normal 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();
|
||||
}
|
||||
}
|
||||
29
app/Console/Commands/TenantpilotDispatchBackupSchedules.php
Normal file
29
app/Console/Commands/TenantpilotDispatchBackupSchedules.php
Normal 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;
|
||||
}
|
||||
}
|
||||
164
app/Console/Commands/TenantpilotPurgeNonPersistentData.php
Normal file
164
app/Console/Commands/TenantpilotPurgeNonPersistentData.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
17
app/Exceptions/InvalidPolicyTypeException.php
Normal file
17
app/Exceptions/InvalidPolicyTypeException.php
Normal 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));
|
||||
}
|
||||
}
|
||||
83
app/Filament/Pages/Tenancy/RegisterTenant.php
Normal file
83
app/Filament/Pages/Tenancy/RegisterTenant.php
Normal 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;
|
||||
}
|
||||
}
|
||||
856
app/Filament/Resources/BackupScheduleResource.php
Normal file
856
app/Filament/Resources/BackupScheduleResource.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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([]);
|
||||
}
|
||||
}
|
||||
@ -4,17 +4,15 @@
|
||||
|
||||
use App\Filament\Resources\PolicyResource;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\Policy;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\BackupService;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class BackupItemsRelationManager extends RelationManager
|
||||
{
|
||||
@ -99,113 +97,110 @@ public function table(Table $table): Table
|
||||
Actions\Action::make('addPolicies')
|
||||
->label('Add Policies')
|
||||
->icon('heroicon-o-plus')
|
||||
->form([
|
||||
Forms\Components\Select::make('policy_ids')
|
||||
->label('Policies')
|
||||
->multiple()
|
||||
->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;
|
||||
}
|
||||
|
||||
->modalHeading('Add Policies')
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelActionLabel('Close')
|
||||
->modalContent(function (): View {
|
||||
$backupSet = $this->getOwnerRecord();
|
||||
$tenant = $backupSet?->tenant ?? Tenant::current();
|
||||
|
||||
$service->addPoliciesToSet(
|
||||
tenant: $tenant,
|
||||
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();
|
||||
return view('filament.modals.backup-set-policy-picker', [
|
||||
'backupSetId' => $backupSet->getKey(),
|
||||
]);
|
||||
}),
|
||||
])
|
||||
->actions([
|
||||
Actions\ViewAction::make()
|
||||
->label('View policy')
|
||||
->url(fn ($record) => $record->policy_id ? PolicyResource::getUrl('view', ['record' => $record->policy_id]) : null)
|
||||
->hidden(fn ($record) => ! $record->policy_id)
|
||||
->openUrlInNewTab(true),
|
||||
Actions\Action::make('remove')
|
||||
->label('Remove')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->requiresConfirmation()
|
||||
->action(function (BackupItem $record, AuditLogger $auditLogger) {
|
||||
$record->delete();
|
||||
Actions\ActionGroup::make([
|
||||
Actions\ViewAction::make()
|
||||
->label('View policy')
|
||||
->url(function (BackupItem $record): ?string {
|
||||
if (! $record->policy_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($record->backupSet) {
|
||||
$record->backupSet->update([
|
||||
'item_count' => $record->backupSet->items()->count(),
|
||||
]);
|
||||
}
|
||||
$tenant = $this->getOwnerRecord()->tenant ?? \App\Models\Tenant::current();
|
||||
|
||||
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]]
|
||||
);
|
||||
}
|
||||
return PolicyResource::getUrl('view', ['record' => $record->policy_id], tenant: $tenant);
|
||||
})
|
||||
->hidden(fn (BackupItem $record) => ! $record->policy_id)
|
||||
->openUrlInNewTab(true),
|
||||
Actions\Action::make('remove')
|
||||
->label('Remove')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->requiresConfirmation()
|
||||
->action(function (BackupItem $record, AuditLogger $auditLogger) {
|
||||
$record->delete();
|
||||
|
||||
Notification::make()
|
||||
->title('Policy removed from backup')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
if ($record->backupSet) {
|
||||
$record->backupSet->update([
|
||||
'item_count' => $record->backupSet->items()->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
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();
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -58,6 +58,26 @@ public static function infolist(Schema $schema): Schema
|
||||
TextEntry::make('external_id')->label('External ID'),
|
||||
TextEntry::make('last_synced_at')->dateTime()->label('Last synced'),
|
||||
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)
|
||||
->columnSpanFull(),
|
||||
@ -597,6 +617,20 @@ private static function latestSnapshot(Policy $record): array
|
||||
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>
|
||||
*/
|
||||
@ -623,6 +657,7 @@ private static function normalizedPolicyState(Policy $record): array
|
||||
|
||||
$normalized['context'] = 'policy';
|
||||
$normalized['record_id'] = (string) $record->getKey();
|
||||
$normalized['policy_type'] = $record->policy_type;
|
||||
|
||||
$request->attributes->set($cacheKey, $normalized);
|
||||
|
||||
@ -763,7 +798,7 @@ private static function settingsTabState(Policy $record): array
|
||||
$rows = $normalized['settings_table']['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);
|
||||
|
||||
return $split['normalized'];
|
||||
|
||||
@ -28,11 +28,35 @@ protected function getHeaderActions(): array
|
||||
/** @var PolicySyncService $service */
|
||||
$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()
|
||||
->title('Policy sync completed')
|
||||
->body(count($synced).' policies synced')
|
||||
->body($body)
|
||||
->success()
|
||||
->sendToDatabase(auth()->user())
|
||||
->send();
|
||||
|
||||
@ -49,7 +49,7 @@ protected function getActions(): array
|
||||
return;
|
||||
}
|
||||
|
||||
app(VersionService::class)->captureFromGraph(
|
||||
$version = app(VersionService::class)->captureFromGraph(
|
||||
tenant: $tenant,
|
||||
policy: $policy,
|
||||
createdBy: auth()->user()?->email ?? null,
|
||||
@ -57,10 +57,23 @@ protected function getActions(): array
|
||||
includeScopeTags: $data['include_scope_tags'] ?? false,
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Snapshot captured successfully.')
|
||||
->success()
|
||||
->send();
|
||||
if (($version->metadata['source'] ?? null) === 'metadata_only') {
|
||||
$status = $version->metadata['original_status'] ?? null;
|
||||
|
||||
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()]));
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
@ -34,6 +34,8 @@ public function table(Table $table): Table
|
||||
->label('Restore to Intune')
|
||||
->icon('heroicon-o-arrow-path-rounded-square')
|
||||
->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()
|
||||
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?")
|
||||
->modalSubheading('Creates a restore run using this policy version snapshot.')
|
||||
|
||||
@ -74,7 +74,7 @@ public static function infolist(Schema $schema): Schema
|
||||
|
||||
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')
|
||||
->view('filament.infolists.entries.policy-settings-standard')
|
||||
@ -87,10 +87,11 @@ public static function infolist(Schema $schema): Schema
|
||||
|
||||
$normalized['context'] = 'version';
|
||||
$normalized['record_id'] = (string) $record->getKey();
|
||||
$normalized['policy_type'] = $record->policy_type;
|
||||
|
||||
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')
|
||||
->id('raw-json')
|
||||
@ -114,7 +115,10 @@ public static function infolist(Schema $schema): Schema
|
||||
: [];
|
||||
$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')
|
||||
->label('Raw diff (advanced)')
|
||||
@ -182,14 +186,14 @@ public static function table(Table $table): Table
|
||||
->falseLabel('Archived'),
|
||||
])
|
||||
->actions([
|
||||
Actions\ViewAction::make()
|
||||
->url(fn (PolicyVersion $record) => static::getUrl('view', ['record' => $record]))
|
||||
->openUrlInNewTab(false),
|
||||
Actions\ViewAction::make(),
|
||||
Actions\ActionGroup::make([
|
||||
Actions\Action::make('restore_via_wizard')
|
||||
->label('Restore via Wizard')
|
||||
->icon('heroicon-o-arrow-path-rounded-square')
|
||||
->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()
|
||||
->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.')
|
||||
|
||||
@ -4,13 +4,18 @@
|
||||
|
||||
use App\Filament\Resources\TenantResource\Pages;
|
||||
use App\Http\Controllers\RbacDelegatedAuthController;
|
||||
use App\Jobs\BulkTenantSyncJob;
|
||||
use App\Jobs\SyncPoliciesJob;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\RbacHealthService;
|
||||
use App\Services\Intune\RbacOnboardingService;
|
||||
use App\Services\Intune\TenantConfigService;
|
||||
use App\Services\Intune\TenantPermissionService;
|
||||
use App\Support\TenantRole;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\ActionGroup;
|
||||
@ -23,6 +28,8 @@
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
@ -33,6 +40,8 @@ class TenantResource extends Resource
|
||||
{
|
||||
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|UnitEnum|null $navigationGroup = 'Settings';
|
||||
@ -44,6 +53,15 @@ public static function form(Schema $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()
|
||||
@ -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
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(fn (\Illuminate\Database\Eloquent\Builder $query) => $query->withTrashed())
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->searchable(),
|
||||
@ -80,6 +116,23 @@ public static function table(Table $table): Table
|
||||
->label('Tenant ID')
|
||||
->copyable()
|
||||
->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')
|
||||
->copyable()
|
||||
->toggleable(),
|
||||
@ -102,6 +155,13 @@ public static function table(Table $table): Table
|
||||
->trueLabel('All')
|
||||
->falseLabel('Archived')
|
||||
->default(true),
|
||||
Tables\Filters\SelectFilter::make('environment')
|
||||
->options([
|
||||
'prod' => 'PROD',
|
||||
'dev' => 'DEV',
|
||||
'staging' => 'STAGING',
|
||||
'other' => 'Other',
|
||||
]),
|
||||
Tables\Filters\SelectFilter::make('app_status')
|
||||
->options([
|
||||
'ok' => 'OK',
|
||||
@ -113,6 +173,51 @@ public static function table(Table $table): Table
|
||||
->actions([
|
||||
Actions\ViewAction::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\RestoreAction::make()
|
||||
->label('Restore')
|
||||
@ -157,6 +262,12 @@ public static function table(Table $table): Table
|
||||
->url(fn (Tenant $record) => static::adminConsentUrl($record))
|
||||
->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null)
|
||||
->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')
|
||||
->label('Verify configuration')
|
||||
->icon('heroicon-o-check-badge')
|
||||
@ -236,7 +347,106 @@ public static function table(Table $table): Table
|
||||
}),
|
||||
])->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([]);
|
||||
}
|
||||
|
||||
@ -434,7 +644,10 @@ public static function rbacAction(): Actions\Action
|
||||
->label('Open RBAC login')
|
||||
->url(route('admin.rbac.start', [
|
||||
'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()
|
||||
@ -573,7 +786,10 @@ private static function loginToSearchRolesAction(?Tenant $tenant): ?Actions\Acti
|
||||
->label('Login to load roles')
|
||||
->url(route('admin.rbac.start', [
|
||||
'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')
|
||||
->url(route('admin.rbac.start', [
|
||||
'tenant' => $tenant->graphTenantId(),
|
||||
'return' => route('filament.admin.resources.tenants.view', $tenant),
|
||||
'return' => route('filament.admin.resources.tenants.view', [
|
||||
'tenant' => $tenant->external_id,
|
||||
'record' => $tenant,
|
||||
]),
|
||||
]));
|
||||
}
|
||||
|
||||
|
||||
@ -3,9 +3,24 @@
|
||||
namespace App\Filament\Resources\TenantResource\Pages;
|
||||
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Models\User;
|
||||
use App\Support\TenantRole;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateTenant extends CreateRecord
|
||||
{
|
||||
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],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
use App\Services\Intune\TenantConfigService;
|
||||
use App\Services\Intune\TenantPermissionService;
|
||||
use Filament\Actions;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewTenant extends ViewRecord
|
||||
@ -18,34 +19,63 @@ class ViewTenant extends ViewRecord
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\EditAction::make(),
|
||||
Actions\Action::make('admin_consent')
|
||||
->label('Admin consent')
|
||||
->icon('heroicon-o-clipboard-document')
|
||||
->url(fn (Tenant $record) => TenantResource::adminConsentUrl($record))
|
||||
->visible(fn (Tenant $record) => TenantResource::adminConsentUrl($record) !== null)
|
||||
->openUrlInNewTab(),
|
||||
Actions\Action::make('open_in_entra')
|
||||
->label('Open in Entra')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->url(fn (Tenant $record) => TenantResource::entraUrl($record))
|
||||
->visible(fn (Tenant $record) => TenantResource::entraUrl($record) !== null)
|
||||
->openUrlInNewTab(),
|
||||
Actions\Action::make('verify')
|
||||
->label('Verify configuration')
|
||||
->icon('heroicon-o-check-badge')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->action(function (
|
||||
Tenant $record,
|
||||
TenantConfigService $configService,
|
||||
TenantPermissionService $permissionService,
|
||||
RbacHealthService $rbacHealthService,
|
||||
AuditLogger $auditLogger
|
||||
) {
|
||||
TenantResource::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
|
||||
}),
|
||||
TenantResource::rbacAction(),
|
||||
Actions\ActionGroup::make([
|
||||
Actions\EditAction::make(),
|
||||
Actions\Action::make('admin_consent')
|
||||
->label('Admin consent')
|
||||
->icon('heroicon-o-clipboard-document')
|
||||
->url(fn (Tenant $record) => TenantResource::adminConsentUrl($record))
|
||||
->visible(fn (Tenant $record) => TenantResource::adminConsentUrl($record) !== null)
|
||||
->openUrlInNewTab(),
|
||||
Actions\Action::make('open_in_entra')
|
||||
->label('Open in Entra')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->url(fn (Tenant $record) => TenantResource::entraUrl($record))
|
||||
->visible(fn (Tenant $record) => TenantResource::entraUrl($record) !== null)
|
||||
->openUrlInNewTab(),
|
||||
Actions\Action::make('verify')
|
||||
->label('Verify configuration')
|
||||
->icon('heroicon-o-check-badge')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->action(function (
|
||||
Tenant $record,
|
||||
TenantConfigService $configService,
|
||||
TenantPermissionService $permissionService,
|
||||
RbacHealthService $rbacHealthService,
|
||||
AuditLogger $auditLogger
|
||||
) {
|
||||
TenantResource::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
|
||||
}),
|
||||
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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
100
app/Jobs/ApplyBackupScheduleRetentionJob.php
Normal file
100
app/Jobs/ApplyBackupScheduleRetentionJob.php
Normal 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,
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
152
app/Jobs/BulkTenantSyncJob.php
Normal file
152
app/Jobs/BulkTenantSyncJob.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
399
app/Jobs/RunBackupScheduleJob.php
Normal file
399
app/Jobs/RunBackupScheduleJob.php
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
277
app/Livewire/BackupSetPolicyPickerTable.php
Normal file
277
app/Livewire/BackupSetPolicyPickerTable.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -2,8 +2,10 @@
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Support\Arr;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
|
||||
@ -13,9 +15,12 @@ class BulkOperationProgress extends Component
|
||||
|
||||
public int $pollSeconds = 3;
|
||||
|
||||
public int $recentFinishedSeconds = 12;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$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();
|
||||
}
|
||||
|
||||
@ -35,12 +40,102 @@ public function loadRuns()
|
||||
return;
|
||||
}
|
||||
|
||||
$recentThreshold = now()->subSeconds($this->recentFinishedSeconds);
|
||||
|
||||
$this->runs = BulkOperationRun::query()
|
||||
->where('tenant_id', $tenant->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')
|
||||
->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
|
||||
|
||||
34
app/Models/BackupSchedule.php
Normal file
34
app/Models/BackupSchedule.php
Normal 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);
|
||||
}
|
||||
}
|
||||
53
app/Models/BackupScheduleRun.php
Normal file
53
app/Models/BackupScheduleRun.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -2,16 +2,19 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Models\Contracts\HasName;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
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\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
class Tenant extends Model
|
||||
class Tenant extends Model implements HasName
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
@ -104,13 +107,23 @@ public function makeCurrent(): void
|
||||
DB::transaction(function () {
|
||||
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
|
||||
{
|
||||
$envTenantId = env('INTUNE_TENANT_ID') ?: null;
|
||||
$filamentTenant = Filament::getTenant();
|
||||
|
||||
if ($filamentTenant instanceof self) {
|
||||
return $filamentTenant;
|
||||
}
|
||||
|
||||
$envTenantId = getenv('INTUNE_TENANT_ID') ?: null;
|
||||
|
||||
if ($envTenantId) {
|
||||
$tenant = static::activeQuery()
|
||||
@ -138,6 +151,20 @@ public static function current(): self
|
||||
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
|
||||
{
|
||||
return $this->hasMany(Policy::class);
|
||||
@ -148,6 +175,16 @@ public function backupSets(): HasMany
|
||||
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
|
||||
{
|
||||
return $this->hasMany(PolicyVersion::class);
|
||||
|
||||
@ -2,13 +2,21 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\TenantRole;
|
||||
use Filament\Models\Contracts\FilamentUser;
|
||||
use Filament\Models\Contracts\HasDefaultTenant;
|
||||
use Filament\Models\Contracts\HasTenants;
|
||||
use Filament\Panel;
|
||||
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\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, Notifiable;
|
||||
@ -51,4 +59,113 @@ public function canAccessPanel(Panel $panel): bool
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
26
app/Models/UserTenantPreference.php
Normal file
26
app/Models/UserTenantPreference.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
46
app/Policies/BackupSchedulePolicy.php
Normal file
46
app/Policies/BackupSchedulePolicy.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -2,14 +2,31 @@
|
||||
|
||||
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\MicrosoftGraphClient;
|
||||
use App\Services\Graph\NullGraphClient;
|
||||
use App\Services\Intune\AppProtectionPolicyNormalizer;
|
||||
use App\Services\Intune\CompliancePolicyNormalizer;
|
||||
use App\Services\Intune\DeviceConfigurationPolicyNormalizer;
|
||||
use App\Services\Intune\EnrollmentAutopilotPolicyNormalizer;
|
||||
use App\Services\Intune\GroupPolicyConfigurationNormalizer;
|
||||
use App\Services\Intune\ManagedDeviceAppConfigurationNormalizer;
|
||||
use App\Services\Intune\ScriptsPolicyNormalizer;
|
||||
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;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
@ -38,8 +55,16 @@ public function register(): void
|
||||
AppProtectionPolicyNormalizer::class,
|
||||
CompliancePolicyNormalizer::class,
|
||||
DeviceConfigurationPolicyNormalizer::class,
|
||||
EnrollmentAutopilotPolicyNormalizer::class,
|
||||
GroupPolicyConfigurationNormalizer::class,
|
||||
ManagedDeviceAppConfigurationNormalizer::class,
|
||||
ScriptsPolicyNormalizer::class,
|
||||
SettingsCatalogPolicyNormalizer::class,
|
||||
TermsAndConditionsNormalizer::class,
|
||||
WindowsDriverUpdateProfileNormalizer::class,
|
||||
WindowsFeatureUpdateProfileNormalizer::class,
|
||||
WindowsQualityUpdateProfileNormalizer::class,
|
||||
WindowsUpdateRingNormalizer::class,
|
||||
],
|
||||
'policy-type-normalizers'
|
||||
);
|
||||
@ -50,6 +75,37 @@ public function register(): 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Providers\Filament;
|
||||
|
||||
use App\Filament\Pages\Tenancy\RegisterTenant;
|
||||
use App\Models\Tenant;
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
@ -29,6 +31,10 @@ public function panel(Panel $panel): Panel
|
||||
->id('admin')
|
||||
->path('admin')
|
||||
->login()
|
||||
->tenant(Tenant::class, slugAttribute: 'external_id')
|
||||
->tenantRoutePrefix('t')
|
||||
->searchableTenantMenu()
|
||||
->tenantRegistration(RegisterTenant::class)
|
||||
->colors([
|
||||
'primary' => Color::Amber,
|
||||
])
|
||||
|
||||
22
app/Rules/SupportedPolicyTypesRule.php
Normal file
22
app/Rules/SupportedPolicyTypesRule.php
Normal 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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
143
app/Services/BackupScheduling/BackupScheduleDispatcher.php
Normal file
143
app/Services/BackupScheduling/BackupScheduleDispatcher.php
Normal 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));
|
||||
}
|
||||
}
|
||||
55
app/Services/BackupScheduling/PolicyTypeResolver.php
Normal file
55
app/Services/BackupScheduling/PolicyTypeResolver.php
Normal 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));
|
||||
}
|
||||
}
|
||||
86
app/Services/BackupScheduling/RunErrorMapper.php
Normal file
86
app/Services/BackupScheduling/RunErrorMapper.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
88
app/Services/BackupScheduling/ScheduleTimeService.php
Normal file
88
app/Services/BackupScheduling/ScheduleTimeService.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@ -109,8 +109,24 @@ public function recordSkippedWithReason(BulkOperationRun $run, string $itemId, s
|
||||
|
||||
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';
|
||||
$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 ?? []);
|
||||
$failedReasons = $failureEntries
|
||||
|
||||
@ -39,7 +39,7 @@ public function fetch(
|
||||
|
||||
$primaryException = null;
|
||||
$assignments = [];
|
||||
$primarySucceeded = false;
|
||||
$lastSuccessfulAssignments = null;
|
||||
|
||||
// Try primary endpoint(s)
|
||||
$listPathTemplates = [];
|
||||
@ -65,7 +65,12 @@ public function fetch(
|
||||
$context,
|
||||
$throwOnFailure
|
||||
);
|
||||
$primarySucceeded = true;
|
||||
|
||||
if ($assignments === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lastSuccessfulAssignments = $assignments;
|
||||
|
||||
if (! empty($assignments)) {
|
||||
Log::debug('Fetched assignments via primary endpoint', [
|
||||
@ -77,20 +82,25 @@ public function fetch(
|
||||
|
||||
return $assignments;
|
||||
}
|
||||
|
||||
if ($policyType !== 'appProtectionPolicy') {
|
||||
// Empty is a valid outcome (policy not assigned). Do not attempt fallback.
|
||||
return [];
|
||||
}
|
||||
} catch (GraphException $e) {
|
||||
$primaryException = $primaryException ?? $e;
|
||||
}
|
||||
}
|
||||
|
||||
if ($primarySucceeded && $policyType === 'appProtectionPolicy') {
|
||||
if ($lastSuccessfulAssignments !== null && $policyType === 'appProtectionPolicy') {
|
||||
Log::debug('Assignments fetched via primary endpoint(s)', [
|
||||
'tenant_id' => $tenantId,
|
||||
'policy_type' => $policyType,
|
||||
'policy_id' => $policyId,
|
||||
'count' => count($assignments),
|
||||
'count' => count($lastSuccessfulAssignments),
|
||||
]);
|
||||
|
||||
return $assignments;
|
||||
return $lastSuccessfulAssignments;
|
||||
}
|
||||
|
||||
// Try fallback with $expand
|
||||
@ -215,15 +225,15 @@ private function fetchPrimary(
|
||||
array $options,
|
||||
array $context,
|
||||
bool $throwOnFailure
|
||||
): array {
|
||||
): ?array {
|
||||
if (! is_string($listPathTemplate) || $listPathTemplate === '') {
|
||||
return [];
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = $this->resolvePath($listPathTemplate, $policyId);
|
||||
|
||||
if ($path === null) {
|
||||
return [];
|
||||
return null;
|
||||
}
|
||||
|
||||
$response = $this->graphClient->request('GET', $path, $options);
|
||||
@ -239,7 +249,7 @@ private function fetchPrimary(
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
return null;
|
||||
}
|
||||
|
||||
return $response->data['value'] ?? [];
|
||||
|
||||
@ -32,6 +32,16 @@ public function sanitizeQuery(string $policyType, array $query): array
|
||||
: array_map('trim', explode(',', (string) $original));
|
||||
$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)) {
|
||||
$warnings[] = 'Trimmed unsupported $select fields for capability safety.';
|
||||
}
|
||||
|
||||
@ -14,6 +14,8 @@ class MicrosoftGraphClient implements GraphClientInterface
|
||||
{
|
||||
private const DEFAULT_SCOPE = 'https://graph.microsoft.com/.default';
|
||||
|
||||
private const MAX_LIST_PAGES = 50;
|
||||
|
||||
private string $baseUrl;
|
||||
|
||||
private string $tokenUrlTemplate;
|
||||
@ -51,12 +53,21 @@ public function __construct(
|
||||
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||
{
|
||||
$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,
|
||||
'$filter' => $options['filter'] ?? null,
|
||||
'$select' => $defaultSelect,
|
||||
'platform' => $options['platform'] ?? null,
|
||||
], fn ($value) => $value !== null && $value !== '');
|
||||
|
||||
$sanitized = $this->contracts->sanitizeQuery($policyType, $queryInput);
|
||||
$query = $sanitized['query'];
|
||||
$warnings = $sanitized['warnings'];
|
||||
|
||||
$context = $this->resolveContext($options);
|
||||
$clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
|
||||
$fullPath = $this->buildFullPath($endpoint, $query);
|
||||
@ -79,19 +90,178 @@ public function listPolicies(string $policyType, array $options = []): GraphResp
|
||||
|
||||
$response = $this->send('GET', $endpoint, $sendOptions, $context);
|
||||
|
||||
return $this->toGraphResponse(
|
||||
action: 'list_policies',
|
||||
response: $response,
|
||||
transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []),
|
||||
meta: [
|
||||
'tenant' => $context['tenant'] ?? null,
|
||||
'path' => $endpoint,
|
||||
'full_path' => $fullPath,
|
||||
if ($response->failed()) {
|
||||
$graphResponse = $this->toGraphResponse(
|
||||
action: 'list_policies',
|
||||
response: $response,
|
||||
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,
|
||||
],
|
||||
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',
|
||||
'query' => $query ?: null,
|
||||
'policy_type' => $policyType,
|
||||
'tenant' => $context['tenant'],
|
||||
'query' => $fallbackQuery ?: null,
|
||||
'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
|
||||
@ -182,6 +352,37 @@ public function getPolicy(string $policyType, string $policyId, array $options =
|
||||
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
|
||||
{
|
||||
$context = $this->resolveContext($options);
|
||||
@ -575,8 +776,22 @@ private function normalizeScopes(array|string|null $scope): array
|
||||
|
||||
private function endpointFor(string $policyType): string
|
||||
{
|
||||
$supported = config('tenantpilot.supported_policy_types', []);
|
||||
foreach ($supported as $type) {
|
||||
$contractResource = $this->contracts->resourcePath($policyType);
|
||||
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'])) {
|
||||
return $type['endpoint'];
|
||||
}
|
||||
@ -585,6 +800,16 @@ private function endpointFor(string $policyType): string
|
||||
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
|
||||
{
|
||||
$tenant = $context['tenant'] ?? $this->tenantId;
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\AssignmentBackupService;
|
||||
use Carbon\CarbonImmutable;
|
||||
@ -289,13 +290,46 @@ private function snapshotPolicy(
|
||||
$captured = $captureResult['captured'];
|
||||
$payload = $captured['payload'];
|
||||
$metadata = $captured['metadata'] ?? [];
|
||||
$metadataWarnings = $captured['warnings'] ?? [];
|
||||
|
||||
// Validate snapshot
|
||||
$validation = $this->snapshotValidator->validate(is_array($payload) ? $payload : []);
|
||||
return [
|
||||
$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']);
|
||||
|
||||
$odataWarning = BackupItem::odataTypeWarning(is_array($payload) ? $payload : [], $policy->policy_type, $policy->platform);
|
||||
$odataWarning = BackupItem::odataTypeWarning($payload, $policy->policy_type, $policy->platform);
|
||||
|
||||
if ($odataWarning) {
|
||||
$metadataWarnings[] = $odataWarning;
|
||||
@ -305,29 +339,23 @@ private function snapshotPolicy(
|
||||
$metadata['warnings'] = array_values(array_unique($metadataWarnings));
|
||||
}
|
||||
|
||||
$capturedScopeTags = $captured['scope_tags'] ?? null;
|
||||
if (is_array($capturedScopeTags)) {
|
||||
$metadata['scope_tag_ids'] = $capturedScopeTags['ids'] ?? null;
|
||||
$metadata['scope_tag_names'] = $capturedScopeTags['names'] ?? null;
|
||||
if (is_array($scopeTags)) {
|
||||
$metadata['scope_tag_ids'] = $scopeTags['ids'] ?? null;
|
||||
$metadata['scope_tag_names'] = $scopeTags['names'] ?? null;
|
||||
}
|
||||
|
||||
// Create BackupItem as a copy/reference of the PolicyVersion
|
||||
$backupItem = BackupItem::create([
|
||||
return BackupItem::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'policy_id' => $policy->id,
|
||||
'policy_version_id' => $version->id, // Link to version
|
||||
'policy_version_id' => $version->id,
|
||||
'policy_identifier' => $policy->external_id,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'payload' => $payload,
|
||||
'metadata' => $metadata,
|
||||
// Copy assignments from version (already captured)
|
||||
// Note: scope_tags are only stored in PolicyVersion
|
||||
'assignments' => $captured['assignments'] ?? null,
|
||||
'assignments' => $assignments,
|
||||
]);
|
||||
|
||||
return [$backupItem, null];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
388
app/Services/Intune/ConfigurationPolicyTemplateResolver.php
Normal file
388
app/Services/Intune/ConfigurationPolicyTemplateResolver.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -35,6 +35,8 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor
|
||||
$resultWarnings = [];
|
||||
$status = 'success';
|
||||
$settingsTable = null;
|
||||
$usesSettingsCatalogTable = in_array($policyType, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true);
|
||||
$fallbackCategoryName = $this->extractConfigurationPolicyFallbackCategoryName($snapshot);
|
||||
|
||||
$validation = $this->validator->validate($snapshot);
|
||||
$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 ($policyType === 'settingsCatalogPolicy') {
|
||||
$normalized = $this->buildSettingsCatalogSettingsTable($snapshot['settings']);
|
||||
if ($usesSettingsCatalogTable) {
|
||||
$normalized = $this->buildSettingsCatalogSettingsTable(
|
||||
$snapshot['settings'],
|
||||
fallbackCategoryName: $fallbackCategoryName
|
||||
);
|
||||
$settingsTable = $normalized['table'];
|
||||
$resultWarnings = array_merge($resultWarnings, $normalized['warnings']);
|
||||
} else {
|
||||
$settings[] = $this->normalizeSettingsCatalog($snapshot['settings']);
|
||||
}
|
||||
} elseif (isset($snapshot['settingsDelta']) && is_array($snapshot['settingsDelta'])) {
|
||||
if ($policyType === 'settingsCatalogPolicy') {
|
||||
$normalized = $this->buildSettingsCatalogSettingsTable($snapshot['settingsDelta'], 'Settings delta');
|
||||
if ($usesSettingsCatalogTable) {
|
||||
$normalized = $this->buildSettingsCatalogSettingsTable(
|
||||
$snapshot['settingsDelta'],
|
||||
'Settings delta',
|
||||
$fallbackCategoryName
|
||||
);
|
||||
$settingsTable = $normalized['table'];
|
||||
$resultWarnings = array_merge($resultWarnings, $normalized['warnings']);
|
||||
} else {
|
||||
$settings[] = $this->normalizeSettingsCatalog($snapshot['settingsDelta'], 'Settings delta');
|
||||
}
|
||||
} elseif ($policyType === 'settingsCatalogPolicy') {
|
||||
$resultWarnings[] = 'Settings not hydrated for this Settings Catalog policy.';
|
||||
} elseif ($usesSettingsCatalogTable) {
|
||||
$resultWarnings[] = 'Settings not hydrated for this Configuration Policy.';
|
||||
}
|
||||
|
||||
$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
|
||||
* @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 [
|
||||
'table' => [
|
||||
@ -252,7 +289,7 @@ private function buildSettingsCatalogSettingsTable(array $settings, string $titl
|
||||
* @param array<int, mixed> $settings
|
||||
* @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 = [];
|
||||
$warnings = [];
|
||||
@ -292,7 +329,8 @@ private function flattenSettingsCatalogSettingInstances(array $settings): array
|
||||
&$warnedRowLimit,
|
||||
$definitions,
|
||||
$categories,
|
||||
$defaultCategoryName
|
||||
$defaultCategoryName,
|
||||
$fallbackCategoryName,
|
||||
): void {
|
||||
if ($rowCount >= self::SETTINGS_CATALOG_MAX_ROWS) {
|
||||
if (! $warnedRowLimit) {
|
||||
@ -364,6 +402,16 @@ private function flattenSettingsCatalogSettingInstances(array $settings): array
|
||||
$categoryName = $defaultCategoryName;
|
||||
}
|
||||
|
||||
if (
|
||||
$categoryName === '-'
|
||||
&& is_string($fallbackCategoryName)
|
||||
&& $fallbackCategoryName !== ''
|
||||
&& is_array($definition)
|
||||
&& ($definition['isFallback'] ?? false)
|
||||
) {
|
||||
$categoryName = $fallbackCategoryName;
|
||||
}
|
||||
|
||||
// Convert technical type to user-friendly data type
|
||||
$dataType = $this->getUserFriendlyDataType($rawInstanceType, $value);
|
||||
|
||||
@ -516,11 +564,41 @@ private function extractSettingsCatalogValue(array $setting, ?array $instance):
|
||||
$type = $instance['@odata.type'] ?? null;
|
||||
$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)) {
|
||||
$simple = $instance['simpleSettingValue'] ?? null;
|
||||
|
||||
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;
|
||||
@ -530,7 +608,13 @@ private function extractSettingsCatalogValue(array $setting, ?array $instance):
|
||||
$choice = $instance['choiceSettingValue'] ?? null;
|
||||
|
||||
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;
|
||||
@ -748,11 +832,17 @@ private function formatSettingsCatalogValue(mixed $value): string
|
||||
if (is_string($value)) {
|
||||
// Remove {tenantid} placeholder
|
||||
$value = str_replace(['{tenantid}', '_tenantid_'], ['', '_'], $value);
|
||||
$value = preg_replace('/\{[^}]+\}/', '', $value);
|
||||
$value = preg_replace('/_+/', '_', $value);
|
||||
|
||||
// Extract choice label from choice values (last meaningful part)
|
||||
// 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);
|
||||
$lastPart = end($parts);
|
||||
|
||||
@ -761,6 +851,29 @@ private function formatSettingsCatalogValue(mixed $value): string
|
||||
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 (is_numeric($lastPart) && count($parts) > 1) {
|
||||
$secondLast = $parts[count($parts) - 2];
|
||||
@ -792,6 +905,33 @@ private function formatSettingsCatalogValue(mixed $value): string
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
609
app/Services/Intune/EnrollmentAutopilotPolicyNormalizer.php
Normal file
609
app/Services/Intune/EnrollmentAutopilotPolicyNormalizer.php
Normal 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);
|
||||
}
|
||||
}
|
||||
143
app/Services/Intune/ManagedDeviceAppConfigurationNormalizer.php
Normal file
143
app/Services/Intune/ManagedDeviceAppConfigurationNormalizer.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -47,13 +47,21 @@ public function capture(
|
||||
$snapshot = $this->snapshotService->fetch($tenant, $policy, $createdBy);
|
||||
|
||||
if (isset($snapshot['failure'])) {
|
||||
throw new \RuntimeException($snapshot['failure']['reason'] ?? 'Unable to fetch policy snapshot');
|
||||
return [
|
||||
'failure' => $snapshot['failure'],
|
||||
];
|
||||
}
|
||||
|
||||
$payload = $snapshot['payload'];
|
||||
$assignments = 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
|
||||
if ($includeAssignments) {
|
||||
@ -179,9 +187,9 @@ public function capture(
|
||||
|
||||
// 5. Create new PolicyVersion with all captured data
|
||||
$metadata = array_merge(
|
||||
['source' => 'orchestrated_capture'],
|
||||
['capture_source' => 'orchestrated_capture'],
|
||||
$metadata,
|
||||
$captureMetadata
|
||||
$captureMetadata,
|
||||
);
|
||||
|
||||
$version = $this->versionService->captureVersion(
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Services\Graph\GraphContractRegistry;
|
||||
use App\Services\Graph\GraphErrorMapper;
|
||||
use App\Services\Graph\GraphLogger;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use Illuminate\Support\Arr;
|
||||
use Throwable;
|
||||
|
||||
@ -62,6 +63,11 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
|
||||
} catch (Throwable $throwable) {
|
||||
$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 [
|
||||
'failure' => [
|
||||
'policy_id' => $policy->id,
|
||||
@ -77,8 +83,19 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
|
||||
$metadata = Arr::except($response->data, ['payload']);
|
||||
$metadataWarnings = $metadata['warnings'] ?? [];
|
||||
|
||||
if ($policy->policy_type === 'settingsCatalogPolicy') {
|
||||
[$payload, $metadata] = $this->hydrateSettingsCatalog(
|
||||
if ($policy->policy_type === 'windowsUpdateRing') {
|
||||
[$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,
|
||||
tenant: $tenant,
|
||||
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()) {
|
||||
$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 = [
|
||||
'policy_id' => $policy->id,
|
||||
'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
|
||||
{
|
||||
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}
|
||||
*/
|
||||
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');
|
||||
$settingsPath = $this->contracts->subresourceSettingsPath('settingsCatalogPolicy', $policyId);
|
||||
$strategy = $this->contracts->memberHydrationStrategy($policyType);
|
||||
$settingsPath = $this->contracts->subresourceSettingsPath($policyType, $policyId);
|
||||
|
||||
if ($strategy !== 'subresource_settings' || ! $settingsPath) {
|
||||
return [$payload, $metadata];
|
||||
@ -493,6 +616,126 @@ private function hydrateComplianceActions(string $tenantIdentifier, Tenant $tena
|
||||
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.
|
||||
*/
|
||||
@ -531,6 +774,69 @@ private function stripGraphBaseUrl(string $nextLink): string
|
||||
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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Services\Intune;
|
||||
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphErrorMapper;
|
||||
@ -24,6 +25,19 @@ public function __construct(
|
||||
* @return array<int> IDs of policies synced or created
|
||||
*/
|
||||
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()) {
|
||||
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', []);
|
||||
$synced = [];
|
||||
$failures = [];
|
||||
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
|
||||
|
||||
foreach ($types as $typeConfig) {
|
||||
@ -68,6 +83,13 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
$failures[] = [
|
||||
'policy_type' => $policyType,
|
||||
'status' => $response->status,
|
||||
'errors' => $response->errors,
|
||||
'meta' => $response->meta,
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -78,6 +100,12 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr
|
||||
continue;
|
||||
}
|
||||
|
||||
$canonicalPolicyType = $this->resolveCanonicalPolicyType($policyType, $policyData);
|
||||
|
||||
if ($canonicalPolicyType !== $policyType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($policyType === 'appProtectionPolicy') {
|
||||
$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';
|
||||
$policyPlatform = $platform ?? ($policyData['platform'] ?? null);
|
||||
|
||||
$existingWithDifferentType = Policy::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('external_id', $externalId)
|
||||
->where('policy_type', '!=', $policyType)
|
||||
->exists();
|
||||
$this->reclassifyEnrollmentConfigurationPoliciesIfNeeded(
|
||||
tenantId: $tenant->id,
|
||||
externalId: $externalId,
|
||||
policyType: $policyType,
|
||||
);
|
||||
|
||||
if ($existingWithDifferentType) {
|
||||
continue;
|
||||
}
|
||||
$this->reclassifyConfigurationPoliciesIfNeeded(
|
||||
tenantId: $tenant->id,
|
||||
externalId: $externalId,
|
||||
policyType: $policyType,
|
||||
);
|
||||
|
||||
$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]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -16,6 +16,7 @@ class RestoreRiskChecker
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GroupResolver $groupResolver,
|
||||
private readonly ConfigurationPolicyTemplateResolver $templateResolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -38,7 +39,9 @@ public function check(Tenant $tenant, BackupSet $backupSet, ?array $selectedItem
|
||||
$results = [];
|
||||
|
||||
$results[] = $this->checkOrphanedGroups($tenant, $policyItems, $groupMapping);
|
||||
$results[] = $this->checkMetadataOnlySnapshots($policyItems);
|
||||
$results[] = $this->checkPreviewOnlyPolicies($policyItems);
|
||||
$results[] = $this->checkEndpointSecurityTemplates($tenant, $policyItems);
|
||||
$results[] = $this->checkMissingPolicies($tenant, $policyItems);
|
||||
$results[] = $this->checkStalePolicies($tenant, $policyItems);
|
||||
$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
|
||||
* @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);
|
||||
|
||||
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
|
||||
|
||||
@ -27,6 +27,7 @@ public function __construct(
|
||||
private readonly VersionService $versionService,
|
||||
private readonly SnapshotValidator $snapshotValidator,
|
||||
private readonly GraphContractRegistry $contracts,
|
||||
private readonly ConfigurationPolicyTemplateResolver $templateResolver,
|
||||
private readonly AssignmentRestoreService $assignmentRestoreService,
|
||||
private readonly FoundationMappingService $foundationMappingService,
|
||||
) {}
|
||||
@ -151,6 +152,18 @@ public function executeFromPolicyVersion(
|
||||
'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 !== []) {
|
||||
$backupItemMetadata['scope_tag_ids'] = $scopeTagIds;
|
||||
}
|
||||
@ -418,12 +431,13 @@ public function execute(
|
||||
$createdPolicyMode = null;
|
||||
$settingsApplyEligible = false;
|
||||
|
||||
if ($item->policy_type === 'settingsCatalogPolicy') {
|
||||
if (in_array($item->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy'], true)) {
|
||||
$policyType = $item->policy_type;
|
||||
$settings = $this->extractSettingsCatalogSettings($originalPayload);
|
||||
$policyPayload = $this->stripSettingsFromPayload($payload);
|
||||
|
||||
$response = $this->graphClient->applyPolicy(
|
||||
$item->policy_type,
|
||||
$policyType,
|
||||
$item->policy_identifier,
|
||||
$policyPayload,
|
||||
$graphOptions + ['method' => $updateMethod]
|
||||
@ -431,8 +445,19 @@ public function execute(
|
||||
|
||||
$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(
|
||||
policyType: $policyType,
|
||||
originalPayload: $originalPayload,
|
||||
settings: $settings,
|
||||
graphOptions: $graphOptions,
|
||||
@ -476,6 +501,7 @@ public function execute(
|
||||
|
||||
if ($settingsApplyEligible && $settings !== []) {
|
||||
[$settingsApply, $itemStatus] = $this->applySettingsCatalogPolicySettings(
|
||||
policyType: $policyType,
|
||||
policyId: $item->policy_identifier,
|
||||
settings: $settings,
|
||||
graphOptions: $graphOptions,
|
||||
@ -484,7 +510,18 @@ public function execute(
|
||||
|
||||
if ($itemStatus === 'manual_required' && $settingsApply !== null
|
||||
&& $this->shouldAttemptSettingsCatalogCreate($settingsApply)) {
|
||||
if ($policyType === 'endpointSecurityPolicy') {
|
||||
$originalPayload = $this->prepareEndpointSecurityPolicyForCreate(
|
||||
tenant: $tenant,
|
||||
originalPayload: $originalPayload,
|
||||
settings: $settings,
|
||||
graphOptions: $graphOptions,
|
||||
context: $context,
|
||||
);
|
||||
}
|
||||
|
||||
$createOutcome = $this->createSettingsCatalogPolicy(
|
||||
policyType: $policyType,
|
||||
originalPayload: $originalPayload,
|
||||
settings: $settings,
|
||||
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 {
|
||||
if ($item->policy_type === 'appProtectionPolicy') {
|
||||
@ -555,6 +584,23 @@ public function execute(
|
||||
$payload,
|
||||
$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 {
|
||||
$response = $this->graphClient->applyPolicy(
|
||||
$item->policy_type,
|
||||
@ -630,6 +676,8 @@ public function execute(
|
||||
'graph_error_code' => $response->meta['error_code'] ?? null,
|
||||
'graph_request_id' => $response->meta['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++;
|
||||
|
||||
@ -885,6 +933,11 @@ private function resolveTypeMeta(string $policyType): array
|
||||
private function resolveRestoreMode(string $policyType): string
|
||||
{
|
||||
$meta = $this->resolveTypeMeta($policyType);
|
||||
|
||||
if ($meta === []) {
|
||||
return 'preview-only';
|
||||
}
|
||||
|
||||
$restore = $meta['restore'] ?? 'enabled';
|
||||
|
||||
if (! is_string($restore) || $restore === '') {
|
||||
@ -931,6 +984,10 @@ private function isNotFoundResponse(object $response): bool
|
||||
$code = strtolower((string) ($response->meta['error_code'] ?? ''));
|
||||
$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'))) {
|
||||
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}
|
||||
*/
|
||||
private function applySettingsCatalogPolicySettings(
|
||||
string $policyType,
|
||||
string $policyId,
|
||||
array $settings,
|
||||
array $graphOptions,
|
||||
array $context,
|
||||
): array {
|
||||
$method = $this->contracts->settingsWriteMethod('settingsCatalogPolicy');
|
||||
$path = $this->contracts->settingsWritePath('settingsCatalogPolicy', $policyId);
|
||||
$bodyShape = strtolower($this->contracts->settingsWriteBodyShape('settingsCatalogPolicy'));
|
||||
$fallbackShape = $this->contracts->settingsWriteFallbackBodyShape('settingsCatalogPolicy');
|
||||
$method = $this->contracts->settingsWriteMethod($policyType);
|
||||
$path = $this->contracts->settingsWritePath($policyType, $policyId);
|
||||
$bodyShape = strtolower($this->contracts->settingsWriteBodyShape($policyType));
|
||||
$fallbackShape = $this->contracts->settingsWriteFallbackBodyShape($policyType);
|
||||
|
||||
$buildIssues = function (string $reason) use ($settings): array {
|
||||
$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 === []) {
|
||||
return [
|
||||
@ -1654,14 +1712,15 @@ private function shouldAttemptSettingsCatalogCreate(array $settingsApply): bool
|
||||
* @return array{success:bool,policy_id:?string,response:?object,mode:string}
|
||||
*/
|
||||
private function createSettingsCatalogPolicy(
|
||||
string $policyType,
|
||||
array $originalPayload,
|
||||
array $settings,
|
||||
array $graphOptions,
|
||||
array $context,
|
||||
string $fallbackName,
|
||||
): array {
|
||||
$resource = $this->contracts->resourcePath('settingsCatalogPolicy') ?? 'deviceManagement/configurationPolicies';
|
||||
$sanitizedSettings = $this->contracts->sanitizeSettingsApplyPayload('settingsCatalogPolicy', $settings);
|
||||
$resource = $this->contracts->resourcePath($policyType) ?? 'deviceManagement/configurationPolicies';
|
||||
$sanitizedSettings = $this->contracts->sanitizeSettingsApplyPayload($policyType, $settings);
|
||||
|
||||
if ($sanitizedSettings === []) {
|
||||
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}
|
||||
*/
|
||||
|
||||
274
app/Services/Intune/ScriptsPolicyNormalizer.php
Normal file
274
app/Services/Intune/ScriptsPolicyNormalizer.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -269,10 +269,49 @@ public function prettifyDefinitionId(string $definitionId): string
|
||||
// Remove {tenantid} placeholder - it's a Microsoft template variable, not part of the name
|
||||
$cleaned = str_replace(['{tenantid}', '_tenantid_', '_{tenantid}_'], ['', '_', '_'], $definitionId);
|
||||
|
||||
// Remove other template placeholders, e.g. "{FirewallRuleId}"
|
||||
$cleaned = preg_replace('/\{[^}]+\}/', '', $cleaned);
|
||||
|
||||
// Clean up consecutive underscores
|
||||
$cleaned = preg_replace('/_+/', '_', $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
|
||||
$prettified = Str::title(str_replace('_', ' ', $cleaned));
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ public function __construct(
|
||||
|
||||
public function supports(string $policyType): bool
|
||||
{
|
||||
return $policyType === 'settingsCatalogPolicy';
|
||||
return in_array($policyType, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
94
app/Services/Intune/TermsAndConditionsNormalizer.php
Normal file
94
app/Services/Intune/TermsAndConditionsNormalizer.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -85,6 +85,8 @@ public function captureFromGraph(
|
||||
}
|
||||
|
||||
$payload = $snapshot['payload'];
|
||||
$snapshotMetadata = is_array($snapshot['metadata'] ?? null) ? $snapshot['metadata'] : [];
|
||||
$snapshotWarnings = is_array($snapshot['warnings'] ?? null) ? $snapshot['warnings'] : [];
|
||||
$assignments = null;
|
||||
$scopeTags = null;
|
||||
$assignmentMetadata = [];
|
||||
@ -141,11 +143,17 @@ public function captureFromGraph(
|
||||
}
|
||||
|
||||
$metadata = array_merge(
|
||||
['source' => 'version_capture'],
|
||||
$snapshotMetadata,
|
||||
['capture_source' => 'version_capture'],
|
||||
$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(
|
||||
policy: $policy,
|
||||
payload: $payload,
|
||||
|
||||
125
app/Services/Intune/WindowsDriverUpdateProfileNormalizer.php
Normal file
125
app/Services/Intune/WindowsDriverUpdateProfileNormalizer.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
107
app/Services/Intune/WindowsFeatureUpdateProfileNormalizer.php
Normal file
107
app/Services/Intune/WindowsFeatureUpdateProfileNormalizer.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
137
app/Services/Intune/WindowsUpdateRingNormalizer.php
Normal file
137
app/Services/Intune/WindowsUpdateRingNormalizer.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -29,6 +29,14 @@ protected static function odataTypeMap(): array
|
||||
'windows' => '#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' => [
|
||||
'windows' => '#microsoft.graph.windows10CompliancePolicy',
|
||||
'ios' => '#microsoft.graph.iosCompliancePolicy',
|
||||
@ -54,9 +62,26 @@ protected static function odataTypeMap(): array
|
||||
'windows' => '#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' => [
|
||||
'all' => '#microsoft.graph.deviceEnrollmentConfiguration',
|
||||
],
|
||||
'deviceEnrollmentLimitConfiguration' => [
|
||||
'all' => '#microsoft.graph.deviceEnrollmentLimitConfiguration',
|
||||
],
|
||||
'deviceEnrollmentPlatformRestrictionsConfiguration' => [
|
||||
'all' => '#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration',
|
||||
],
|
||||
'deviceEnrollmentNotificationConfiguration' => [
|
||||
'all' => '#microsoft.graph.deviceEnrollmentNotificationConfiguration',
|
||||
],
|
||||
'windowsAutopilotDeploymentProfile' => [
|
||||
'windows' => '#microsoft.graph.windowsAutopilotDeploymentProfile',
|
||||
],
|
||||
|
||||
40
app/Support/TenantRole.php
Normal file
40
app/Support/TenantRole.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,7 @@
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"filament/filament": "^4.0",
|
||||
"lara-zeus/torch-filament": "^2.0",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"pepperfm/filament-json": "^4"
|
||||
|
||||
193
composer.lock
generated
193
composer.lock
generated
@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "c4f08fd9fc4b86cc13b75332dd6e1b7a",
|
||||
"content-hash": "20819254265bddd0aa70006919cb735f",
|
||||
"packages": [
|
||||
{
|
||||
"name": "anourvalar/eloquent-serialize",
|
||||
@ -2082,6 +2082,87 @@
|
||||
},
|
||||
"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",
|
||||
"version": "v12.42.0",
|
||||
@ -4265,6 +4346,60 @@
|
||||
},
|
||||
"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",
|
||||
"version": "1.9.4",
|
||||
@ -8110,6 +8245,62 @@
|
||||
},
|
||||
"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",
|
||||
"version": "2.0.0",
|
||||
|
||||
@ -80,7 +80,7 @@
|
||||
],
|
||||
'settingsCatalogPolicy' => [
|
||||
'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'],
|
||||
'type_family' => [
|
||||
'#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||
@ -134,6 +134,96 @@
|
||||
'supports_scope_tags' => true,
|
||||
'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' => [
|
||||
'resource' => 'deviceManagement/deviceConfigurations',
|
||||
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'],
|
||||
@ -145,6 +235,13 @@
|
||||
'update_method' => 'PATCH',
|
||||
'id_field' => 'id',
|
||||
'hydration' => 'properties',
|
||||
'update_strip_keys' => [
|
||||
'version',
|
||||
'qualityUpdatesPauseStartDate',
|
||||
'featureUpdatesPauseStartDate',
|
||||
'qualityUpdatesWillBeRolledBack',
|
||||
'featureUpdatesWillBeRolledBack',
|
||||
],
|
||||
'assignments_list_path' => '/deviceManagement/deviceConfigurations/{id}/assignments',
|
||||
'assignments_create_path' => '/deviceManagement/deviceConfigurations/{id}/assign',
|
||||
'assignments_create_method' => 'POST',
|
||||
@ -155,6 +252,88 @@
|
||||
'supports_scope_tags' => true,
|
||||
'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' => [
|
||||
'resource' => 'deviceManagement/deviceCompliancePolicies',
|
||||
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'],
|
||||
@ -215,6 +394,43 @@
|
||||
'assignments_create_method' => 'POST',
|
||||
'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' => [
|
||||
'resource' => 'identity/conditionalAccess/policies',
|
||||
'allowed_select' => ['id', 'displayName', 'state', 'createdDateTime', 'modifiedDateTime', '@odata.type'],
|
||||
@ -227,6 +443,26 @@
|
||||
'id_field' => 'id',
|
||||
'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' => [
|
||||
'resource' => 'deviceManagement/deviceManagementScripts',
|
||||
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'lastModifiedDateTime'],
|
||||
@ -287,13 +523,64 @@
|
||||
'assignments_delete_path' => '/deviceManagement/deviceHealthScripts/{id}/assignments/{assignmentId}',
|
||||
'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' => [
|
||||
'resource' => 'deviceManagement/deviceEnrollmentConfigurations',
|
||||
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version'],
|
||||
'allowed_expand' => [],
|
||||
'type_family' => [
|
||||
'#microsoft.graph.deviceEnrollmentConfiguration',
|
||||
'#microsoft.graph.windows10EnrollmentCompletionPageConfiguration',
|
||||
'#microsoft.graph.deviceEnrollmentWindowsHelloForBusinessConfiguration',
|
||||
'#microsoft.graph.windowsRestoreDeviceEnrollmentConfiguration',
|
||||
],
|
||||
@ -306,6 +593,48 @@
|
||||
'assignments_create_method' => 'POST',
|
||||
'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' => [
|
||||
'resource' => 'deviceManagement/windowsAutopilotDeploymentProfiles',
|
||||
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'lastModifiedDateTime'],
|
||||
@ -360,6 +689,11 @@
|
||||
'update_method' => 'PATCH',
|
||||
'id_field' => 'id',
|
||||
'hydration' => 'properties',
|
||||
'update_strip_keys' => [
|
||||
'isAssigned',
|
||||
'templateId',
|
||||
'isMigratingToConfigurationPolicy',
|
||||
],
|
||||
],
|
||||
'mobileApp' => [
|
||||
'resource' => 'deviceAppManagement/mobileApps',
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
'category' => 'Configuration',
|
||||
'platform' => 'all',
|
||||
'endpoint' => 'deviceManagement/deviceConfigurations',
|
||||
'filter' => "@odata.type ne '#microsoft.graph.windowsUpdateForBusinessConfiguration'",
|
||||
'filter' => "not isof('microsoft.graph.windowsUpdateForBusinessConfiguration')",
|
||||
'backup' => 'full',
|
||||
'restore' => 'enabled',
|
||||
'risk' => 'medium',
|
||||
@ -39,11 +39,41 @@
|
||||
'category' => 'Update Management',
|
||||
'platform' => 'windows',
|
||||
'endpoint' => 'deviceManagement/deviceConfigurations',
|
||||
'filter' => "@odata.type eq '#microsoft.graph.windowsUpdateForBusinessConfiguration'",
|
||||
'filter' => "isof('microsoft.graph.windowsUpdateForBusinessConfiguration')",
|
||||
'backup' => 'full',
|
||||
'restore' => 'enabled',
|
||||
'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',
|
||||
'label' => 'Device Compliance',
|
||||
@ -64,6 +94,27 @@
|
||||
'restore' => 'enabled',
|
||||
'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',
|
||||
'label' => 'Conditional Access',
|
||||
@ -105,14 +156,14 @@
|
||||
'risk' => 'medium',
|
||||
],
|
||||
[
|
||||
'type' => 'enrollmentRestriction',
|
||||
'label' => 'Enrollment Restrictions',
|
||||
'category' => 'Enrollment',
|
||||
'platform' => 'all',
|
||||
'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations',
|
||||
'type' => 'deviceComplianceScript',
|
||||
'label' => 'Custom Compliance Scripts',
|
||||
'category' => 'Compliance',
|
||||
'platform' => 'windows',
|
||||
'endpoint' => 'deviceManagement/deviceComplianceScripts',
|
||||
'backup' => 'full',
|
||||
'restore' => 'preview-only',
|
||||
'risk' => 'high',
|
||||
'restore' => 'enabled',
|
||||
'risk' => 'medium-high',
|
||||
],
|
||||
[
|
||||
'type' => 'windowsAutopilotDeploymentProfile',
|
||||
@ -130,11 +181,61 @@
|
||||
'category' => 'Enrollment',
|
||||
'platform' => 'all',
|
||||
'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations',
|
||||
'filter' => "@odata.type eq '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration'",
|
||||
'backup' => 'full',
|
||||
'restore' => 'enabled',
|
||||
'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',
|
||||
'label' => 'Endpoint Security Intents',
|
||||
@ -145,6 +246,26 @@
|
||||
'restore' => 'enabled',
|
||||
'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',
|
||||
'label' => 'Applications (Metadata only)',
|
||||
@ -198,4 +319,9 @@
|
||||
'chunk_size' => (int) env('TENANTPILOT_BULK_CHUNK_SIZE', 10),
|
||||
'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),
|
||||
],
|
||||
];
|
||||
|
||||
@ -26,6 +26,7 @@ public function definition(): array
|
||||
'app_status' => 'ok',
|
||||
'app_notes' => null,
|
||||
'status' => 'active',
|
||||
'environment' => 'other',
|
||||
'is_current' => false,
|
||||
'metadata' => [],
|
||||
];
|
||||
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -18,7 +18,9 @@
|
||||
</include>
|
||||
</source>
|
||||
<php>
|
||||
<ini name="memory_limit" value="512M"/>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="INTUNE_TENANT_ID" value="" force="true"/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||
|
||||
@ -31,7 +31,11 @@
|
||||
<p>Admin consent wurde bestätigt.</p>
|
||||
@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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
@php
|
||||
$diff = $getState() ?? ['summary' => [], 'added' => [], 'removed' => [], 'changed' => []];
|
||||
$summary = $diff['summary'] ?? [];
|
||||
$policyType = $diff['policy_type'] ?? null;
|
||||
|
||||
$groupByBlock = static function (array $items): array {
|
||||
$groups = [];
|
||||
@ -50,6 +51,180 @@
|
||||
|
||||
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
|
||||
|
||||
<div class="space-y-4">
|
||||
@ -103,37 +278,467 @@
|
||||
$to = $value['to'];
|
||||
$fromText = $stringify($from);
|
||||
$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
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ (string) $name }}
|
||||
</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 ($isExpandable($from))
|
||||
<details class="mt-1">
|
||||
|
||||
@if ($isScriptContent)
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300 sm:col-span-2">
|
||||
<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">
|
||||
View
|
||||
</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>
|
||||
@else
|
||||
<div class="mt-1">{{ $fromText }}</div>
|
||||
@endif
|
||||
</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">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>
|
||||
</div>
|
||||
@else
|
||||
<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 ($isExpandable($from))
|
||||
<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">{{ $fromText }}</pre>
|
||||
</details>
|
||||
@else
|
||||
<div class="mt-1">{{ $fromText }}</div>
|
||||
@endif
|
||||
</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">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>
|
||||
@else
|
||||
@php
|
||||
@ -149,7 +754,20 @@
|
||||
<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">{{ $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>
|
||||
@else
|
||||
<div class="break-words">{{ $text }}</div>
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
@php
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
$general = $getState();
|
||||
$entries = is_array($general) ? ($general['entries'] ?? []) : [];
|
||||
$cards = [];
|
||||
@ -61,6 +64,27 @@
|
||||
'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',
|
||||
];
|
||||
|
||||
$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
|
||||
|
||||
@if (empty($cards))
|
||||
@ -72,6 +96,9 @@
|
||||
$keyLower = $entry['key_lower'] ?? '';
|
||||
$value = $entry['value'] ?? null;
|
||||
$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) {
|
||||
str_contains($keyLower, 'name') => 'name',
|
||||
str_contains($keyLower, 'platform') => 'platform',
|
||||
@ -88,6 +115,15 @@
|
||||
$isBooleanValue = is_bool($value);
|
||||
$isBooleanString = is_string($value) && in_array(strtolower($value), ['true', 'false', 'enabled', 'disabled'], true);
|
||||
$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
|
||||
|
||||
<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'] ?? '-' }}
|
||||
</dt>
|
||||
<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">
|
||||
@foreach ($value as $item)
|
||||
@foreach ($badgeItems as $item)
|
||||
<x-filament::badge :color="$isPlatform ? 'info' : 'gray'" size="sm">
|
||||
{{ $item }}
|
||||
</x-filament::badge>
|
||||
@endforeach
|
||||
</div>
|
||||
@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)
|
||||
@php
|
||||
$boolValue = $isBooleanValue
|
||||
@ -126,7 +196,7 @@
|
||||
</div>
|
||||
@else
|
||||
<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>
|
||||
@endif
|
||||
</dd>
|
||||
|
||||
@ -7,6 +7,76 @@
|
||||
$warnings = $state['warnings'] ?? [];
|
||||
$settings = $state['settings'] ?? [];
|
||||
$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
|
||||
|
||||
<div class="space-y-4">
|
||||
@ -46,9 +116,13 @@
|
||||
</dt>
|
||||
<dd class="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<span class="text-sm text-gray-900 dark:text-white">
|
||||
@if(is_bool($row['value']))
|
||||
<x-filament::badge :color="$row['value'] ? 'success' : 'gray'" size="sm">
|
||||
{{ $row['value'] ? 'Enabled' : 'Disabled' }}
|
||||
@php
|
||||
$badgeValue = $asEnabledDisabledBadgeValue($row['value'] ?? null);
|
||||
@endphp
|
||||
|
||||
@if(! is_null($badgeValue))
|
||||
<x-filament::badge :color="$badgeValue ? 'success' : 'gray'" size="sm">
|
||||
{{ $badgeValue ? 'Enabled' : 'Disabled' }}
|
||||
</x-filament::badge>
|
||||
@elseif(is_numeric($row['value']))
|
||||
<span class="font-mono font-semibold">{{ $row['value'] }}</span>
|
||||
@ -65,7 +139,11 @@
|
||||
|
||||
{{-- Settings Blocks (for OMA Settings, Key/Value pairs, etc.) --}}
|
||||
@foreach($settings as $block)
|
||||
@if($block['type'] === 'table')
|
||||
@php
|
||||
$blockType = is_array($block) ? ($block['type'] ?? null) : null;
|
||||
@endphp
|
||||
|
||||
@if($blockType === 'table')
|
||||
<x-filament::section
|
||||
:heading="$block['title'] ?? 'Settings'"
|
||||
collapsible
|
||||
@ -79,24 +157,36 @@
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@foreach($block['rows'] ?? [] as $row)
|
||||
<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' }}
|
||||
@if(!empty($row['description']))
|
||||
<p class="text-xs text-gray-400 mt-0.5">{{ Str::limit($row['description'], 80) }}</p>
|
||||
@endif
|
||||
</dt>
|
||||
<dd class="mt-1 sm:mt-0 sm:col-span-2">
|
||||
@if(is_bool($row['value']))
|
||||
<x-filament::badge :color="$row['value'] ? 'success' : 'gray'" size="sm">
|
||||
{{ $row['value'] ? 'Enabled' : 'Disabled' }}
|
||||
@php
|
||||
$badgeValue = $asEnabledDisabledBadgeValue($row['value'] ?? null);
|
||||
@endphp
|
||||
|
||||
@if(! is_null($badgeValue))
|
||||
<x-filament::badge :color="$badgeValue ? 'success' : 'gray'" size="sm">
|
||||
{{ $badgeValue ? 'Enabled' : 'Disabled' }}
|
||||
</x-filament::badge>
|
||||
@elseif(is_numeric($row['value']))
|
||||
<span class="font-mono text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ $row['value'] }}
|
||||
</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
|
||||
<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>
|
||||
@endif
|
||||
</dd>
|
||||
@ -105,7 +195,7 @@
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
@elseif($block['type'] === 'keyValue')
|
||||
@elseif($blockType === 'keyValue')
|
||||
<x-filament::section
|
||||
:heading="$block['title'] ?? 'Settings'"
|
||||
collapsible
|
||||
@ -123,9 +213,106 @@
|
||||
{{ $entry['key'] }}
|
||||
</dt>
|
||||
<dd class="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<span class="text-sm text-gray-900 dark:text-white break-words">
|
||||
{{ Str::limit($entry['value'] ?? 'N/A', 200) }}
|
||||
</span>
|
||||
@php
|
||||
$rawValue = $entry['value'] ?? null;
|
||||
|
||||
$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>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
@ -268,10 +268,16 @@
|
||||
@if (! empty($item['graph_error_code']))
|
||||
<div class="mt-1 text-[11px] text-amber-800">Code: {{ $item['graph_error_code'] }}</div>
|
||||
@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">
|
||||
<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">
|
||||
@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']))
|
||||
<div>request-id: {{ $item['graph_request_id'] }}</div>
|
||||
@endif
|
||||
|
||||
@ -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>
|
||||
@ -0,0 +1,3 @@
|
||||
<div class="space-y-4">
|
||||
<livewire:backup-set-policy-picker-table :backupSetId="$backupSetId" />
|
||||
</div>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -13,12 +13,13 @@
|
||||
</h4>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
@if($run->status === 'pending')
|
||||
@php($isStalePending = $run->created_at->lt(now()->subSeconds(30)))
|
||||
<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">
|
||||
<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>
|
||||
</svg>
|
||||
Starting...
|
||||
{{ $isStalePending ? 'Queued…' : 'Starting...' }}
|
||||
</span>
|
||||
@elseif($run->status === 'running')
|
||||
<span class="inline-flex items-center">
|
||||
@ -28,6 +29,10 @@
|
||||
</svg>
|
||||
Processing...
|
||||
</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
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -2,7 +2,10 @@
|
||||
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Artisan::command('inspire', function () {
|
||||
$this->comment(Inspiring::quote());
|
||||
})->purpose('Display an inspiring quote');
|
||||
|
||||
Schedule::command('tenantpilot:schedules:dispatch')->everyMinute();
|
||||
|
||||
@ -7,9 +7,12 @@ # Implementation Plan: Windows Update Rings (012)
|
||||
## Summary
|
||||
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
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
|
||||
@ -8,6 +8,10 @@ # Feature Specification: Windows Update Rings (012)
|
||||
## Overview
|
||||
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.
|
||||
|
||||
## In Scope
|
||||
@ -17,6 +21,18 @@ ## In Scope
|
||||
- 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.
|
||||
|
||||
- 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)
|
||||
- Advanced analytics or reporting on update compliance.
|
||||
- Per-setting partial restore.
|
||||
@ -43,3 +59,19 @@ ### User Story 3 — Restore settings
|
||||
**Acceptance**
|
||||
1. The restore operation correctly applies the settings from the snapshot to the target policy in Intune.
|
||||
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.
|
||||
|
||||
@ -4,20 +4,23 @@ # Tasks: Windows Update Rings (012)
|
||||
**Input**: [spec.md](./spec.md), [plan.md](./plan.md)
|
||||
|
||||
## Phase 1: Contracts + Snapshot Hydration
|
||||
- [ ] T001 Verify `config/graph_contracts.php` for `windowsUpdateRing` (resource, allowed_select, type_family, etc.).
|
||||
- [ ] T002 Extend `PolicySnapshotService` to hydrate `windowsUpdateForBusinessConfiguration` settings.
|
||||
- [X] T001 Verify `config/graph_contracts.php` for `windowsUpdateRing` (resource, allowed_select, type_family, etc.).
|
||||
- [X] T002 Extend `PolicySnapshotService` to hydrate `windowsUpdateForBusinessConfiguration` settings.
|
||||
- [X] T001b Fix Graph filters for update rings (`isof(...)`) and add `windowsFeatureUpdateProfile` support.
|
||||
|
||||
## 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
|
||||
- [ ] 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
|
||||
- [ ] T005 Add tests for hydration + UI display.
|
||||
- [ ] T006 Add tests for restore apply.
|
||||
- [ ] T007 Run tests (targeted).
|
||||
- [ ] T008 Run Pint (`./vendor/bin/pint --dirty`).
|
||||
- [X] T005 Add tests for sync filters + supported types.
|
||||
- [X] T006 Add tests for restore apply.
|
||||
- [X] T007 Run tests (targeted).
|
||||
- [X] T008 Run Pint (`./vendor/bin/pint --dirty`).
|
||||
|
||||
## Open TODOs (Follow-up)
|
||||
- None yet.
|
||||
|
||||
34
specs/013-scripts-management/checklists/requirements.md
Normal file
34
specs/013-scripts-management/checklists/requirements.md
Normal 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.
|
||||
42
specs/013-scripts-management/plan.md
Normal file
42
specs/013-scripts-management/plan.md
Normal 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.
|
||||
112
specs/013-scripts-management/spec.md
Normal file
112
specs/013-scripts-management/spec.md
Normal 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 policy’s 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.
|
||||
28
specs/013-scripts-management/tasks.md
Normal file
28
specs/013-scripts-management/tasks.md
Normal 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
Loading…
Reference in New Issue
Block a user