#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