Compare commits

...

3 Commits

Author SHA1 Message Date
Ahmed Darrazi
cb5cf9b3bd merge: agent session work 2026-01-04 00:19:08 +01:00
Ahmed Darrazi
662b0e0aa8 spec: refresh specs/plans + add 018 scaffolding 2026-01-04 00:18:34 +01:00
d120ed7c92 feat: endpoint security restore execution (023) (#25)
Added a resolver/validation flow that fetches endpoint security template definitions and enforces them before CREATE/PATCH so we don’t call Graph with invalid settings.
Hardened restore endpoint resolution (built-in fallback to deviceManagement/configurationPolicies, clearer error metadata, preview-only fallback when metadata is missing) and exposed Graph path/method in restore UI details.
Stripped read-only fields when PATCHing endpointSecurityIntent so the request no longer fails with “properties not patchable”.
Added regression tests covering endpoint security restore, intent sanitization, unknown type safety, Graph error metadata, and endpoint resolution behavior.
Testing

GraphClientEndpointResolutionTest.php
./vendor/bin/pint --dirty

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #25
2026-01-03 22:44:08 +00:00
25 changed files with 1653 additions and 123 deletions

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,46 @@
# 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: deviceCompliancePolicy
name: "Device Compliance"
@ -25,6 +51,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 +71,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 +90,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 +132,32 @@ ## 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
deviceCompliancePolicy:
backup: full
restore: enabled
@ -82,6 +170,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 +192,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 +226,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

@ -781,8 +781,17 @@ private function endpointFor(string $policyType): string
return $contractResource;
}
$supported = config('tenantpilot.supported_policy_types', []);
foreach ($supported as $type) {
$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'];
}
@ -791,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

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

