Compare commits
No commits in common. "cb5cf9b3bdc280b8dada7b89a56858a45293070c" and "d6a57c1828d784431831665bc6edd4087684ec40" have entirely different histories.
cb5cf9b3bd
...
d6a57c1828
@ -1,35 +1,50 @@
|
||||
# TenantPilot Constitution
|
||||
# [PROJECT_NAME] Constitution
|
||||
<!-- Example: Spec Constitution, TaskFlow Constitution, etc. -->
|
||||
|
||||
## Core Principles
|
||||
|
||||
### 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_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 -->
|
||||
|
||||
### 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_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 -->
|
||||
|
||||
### 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_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 -->
|
||||
|
||||
### 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_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 -->
|
||||
|
||||
### 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).
|
||||
### [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 -->
|
||||
|
||||
## Quality Gates
|
||||
- Changes MUST be programmatically tested (Pest) and run via targeted `php artisan test ...`.
|
||||
- Run `./vendor/bin/pint --dirty` before finalizing.
|
||||
## [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. -->
|
||||
|
||||
## Governance
|
||||
- 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.
|
||||
<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan -->
|
||||
|
||||
**Version**: 1.0.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-01-03
|
||||
[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 -->
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
# Implementation Plan: TenantPilot v1
|
||||
|
||||
**Branch**: `dev`
|
||||
**Date**: 2026-01-03
|
||||
**Spec Source**: `.specify/spec.md` (scope/restore matrix is config-driven)
|
||||
**Branch**: `tenantpilot-v1`
|
||||
**Date**: 2025-12-12
|
||||
**Spec Source**: `.specify/spec.md` (scope/restore matrix unchanged)
|
||||
|
||||
## Summary
|
||||
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).
|
||||
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).
|
||||
|
||||
## Status Snapshot (tasks.md is source of truth)
|
||||
- **Done**: Phases 1–15 (US1–US8, Settings Catalog hydration/display, restore rerun, Highlander, permissions/health, housekeeping/UX, ops).
|
||||
- **Open**: T167 (optional) CLI/Job for CHECK/REPORT only (no grant).
|
||||
- **Next up**: Feature 018 (Driver Updates / WUfB add-on) in `specs/018-driver-updates-wufb/`.
|
||||
- **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).
|
||||
|
||||
## Technical Baseline
|
||||
- Laravel 12, Filament 4, PHP 8.4; Sail-first with PostgreSQL.
|
||||
@ -28,12 +28,10 @@ ## Completed Workstreams (no new action needed)
|
||||
- **US6 Tenant Setup & Highlander (Phases 8 & 12)**: Tenant CRUD/verify, INTUNE_TENANT_ID override, `is_current` unique enforcement, “Make current” action, block deactivated tenants.
|
||||
- **US6 Permissions/Health (Phase 9)**: Required permissions list, compare/check service, Verify action updates status and audit, permissions panel in Tenant detail.
|
||||
- **US1b Settings Display (Phase 13)**: PolicyNormalizer + SnapshotValidator, warnings for malformed snapshots, normalized settings and pretty JSON on policy/version detail, list badges, README section.
|
||||
- **US7 RBAC Wizard (Phase 14)**: Delegated, synchronous onboarding wizard with post-verify canary checks and audit trail.
|
||||
- **US8 Graph Contracts & Drift Guard (Phase 15)**: Config-driven contract registry, type-family handling, capability downgrade fallbacks, and a drift-check command.
|
||||
- **Housekeeping/UX (Phases 10–12)**: Soft/force deletes for tenants/backups/versions/restore runs with guards; table actions in ActionGroup per UX guideline.
|
||||
- **Ops (Phase 7)**: Sail runbook and Dokploy staging→prod guidance captured.
|
||||
|
||||
## Completed: US7 Intune RBAC Onboarding Wizard (Phase 14)
|
||||
## Execution Plan: US7 Intune RBAC Onboarding Wizard (Phase 14)
|
||||
|
||||
- Objectives: deliver delegated, tenant-scoped wizard that safely converges the Intune RBAC state for the configured service principal; fully audited, idempotent, least-privilege by default.
|
||||
- Scope alignment: FR-023–FR-030, constitution (Safety-First, Auditability, Tenant-Aware, Graph Abstraction). No secret/token persistence; delegated tokens stay request-local and are not stored in DB/cache.
|
||||
@ -58,7 +56,7 @@ ## Completed: 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.
|
||||
|
||||
## Completed: US8 Graph Contract Registry & Drift Guard (Phase 15)
|
||||
## Upcoming: US8 Graph Contract Registry & Drift Guard (Phase 15)
|
||||
|
||||
- Objectives: centralize Graph contract assumptions per supported type/endpoint and provide drift detection + safe fallbacks so preview/restore remain stable on Graph shape/capability changes.
|
||||
- Scope alignment: FR-031–FR-034 (spec), constitution (Safety-First, Auditability, Graph Abstraction, Tenant-Aware).
|
||||
@ -76,7 +74,7 @@ ## Completed: 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 when RBAC/contract behavior changes.
|
||||
- Continue using targeted Pest runs per change set; add/extend tests for US7 wizard now, and for US8 contracts when implemented.
|
||||
- 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.
|
||||
|
||||
@ -85,6 +83,6 @@ ### Restore Safety Gate
|
||||
- Restore preview MAY still render details + warnings for out-of-family snapshots, but MUST NOT offer an apply action.
|
||||
|
||||
## Coordination
|
||||
- Keep `.specify/tasks.md` and per-feature specs under `specs/` aligned with implementation changes.
|
||||
- Update `.specify/tasks.md` to reflect progress on US7 wizard and future US8 contract tasks; no new entities or scope changes introduced here.
|
||||
- Stage validation required before production for any migration or restore-impacting change.
|
||||
- Keep Graph integration behind abstraction; no secrets in logs; follow existing UX patterns (ActionGroup, warnings for risky ops).
|
||||
- Keep Graph integration behind abstraction; no secrets in logs; follow existing UX patterns (ActionGroup, warnings for risky ops).
|
||||
155
.specify/spec.md
155
.specify/spec.md
@ -1,46 +1,20 @@
|
||||
# Feature Specification: TenantPilot v1
|
||||
|
||||
**Feature Branch**: `dev`
|
||||
**Feature Branch**: `tenantpilot-v1`
|
||||
**Created**: 2025-12-10
|
||||
**Status**: Active
|
||||
**Last Updated**: 2026-01-03
|
||||
**Status**: Draft
|
||||
**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. Single Source of Truth: config/tenantpilot.php + config/graph_contracts.php."
|
||||
description: "v1 muss folgende Intune-Objekttypen inventarisieren, sichern und – je nach Risikoklasse – wiederherstellen können."
|
||||
supported_types:
|
||||
- key: deviceConfiguration
|
||||
name: "Device Configuration"
|
||||
graph_resource: "deviceManagement/deviceConfigurations"
|
||||
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"
|
||||
notes: "Inklusive Custom OMA-URI, Administrative Templates und Settings Catalog."
|
||||
|
||||
- key: deviceCompliancePolicy
|
||||
name: "Device Compliance"
|
||||
@ -51,16 +25,6 @@ ## 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"
|
||||
@ -71,14 +35,6 @@ ## 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"
|
||||
@ -90,40 +46,22 @@ ## Scope
|
||||
- key: windowsEnrollmentStatusPage
|
||||
name: "Enrollment Status Page (ESP)"
|
||||
graph_resource: "deviceManagement/deviceEnrollmentConfigurations"
|
||||
notes: "Filtered to #microsoft.graph.windows10EnrollmentCompletionPageConfiguration."
|
||||
filter: "odata.type eq '#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)."
|
||||
|
||||
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"
|
||||
- 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."
|
||||
|
||||
restore_matrix:
|
||||
deviceConfiguration:
|
||||
@ -132,32 +70,6 @@ ## 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
|
||||
@ -170,16 +82,6 @@ ## 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
|
||||
@ -192,16 +94,6 @@ ## 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
|
||||
@ -226,38 +118,17 @@ ## Scope
|
||||
risk: high
|
||||
notes: "Security-relevante Einstellungen (z. B. Credential Guard); Preview + klare Konflikt-Warnungen nötig."
|
||||
|
||||
endpointSecurityPolicy:
|
||||
settingsCatalogPolicy:
|
||||
backup: full
|
||||
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."
|
||||
restore: enableds
|
||||
risk: medium
|
||||
notes: "Settings Catalog Policies sind Standard-Config-Policies (Settings Catalog). Preview/Audit Pflicht; Restore automatisierbar."
|
||||
|
||||
mobileApp:
|
||||
backup: metadata-only
|
||||
restore: enabled
|
||||
risk: low-medium
|
||||
notes: "Nur Metadaten/Zuweisungen; kein Binary; Restore setzt Konfigurationen/Zuweisungen wieder."
|
||||
|
||||
assignmentFilter:
|
||||
backup: full
|
||||
restore: enabled
|
||||
risk: low
|
||||
|
||||
roleScopeTag:
|
||||
backup: full
|
||||
restore: enabled
|
||||
risk: low
|
||||
|
||||
notificationMessageTemplate:
|
||||
backup: full
|
||||
restore: enabled
|
||||
risk: low
|
||||
```
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
@ -8,9 +8,9 @@ # Tasks: TenantPilot v1
|
||||
**Prerequisites**: plan.md (complete), spec.md (complete)
|
||||
|
||||
**Status snapshot**
|
||||
- Done: Phases 1–15 (US1–US8, Settings Catalog hydration/display, restore rerun, Highlander, US6 permissions/health, housekeeping/UX, ops)
|
||||
- Open: T167 (optional) CLI/Job for CHECK/REPORT only (no grant)
|
||||
- Next up: Feature 018 (Driver Updates / WUfB add-on) in `specs/018-driver-updates-wufb/`
|
||||
- Done: Phases 1–13 (US1–US4, Settings normalization/display, Highlander, US6 permissions/health, housekeeping/UX, ops)
|
||||
- Next up: Phase 14 (US7) delegated Intune RBAC onboarding wizard (synchronous)
|
||||
- Upcoming: Phase 15 (US8) Graph Contract Registry & Drift Guard
|
||||
|
||||
---
|
||||
|
||||
@ -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).
|
||||
|
||||
- [x] T179 [US1b][Scope][settingsCatalogPolicy] Hydrate Settings Catalog “Configuration settings” for snapshots + normalized display
|
||||
- [ ] 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
|
||||
|
||||
|
||||
|
||||
- [x] T180 [US1b][Bug][settingsCatalogPolicy] Hydrate Settings Catalog settings in Version capture + Policy detail uses hydrated snapshot
|
||||
- [ ] 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
|
||||
|
||||
|
||||
|
||||
- [x] T185 [UX][US1b][settingsCatalogPolicy] Make Settings Catalog settings readable (label/value parsing + table ergonomics)
|
||||
- [ ]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.)
|
||||
|
||||
- [x] T186 [US4][Bugfix][settingsCatalogPolicy] Fix settings_apply payload typing (@odata.type) + body shape for configurationPolicies/{id}/settings
|
||||
- [ ] 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`.
|
||||
- [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.
|
||||
- [ ] 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)
|
||||
|
||||
@ -781,17 +781,8 @@ private function endpointFor(string $policyType): string
|
||||
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) {
|
||||
$supported = config('tenantpilot.supported_policy_types', []);
|
||||
foreach ($supported as $type) {
|
||||
if (($type['type'] ?? null) === $policyType && ! empty($type['endpoint'])) {
|
||||
return $type['endpoint'];
|
||||
}
|
||||
@ -800,16 +791,6 @@ 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;
|
||||
|
||||
@ -1,388 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@ -16,7 +16,6 @@ class RestoreRiskChecker
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GroupResolver $groupResolver,
|
||||
private readonly ConfigurationPolicyTemplateResolver $templateResolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -41,7 +40,6 @@ 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);
|
||||
@ -231,91 +229,6 @@ 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.
|
||||
*
|
||||
@ -756,17 +669,7 @@ private function resolveRestoreMode(?string $policyType): string
|
||||
{
|
||||
$meta = $this->resolveTypeMeta($policyType);
|
||||
|
||||
if ($meta === []) {
|
||||
return 'preview-only';
|
||||
}
|
||||
|
||||
$restore = $meta['restore'] ?? 'enabled';
|
||||
|
||||
if (! is_string($restore) || $restore === '') {
|
||||
return 'enabled';
|
||||
}
|
||||
|
||||
return $restore;
|
||||
return (string) ($meta['restore'] ?? 'enabled');
|
||||
}
|
||||
|
||||
private function resolveTypeLabel(?string $policyType): string
|
||||
|
||||
@ -27,7 +27,6 @@ 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,
|
||||
) {}
|
||||
@ -431,13 +430,12 @@ public function execute(
|
||||
$createdPolicyMode = null;
|
||||
$settingsApplyEligible = false;
|
||||
|
||||
if (in_array($item->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy'], true)) {
|
||||
$policyType = $item->policy_type;
|
||||
if ($item->policy_type === 'settingsCatalogPolicy') {
|
||||
$settings = $this->extractSettingsCatalogSettings($originalPayload);
|
||||
$policyPayload = $this->stripSettingsFromPayload($payload);
|
||||
|
||||
$response = $this->graphClient->applyPolicy(
|
||||
$policyType,
|
||||
$item->policy_type,
|
||||
$item->policy_identifier,
|
||||
$policyPayload,
|
||||
$graphOptions + ['method' => $updateMethod]
|
||||
@ -445,19 +443,8 @@ public function execute(
|
||||
|
||||
$settingsApplyEligible = $response->successful();
|
||||
|
||||
if ($response->failed() && $this->shouldAttemptPolicyCreate($policyType, $response)) {
|
||||
if ($policyType === 'endpointSecurityPolicy') {
|
||||
$originalPayload = $this->prepareEndpointSecurityPolicyForCreate(
|
||||
tenant: $tenant,
|
||||
originalPayload: $originalPayload,
|
||||
settings: $settings,
|
||||
graphOptions: $graphOptions,
|
||||
context: $context,
|
||||
);
|
||||
}
|
||||
|
||||
if ($response->failed() && $this->shouldAttemptPolicyCreate($item->policy_type, $response)) {
|
||||
$createOutcome = $this->createSettingsCatalogPolicy(
|
||||
policyType: $policyType,
|
||||
originalPayload: $originalPayload,
|
||||
settings: $settings,
|
||||
graphOptions: $graphOptions,
|
||||
@ -501,7 +488,6 @@ public function execute(
|
||||
|
||||
if ($settingsApplyEligible && $settings !== []) {
|
||||
[$settingsApply, $itemStatus] = $this->applySettingsCatalogPolicySettings(
|
||||
policyType: $policyType,
|
||||
policyId: $item->policy_identifier,
|
||||
settings: $settings,
|
||||
graphOptions: $graphOptions,
|
||||
@ -510,18 +496,7 @@ 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,
|
||||
@ -564,6 +539,14 @@ 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') {
|
||||
@ -676,8 +659,6 @@ 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++;
|
||||
|
||||
@ -933,11 +914,6 @@ 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 === '') {
|
||||
@ -984,10 +960,6 @@ 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;
|
||||
}
|
||||
@ -1536,16 +1508,15 @@ 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($policyType);
|
||||
$path = $this->contracts->settingsWritePath($policyType, $policyId);
|
||||
$bodyShape = strtolower($this->contracts->settingsWriteBodyShape($policyType));
|
||||
$fallbackShape = $this->contracts->settingsWriteFallbackBodyShape($policyType);
|
||||
$method = $this->contracts->settingsWriteMethod('settingsCatalogPolicy');
|
||||
$path = $this->contracts->settingsWritePath('settingsCatalogPolicy', $policyId);
|
||||
$bodyShape = strtolower($this->contracts->settingsWriteBodyShape('settingsCatalogPolicy'));
|
||||
$fallbackShape = $this->contracts->settingsWriteFallbackBodyShape('settingsCatalogPolicy');
|
||||
|
||||
$buildIssues = function (string $reason) use ($settings): array {
|
||||
$issues = [];
|
||||
@ -1578,7 +1549,7 @@ private function applySettingsCatalogPolicySettings(
|
||||
];
|
||||
}
|
||||
|
||||
$sanitized = $this->contracts->sanitizeSettingsApplyPayload($policyType, $settings);
|
||||
$sanitized = $this->contracts->sanitizeSettingsApplyPayload('settingsCatalogPolicy', $settings);
|
||||
|
||||
if (! is_array($sanitized) || $sanitized === []) {
|
||||
return [
|
||||
@ -1712,15 +1683,14 @@ 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($policyType) ?? 'deviceManagement/configurationPolicies';
|
||||
$sanitizedSettings = $this->contracts->sanitizeSettingsApplyPayload($policyType, $settings);
|
||||
$resource = $this->contracts->resourcePath('settingsCatalogPolicy') ?? 'deviceManagement/configurationPolicies';
|
||||
$sanitizedSettings = $this->contracts->sanitizeSettingsApplyPayload('settingsCatalogPolicy', $settings);
|
||||
|
||||
if ($sanitizedSettings === []) {
|
||||
return [
|
||||
@ -1777,79 +1747,6 @@ 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}
|
||||
*/
|
||||
|
||||
@ -143,19 +143,6 @@
|
||||
'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' => [
|
||||
@ -166,13 +153,6 @@
|
||||
'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',
|
||||
@ -534,11 +514,6 @@
|
||||
'update_method' => 'PATCH',
|
||||
'id_field' => 'id',
|
||||
'hydration' => 'properties',
|
||||
'update_strip_keys' => [
|
||||
'isAssigned',
|
||||
'templateId',
|
||||
'isMigratingToConfigurationPolicy',
|
||||
],
|
||||
],
|
||||
'mobileApp' => [
|
||||
'resource' => 'deviceAppManagement/mobileApps',
|
||||
|
||||
@ -192,7 +192,7 @@
|
||||
'platform' => 'windows',
|
||||
'endpoint' => 'deviceManagement/configurationPolicies',
|
||||
'backup' => 'full',
|
||||
'restore' => 'enabled',
|
||||
'restore' => 'preview-only',
|
||||
'risk' => 'high',
|
||||
],
|
||||
[
|
||||
|
||||
@ -268,16 +268,10 @@
|
||||
@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']) || ! empty($item['graph_method']) || ! empty($item['graph_path']))
|
||||
@if (! empty($item['graph_request_id']) || ! empty($item['graph_client_request_id']))
|
||||
<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
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
# 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.
|
||||
|
||||
@ -1,24 +0,0 @@
|
||||
# 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.
|
||||
|
||||
@ -1,77 +0,0 @@
|
||||
# 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.
|
||||
|
||||
@ -1,32 +0,0 @@
|
||||
# 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`).
|
||||
@ -3,11 +3,12 @@ # Requirements Checklist (023)
|
||||
**Created**: 2026-01-03
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
- [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).
|
||||
- [ ] `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).
|
||||
|
||||
|
||||
@ -3,7 +3,6 @@ # 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.
|
||||
@ -30,4 +29,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.
|
||||
|
||||
|
||||
@ -2,13 +2,13 @@ # Feature Specification: Enable Endpoint Security Policy Restore (023)
|
||||
|
||||
**Feature Branch**: `feat/023-endpoint-security-restore`
|
||||
**Created**: 2026-01-03
|
||||
**Status**: Implemented (ready to merge)
|
||||
**Status**: Draft
|
||||
**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 **restore-mode enablement** with additional validation/testing and targeted restore hardening, not a new policy type implementation.
|
||||
This is a **configuration-only change** with additional validation/testing, not a new policy type implementation.
|
||||
|
||||
## User Scenarios & Testing
|
||||
|
||||
|
||||
@ -8,30 +8,25 @@ ## Phase 1: Setup
|
||||
- [x] T001 Create spec/plan/tasks and checklist.
|
||||
|
||||
## Phase 2: Inventory & Design
|
||||
- [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.
|
||||
- [ ] 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.
|
||||
|
||||
## Phase 3: Tests (TDD)
|
||||
- [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.
|
||||
- [ ] 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.
|
||||
|
||||
## Phase 4: Implementation
|
||||
- [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.
|
||||
- [ ] 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.
|
||||
|
||||
## Phase 5: Verification
|
||||
- [x] T015 Run targeted tests.
|
||||
- [x] T016 Run Pint (`./vendor/bin/pint --dirty`).
|
||||
- [ ] T015 Run targeted tests.
|
||||
- [ ] 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`).
|
||||
|
||||
@ -1,102 +0,0 @@
|
||||
<?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');
|
||||
});
|
||||
@ -1,265 +0,0 @@
|
||||
<?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');
|
||||
});
|
||||
@ -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('enabled');
|
||||
expect($byType['endpointSecurityPolicy']['restore_mode'])->toBe('preview-only');
|
||||
expect($byType['securityBaselinePolicy']['restore_mode'])->toBe('preview-only');
|
||||
});
|
||||
|
||||
@ -1,102 +0,0 @@
|
||||
<?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');
|
||||
});
|
||||
@ -1,117 +0,0 @@
|
||||
<?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');
|
||||
});
|
||||
@ -61,40 +61,3 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user