merge: resolve dev conflicts

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

View File

@ -5,6 +5,7 @@ # TenantAtlas Development Guidelines
## Active Technologies
- 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
View File

@ -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

View File

@ -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

View File

@ -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 115 (US1US8, 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 1012)**: Soft/force deletes for tenants/backups/versions/restore runs with guards; table actions in ActionGroup per UX guideline.
- **Ops (Phase 7)**: Sail runbook and Dokploy staging→prod guidance captured.
## 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-023FR-030, constitution (Safety-First, Auditability, Tenant-Aware, Graph Abstraction). No secret/token persistence; delegated tokens stay request-local and are not stored in DB/cache.
@ -56,7 +58,7 @@ ## Execution Plan: US7 Intune RBAC Onboarding Wizard (Phase 14)
- Health integration: Verify reflects RBAC status and prompts to run wizard when missing.
- 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-031FR-034 (spec), constitution (Safety-First, Auditability, Graph Abstraction, Tenant-Aware).
@ -74,7 +76,7 @@ ## Upcoming: US8 Graph Contract Registry & Drift Guard (Phase 15)
- Testing outline: unit for registry lookups/type-family matching/fallback selection; integration/Pest to simulate capability errors and ensure downgrade path + correct routing for derived types.
## Testing & 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).

View File

@ -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)*

View File

@ -8,9 +8,9 @@ # Tasks: TenantPilot v1
**Prerequisites**: plan.md (complete), spec.md (complete)
**Status snapshot**
- Done: Phases 113 (US1US4, 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 115 (US1US8, 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)

View File

@ -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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,17 +4,15 @@
use App\Filament\Resources\PolicyResource;
use App\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();
}),
]),
]);
}
/**

View File

@ -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'];

View File

@ -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();

View File

@ -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) {

View File

@ -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.')

View File

@ -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.')

View File

@ -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,
]),
]));
}

View File

@ -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],
]);
}
}

View File

@ -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'),
];
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -2,8 +2,10 @@
namespace App\Livewire;
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

View File

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

View File

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

View File

@ -2,16 +2,19 @@
namespace App\Models;
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);

View File

@ -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();
}
}

View File

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

View File

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

View File

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

View File

@ -2,14 +2,31 @@
namespace App\Providers;
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);
}
}

View File

@ -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,
])

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -109,8 +109,24 @@ public function recordSkippedWithReason(BulkOperationRun $run, string $itemId, s
public function complete(BulkOperationRun $run): void
{
$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

View File

@ -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'] ?? [];

View File

@ -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.';
}

View File

@ -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;

View File

@ -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];
}
/**

View File

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

View File

@ -35,6 +35,8 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor
$resultWarnings = [];
$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);
}

View File

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

View File

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

View File

@ -47,13 +47,21 @@ public function capture(
$snapshot = $this->snapshotService->fetch($tenant, $policy, $createdBy);
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(

View File

@ -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'],
];
}
}

View File

@ -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]);
}
/**

View File

@ -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

View File

@ -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}
*/

View File

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

View File

@ -269,10 +269,49 @@ public function prettifyDefinitionId(string $definitionId): string
// Remove {tenantid} placeholder - it's a Microsoft template variable, not part of the name
$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));

View File

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

View File

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

View File

@ -85,6 +85,8 @@ public function captureFromGraph(
}
$payload = $snapshot['payload'];
$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,

View File

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

View File

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

View File

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

View File

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

View File

@ -29,6 +29,14 @@ protected static function odataTypeMap(): array
'windows' => '#microsoft.graph.windowsUpdateForBusinessConfiguration',
'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',
],

View File

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

View File

@ -8,6 +8,7 @@
"require": {
"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
View File

@ -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",

View File

@ -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',

View File

@ -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),
],
];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -18,7 +18,9 @@
</include>
</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"/>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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

View File

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

View File

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

View File

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

View File

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

View File

@ -13,12 +13,13 @@
</h4>
<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>

View File

@ -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();

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

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

View File

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

View File

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

View File

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

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