@ -16,6 +16,7 @@ class RestoreRiskChecker
{
public function __construct(
private readonly GroupResolver $groupResolver,
private readonly ConfigurationPolicyTemplateResolver $templateResolver,
) {}
/**
@ -40,6 +41,7 @@ public function check(Tenant $tenant, BackupSet $backupSet, ?array $selectedItem
$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);
@ -229,6 +231,91 @@ 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.
*
@ -669,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,
) {}
@ -430,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]
@ -443,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,
@ -488,6 +501,7 @@ public function execute(
if ($settingsApplyEligible && $settings !== []) {
[$settingsApply, $itemStatus] = $this->applySettingsCatalogPolicySettings(
policyType: $policyType,
policyId: $item->policy_identifier,
settings: $settings,
graphOptions: $graphOptions,
@ -496,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,
@ -539,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') {
@ -659,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++;
@ -914,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 === '') {
@ -960,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;
}
@ -1508,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 = [];
@ -1549,7 +1578,7 @@ private function applySettingsCatalogPolicySettings(
];
}
$sanitized = $this->contracts->sanitizeSettingsApplyPayload('settingsCatalogPolicy', $settings);
$sanitized = $this->contracts->sanitizeSettingsApplyPayload($policyType, $settings);
if (! is_array($sanitized) || $sanitized === []) {
return [
@ -1683,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 [
@ -1747,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

@ -143,6 +143,19 @@
'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' => [
@ -153,6 +166,13 @@
'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',
@ -514,6 +534,11 @@
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'update_strip_keys' => [
'isAssigned',
'templateId',
'isMigratingToConfigurationPolicy',
],
],
'mobileApp' => [
'resource' => 'deviceAppManagement/mobileApps',

View File

@ -192,7 +192,7 @@
'platform' => 'windows',
'endpoint' => 'deviceManagement/configurationPolicies',
'backup' => 'full',
'restore' => 'preview-only',
'restore' => 'enabled',
'risk' => 'high',
],
[

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,15 @@
# Requirements Checklist (018)
**Created**: 2026-01-03
**Feature**: [spec.md](../spec.md)
- [ ] `windowsDriverUpdateProfile` is added to `config/tenantpilot.php` (metadata, endpoint, backup/restore mode, risk).
- [ ] Graph contract exists in `config/graph_contracts.php` (resource, type family, create/update methods, assignments paths).
- [ ] Sync lists and stores driver update profiles in the Policies inventory.
- [ ] Snapshot capture stores a complete payload for backups and versions.
- [ ] Restore preview is available and respects the configured restore mode.
- [ ] Restore execution applies only patchable properties and records audit logs.
- [ ] Normalized settings view is readable for admins (no raw-only UX).
- [ ] Pest tests cover sync + snapshot + restore + normalized display.
- [ ] Pint run (`./vendor/bin/pint --dirty`) on touched files.

View File

@ -0,0 +1,24 @@
# Plan: Driver Updates (WUfB Add-on) (018)
**Branch**: `feat/018-driver-updates-wufb`
**Date**: 2026-01-03
**Input**: [spec.md](./spec.md)
## Goal
Add first-class support for Windows Driver Update profiles (`windowsDriverUpdateProfile`) across inventory, backup/version snapshots, restore (preview + execution), and normalized display.
## Approach
1. Confirm Graph API details for driver update profiles (resource path, `@odata.type`, patchable properties, assignment endpoints).
2. Add type metadata to `config/tenantpilot.php` (category, endpoint, backup/restore mode, risk).
3. Add Graph contract entry in `config/graph_contracts.php` (resource, type family, create/update methods, assignments).
4. Ensure sync lists and stores these policies (config-driven loop) and add a targeted sync test.
5. Ensure snapshots capture the complete payload and add tests for version/backup capture.
6. Implement restore apply via contract-driven sanitization; add failure-safe behavior and tests.
7. Add a normalizer for readable UI output; add tests for normalized display.
8. Run Pint and targeted tests.
## Decisions / Notes
- Default to contract-driven restore semantics; avoid bespoke Graph calls unless strictly required.
- If Graph rejects PATCH due to read-only fields, extend `update_strip_keys` for this type (do not loosen safety).
- Keep restore risk high; require clear preview and audit trail.

View File

@ -0,0 +1,77 @@
# Feature Specification: Driver Updates (WUfB Add-on) (018)
**Feature Branch**: `feat/018-driver-updates-wufb`
**Created**: 2026-01-03
**Status**: Draft
**Priority**: P1
## Context
TenantPilot already covers core Windows Update for Business (WUfB) objects like:
- Update Rings (`windowsUpdateRing`)
- Feature Update Profiles (`windowsFeatureUpdateProfile`)
- Quality Update Profiles (`windowsQualityUpdateProfile`)
This feature adds **Windows Driver Updates** coverage to the same Update Management area so driver rollout configuration can be inventoried, snapshotted, diffed, and restored safely.
## In Scope
- New policy type: `windowsDriverUpdateProfile`
- Inventory/sync: list driver update profiles from Microsoft Graph and store them as policies.
- Snapshot capture: full snapshot of the profile payload (and assignments where supported).
- Restore:
- Preview/dry-run with diff + risk checks.
- Execution (PATCH/POST) as allowed by Graph, with audit logging.
- UI: normalized settings display (readable, admin-focused).
## Out of Scope (v1)
- Per-driver approval workflows / driver inventory insights.
- Advanced reporting on driver compliance.
- Partial per-setting restore.
## Graph API Assumptions (to verify)
- **Resource**: `deviceManagement/windowsDriverUpdateProfiles`
- **@odata.type**: `#microsoft.graph.windowsDriverUpdateProfile`
- **Assignments**: standard pattern with:
- list: `/deviceManagement/windowsDriverUpdateProfiles/{id}/assignments`
- assign action: `/deviceManagement/windowsDriverUpdateProfiles/{id}/assign`
## User Scenarios & Testing
### User Story 1 — Inventory + readable view (P1)
As an admin, I can see Windows Driver Update profiles in the Policies list and view their configuration in a readable way.
**Acceptance**
1. Driver update profiles appear in the policy inventory with the correct type and category.
2. Policy detail shows a normalized settings table (not only raw JSON).
3. Policy Versions render “Normalized settings” consistently.
### User Story 2 — Snapshot capture (P1)
As an admin, when I capture a version or add a driver update profile to a backup set, the snapshot contains all relevant settings.
**Acceptance**
1. Snapshot stores the full Graph payload in JSON (immutable).
2. Any non-patchable/read-only properties are still preserved in the snapshot (but not sent on restore).
### User Story 3 — Restore preview + execution (P1)
As an admin, I can restore a driver update profile from a snapshot with a clear preview and safe execution.
**Acceptance**
1. Preview shows what would change and blocks if risk checks fail.
2. Execution applies only patchable properties (contract-driven sanitization).
3. Restore results include Graph error details (request-id, client-request-id, path/method) on failure.
## Requirements
### Functional Requirements
- **FR-001**: Add `windowsDriverUpdateProfile` to `config/tenantpilot.php` with category “Update Management”.
- **FR-002**: Add Graph contract entry for `windowsDriverUpdateProfile` in `config/graph_contracts.php` (resource, type family, create/update methods, assignments paths).
- **FR-003**: Ensure `PolicySyncService` syncs driver update profiles via config-driven type list.
- **FR-004**: Ensure `PolicySnapshotService` captures a complete payload for this type.
- **FR-005**: Ensure `RestoreService` applies snapshots using contract-driven sanitization and audit logging.
- **FR-006**: Add normalized display support for the key driver update profile fields.
- **FR-007**: Add automated Pest tests for sync + snapshot + restore preview/execution.
### Non-Functional Requirements
- **NFR-001**: Preserve tenant isolation and least privilege.
- **NFR-002**: Keep restore safe-by-default (preview/confirmation/audit).
- **NFR-003**: No new external services or dependencies.

View File

@ -0,0 +1,32 @@
# Tasks: Driver Updates (WUfB Add-on) (018)
**Branch**: `feat/018-driver-updates-wufb`
**Date**: 2026-01-03
**Input**: [spec.md](./spec.md), [plan.md](./plan.md)
## Phase 1: Setup
- [x] T001 Create/confirm spec, plan, tasks, checklist.
## Phase 2: Research & Design
- [ ] T002 Verify Graph resource + `@odata.type` for driver update profiles.
- [ ] T003 Verify PATCHable fields and define `update_strip_keys` / `update_whitelist`.
- [ ] T004 Verify assignment endpoints (`/assignments`, `/assign`) for this resource.
- [ ] T005 Decide restore mode (`enabled` vs `preview-only`) based on risk + patchability.
## Phase 3: Tests (TDD)
- [ ] T006 Add sync test ensuring `windowsDriverUpdateProfile` policies are imported and typed correctly.
- [ ] T007 Add snapshot/version capture test asserting full payload is stored.
- [ ] T008 Add restore preview test for this type (entries + restore_mode shown).
- [ ] T009 Add restore execution test asserting only patchable properties are sent and failures are reported with Graph metadata.
- [ ] T010 Add normalized display test for key fields.
## Phase 4: Implementation
- [ ] T011 Add `windowsDriverUpdateProfile` to `config/tenantpilot.php`.
- [ ] T012 Add Graph contract entry in `config/graph_contracts.php`.
- [ ] T013 Implement any required snapshot hydration (if Graph uses subresources).
- [ ] T014 Implement restore apply support in `RestoreService` (contract-driven sanitization).
- [ ] T015 Add a `WindowsDriverUpdateProfileNormalizer` and register it.
## Phase 5: Verification
- [ ] T016 Run targeted tests.
- [ ] T017 Run Pint (`./vendor/bin/pint --dirty`).

View File

@ -3,12 +3,11 @@ # Requirements Checklist (023)
**Created**: 2026-01-03
**Feature**: [spec.md](../spec.md)
- [ ] `endpointSecurityPolicy.restore` is changed to `enabled` in `config/tenantpilot.php`.
- [ ] Restore preview validates template existence and reports missing/ambiguous templates.
- [ ] Restore execution blocks on missing/ambiguous templates with a clear, actionable error message.
- [ ] Settings instances are validated against resolved template definitions before execution.
- [ ] Template mapping strategy is defined for cross-tenant differences (if required) and is tested.
- [ ] Restore create + update paths for Endpoint Security policies are covered by automated tests.
- [ ] Assignments mapping/application for Endpoint Security policies are covered by automated tests.
- [ ] Audit log entries exist for restore execution attempts (success and failure).
- [x] `endpointSecurityPolicy.restore` is changed to `enabled` in `config/tenantpilot.php`.
- [x] Restore preview validates template existence and reports missing/ambiguous templates.
- [x] Restore execution blocks on missing/ambiguous templates with a clear, actionable error message.
- [x] Settings instances are validated against resolved template definitions before execution.
- [x] Template mapping strategy is defined for cross-tenant differences (if required) and is tested.
- [x] Restore create + update paths for Endpoint Security policies are covered by automated tests.
- [x] Assignments mapping/application for Endpoint Security policies are covered by automated tests.
- [x] Audit log entries exist for restore execution attempts (success and failure).

View File

@ -3,6 +3,7 @@ # Plan: Endpoint Security Policy Restore (023)
**Branch**: `feat/023-endpoint-security-restore`
**Date**: 2026-01-03
**Input**: [spec.md](./spec.md)
**Status**: Implemented (ready to merge)
## Goal
Enable full restore execution for Endpoint Security Policies (`endpointSecurityPolicy`) instead of preview-only, with defensive validation around templates and settings payloads.
@ -29,4 +30,4 @@ ## Approach
## Decisions / Notes
- Assume template identifiers may differ across tenants; prefer mapping by `templateFamily` with display-name fallback when required.
- Safety-first: if template resolution is ambiguous, treat as missing and block execution.
- Incident hardening: make restore failures actionable by surfacing Graph path/method and avoid unsafe fallback endpoints.

View File

@ -2,13 +2,13 @@ # Feature Specification: Enable Endpoint Security Policy Restore (023)
**Feature Branch**: `feat/023-endpoint-security-restore`
**Created**: 2026-01-03
**Status**: Draft
**Status**: Implemented (ready to merge)
**Priority**: P1 (Quick Win)
## Context
Endpoint Security Policies are already in the `tenantpilot.php` config as `endpointSecurityPolicy` with `restore => 'preview-only'`. Based on Microsoft's recommendation to use the unified `deviceManagement/configurationPolicies` endpoint (over the deprecated `intents` API for new creations), we should enable full restore for this type.
This is a **configuration-only change** with additional validation/testing, not a new policy type implementation.
This is a **restore-mode enablement** with additional validation/testing and targeted restore hardening, not a new policy type implementation.
## User Scenarios & Testing

View File

@ -8,25 +8,30 @@ ## Phase 1: Setup
- [x] T001 Create spec/plan/tasks and checklist.
## Phase 2: Inventory & Design
- [ ] T002 Confirm current restore mode + code paths for `endpointSecurityPolicy` (`config/tenantpilot.php`, restore services).
- [ ] T003 Decide template resolution strategy (ID vs family/display name) and required Graph calls.
- [ ] T004 Define settings instance validation rules (warning vs block) for restore preview/execution.
- [x] T002 Confirm current restore mode + code paths for `endpointSecurityPolicy` (`config/tenantpilot.php`, restore services).
- [x] T003 Decide template resolution strategy (ID vs family/display name) and required Graph calls.
- [x] T004 Define settings instance validation rules (warning vs block) for restore preview/execution.
## Phase 3: Tests (TDD)
- [ ] T005 Add feature tests for restore execution create/update for `endpointSecurityPolicy`.
- [ ] T006 Add feature tests for preview warnings when template is missing.
- [ ] T007 Add feature tests asserting restore execution fails gracefully when template is missing.
- [ ] T008 Add tests for settings validation failure paths (invalid/unknown settings instances).
- [ ] T009 Add feature tests asserting assignments are applied for endpoint security policies.
- [x] T005 Add feature tests for restore execution create/update for `endpointSecurityPolicy`.
- [x] T006 Add feature tests for preview warnings when template is missing.
- [x] T007 Add feature tests asserting restore execution fails gracefully when template is missing.
- [x] T008 Add tests for settings validation failure paths (invalid/unknown settings instances).
- [x] T009 Add feature tests asserting assignments are applied for endpoint security policies.
## Phase 4: Implementation
- [ ] T010 Enable restore for `endpointSecurityPolicy` in `config/tenantpilot.php`.
- [ ] T011 Implement template existence validation in restore preview and execution gating.
- [ ] T012 Implement settings instance validation against resolved template definitions.
- [ ] T013 Implement template mapping (if required) and ensure restore payload uses mapped template reference.
- [ ] T014 Ensure restore applies assignments for endpoint security policies using existing mapping logic.
- [x] T010 Enable restore for `endpointSecurityPolicy` in `config/tenantpilot.php`.
- [x] T011 Implement template existence validation in restore preview and execution gating.
- [x] T012 Implement settings instance validation against resolved template definitions.
- [x] T013 Implement template mapping (if required) and ensure restore payload uses mapped template reference.
- [x] T014 Ensure restore applies assignments for endpoint security policies using existing mapping logic.
## Phase 5: Verification
- [ ] T015 Run targeted tests.
- [ ] T016 Run Pint (`./vendor/bin/pint --dirty`).
- [x] T015 Run targeted tests.
- [x] T016 Run Pint (`./vendor/bin/pint --dirty`).
## Phase 6: Hardening (Incident-driven)
- [x] T017 Default unknown policy types to `preview-only` to avoid invalid Graph endpoints.
- [x] T018 Harden endpoint resolution fallback for configuration policy types (avoid `deviceManagement/{policyType}`).
- [x] T019 Surface Graph method/path in RestoreRun Results for faster debugging.
- [x] T020 Strip non-patchable fields for `endpointSecurityIntent` PATCH (`isAssigned`, `templateId`, `isMigratingToConfigurationPolicy`).

View File

@ -0,0 +1,102 @@
<?php
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Intune\RestoreService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
class EndpointSecurityIntentRestoreGraphClient implements GraphClientInterface
{
/** @var array<int, array{policyType:string,policyId:string,payload:array,options:array<string,mixed>}> */
public array $applyPolicyCalls = [];
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
return new GraphResponse(true, ['payload' => []]);
}
public function getOrganization(array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
$this->applyPolicyCalls[] = [
'policyType' => $policyType,
'policyId' => $policyId,
'payload' => $payload,
'options' => $options,
];
return new GraphResponse(true, []);
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
}
test('restore strips non-patchable fields from endpoint security intent updates', function () {
$client = new EndpointSecurityIntentRestoreGraphClient;
app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::factory()->create();
$backupSet = BackupSet::factory()->for($tenant)->create([
'status' => 'completed',
'item_count' => 1,
]);
$backupItem = BackupItem::factory()->for($tenant)->for($backupSet)->create([
'policy_id' => null,
'policy_identifier' => 'intent-1',
'policy_type' => 'endpointSecurityIntent',
'platform' => 'windows',
'payload' => [
'id' => 'intent-1',
'@odata.type' => '#microsoft.graph.deviceManagementIntent',
'displayName' => 'SPO Account Protection',
'description' => 'Demo',
'isAssigned' => false,
'templateId' => '0f2b5d70-d4e9-4156-8c16-1397eb6c54a5',
'isMigratingToConfigurationPolicy' => false,
],
'assignments' => null,
]);
$service = app(RestoreService::class);
$run = $service->execute(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: [$backupItem->id],
dryRun: false,
);
expect($run->status)->toBe('completed');
expect($client->applyPolicyCalls)->toHaveCount(1);
$payload = $client->applyPolicyCalls[0]['payload'] ?? [];
expect($payload)->toBeArray();
expect($payload)->toHaveKey('displayName');
expect($payload)->toHaveKey('description');
expect($payload)->not->toHaveKey('id');
expect($payload)->not->toHaveKey('isAssigned');
expect($payload)->not->toHaveKey('templateId');
expect($payload)->not->toHaveKey('isMigratingToConfigurationPolicy');
});

