diff --git a/Baselines/OpenIntuneBaseline.example.yaml b/Baselines/OpenIntuneBaseline.example.yaml new file mode 100644 index 0000000..faffc00 --- /dev/null +++ b/Baselines/OpenIntuneBaseline.example.yaml @@ -0,0 +1,81 @@ +baseline: + name: OpenIntuneBaseline-v3-Example + conflictResolution: Skip # Skip | Update | Error + whatIf: false + + # Global name mutation applied to every policy (optional) + tenantMutation: + search: "OIB-" + replace: "CONTOSO-" + # Alternatively use prefix instead of search/replace: + # prefix: "CONTOSO-" + + # Cloud-only security groups to create if they do not exist + groups: + - displayName: "Baseline - Windows Devices" + mailNickname: "BaselineWinDevices" + securityEnabled: true + - displayName: "Baseline - macOS Devices" + mailNickname: "BaselineMacDevices" + securityEnabled: true + - displayName: "Baseline - Pilot Users" + mailNickname: "BaselinePilotUsers" + securityEnabled: true + + policies: + # Device Configuration + - sourcePath: ./policies/OIB-Windows-Defender-ASR.json + type: DeviceConfiguration + assignments: + - targetType: Group + groupName: "Baseline - Windows Devices" + + # Settings Catalog (uses 'name' instead of displayName) + - sourcePath: ./policies/OIB-SettingsCatalog-LoginWindow.json + type: SettingsCatalog + # Per-policy mutation override + mutation: + search: "OIB-" + replace: "CONTOSO-" + assignments: + - targetType: Group + groupName: "Baseline - macOS Devices" + - targetType: AllDevices + + # Compliance Policy + - sourcePath: ./policies/OIB-Compliance-Windows.json + type: CompliancePolicies + assignments: + - targetType: Group + groupName: "Baseline - Windows Devices" + + # Endpoint Security (DeviceManagementIntents) + # If a sibling file *_Settings.json exists, it will be imported automatically. + - sourcePath: ./policies/OIB-EndpointSecurity-Defender.json + type: EndpointSecurity + assignments: + - targetType: Group + groupName: "Baseline - Windows Devices" + + # Administrative Templates + - sourcePath: ./policies/OIB-ADMX-OfficeSettings.json + type: AdministrativeTemplates + assignments: + - targetType: Group + groupName: "Baseline - Pilot Users" + + # macOS Script + - sourcePath: ./policies/OIB-MacScript-CompanyBranding.json + type: MacScripts + assignments: + - targetType: Group + groupName: "Baseline - macOS Devices" + + # Application (metadata JSON only; .intunewin binary upload is NOT handled here) + - sourcePath: ./apps/OIB-CompanyPortal.json + type: Applications + assignments: + - targetType: AllUsers + intent: Available + - targetType: AllDevices + intent: Required diff --git a/CHANGELOG_macOS_IntuneToolkit.md b/CHANGELOG_macOS_IntuneToolkit.md index 06c3eb2..ec6129c 100644 --- a/CHANGELOG_macOS_IntuneToolkit.md +++ b/CHANGELOG_macOS_IntuneToolkit.md @@ -47,3 +47,26 @@ - Assignments use `#microsoft.graph.deviceManagementConfigurationPolicyAssignment` and the bulk `POST …/assign` endpoint - **TUI / `fzf`** - Spacebar toggle, Esc to go back, reverse numbering (10→1) in unified launcher + + +## 2026-04-13 — Declarative Baseline Deployer + +### Added +- **`Scripts/Deploy-IntuneBaseline.ps1`** + - YAML-driven one-click deployment of Intune policies + assignments to new tenants. + - Supports global and per-policy name mutations (`search`/`replace` or `prefix`). + - Auto-creates cloud-only security groups if missing. + - Idempotent imports with configurable conflict resolution (`Skip`, `Update`, `Error`). + - Full `-WhatIf` dry-run support. + - Handles 20+ policy types including Settings Catalog (`name` property), EndpointSecurity (settings file companion upload), and Applications. + - Integrates with existing auth stack (Settings.json / macOS Keychain). + +- **`Scripts/ConvertTo-IntuneBaseline.ps1`** + - Converts an existing toolkit export folder into a baseline YAML skeleton. + - Maps folder names to baseline types, extracts display names, and generates empty assignment blocks. + +- **`Baselines/OpenIntuneBaseline.example.yaml`** + - Example manifest demonstrating groups, mutations, policies, and assignments. + +### Dependencies +- `powershell-yaml` module (auto-install prompt if missing). diff --git a/OPERATIONS_LOG.md b/OPERATIONS_LOG.md index d1ef1bd..67a53a8 100644 --- a/OPERATIONS_LOG.md +++ b/OPERATIONS_LOG.md @@ -62,3 +62,50 @@ Update `Initialize-IntuneAuth.ps1` to ensure the Entra app registration is grant ## Files created in this session - `CHANGELOG_macOS_IntuneToolkit.md` - `OPERATIONS_LOG.md` + + +## Session: 2026-04-13 (continuation) + +### Task +Build a declarative Intune baseline deployment system (YAML manifest + deployer script + converter helper). + +--- + +## Operations performed + +1. **ReadFile** + - Path: `/Users/avedelphina/Local/IntuneManagement/Scripts/Bulk-AssignmentManager.ps1` + - Purpose: Studied auth bootstrap pattern, `$assignableTypes` registry, and assignment payload format. + +2. **ReadFile** + - Path: `/Users/avedelphina/Local/IntuneManagement/Extensions/MSGraph.psm1` + - Purpose: Verified `Invoke-GraphRequest` parameter names (`Content` alias `Body`, `HttpMethod` alias `Method`). + +3. **WriteFile** + - Path: `/Users/avedelphina/Local/IntuneManagement/Baselines/OpenIntuneBaseline.example.yaml` + - Content: Example baseline manifest with groups, mutations, and policy assignments. + +4. **WriteFile** + - Path: `/Users/avedelphina/Local/IntuneManagement/Scripts/Deploy-IntuneBaseline.ps1` + - Content: Main deployer script (~550 lines) with YAML parsing, group creation, policy import/update, mutation, idempotency, and assignment deployment. + +5. **WriteFile** + - Path: `/Users/avedelphina/Local/IntuneManagement/Scripts/ConvertTo-IntuneBaseline.ps1` + - Content: Helper script that scans an export folder and emits a baseline YAML skeleton. + +6. **Shell** + - Commands: Syntax validation (`Get-Command`) on both new scripts. + - Result: Both scripts parsed successfully. + +7. **WriteFile (append)** + - Path: `/Users/avedelphina/Local/IntuneManagement/CHANGELOG_macOS_IntuneToolkit.md` + - Added changelog entry for the baseline deployer feature. + +8. **WriteFile (append)** + - Path: `/Users/avedelphina/Local/IntuneManagement/OPERATIONS_LOG.md` + - Added this very entry. + +## Files created in this session +- `Baselines/OpenIntuneBaseline.example.yaml` +- `Scripts/Deploy-IntuneBaseline.ps1` +- `Scripts/ConvertTo-IntuneBaseline.ps1` diff --git a/Scripts/ConvertTo-IntuneBaseline.ps1 b/Scripts/ConvertTo-IntuneBaseline.ps1 new file mode 100644 index 0000000..5280c02 --- /dev/null +++ b/Scripts/ConvertTo-IntuneBaseline.ps1 @@ -0,0 +1,148 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Converts an existing IntuneManagement export folder into a baseline YAML manifest. +.DESCRIPTION + Scans a toolkit export directory, infers policy types from folder names, + extracts display names from JSON files, and emits a baseline YAML skeleton + with empty assignment blocks ready for editing. +.EXAMPLE + ./Scripts/ConvertTo-IntuneBaseline.ps1 -ExportPath ./Exports/2025-01-15 -OutputPath ./Baselines/mybaseline.yaml +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$ExportPath, + + [Parameter(Mandatory = $true)] + [string]$OutputPath, + + [string]$BaselineName = "ConvertedBaseline" +) + +$ErrorActionPreference = "Stop" + +#region Dependency check +$yamlModule = Get-Module -ListAvailable -Name powershell-yaml | Select-Object -First 1 +if(-not $yamlModule) +{ + Write-Warning "powershell-yaml module not found. Installing..." + Install-Module powershell-yaml -Scope CurrentUser -Force +} +Import-Module powershell-yaml -Force +#endregion + +$exportPathResolved = Resolve-Path $ExportPath | Select-Object -ExpandProperty Path +$outputPathResolved = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath) + +if(-not (Test-Path $exportPathResolved)) +{ + throw "Export path not found: $ExportPath" +} + +# Folder-to-type whitelist (matches toolkit export folders and baseline types) +$folderTypeMap = @{ + "DeviceConfiguration" = "DeviceConfiguration" + "SettingsCatalog" = "SettingsCatalog" + "CompliancePolicies" = "CompliancePolicies" + "CompliancePoliciesV2" = "CompliancePoliciesV2" + "AdministrativeTemplates" = "AdministrativeTemplates" + "EndpointSecurity" = "EndpointSecurity" + "DeviceManagementIntents" = "DeviceManagementIntents" + "AppProtection" = "AppProtection" + "AppConfigurationManagedDevice" = "AppConfigurationManagedDevice" + "PlatformScripts" = "PlatformScripts" + "MacScripts" = "MacScripts" + "DeviceHealthScripts" = "DeviceHealthScripts" + "MacCustomAttributes" = "MacCustomAttributes" + "EnrollmentRestrictions" = "EnrollmentRestrictions" + "EnrollmentStatusPage" = "EnrollmentStatusPage" + "Autopilot" = "Autopilot" + "TermsAndConditions" = "TermsAndConditions" + "PolicySets" = "PolicySets" + "UpdatePolicies" = "UpdatePolicies" + "FeatureUpdates" = "FeatureUpdates" + "QualityUpdates" = "QualityUpdates" + "Applications" = "Applications" +} + +$policies = @() + +foreach($folder in Get-ChildItem -Path $exportPathResolved -Directory) +{ + $folderName = $folder.Name + if(-not $folderTypeMap.ContainsKey($folderName)) + { + Write-Verbose "Skipping unrecognized folder: $folderName" + continue + } + $typeName = $folderTypeMap[$folderName] + $nameProp = if($typeName -eq "SettingsCatalog" -or $typeName -eq "CompliancePoliciesV2") { "name" } else { "displayName" } + + $jsonFiles = Get-ChildItem -Path $folder.FullName -Filter "*.json" + foreach($file in $jsonFiles) + { + # Skip *_Settings.json companion files + if($file.BaseName -like "*_Settings") + { + continue + } + + try + { + $json = Get-Content $file.FullName -Raw | ConvertFrom-Json -Depth 10 + $displayName = $json.$nameProp + if(-not $displayName) + { + $displayName = $json.displayName + } + if(-not $displayName) + { + $displayName = $file.BaseName + } + } + catch + { + Write-Warning "Could not parse $($file.FullName); using filename as display name." + $displayName = $file.BaseName + } + + $relativePath = "." + $file.FullName.Substring($exportPathResolved.Length).Replace("\", "/") + + $policies += [ordered]@{ + sourcePath = $relativePath + type = $typeName + assignments = @() + } + + Write-Host "Mapped: [$typeName] $displayName -> $relativePath" + } +} + +if($policies.Count -eq 0) +{ + throw "No convertible policies found in $exportPathResolved" +} + +$baseline = [ordered]@{ + baseline = [ordered]@{ + name = $BaselineName + conflictResolution = "Skip" + whatIf = $false + tenantMutation = [ordered]@{ + search = "" + replace = "" + } + groups = @() + policies = $policies + } +} + +$yaml = ConvertTo-Yaml -Data $baseline +$yaml | Set-Content -Path $outputPathResolved -Encoding UTF8 + +Write-Host "`nBaseline skeleton written to: $outputPathResolved" -ForegroundColor Green +Write-Host "Policies found: $($policies.Count)" -ForegroundColor Green +Write-Host "Next steps:" -ForegroundColor Cyan +Write-Host " 1. Edit the YAML to add group names and assignments." +Write-Host " 2. Run Deploy-IntuneBaseline.ps1 against a target tenant." diff --git a/Scripts/Deploy-IntuneBaseline.ps1 b/Scripts/Deploy-IntuneBaseline.ps1 new file mode 100644 index 0000000..cd0c2f2 --- /dev/null +++ b/Scripts/Deploy-IntuneBaseline.ps1 @@ -0,0 +1,582 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Deploys a declarative Intune baseline from a YAML manifest. +.DESCRIPTION + Reads a baseline YAML file, creates missing groups, imports policies, + applies name mutations, and assigns objects — all in a single command. + Ideal for seeding new tenants with OpenIntuneBaseline-style configurations. +.EXAMPLE + ./Scripts/Deploy-IntuneBaseline.ps1 -BaselinePath ./Baselines/mybaseline.yaml -TenantId "contoso.onmicrosoft.com" +.EXAMPLE + ./Scripts/Deploy-IntuneBaseline.ps1 -BaselinePath ./Baselines/mybaseline.yaml -WhatIf +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$BaselinePath, + + [Parameter(Mandatory = $true)] + [string]$TenantId, + + [string]$AppId, + + [string]$Secret, + + [string]$Certificate, + + [ValidateSet("AppOnly","Browser","DeviceCode")] + [string]$AuthMode = "AppOnly", + + [string]$RedirectUri, + + [string]$SettingsFile, + + [switch]$WhatIf +) + +$ErrorActionPreference = "Stop" + +#region Helper functions +function Get-DefaultSettingsPath +{ + if($IsWindows -or $env:OS -eq "Windows_NT") + { + if($env:LOCALAPPDATA) { return (Join-Path $env:LOCALAPPDATA "macOS_IntuneManagement\Settings.json") } + return (Join-Path $env:USERPROFILE "AppData\Local\macOS_IntuneManagement\Settings.json") + } + if($IsMacOS) { return (Join-Path $HOME "Library/Application Support/macOS_IntuneManagement/Settings.json") } + return (Join-Path $HOME ".local/share/macOS_IntuneManagement/Settings.json") +} + +function Resolve-RelativePath +{ + param([string]$Path, [string]$BasePath) + if([System.IO.Path]::IsPathRooted($Path)) { return $Path } + $baseDir = Split-Path -Parent $BasePath + return Join-Path $baseDir $Path +} + +function Test-YamlModule +{ + return [bool](Get-Module -ListAvailable -Name powershell-yaml) +} + +function Install-YamlModule +{ + Write-Host "powershell-yaml module is required but not installed." -ForegroundColor Yellow + if(-not $WhatIf) + { + $confirm = Read-Host "Install powershell-yaml from PSGallery now? [Y/n]" + if($confirm -match "^\s*n") + { + throw "powershell-yaml is required. Install it with: Install-Module powershell-yaml -Scope CurrentUser -Force" + } + Install-Module powershell-yaml -Scope CurrentUser -Force + Import-Module powershell-yaml -Force + } +} + +function Get-BaselineTypeMap +{ + return @{ + "Applications" = @{ API = "/deviceAppManagement/mobileApps"; AssignmentsType = "mobileAppAssignments"; AssignmentODataType = "#microsoft.graph.mobileAppAssignment"; HasIntent = $true; NameProp = "displayName"; SettingsAPI = $null } + "DeviceConfiguration" = @{ API = "/deviceManagement/deviceConfigurations"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.deviceConfigurationAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null } + "SettingsCatalog" = @{ API = "/deviceManagement/configurationPolicies"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.deviceManagementConfigurationPolicyAssignment"; HasIntent = $false; NameProp = "name"; SettingsAPI = $null } + "CompliancePolicies" = @{ API = "/deviceManagement/deviceCompliancePolicies"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.deviceCompliancePolicyAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null } + "CompliancePoliciesV2" = @{ API = "/deviceManagement/compliancePolicies"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.deviceCompliancePolicyAssignment"; HasIntent = $false; NameProp = "name"; SettingsAPI = $null } + "AdministrativeTemplates" = @{ API = "/deviceManagement/groupPolicyConfigurations"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.groupPolicyConfigurationAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null } + "EndpointSecurity" = @{ API = "/deviceManagement/intents"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.deviceManagementIntentAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = "updateSettings" } + "DeviceManagementIntents" = @{ API = "/deviceManagement/intents"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.deviceManagementIntentAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = "updateSettings" } + "AppProtection" = @{ API = "/deviceAppManagement/managedAppPolicies"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.targetedManagedAppPolicyAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null } + "AppConfigurationManagedDevice" = @{ API = "/deviceAppManagement/mobileAppConfigurations"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.managedDeviceMobileAppConfigurationAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null } + "PlatformScripts" = @{ API = "/deviceManagement/deviceManagementScripts"; AssignmentsType = "deviceManagementScriptAssignments"; AssignmentODataType = "#microsoft.graph.deviceManagementScriptAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null } + "MacScripts" = @{ API = "/deviceManagement/deviceShellScripts"; AssignmentsType = "deviceManagementScriptAssignments"; AssignmentODataType = "#microsoft.graph.deviceManagementScriptAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null } + "DeviceHealthScripts" = @{ API = "/deviceManagement/deviceHealthScripts"; AssignmentsType = "deviceHealthScriptAssignments"; AssignmentODataType = "#microsoft.graph.deviceHealthScriptAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null } + "MacCustomAttributes" = @{ API = "/deviceManagement/deviceCustomAttributeShellScripts"; AssignmentsType = "deviceManagementScriptAssignments"; AssignmentODataType = "#microsoft.graph.deviceManagementScriptAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null } + "EnrollmentRestrictions" = @{ API = "/deviceManagement/deviceEnrollmentConfigurations"; AssignmentsType = "enrollmentConfigurationAssignments"; AssignmentODataType = "#microsoft.graph.enrollmentConfigurationAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null } + "EnrollmentStatusPage" = @{ API = "/deviceManagement/deviceEnrollmentConfigurations"; AssignmentsType = "enrollmentConfigurationAssignments"; AssignmentODataType = "#microsoft.graph.enrollmentConfigurationAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null } + "Autopilot" = @{ API = "/deviceManagement/windowsAutopilotDeploymentProfiles"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.windowsAutopilotDeploymentProfileAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null } + "TermsAndConditions" = @{ API = "/deviceManagement/termsAndConditions"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.termsAndConditionsAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null } + "PolicySets" = @{ API = "/deviceAppManagement/policySets"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.policySetAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null } + "UpdatePolicies" = @{ API = "/deviceManagement/windowsUpdateForBusinessConfigurations"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.windowsUpdateForBusinessConfigurationAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null } + "FeatureUpdates" = @{ API = "/deviceManagement/windowsFeatureUpdateProfiles"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.windowsFeatureUpdateProfileAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null } + "QualityUpdates" = @{ API = "/deviceManagement/windowsQualityUpdateProfiles"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.windowsQualityUpdateProfileAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null } + } +} + +function Invoke-SanitizeObject +{ + param($Obj) + $propsToRemove = @("id","createdDateTime","lastModifiedDateTime","source","status","version","isAssigned","publishingState") + foreach($prop in $propsToRemove) + { + if($Obj.PSObject.Properties[$prop]) + { + $Obj.PSObject.Properties.Remove($prop) + } + } + return $Obj +} + +function Invoke-ApplyMutation +{ + param($Obj, $NameProp, [hashtable]$Mutation) + if(-not $Mutation) { return $Obj } + + $search = $Mutation["search"] + $replace = $Mutation["replace"] + $prefix = $Mutation["prefix"] + + foreach($prop in @($NameProp, "description")) + { + if($Obj.PSObject.Properties[$prop] -and $Obj.$prop) + { + $val = $Obj.$prop + if($search -and $replace) + { + $val = $val -replace $search, $replace + } + elseif($prefix) + { + if(-not $val.StartsWith($prefix)) + { + $val = "$prefix$val" + } + } + $Obj.$prop = $val + } + } + return $Obj +} + +function Get-ExistingObject +{ + param($Api, $NameProp, $NameValue) + $escaped = $NameValue -replace "'","''" + $filter = "$NameProp eq '$escaped'" + $url = "$Api`?`$filter=$filter" + try + { + $resp = Invoke-GraphRequest -Url $url + if($resp.value -and $resp.value.Count -gt 0) + { + return $resp.value[0] + } + } + catch + { + # Some APIs don't support $filter; swallow and return null + } + return $null +} + +function New-CloudOnlyGroup +{ + param([string]$DisplayName, [string]$MailNickname, [bool]$SecurityEnabled = $true) + $body = @{ + displayName = $DisplayName + mailEnabled = $false + mailNickname = $MailNickname + securityEnabled = $SecurityEnabled + } | ConvertTo-Json -Depth 5 + return Invoke-GraphRequest -Url "/groups" -HttpMethod POST -Content $body +} + +function Invoke-DeployAssignments +{ + param( + [string]$ObjectId, + [hashtable]$TypeMeta, + [array]$Assignments, + [hashtable]$GroupCache, + [switch]$WhatIf + ) + if(-not $Assignments -or $Assignments.Count -eq 0) { return } + + $assignmentList = @() + foreach($ass in $Assignments) + { + $targetType = $ass["targetType"] + $groupName = $ass["groupName"] + $intent = $ass["intent"] + + $odataType = switch($targetType) + { + "Group" { "#microsoft.graph.groupAssignmentTarget" } + "AllUsers" { "#microsoft.graph.allLicensedUsersAssignmentTarget" } + "AllDevices" { "#microsoft.graph.allDevicesAssignmentTarget" } + "ExcludeGroup" { "#microsoft.graph.exclusionGroupAssignmentTarget" } + default { throw "Unknown targetType: $targetType" } + } + + $targetPayload = @{ + "@odata.type" = $odataType + } + if($targetType -in @("Group","ExcludeGroup")) + { + if(-not $groupName) { throw "groupName is required for targetType $targetType" } + $gid = $GroupCache[$groupName] + if(-not $gid) { throw "Group '$groupName' not found in cache" } + $targetPayload["groupId"] = $gid + } + + $payload = @{ + "@odata.type" = $TypeMeta.AssignmentODataType + target = $targetPayload + } + if($TypeMeta.HasIntent -and $intent) + { + $payload["intent"] = $intent.ToString().ToLower() + } + $assignmentList += $payload + } + + if($assignmentList.Count -eq 0) { return } + + $assignBody = @{ + $TypeMeta.AssignmentsType = $assignmentList + } | ConvertTo-Json -Depth 50 -Compress + + $assignUrl = "$($TypeMeta.API)/$ObjectId/assign" + if($WhatIf) + { + Write-Host " [WHATIF] Would assign $($assignmentList.Count) target(s) to $assignUrl" -ForegroundColor Magenta + return + } + $null = Invoke-GraphRequest -Url $assignUrl -HttpMethod POST -Content $assignBody +} +#endregion + +#region Initialize Runtime +$projectRoot = Split-Path -Parent $PSScriptRoot +$runtimeModule = Join-Path $projectRoot "Runtime/IntuneManagement.Runtime.psd1" +if(-not (Test-Path $runtimeModule)) +{ + throw "Could not find IntuneManagement.Runtime.psd1 in $projectRoot" +} + +$settingsPath = $SettingsFile +if(-not $settingsPath) +{ + $settingsPath = Get-DefaultSettingsPath +} + +# Pre-load auth from settings +if($AuthMode -eq "AppOnly" -and (Test-Path $settingsPath) -and (-not $AppId -or (-not $Secret -and -not $Certificate))) +{ + try + { + $raw = Get-Content -Path $settingsPath -Raw -ErrorAction Stop + $settingsObj = ConvertFrom-Json $raw -AsHashtable -ErrorAction Stop + if($settingsObj -and $settingsObj.ContainsKey($TenantId)) + { + $tenantNode = $settingsObj[$TenantId] + if(-not $AppId -and $tenantNode.ContainsKey("GraphAzureAppId")) + { + $AppId = $tenantNode["GraphAzureAppId"] + } + if(-not $Secret -and $tenantNode.ContainsKey("GraphAzureAppSecret")) + { + $Secret = $tenantNode["GraphAzureAppSecret"] + } + if(-not $Certificate -and $tenantNode.ContainsKey("GraphAzureAppCert")) + { + $Certificate = $tenantNode["GraphAzureAppCert"] + } + } + + if(-not $Secret -and $IsMacOS -and $AppId) + { + try + { + $keychainSecret = security find-generic-password -a "IntuneManagement" -s "IntuneMgmt-$AppId" -w 2>$null + if($keychainSecret) { $Secret = $keychainSecret } + } + catch { } + } + } + catch { } +} + +$invokeParams = @{ + Silent = $true + JSonSettings = $true + JSonFile = $settingsPath + TenantId = $TenantId + AppId = $AppId + AuthMode = $AuthMode +} +if($RedirectUri) { $invokeParams.RedirectUri = $RedirectUri } +if($AuthMode -eq "AppOnly" -and $Secret) { $invokeParams.Secret = $Secret } +elseif($AuthMode -eq "AppOnly") { $invokeParams.Certificate = $Certificate } + +Import-Module $runtimeModule -Force +Initialize-IntuneManagementRuntime -View "IntuneGraphAPI" @invokeParams +#endregion + +#region Ensure Graph connectivity +if(-not (Get-Command Invoke-GraphRequest -ErrorAction SilentlyContinue)) +{ + throw "Graph runtime did not load Invoke-GraphRequest. Aborting." +} + +Write-Host "`nConnecting to Microsoft Graph..." -ForegroundColor Cyan +try +{ + $org = Invoke-GraphRequest "/organization" + Write-Host "Connected to tenant: $($org.value[0].displayName) ($($org.value[0].id))" -ForegroundColor Green +} +catch +{ + throw "Failed to connect to Graph. Ensure auth parameters are correct. Error: $_" +} +#endregion + +#region Dependency check +if(-not (Test-YamlModule)) +{ + Install-YamlModule +} +Import-Module powershell-yaml -Force +#endregion + +#region Load and validate baseline +$baselinePathResolved = Resolve-Path $BaselinePath | Select-Object -ExpandProperty Path +if(-not (Test-Path $baselinePathResolved)) +{ + throw "Baseline file not found: $BaselinePath" +} + +Write-Host "`nLoading baseline: $baselinePathResolved" -ForegroundColor Cyan +$yamlText = Get-Content $baselinePathResolved -Raw +$yamlRoot = ConvertFrom-Yaml -Yaml $yamlText + +if(-not $yamlRoot -or -not $yamlRoot.ContainsKey("baseline")) +{ + throw "Invalid baseline YAML: missing 'baseline' root node." +} +$baseline = $yamlRoot["baseline"] + +$conflictResolution = if($baseline.ContainsKey("conflictResolution")) { $baseline["conflictResolution"] } else { "Skip" } +$baselineWhatIf = if($baseline.ContainsKey("whatIf")) { [bool]$baseline["whatIf"] } else { $false } +$effectiveWhatIf = $WhatIf.IsPresent -or $baselineWhatIf + +$globalMutation = $null +if($baseline.ContainsKey("tenantMutation")) +{ + $globalMutation = $baseline["tenantMutation"] +} + +Write-Host "Baseline name : $($baseline["name"])" -ForegroundColor Cyan +Write-Host "Conflict mode : $conflictResolution" -ForegroundColor Cyan +if($effectiveWhatIf) { Write-Host "*** DRY-RUN MODE ENABLED ***" -ForegroundColor Magenta } +#endregion + +#region Resolve / create groups +$groupCache = @{} +if($baseline.ContainsKey("groups") -and $baseline["groups"]) +{ + Write-Host "`nResolving groups..." -ForegroundColor Cyan + $existingGroupsResp = Invoke-GraphRequest "/groups?`$select=id,displayName&`$orderby=displayName&`$top=999" + $existingGroups = $existingGroupsResp.value + + foreach($grpDef in $baseline["groups"]) + { + $displayName = $grpDef["displayName"] + $existing = $existingGroups | Where-Object { $_.displayName -eq $displayName } | Select-Object -First 1 + if($existing) + { + Write-Host " Group exists: $displayName ($($existing.id))" -ForegroundColor Green + $groupCache[$displayName] = $existing.id + } + else + { + $mailNick = $grpDef["mailNickname"] + $secEnabled = if($grpDef.ContainsKey("securityEnabled")) { [bool]$grpDef["securityEnabled"] } else { $true } + if($effectiveWhatIf) + { + Write-Host " [WHATIF] Would create group: $displayName" -ForegroundColor Magenta + $groupCache[$displayName] = "WHATIF-$displayName" + } + else + { + Write-Host " Creating group: $displayName" -ForegroundColor Yellow + $newGrp = New-CloudOnlyGroup -DisplayName $displayName -MailNickname $mailNick -SecurityEnabled $secEnabled + $groupCache[$displayName] = $newGrp.id + Write-Host " Created: $($newGrp.id)" -ForegroundColor Green + } + } + } +} +#endregion + +#region Pre-load all existing groups for assignment resolution +Write-Host "`nPre-loading group directory..." -ForegroundColor Cyan +$allGroupsResp = Invoke-GraphRequest "/groups?`$select=id,displayName&`$orderby=displayName&`$top=999" +foreach($g in $allGroupsResp.value) +{ + if(-not $groupCache.ContainsKey($g.displayName)) + { + $groupCache[$g.displayName] = $g.id + } +} +#endregion + +#region Process policies +$typeMap = Get-BaselineTypeMap +$stats = @{ + Created = 0 + Updated = 0 + Skipped = 0 + Failed = 0 + Assigned = 0 +} + +if($baseline.ContainsKey("policies") -and $baseline["policies"]) +{ + $policies = $baseline["policies"] + Write-Host "`nDeploying $($policies.Count) policy(ies)..." -ForegroundColor Cyan + + foreach($policyDef in $policies) + { + $sourcePath = Resolve-RelativePath -Path $policyDef["sourcePath"] -BasePath $baselinePathResolved + $typeName = $policyDef["type"] + + if(-not $typeMap.ContainsKey($typeName)) + { + Write-Warning "Unknown policy type '$typeName'. Skipping." + $stats.Failed++ + continue + } + $typeMeta = $typeMap[$typeName] + + if(-not (Test-Path $sourcePath)) + { + Write-Warning "Policy file not found: $sourcePath" + $stats.Failed++ + continue + } + + try + { + $jsonRaw = Get-Content $sourcePath -Raw + $policyObj = $jsonRaw | ConvertFrom-Json -Depth 100 + + # Sanitize + $policyObj = Invoke-SanitizeObject -Obj $policyObj + + # Mutate + $mutation = $globalMutation + if($policyDef.ContainsKey("mutation")) + { + $mutation = $policyDef["mutation"] + } + $policyObj = Invoke-ApplyMutation -Obj $policyObj -NameProp $typeMeta.NameProp -Mutation $mutation + $mutatedName = $policyObj.($typeMeta.NameProp) + + Write-Host "`nPolicy: $mutatedName [$typeName]" -ForegroundColor Cyan + + # Idempotency check + $existingObj = Get-ExistingObject -Api $typeMeta.API -NameProp $typeMeta.NameProp -NameValue $mutatedName + $objectId = $null + $shouldAssign = $false + + if($existingObj) + { + Write-Host " Existing object found: $($existingObj.id)" -ForegroundColor Yellow + if($conflictResolution -eq "Error") + { + throw "Conflict: object '$mutatedName' already exists and conflictResolution is Error." + } + elseif($conflictResolution -eq "Skip") + { + Write-Host " Skipping import (Skip mode)." -ForegroundColor Yellow + $objectId = $existingObj.id + $shouldAssign = $true # still apply assignments to existing object + $stats.Skipped++ + } + elseif($conflictResolution -eq "Update") + { + if($effectiveWhatIf) + { + Write-Host " [WHATIF] Would PATCH existing object $($existingObj.id)" -ForegroundColor Magenta + } + else + { + $patchBody = $policyObj | Select-Object * | ConvertTo-Json -Depth 50 + $null = Invoke-GraphRequest -Url "$($typeMeta.API)/$($existingObj.id)" -HttpMethod PATCH -Content $patchBody + Write-Host " Updated existing object." -ForegroundColor Green + } + $objectId = $existingObj.id + $shouldAssign = $true + $stats.Updated++ + } + } + else + { + if($effectiveWhatIf) + { + Write-Host " [WHATIF] Would POST new object to $($typeMeta.API)" -ForegroundColor Magenta + $objectId = "WHATIF-NEW" + $shouldAssign = $true + $stats.Created++ + } + else + { + $postBody = $policyObj | ConvertTo-Json -Depth 50 + $newObj = Invoke-GraphRequest -Url $typeMeta.API -HttpMethod POST -Content $postBody + $objectId = $newObj.id + Write-Host " Created: $objectId" -ForegroundColor Green + $shouldAssign = $true + $stats.Created++ + + # Secondary settings upload (EndpointSecurity / DeviceManagementIntents) + if($typeMeta.SettingsAPI) + { + $baseName = [System.IO.Path]::GetFileNameWithoutExtension($sourcePath) + $settingsPathCandidate = Join-Path (Split-Path -Parent $sourcePath) "$baseName`_Settings.json" + if(Test-Path $settingsPathCandidate) + { + $settingsRaw = Get-Content $settingsPathCandidate -Raw + $settingsJson = $settingsRaw | ConvertFrom-Json + # The toolkit exports settings as { "settings": [...] } + $settingsBody = $settingsJson | ConvertTo-Json -Depth 50 + $settingsUrl = "$($typeMeta.API)/$objectId/$($typeMeta.SettingsAPI)" + Write-Host " Uploading settings from $settingsPathCandidate" -ForegroundColor Cyan + $null = Invoke-GraphRequest -Url $settingsUrl -HttpMethod POST -Content $settingsBody + } + } + } + } + + # Assignments + if($shouldAssign -and $policyDef.ContainsKey("assignments")) + { + Invoke-DeployAssignments -ObjectId $objectId -TypeMeta $typeMeta -Assignments $policyDef["assignments"] -GroupCache $groupCache -WhatIf:$effectiveWhatIf + $stats.Assigned++ + } + } + catch + { + Write-Warning "Failed to deploy policy '$sourcePath': $_" + $stats.Failed++ + } + } +} +#endregion + +#region Summary +Write-Host "`n========================================" -ForegroundColor Cyan +Write-Host "Baseline deployment summary" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "Created : $($stats.Created)" +Write-Host "Updated : $($stats.Updated)" +Write-Host "Skipped : $($stats.Skipped)" +Write-Host "Assigned: $($stats.Assigned)" +Write-Host "Failed : $($stats.Failed)" +if($effectiveWhatIf) +{ + Write-Host "`n*** This was a dry-run (WhatIf). No changes were made. ***" -ForegroundColor Magenta +} +#endregion