View File

@ -0,0 +1,265 @@
<?php
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Intune\RestoreRiskChecker;
use App\Services\Intune\RestoreService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
class EndpointSecurityRestoreGraphClient implements GraphClientInterface
{
/** @var array<int, array{policyType:string,policyId:string,payload:array,options:array<string,mixed>}> */
public array $applyPolicyCalls = [];
/** @var array<int, array{method:string,path:string,options:array<string,mixed>}> */
public array $requestCalls = [];
/**
* @param array<string, GraphResponse> $requestMap
*/
public function __construct(
private readonly GraphResponse $applyPolicyResponse,
private readonly array $requestMap = [],
) {}
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
return new GraphResponse(true, ['payload' => []]);
}
public function getOrganization(array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
$this->applyPolicyCalls[] = [
'policyType' => $policyType,
'policyId' => $policyId,
'payload' => $payload,
'options' => $options,
];
return $this->applyPolicyResponse;
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
$this->requestCalls[] = [
'method' => strtoupper($method),
'path' => $path,
'options' => $options,
];
foreach ($this->requestMap as $needle => $response) {
if (is_string($needle) && $needle !== '' && str_contains($path, $needle)) {
return $response;
}
}
return new GraphResponse(true, []);
}
}
test('restore executes endpoint security policy settings via settings endpoint', function () {
$client = new EndpointSecurityRestoreGraphClient(new GraphResponse(true, []));
app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::factory()->create();
$backupSet = BackupSet::factory()->for($tenant)->create([
'status' => 'completed',
'item_count' => 1,
]);
$backupItem = BackupItem::factory()->for($tenant)->for($backupSet)->create([
'policy_id' => null,
'policy_identifier' => 'esp-1',
'policy_type' => 'endpointSecurityPolicy',
'platform' => 'windows',
'payload' => [
'id' => 'esp-1',
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
'name' => 'Endpoint Security Policy',
'platforms' => ['windows10'],
'technologies' => ['endpointSecurity'],
'templateReference' => [
'templateId' => 'template-1',
'templateFamily' => 'endpointSecurityFirewall',
'templateDisplayName' => 'Windows Firewall Rules',
'templateDisplayVersion' => 'Version 1',
],
'settings' => [
[
'id' => 's1',
'settingInstance' => [
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance',
'settingDefinitionId' => 'device_vendor_msft_policy_config_defender_allowrealtimemonitoring',
'simpleSettingValue' => [
'value' => 1,
],
],
],
],
],
'assignments' => null,
]);
$service = app(RestoreService::class);
$run = $service->execute(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: [$backupItem->id],
dryRun: false,
);
expect($run->status)->toBe('completed');
expect($client->applyPolicyCalls)->toHaveCount(1);
expect($client->applyPolicyCalls[0]['policyType'])->toBe('endpointSecurityPolicy');
expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('settings');
$settingsCalls = collect($client->requestCalls)
->filter(fn (array $call) => $call['method'] === 'POST' && str_contains($call['path'], '/settings'))
->values();
expect($settingsCalls)->toHaveCount(1);
expect($settingsCalls[0]['path'])->toContain('deviceManagement/configurationPolicies/esp-1/settings');
$body = $settingsCalls[0]['options']['json'] ?? null;
expect($body)->toBeArray()->not->toBeEmpty();
expect($body[0]['settingInstance']['settingDefinitionId'] ?? null)
->toBe('device_vendor_msft_policy_config_defender_allowrealtimemonitoring');
});
test('restore fails when endpoint security template is missing', function () {
$applyNotFound = new GraphResponse(false, ['error' => ['message' => 'Not found']], 404, [], [], [
'error_code' => 'NotFound',
'error_message' => 'Not found',
]);
$templateNotFound = new GraphResponse(false, ['error' => ['message' => 'Template missing']], 404, [], [], [
'error_code' => 'NotFound',
'error_message' => 'Template missing',
]);
$client = new EndpointSecurityRestoreGraphClient($applyNotFound, [
'configurationPolicyTemplates' => $templateNotFound,
]);
app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::factory()->create();
$backupSet = BackupSet::factory()->for($tenant)->create([
'status' => 'completed',
'item_count' => 1,
]);
$backupItem = BackupItem::factory()->for($tenant)->for($backupSet)->create([
'policy_id' => null,
'policy_identifier' => 'esp-missing',
'policy_type' => 'endpointSecurityPolicy',
'platform' => 'windows',
'payload' => [
'id' => 'esp-missing',
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
'name' => 'Endpoint Security Policy',
'platforms' => ['windows10'],
'technologies' => ['endpointSecurity'],
'templateReference' => [
'templateId' => 'missing-template',
],
'settings' => [
[
'id' => 's1',
'settingInstance' => [
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance',
'settingDefinitionId' => 'device_vendor_msft_policy_config_defender_allowrealtimemonitoring',
'simpleSettingValue' => [
'value' => 1,
],
],
],
],
],
'assignments' => null,
]);
$service = app(RestoreService::class);
$run = $service->execute(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: [$backupItem->id],
dryRun: false,
);
expect($run->status)->toBe('failed');
$createCalls = collect($client->requestCalls)
->filter(fn (array $call) => $call['method'] === 'POST' && $call['path'] === 'deviceManagement/configurationPolicies')
->values();
expect($createCalls)->toHaveCount(0);
});
test('restore risk checks flag missing endpoint security templates as blocking', function () {
$templateNotFound = new GraphResponse(false, ['error' => ['message' => 'Template missing']], 404, [], [], [
'error_code' => 'NotFound',
'error_message' => 'Template missing',
]);
$client = new EndpointSecurityRestoreGraphClient(new GraphResponse(true, []), [
'configurationPolicyTemplates' => $templateNotFound,
]);
app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::factory()->create();
$backupSet = BackupSet::factory()->for($tenant)->create([
'status' => 'completed',
'item_count' => 1,
]);
$backupItem = BackupItem::factory()->for($tenant)->for($backupSet)->create([
'policy_id' => null,
'policy_identifier' => 'esp-missing',
'policy_type' => 'endpointSecurityPolicy',
'platform' => 'windows',
'payload' => [
'id' => 'esp-missing',
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
'templateReference' => [
'templateId' => 'missing-template',
'templateFamily' => 'endpointSecurityFirewall',
],
'settings' => [],
],
'assignments' => null,
]);
$checker = app(RestoreRiskChecker::class);
$result = $checker->check(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: [$backupItem->id],
groupMapping: [],
);
$results = collect($result['results'] ?? []);
$templateCheck = $results->firstWhere('code', 'endpoint_security_templates');
expect($templateCheck)->not->toBeNull();
expect($templateCheck['severity'] ?? null)->toBe('blocking');
});

View File

@ -262,6 +262,6 @@ public function request(string $method, string $path, array $options = []): Grap
$byType = collect($preview)->keyBy('policy_type');
expect($byType['mamAppConfiguration']['restore_mode'])->toBe('enabled');
expect($byType['endpointSecurityPolicy']['restore_mode'])->toBe('preview-only');
expect($byType['endpointSecurityPolicy']['restore_mode'])->toBe('enabled');
expect($byType['securityBaselinePolicy']['restore_mode'])->toBe('preview-only');
});

View File

@ -0,0 +1,102 @@
<?php
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Intune\RestoreService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
class RestoreGraphErrorMetadataGraphClient implements GraphClientInterface
{
/** @var array<int, array{policyType:string,policyId:string,payload:array,options:array<string,mixed>}> */
public array $applyPolicyCalls = [];
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
return new GraphResponse(true, ['payload' => []]);
}
public function getOrganization(array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
$this->applyPolicyCalls[] = [
'policyType' => $policyType,
'policyId' => $policyId,
'payload' => $payload,
'options' => $options,
];
return new GraphResponse(false, ['error' => ['message' => 'Bad request']], 400, [], [], [
'error_code' => 'BadRequest',
'error_message' => "Resource not found for the segment 'endpointSecurityPolicy'.",
'request_id' => 'req-1',
'client_request_id' => 'client-1',
'method' => 'PATCH',
'path' => 'deviceManagement/endpointSecurityPolicy/esp-1',
]);
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
}
test('restore results include graph path and method on Graph failures', function () {
$client = new RestoreGraphErrorMetadataGraphClient;
app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::factory()->create();
$backupSet = BackupSet::factory()->for($tenant)->create([
'status' => 'completed',
'item_count' => 1,
]);
$backupItem = BackupItem::factory()->for($tenant)->for($backupSet)->create([
'policy_id' => null,
'policy_identifier' => 'esp-1',
'policy_type' => 'endpointSecurityPolicy',
'platform' => 'windows',
'payload' => [
'id' => 'esp-1',
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
'name' => 'Endpoint Security Policy',
'settings' => [],
],
'assignments' => null,
]);
$service = app(RestoreService::class);
$run = $service->execute(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: [$backupItem->id],
dryRun: false,
);
expect($client->applyPolicyCalls)->toHaveCount(1);
expect($run->status)->toBe('failed');
$result = $run->results[0] ?? null;
expect($result)->toBeArray();
expect($result['graph_method'] ?? null)->toBe('PATCH');
expect($result['graph_path'] ?? null)->toBe('deviceManagement/endpointSecurityPolicy/esp-1');
});

View File

@ -0,0 +1,117 @@
<?php
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Intune\RestoreService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
class RestoreUnknownTypeGraphClient implements GraphClientInterface
{
/** @var array<int, array{policyType:string,policyId:string,payload:array,options:array<string,mixed>}> */
public array $applyPolicyCalls = [];
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
return new GraphResponse(true, ['payload' => []]);
}
public function getOrganization(array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
$this->applyPolicyCalls[] = [
'policyType' => $policyType,
'policyId' => $policyId,
'payload' => $payload,
'options' => $options,
];
return new GraphResponse(false, ['error' => ['message' => 'Bad request']], 400, [], [], [
'error_code' => 'BadRequest',
'error_message' => 'Bad request',
]);
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
}
beforeEach(function () {
$this->originalSupportedTypes = config('tenantpilot.supported_policy_types');
$this->originalSecurityBaselineContract = config('graph_contracts.types.securityBaselinePolicy');
});
afterEach(function () {
config()->set('tenantpilot.supported_policy_types', $this->originalSupportedTypes);
if (is_array($this->originalSecurityBaselineContract)) {
config()->set('graph_contracts.types.securityBaselinePolicy', $this->originalSecurityBaselineContract);
}
});
test('restore skips security baseline policies when type metadata is missing', function () {
$client = new RestoreUnknownTypeGraphClient;
app()->instance(GraphClientInterface::class, $client);
$supported = array_values(array_filter(
config('tenantpilot.supported_policy_types', []),
static fn (array $type): bool => ($type['type'] ?? null) !== 'securityBaselinePolicy'
));
config()->set('tenantpilot.supported_policy_types', $supported);
config()->set('graph_contracts.types.securityBaselinePolicy', []);
$tenant = Tenant::factory()->create();
$backupSet = BackupSet::factory()->for($tenant)->create([
'status' => 'completed',
'item_count' => 1,
]);
$backupItem = BackupItem::factory()->for($tenant)->for($backupSet)->create([
'policy_id' => null,
'policy_identifier' => 'baseline-1',
'policy_type' => 'securityBaselinePolicy',
'platform' => 'windows',
'payload' => [
'id' => 'baseline-1',
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
'name' => 'Security Baseline Policy',
],
'assignments' => null,
]);
$service = app(RestoreService::class);
$run = $service->execute(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: [$backupItem->id],
dryRun: false,
);
expect($client->applyPolicyCalls)->toHaveCount(0);
$result = $run->results[0] ?? null;
expect($result)->toBeArray();
expect($result['status'] ?? null)->toBe('skipped');
expect($result['restore_mode'] ?? null)->toBe('preview-only');
});

View File

@ -61,3 +61,40 @@
return str_contains($request->url(), '/beta/deviceAppManagement/targetedManagedAppConfigurations/A_1');
});
});
it('uses built-in endpoint mapping for endpoint security policies when config is missing', function () {
config()->set('graph_contracts.types.endpointSecurityPolicy', []);
config()->set('tenantpilot.foundation_types', []);
Http::fake([
'https://login.microsoftonline.com/*' => Http::response([
'access_token' => 'fake-token',
'expires_in' => 3600,
], 200),
'https://graph.microsoft.com/*' => Http::response(['id' => 'E_1'], 200),
]);
$client = new MicrosoftGraphClient(
logger: app(GraphLogger::class),
contracts: app(GraphContractRegistry::class),
);
$client->applyPolicy(
policyType: 'endpointSecurityPolicy',
policyId: 'E_1',
payload: ['name' => 'Test'],
options: ['tenant' => 'tenant', 'client_id' => 'client', 'client_secret' => 'secret'],
);
Http::assertSent(function (Request $request) {
if (! str_contains($request->url(), 'graph.microsoft.com')) {
return false;
}
if (! str_contains($request->url(), '/beta/deviceManagement/configurationPolicies/E_1')) {
return false;
}
return ! str_contains($request->url(), '/beta/deviceManagement/endpointSecurityPolicy/E_1');
});
});