diff --git a/CS/TokenCacheHelperEx.cs b/CS/TokenCacheHelperEx.cs new file mode 100644 index 0000000..4dc2837 --- /dev/null +++ b/CS/TokenCacheHelperEx.cs @@ -0,0 +1,57 @@ +// Updated original code from +// Added support for custom file location +// https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-net-token-cache-serialization#simple-token-cache-serialization-msal-only + +using System; +using System.IO; +using System.Security.Cryptography; +using Microsoft.Identity.Client; + +public static class TokenCacheHelperEx +{ + public static void EnableSerialization(ITokenCache tokenCache, String fileName = @"%LOCALAPPDATA%\GraphPowerShellManager\MSALToken.bin") + { + tokenCache.SetBeforeAccess(BeforeAccessNotification); + tokenCache.SetAfterAccess(AfterAccessNotification); + + CacheFilePath = Environment.ExpandEnvironmentVariables(fileName); + } + + /// + /// Path to the token cache + /// + + public static string CacheFilePath { get; private set;} + + private static readonly object FileLock = new object(); + + private static void BeforeAccessNotification(TokenCacheNotificationArgs args) + { + lock (FileLock) + { + args.TokenCache.DeserializeMsalV3(File.Exists(CacheFilePath) + ? ProtectedData.Unprotect(File.ReadAllBytes(CacheFilePath), + null, + DataProtectionScope.CurrentUser) + : null); + } + } + + private static void AfterAccessNotification(TokenCacheNotificationArgs args) + { + // if the access operation resulted in a cache update + if (args.HasStateChanged) + { + lock (FileLock) + { + Directory.CreateDirectory(Path.GetDirectoryName(CacheFilePath)); + // reflect changes in the persistent store + File.WriteAllBytes(CacheFilePath, + ProtectedData.Protect(args.TokenCache.SerializeMsalV3(), + null, + DataProtectionScope.CurrentUser) + ); + } + } + } +} \ No newline at end of file diff --git a/CloudAPIPowerShellManagement.psd1 b/CloudAPIPowerShellManagement.psd1 new file mode 100644 index 0000000..3ca7eb2 Binary files /dev/null and b/CloudAPIPowerShellManagement.psd1 differ diff --git a/CloudAPIPowerShellManagement.psm1 b/CloudAPIPowerShellManagement.psm1 new file mode 100644 index 0000000..fe812a9 --- /dev/null +++ b/CloudAPIPowerShellManagement.psm1 @@ -0,0 +1,108 @@ +#region Console functions + +# https://stackoverflow.com/questions/40617800/opening-powershell-script-and-hide-command-prompt-but-not-the-gui +Add-Type -Name Window -Namespace Console -MemberDefinition ' +[DllImport("Kernel32.dll")] +public static extern IntPtr GetConsoleWindow(); + +[DllImport("user32.dll")] +public static extern bool ShowWindow(IntPtr hWnd, Int32 nCmdShow); + +[DllImport("kernel32.dll", SetLastError = true)] +public static extern bool SetConsoleIcon(IntPtr hIcon); + +[DllImport("user32.dll")] +public static extern int SendMessage(int hWnd, uint wMsg, uint wParam, IntPtr lParam); +' + +function Show-Console +{ + $consolePtr = [Console.Window]::GetConsoleWindow() + + # Hide = 0, + # ShowNormal = 1, + # ShowMinimized = 2, + # ShowMaximized = 3, + # Maximize = 3, + # ShowNormalNoActivate = 4, + # Show = 5, + # Minimize = 6, + # ShowMinNoActivate = 7, + # ShowNoActivate = 8, + # Restore = 9, + # ShowDefault = 10, + # ForceMinimized = 11 + + [Console.Window]::ShowWindow($consolePtr, 4) +} + +function Hide-Console +{ + $consolePtr = [Console.Window]::GetConsoleWindow() + #0 hide + [Console.Window]::ShowWindow($consolePtr, 0) | Out-Null +} + +#endregion + +# Unblock all files +# Not 100% OK but avoid issues with loading blocked files +function Unblock-AllFiles +{ + param($folder) + + (Get-ChildItem $folder -force | Where-Object {! $_.PSIsContainer}) | Unblock-File + + foreach($subFolder in (Get-ChildItem $folder -force | Where-Object {$_.PSIsContainer})) + { + Unblock-AllFiles $subFolder.FullName + } +} + +function Initialize-CloudAPIManagement +{ + [CmdletBinding(SupportsShouldProcess=$True)] + param( + [string] + $View = "", + [switch] + $ShowConsoleWindow + ) + + $global:wpfNS = "xmlns='http://schemas.microsoft.com/winfx/2006/xaml/presentation' xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'" + + [void] [System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms") + Add-Type -AssemblyName PresentationFramework + + try + { + [xml]$xaml = Get-Content ([IO.Path]::GetDirectoryName($PSCommandPath) + "\Xaml\SplashScreen.xaml") + $global:SplashScreen = ([Windows.Markup.XamlReader]::Load((New-Object System.Xml.XmlNodeReader $xaml))) + $global:txtSplashTitle = $global:SplashScreen.FindName("txtSplashTitle") + $global:txtSplashText = $global:SplashScreen.FindName("txtSplashText") + + $global:txtSplashTitle.Text = ("Initializing Cloud API PowerShell Management") + + $global:SplashScreen.Show() | Out-Null + [System.Windows.Forms.Application]::DoEvents() + } + catch + { + + } + + if($ShowConsoleWindow -ne $true) + { + Hide-Console + } + + $global:txtSplashText.Text = "Unblock files" + [System.Windows.Forms.Application]::DoEvents() + Unblock-AllFiles $PSScriptRoot + + $global:txtSplashText.Text = "Load core module" + [System.Windows.Forms.Application]::DoEvents() + Import-Module ($PSScriptRoot + "\Core.psm1") -Force -Global + + Start-CoreApp $View +} diff --git a/Core.psm1 b/Core.psm1 new file mode 100644 index 0000000..fd2a093 --- /dev/null +++ b/Core.psm1 @@ -0,0 +1,1435 @@ +<# +.SYNOPSIS +Core UI and Settings fatures for the CloudAPIPowerShellManager solution + +.DESCRIPTION +This module handles the WPF UI + +.NOTES + Version: 3.0.0 + Author: Mikael Karlsson +#> + +function Get-ModuleVersion +{ + '3.0.0' +} + +function Start-CoreApp +{ + param($View) + + if(-not $global:defaultGlobalVariables) + { + $global:defaultGlobalVariables = Get-Variable -Scope Global + } + + $global:useDefaultFolderDialog = $false + $global:WindowsAPICodePackLoaded = $false + + $global:loadedModules = @() + $global:viewObjects = @() + + $global:AppRootFolder = $PSScriptRoot + + # Load all modules in the Modules folder + $global:modulesPath = [IO.Path]::GetDirectoryName($PSCommandPath) + "\Extensions" + + #Import-Module ($PSScriptRoot + "\Core.psm1") -Force -Global + + Add-DefaultSettings + + Write-Log "#####################################################################################" + Write-Log "Application started" + Write-Log "#####################################################################################" + + if(Test-Path $global:modulesPath) + { + Import-AllModules + } + else + { + Write-Warning "Extensions folder $($global:modulesPath) not found. Aborting..." 3 + exit 1 + } + + $global:Debug = Get-SettingValue "Debug" + $global:currentViewObject = $null + $global:FirstTimeRunning = ((Get-Setting "" "FirstTimeRunning" "true") -eq "true") + $global:MainAppStarted = $false + + $global:txtSplashText.Text = "Initialize views" + [System.Windows.Forms.Application]::DoEvents() + + Invoke-ModuleFunction "Invoke-InitializeModule" + + #This will load the main window + $global:txtSplashText.Text = "Load main window" + [System.Windows.Forms.Application]::DoEvents() + Get-MainWindow + + if($global:window) + { + $global:txtSplashText.Text = "Open default view" + [System.Windows.Forms.Application]::DoEvents() + + Show-View $View + + Invoke-ModuleFunction "Invoke-ShowMainWindow" + + $global:txtSplashText.Text = "Open main window" + [System.Windows.Forms.Application]::DoEvents() + $global:window.ShowDialog() | Out-Null + } +} + +function Import-AllModules +{ + foreach($file in (Get-Item -path "$($global:modulesPath)\*.psm1")) + { + $fileName = [IO.Path]::GetFileName($file) + if($skipModules -contains $fileName) { Write-Warning "Module $fileName excluded"; continue; } + + $global:txtSplashText.Text = "Import module $fileName" + [System.Windows.Forms.Application]::DoEvents() + + $module = Import-Module $file -PassThru -Force -Global -ErrorAction SilentlyContinue + if($module) + { + $global:loadedModules += $module + Write-Host "Module $($module.Name) loaded successfully" + } + else + { + Write-Warning "Failed to load module $file" + } + } +} + +#region Log functions +function Write-Log +{ + param($Text, $type = 1) + + if($script:logFailed -eq $true) { return } + + if(-not $global:logFile) { $global:logFile = Get-SettingValue "LogFile" ([IO.Path]::Combine($global:AppRootFolder,"CloudAPIPowerShellManagement.log")) } + + if(-not $global:logFileMaxSize) { [Int64]$global:logFileMaxSize = Get-SettingValue "LogFileSize" 1024; $global:logFileMaxSize = $global:logFileMaxSize * 1kb } + + $fi = [IO.FileInfo]$global:logFile + + if($fi.Length -gt $global:logFileMaxSize) + { + # Larger than max size. Rename current to .bak + # Delete current .bak if it exists + $bakFile = ($fi.DirectoryName + "\" + $fi.BaseName + ".lo_") + if([IO.File]::Exists($bakFile)) + { + try + { + [IO.File]::Delete($bakFile) + } + catch { } + } + try + { + $fi.MoveTo($bakFile) + } + catch { } + } + + try + { + $logPath = [IO.Path]::GetDirectoryName($global:logFile) + if(-not (Test-Path $logPath)) { mkdir -Path $logPath -Force -ErrorAction SilentlyContinue | Out-Null } + } + catch + { + $script:logFailed = $true + return + } + + $date = Get-Date + + if($global:PSCommandPath) + { + $fileObj = [System.IO.FileInfo]$global:PSCommandPath + } + else + { + $fileObj = [System.IO.FileInfo]$PSCommandPath + } + + $timeStr = "$($date.ToString(""HH"")):$($date.ToString(""mm"")):$($date.ToString(""ss"")).000+000" + $dateStr = "$($date.ToString(""MM""))-$($date.ToString(""dd""))-$($date.ToString(""yyyy""))" + $logOut = "" + + if($type -eq 2) + { + Write-Warning $Text + } + elseif($type -eq 3) + { + $host.ui.WriteErrorLine($Text) + } + else + { + write-host $Text + } + + try + { + out-file -filePath $global:logFile -append -encoding "ASCII" -inputObject $logOut + } + catch { } +} + +function Write-LogDebug +{ + param($Text, $type = 1) + + if($global:Debug) + { + Write-Log ("Debug: " + $text) $type + } +} + +function Write-LogError +{ + param($Text, $Exception) + + if($Text -and $Exception.message) + { + $Text += " Exception: $($Exception.Message)" + } + + Write-Log $Text 3 +} + +function Write-Status +{ + param($Text, [switch]$SkipLog, [switch]$Block, [switch]$Force) + + if(-not $text) { $global:BlockStatusUpdates = $false } + elseif($global:BlockStatusUpdates -eq $true -and $Force -ne $true) { return } + elseif($Block -eq $true) { $global:BlockStatusUpdates = $true } + + $global:txtInfo.Content = $Text + if($text) + { + $global:grdStatus.Visibility = "Visible" + if($SkipLog -ne $true) { Write-Log $text } + } + else + { + $global:grdStatus.Visibility = "Collapsed" + } + + [System.Windows.Forms.Application]::DoEvents() +} + +#endregion + +#region Popup +function Show-Popup +{ + param($popup) + + if(-not $global:grdPopup -or -not $global:cvsPopup) { return } + + $global:cvsPopup.AddChild($popup) | Out-Null + $global:grdPopup.Visibility = "Visible" + + [System.Windows.Forms.Application]::DoEvents() +} + +function Hide-Popup +{ + if(-not $global:grdPopup -or -not $global:cvsPopup) { return } + $global:cvsPopup.Children.Clear() + $global:grdPopup.Visibility = "Collapsed" + [System.Windows.Forms.Application]::DoEvents() +} +#endregion + +#region Xaml functions + +function Set-XamlProperty +{ + param($xamlObj, $controlName, $propertyName, $value) + + $obj = $xamlObj.FindName($controlName) + + try + { + if($obj) + { + $obj."$propertyName" = $value + } + else + { + Write-Log "Could not find object with name $controlName" 3 + } + } + catch + { + Write-LogError "Failed to set Xaml property value. Control: $controlName. Property: $propertyName. Error:" $_.Exception + } +} + +function Get-XamlProperty +{ + param($xamlObj, $controlName, $propertyName, $defaultValue) + + $obj = $xamlObj.FindName($controlName) + + try + { + if($obj) + { + return (?? $obj."$propertyName" $null) + } + else + { + Write-Log "Could not find object with name $controlName" 3 + } + } + catch + { + Write-LogError "Failed to set Xaml property value. Control: $controlName. Property: $propertyName. Error:" $_.Exception + } +} + +function Add-XamlEvent +{ + param($xamlObj, $controlName, $eventName, $scriptBlock) + + try { + $obj = $xamlObj.FindName($controlName) + if($obj) + { + $obj."$eventName"($scriptBlock) + } + else + { + Write-Log "Failed to add Xaml event $eventName to $controlName. Control not found" 3 + } + } + catch + { + Write-LogError "Failed to add Xaml event $eventName to $controlName. Error:" $_.Exception + } +} + +function Add-XamlVariables +{ + param($xaml, $obj) + + # Generate a global variable for each object with Name property set + # Ref: https://learn-powershell.net/2014/08/10/powershell-and-wpf-radio-button/ + $xaml.SelectNodes("//*[@*[contains(translate(name(.),'n','N'),'Name')]]") | ForEach-Object { + Write-LogDebug "Add global variable $($_.Name)" + New-Variable -Name $_.Name -Value $obj.FindName($_.Name) -Force -Scope Global + } +} + +function Get-XamlObject +{ + param($fileName, [switch]$AddVariables) + + if(([IO.File]::Exists($fileName))) + { + try + { + [xml]$xaml = Get-Content $fileName + + $xamlObj = ([Windows.Markup.XamlReader]::Load((New-Object System.Xml.XmlNodeReader $xaml))) + + if($xamlObj -and $AddVariables -eq $true) + { + Add-XamlVariables $xaml $xamlObj + } + return $xamlObj + } + catch + { + Write-LogError "Failed to load Xaml file $fileName. Error:" $_.Exception + } + } + else + { + Write-Log "Failed to open Xaml file. File not found: $fileName" + } +} + +#endregion + +#region Dialogs + +function Show-AboutDialog +{ + $script:dlgAbout = Get-XamlObject ($global:AppRootFolder + "\Xaml\AboutDialog.xaml") + if(-not $script:dlgAbout) { return } + + $loadedItems = @() + $externalModules = @("MSAL.PS","Az.Account") + $externalAssemblies = @("Microsoft.Identity.Client.dll") + + foreach($module in (((Get-Module | Where-Object { $_.ModuleBase -like "$($global:AppRootFolder)*" -or $_.Name -in $externalModules })))) + { + $ver = $module.Version + if($module.Version.Major -eq 0 -and $module.Version.Minor -eq 0) + { + $cmd = $module.ExportedFunctions["Get-ModuleVersion"] + if($cmd) + { + $tmpVer = Invoke-Command -ScriptBlock $cmd.ScriptBlock + $ver = ?? $tmpVer $ver + } + } + + $loadedItems += (New-Object PSObject -Property @{ + Name = $module.Name + Version = $ver + Type = "PSModule" + }) + } + + $assms = [System.AppDomain]::CurrentDomain.GetAssemblies() | Where { $_.GlobalAssemblyCache -eq $false -and [String]::IsNullOrEmpty($_.Location) -eq $false } + foreach($assmName in $externalAssemblies) + { + $assmObjs = $assms | Where { $_.Location -like "*\$($assmName)" } + foreach($assmObj in $assmObjs) + { + try + { + $fi = [IO.FileInfo]"$($assmObj.Location)" + $loadedItems += (New-Object PSObject -Property @{ + Name = $fi.Name + Version = $fi.VersionInfo.FileVersion + Type = "Assembly" + }) + } + catch {} + } + } + + Set-XamlProperty $script:dlgAbout "txtTitle" "Text" "CloudAPIPowerShellManagement" + Set-XamlProperty $script:dlgAbout "txtViewTitle" "Text" ("Current view: " + $global:currentViewObject.ViewInfo.Title) + if($global:currentViewObject.ViewInfo.Description) + { + Set-XamlProperty $script:dlgAbout "txtViewDescription" "Text" $global:currentViewObject.ViewInfo.Description + } + + Set-XamlProperty $script:dlgAbout "lstModules" "ItemsSource" $loadedItems + + Add-XamlEvent $script:dlgAbout "linkSource" "Add_RequestNavigate" ({ [System.Diagnostics.Process]::Start($_.Uri.AbsoluteUri); $_.Handled = $true }) + + Show-ModalForm "About" $script:dlgAbout +} + +function Show-InputDialog +{ + param( + $FormTitle = "Input", + $FormText, + $DefaultValue) + + $script:inputBox = Get-XamlObject ($global:AppRootFolder + "\Xaml\InputDialog.xaml") + if(-not $script:inputBox) { return } + + $script:inputBox.Title = $FormTitle + + Set-XamlProperty $script:inputBox "txtLabel" "Content" $FormText + Set-XamlProperty $script:inputBox "txtValue" "Text" $DefaultValue + + $script:txtValue = $script:inputBox.FindName("txtValue") + + Add-XamlEvent $script:inputBox "btnOk" "Add_Click" ({ $script:inputBox.Close() }) + Add-XamlEvent $script:inputBox "btnCancel" "Add_Click" ({ $script:txtValue.Text ="";$script:inputBox.Close() }) + + $inputBox.Add_ContentRendered({ + $script:txtValue.SelectAll(); + $script:txtValue.Focus(); + }) + + $inputBox.ShowDialog() | Out-null + + return $script:txtValue.Text +} + +function Show-ModalForm +{ + param( + $FormTitle = "", + $formObject, + [switch]$HideButtons) + + $xamlStr = Get-Content ($global:AppRootFolder + "\Xaml\ModalForm.xaml") + + $modalForm = [Windows.Markup.XamlReader]::Parse($xamlStr) + + if($HideButtons -eq $true) + { + Set-XamlProperty $modalForm "spButtons" "Visibility" "Collapsed" + } + else + { + Add-XamlEvent $modalForm "btnClose" "Add_Click" ({ + Show-ModalObject + }) + } + + Set-XamlProperty $modalForm "txtTitle" "Text" $FormTitle + + $grdModalContainer = $modalForm.FindName("grdModalContainer") + if($grdModalContainer -and $formObject) + { + $formObject.SetValue([System.Windows.Controls.Grid]::RowProperty,1) + $grdModalContainer.Children.Add($formObject) | Out-Null + } + Show-ModalObject $modalForm +} +function Show-ModalObject +{ + param( $obj ) + + if($obj) + { + $obj.SetValue([System.Windows.Controls.Grid]::RowProperty,1) + $obj.SetValue([System.Windows.Controls.Grid]::ColumnProperty,1) + $global:grdModal.Children.Add($obj) | Out-Null + $global:grdModal.Visibility = "Visible" + } + else + { + $global:grdModal.Children.Clear() + $global:grdModal.Visibility = "Collapsed" + } + + [System.Windows.Forms.Application]::DoEvents() +} +#endregion + +#region Controls +function Show-AuthenticationInfo +{ + if($global:grdMenu) + { + $global:txtSplashText.Text = "Get profile picture" + [System.Windows.Forms.Application]::DoEvents() + + $authenticationProvider = $global:currentViewObject.ViewInfo.Authentication + if($global:grdMenu.Children[-1].Tag -eq "ProfilePicture") + { + $global:grdMenu.Children.Remove($global:grdMenu.Children[-1]) + } + + if($authenticationProvider.ProfilePicture) + { + $profileObj = & $authenticationProvider.ProfilePicture -Size 24 -Fontsize 12 -Popup -AuthenticationProvider $authenticationProvider + if($profileObj) + { + $profileObj.Tag = "ProfilePicture" + $profileObj.SetValue([System.Windows.Controls.Grid]::ColumnProperty,1) | Out-Null + $global:grdMenu.Children.Add($profileObj) | Out-Null + } + } + [System.Windows.Forms.Application]::DoEvents() + } +} +#endregion + +#region Generic functions +function Invoke-Coalesce ($value, $default) +{ + # Use IsNullOrEmpty instead of -not + if ([String]::IsNullOrEmpty($value)) { $value = $default } + + return $value +} + +function Invoke-IfTrue ($expression, $valueIfTrue, $valueIfFalse) +{ + if ($expression) { return $valueIfTrue } + else { return $valueIfFalse } +} + +function Set-ObjectGrid +{ + param( $obj ) + + if($obj) + { + $global:grdObject.Children.Add($obj) + $global:grdObject.Visibility = "Visible" + } + else + { + $global:grdObject.Children.Clear() + $global:grdObject.Visibility = "Collapsed" + } + + [System.Windows.Forms.Application]::DoEvents() +} + +function Remove-InvalidFileNameChars +{ + param($Name) + + $re = "[{0}]" -f [RegEx]::Escape(([IO.Path]::GetInvalidFileNameChars() -join '')) + + $Name = $Name -replace $re + $Name = $Name -replace "[]]", "" + $Name = $Name -replace "[[]", "" + + return $Name +} + +function Remove-ObjectProperty +{ + param($obj, $property) + + if(-not $obj -or -not $property) { return } + + if(($obj | Get-Member -MemberType NoteProperty -Name $property)) + { + $obj.PSObject.Properties.Remove($property) + } +} + +function Get-Folder +{ + param($path = $env:temp, $title = "Select a directory") + + if($global:useDefaultFolderDialog -ne $true) + { + try + { + if($global:WindowsAPICodePackLoaded -eq $false) + { + $apiCodec = Join-Path $global:AppRootFolder "Microsoft.WindowsAPICodePack.Shell.dll" + if([IO.File]::Exists($apiCodec)) + { + Add-Type -Path $apiCodec | Out-Null + $global:WindowsAPICodePackLoaded = $true + } + else + { + Write-Log "Could not find Microsoft.WindowsAPICodePack.Shell.dll" 2 + } + } + $dlgCOFD = New-Object Microsoft.WindowsAPICodePack.Dialogs.CommonOpenFileDialog + } + catch + { + Write-LogError "Failed to load Microsoft.WindowsAPICodePack.Shell.dll. Verify that the .Net 3.5 feature is enabled" $_.Exception + } + } + + if($dlgCOFD -and $global:useDefaultFolderDialog -ne $true) + { + $dlgCOFD.EnsureReadOnly = $true + $dlgCOFD.IsFolderPicker = $true + $dlgCOFD.AllowNonFileSystemItems = $false + $dlgCOFD.Multiselect = $false + $dlgCOFD.Title = $title + + if($path -and (Test-Path $path)) + { + $dlgCOFD.InitialDirectory = $path + } + if($dlgCOFD.ShowDialog($window) = [Microsoft.WindowsAPICodePack.Dialogs.CommonFileDialogResult]::Ok) + { + $dlgCofd.FileName + } + } + else + { + $global:useDefaultFolderDialog = $true + [Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms") | Out-Null + [System.Windows.Forms.Application]::EnableVisualStyles() + $dlgFBD = New-Object System.Windows.Forms.FolderBrowserDialog + $dlgFBD.SelectedPath = "C:\" + $dlgFBD.ShowNewFolderButton = $false + $dlgFBD.Description = $title + if($dlgFBD.ShowDialog() -eq "OK") + { + $dlgFBD.SelectedPath + } + $dlgFBD.Dispose() + } +} +function Remove-Property +{ + param($obj, $prop) + if(($obj | GM -MemberType NoteProperty -Name $prop)) + { + Write-LogDebug "Remove property $prop" + $obj.PSObject.Properties.Remove($prop) | Out-Null + } +} + +#endregion + +#region Reg functions +######################################################################## +# +# Reg functions +# +######################################################################## + +function Save-Setting +{ + param($SubPath, $Key, $Value, $Type = "String") + + $regPath = Get-RegPath $SubPath + if((Test-Path $regPath) -eq $false) + { + New-Item (Get-RegPath $SubPath) -ErrorAction SilentlyContinue + } + New-ItemProperty -Path $regPath -Name $Key -Value $Value -Type $Type -Force | Out-Null +} + +function Get-Setting +{ + param($SubPath, $Key, $defautValue) + + try + { + $val = Get-ItemPropertyValue -Path (Get-RegPath $SubPath) -Name $Key -ErrorAction SilentlyContinue + } + catch { } + if(-not $val) + { + $defautValue + } + else + { + $val + } +} + +function Get-RegPath +{ + param($SubPath) + + $path = "HKCU:\Software\CloudAPIPowerShellManagement" + if($SubPath) + { + $path = $path + "\" + $SubPath + } + + $path +} +#endregion + +#region Setting functions + +######################################################################## +# +# Settings functions +# +######################################################################## + +function Add-SettingsItem +{ + param($settingItem, $title, $description) + + $rd = [System.Windows.Controls.RowDefinition]::new() + $rd.Height = [double]::NaN + $spSettings.RowDefinitions.Add($rd) + $settingItem.SetValue([System.Windows.Controls.Grid]::RowProperty,$spSettings.RowDefinitions.Count-1) + + if(-not $title) + { + $settingItem.SetValue([System.Windows.Controls.Grid]::ColumnSpanProperty, 2) + } + else + { + if($description) + { + $descriptionInfo = "" + } + + $xaml = @" + + + $descriptionInfo + +"@ + $settingsTitle = [Windows.Markup.XamlReader]::Parse($xaml) + $settingsTitle.SetValue([System.Windows.Controls.Grid]::RowProperty,$spSettings.RowDefinitions.Count-1) + $settingItem.SetValue([System.Windows.Controls.Grid]::ColumnProperty, 1) + $spSettings.AddChild($settingsTitle) + $settingItem.Margin = "0,5,0,0" + } + $spSettings.AddChild($settingItem) +} + +function Add-SettingTextBox +{ + param($id, $value) + + $xaml = @" +$value +"@ + return [Windows.Markup.XamlReader]::Parse($xaml) +} + +function Add-SettingCheckBox +{ + param($id, $value) + + $tmpValue = ($value -eq $true -or $value -eq "true").ToString().ToLower() + + $xaml = @" + +"@ + return [Windows.Markup.XamlReader]::Parse($xaml) +} + +function Add-SettingComboBox +{ + param($id, $value, $itemsData) + + $xaml = @" + +"@ + $xamlObj = [Windows.Markup.XamlReader]::Parse($xaml) + + $xamlObj.ItemsSource = $itemsData + if($value) + { + $xamlObj.SelectedValue = $value + } + + $xamlObj +} + +function Add-SettingFolder +{ + param($id, $value) + $xaml = @" + + + + + + + $value + + + +"@ + + $obj = [Windows.Markup.XamlReader]::Parse($xaml) + + $btnBrowse = $obj.FindName("browse_$($id)") + $txtObj = $obj.FindName($id) + if($btnBrowse) + { + $btnBrowse.Tag = $txtObj + $btnBrowse.Add_Click({ + $folder = Get-Folder $this.Tag.Text + if($folder) { $this.Tag.Text = $folder } + }) + } + return $obj +} + +function Add-SettingValue +{ + param($settingValue) + + $id = "id_" + [Guid]::NewGuid().ToString('n') + + $value = Get-SettingValue $settingValue.Key + + if($settingValue.Type -eq "folder") + { + $settingObj = Add-SettingFolder $id $value + } + elseif($settingValue.Type -eq "Boolean") + { + $settingObj = Add-SettingCheckBox $id $value + } + elseif($settingValue.Type -eq "List") + { + $settingObj = Add-SettingComboBox $id $value $settingValue.ItemsSource + } + else + { + $settingObj = Add-SettingTextBox $id $value + } + + if($settingObj) + { + Add-SettingsItem $settingObj $settingValue.Title $settingValue.Description + # Find the control in the setting object that contains the actual value + # $settingObj might be a grid that contains the TextBox with the settings value + $ctrl = $settingObj.FindName($id) + if(($settingValue | Get-Member -MemberType NoteProperty -Name "Control")) + { + $settingValue.Control = $ctrl + } + else + { + $settingValue | Add-Member -MemberType NoteProperty -Name "Control" -Value $ctrl + } + } +} + +function Add-SettingTitle +{ + param($title, $marginTop = "0") + + $xaml = @" + +"@ + + #$global:spSettings.Children.Add([Windows.Markup.XamlReader]::Parse($xaml)) + Add-SettingsItem ([Windows.Markup.XamlReader]::Parse($xaml)) | Out-Null +} + +function Show-SettingsForm +{ + $settingsStr = Get-Content ($global:AppRootFolder+ "\Xaml\SettingsForm.xaml") + + $settingsForm = [Windows.Markup.XamlReader]::Parse($settingsStr) + $global:settingControls = @() + $global:spSettings = $settingsForm.FindName("spSettings") + + Add-XamlEvent $settingsForm "btnSave" "Add_Click" ({ + Save-AllSettings + $global:Debug = Get-SettingValue "Debug" + }) + + Add-XamlEvent $settingsForm "btnClose" "Add_Click" ({ + Show-ModalObject + }) + + $tmp = $global:appSettingSections | Where-Object Id -eq "General" + if($tmp.Values.Count -gt 0) + { + Add-SettingTitle $tmp.Title + foreach($settingObj in $tmp.Values) + { + Add-SettingValue $settingObj + } + } + + foreach($section in $global:appSettingSections) + { + if(-not ($settingObj | Get-Member -MemberType NoteProperty -Name "Priority")) + { + $settingObj | Add-Member -MemberType NoteProperty -Name "Priority" -Value 100 + } + if($settingObj.Priority -lt 1) { $settingObj.Priority = 1} + } + + foreach($section in ($global:appSettingSections | Where-Object Id -ne "General" | Sort-Object -Property Priority,Title)) + { + if($section.Values.Count -eq 0) { continue } + Add-SettingTitle $section.Title 5 + foreach($settingObj in $section.Values) + { + Add-SettingValue $settingObj + } + } + Show-ModalObject $settingsForm +} + +function Add-DefaultSettings +{ + $global:appSettingSections = @() + + $global:appSettingSections += (New-Object PSObject -Property @{ + Title = "General" + Id = "General" + Values = @() + }) + + Add-SettingsObject (New-Object PSObject -Property @{ + Title = "Log file" + Key = "LogFile" + Type = "File" + }) "General" + + Add-SettingsObject (New-Object PSObject -Property @{ + Title = "Max log file size" + Key = "LogFileSize" + Type = "Int" + DefaultValue = 1024 + }) "General" + + Add-SettingsObject (New-Object PSObject -Property @{ + Title = "Debug" + Key = "Debug" + Type = "Boolean" + DefaultValue = $false + }) "General" +} + +function Add-SettingsObject +{ + param($obj, $section) + + $section = $global:appSettingSections | Where-Object Id -eq $section + if(-not $section) + { + Write-Log "Could not find section $section" 3 + return + } + $section.Values += $obj +} + +function Save-AllSettings +{ + Write-Status "Save settings" + foreach($section in $global:appSettingSections) + { + foreach($settingObj in $section.Values) + { + if(-not $settingObj.Control) { continue } + $valueFound = $false + if($settingObj.Control.GetType().Name -eq "TextBox") + { + $value = $settingObj.Control.Text + if($settingObj.Type -eq "Int") + { + try + { + $value = [int]$value + } + catch + { + # Log or set invalid + $value = $settingObj.Value + } + } + $valueFound = $true + } + elseif($settingObj.Control.GetType().Name -eq "CheckBox") + { + $value = $settingObj.Control.IsChecked + $valueFound = $true + } + elseif($settingObj.Control.GetType().Name -eq "ComboBox") + { + Write-LogDebug "$($settingObj.Control.Text) | $($settingObj.Control.SelectedIndex)" + if($settingObj.Control.SelectedIndex -eq -1) + { + $value = $settingObj.Control.Text + } + else + { + $value = $settingObj.Control.SelectedValue + } + $valueFound = $true + } + + if($valueFound) + { + Save-Setting $settingObj.SubPath $settingObj.Key $value + } + } + } + + if($global:currentViewObject.ViewInfo.SaveSettings) + { + & $global:currentViewObject.ViewInfo.SaveSettings + } + Start-Sleep -Seconds 1 # It goes to quick...ToDo: Do this in a better way + Write-Status "" +} + +function Get-SettingValue +{ + param($Key, $defaultValue) + + foreach($section in $global:appSettingSections) + { + $settingObj = $section.Values | Where Key -eq $Key + if($settingObj) { break } + } + + if(-not $defaultValue) { $defaultValue = $settingObj.DefaultValue } + + $value = Get-Setting $settingObj.SubPath $settingObj.Key $defaultValue + if($value) + { + if($settingObj.Type -eq "Boolean") + { + $value = $value -eq $true -or $value -eq "true" + } + elseif($settingObj.Type -eq "Boolean") + { + try + { + $value = [int]$value + } + catch + { + if($settingObj.DefaultValue) + { + try + { + $value = [int]$settingObj.DefaultValue + } + catch { } + } + } + } + + # Keep last read value + if($settingObj -and ($settingObj | Get-Member -MemberType NoteProperty -Name "Value")) + { + $settingObj.Value = $value # Keep last read value + } + else + { + $settingObj | Add-Member -MemberType NoteProperty -Name "Value" -Value $value + } + } + $value +} + +#endregion + +#region Menu functions + +##################################################################################################### +# +# Menu functions +# +##################################################################################################### + +function Add-ViewObject +{ + param($viewObject) + + $global:viewObjects += New-Object PSObject -Property @{ ViewInfo = $viewObject; ViewItems = @() } +} + +function Add-ViewItem +{ + param($viewItem) + + $objSection = $global:viewObjects | Where { $_.ViewInfo.Id -eq $viewItem.ViewID } + if(-not $objSection) + { + if(($arrMenuInlcude -and $arrMenuInlcude -notcontains $viewItem.ViewID) -or ($arrMenuExlcude -and $arrMenuExlcude -contains $viewItem.ViewID)) { return } + + Write-Log "Could not find menu with id $($viewItem.ViewID). Item $($viewItem.Title) not added" 2 + return + } + + ### !!! ToDo: Should not be here... + if(-not ($viewItem.PSObject.Properties | Where Name -eq "ImportOrder")) + { + $viewItem | Add-Member -NotePropertyName "ImportOrder" -NotePropertyValue 1000 + } + + if(-not $global:PermissionScope) { $global:PermissionScope = @() } + foreach($scope in $viewItem.Permissons) + { + if($global:PermissionScope -notcontains $scope) { $global:PermissionScope += $scope } + } + + foreach($required in @("openid","profile","email","User.ReadWrite.All","Group.ReadWrite.All")) #,"https://management.azure.com/user_impersonation") ) + { + if($required -in $global:PermissionScope) { continue } + $global:PermissionScope += $required + Write-LogDebug "Adding required scope $required" + } + + if($viewItem.Icon -or [IO.File]::Exists(($global:AppRootFolder + "\Xaml\Icons\$($viewItem.Id).xaml"))) + { + $ctrl = Get-XamlObject ($global:AppRootFolder + "\Xaml\Icons\$((?? $viewItem.Icon $viewItem.Id)).xaml") + $viewItem | Add-Member -NotePropertyName "IconImage" -NotePropertyValue $ctrl + } + + $objSection.ViewItems += $viewItem +} + +function Show-View +{ + param($viewId) + + if(($global:viewObjects | measure).Count -eq 0) + { + Write-Log "No View Objects loaded!" 3 + return + } + + if(-not $viewId) + { + # Use first View if not specified + # ToDo: Use last or default view + $viewId = $global:viewObjects[0].ViewInfo.Id + } + + if($global:currentViewObject.ViewInfo.ID -eq $viewId) { return } # Current view already selected + + # Get the View object + $viewObject = $global:viewObjects | Where { $_.ViewInfo.Id -eq $viewId } + if(-not $viewObject) + { + Write-Log "Could not find View with id $($viewId)" 3 + return + } + Write-Log "Change view to $($viewObject.ViewInfo.Title)" + + if($global:currentViewObject -ne $viewObject -and $global:currentViewObject.ViewInfo.Deactivating) + { + Write-Log "Deactivating View $($global:currentViewObject.ViewInfo.Title)" + & $global:currentViewObject.ViewInfo.Deactivating + } + + $viewItems = ?: ($viewObject.ViewInfo.Sort -ne $false) ($viewObject.ViewItems | Sort-Object -Property Title) ($viewObject.ViewItems) + + $lblMenuTitle.Content = $viewObject.ViewInfo.Title + + $lstMenuItems.ItemsSource = @($viewItems) + + $grdViewPanel.Children.Clear() + + if($viewObject.ViewInfo.Authenticate) + { + $global:txtSplashText.Text = "Authenticate" + [System.Windows.Forms.Application]::DoEvents() + & $viewObject.ViewInfo.Authenticate + } + + if($viewObject.ViewInfo.Activating) + { + Write-Log "Activating View $($viewObject.ViewInfo.Title)" + & $viewObject.ViewInfo.Activating + } + + if($viewObject.ViewInfo.ViewPanel) + { + $grdViewPanel.Children.Add($viewObject.ViewInfo.ViewPanel) | Out-Null + } + + $global:currentViewObject = $viewObject + + Set-MainTitle + + Show-AuthenticationInfo + + if($viewObject.ViewInfo.Activated) + { + Write-Log "Activated View $($viewObject.ViewInfo.Title)" + & $viewObject.ViewInfo.Activated + } +} + +#endregion + +#region Main Window +function Set-MainTitle +{ + if(-not $global:window -or -not $global:currentViewObject.ViewInfo.Title) { return } + + Write-LogDebug "Set main title to $($global:currentViewObject.ViewInfo.Title)" + + $global:window.Title = ?? $global:currentViewObject.ViewInfo.Title "Cloud API PowerShell Management" +} + +function Get-MainWindow +{ + try + { + [xml]$xaml = Get-Content ($global:AppRootFolder + "\Xaml\MainWindow.xaml") + [xml]$styles = Get-Content ($global:AppRootFolder + "\Themes\Styles.xaml") + + ### Update relative path to full path for ResourceDictionary + [System.Xml.XmlNamespaceManager] $nsm = $xaml.NameTable; + $nsm.AddNamespace("s", 'http://schemas.microsoft.com/winfx/2006/xaml/presentation'); + foreach($rsdNode in ($xaml.SelectNodes("//s:ResourceDictionary[@Source]", $nsm))) + { + $rsdNode.Source = (Join-Path ($PSScriptRoot) ($rsdNode.Source)).ToString() + } + + # Add Styles + foreach($node in $styles.DocumentElement.ChildNodes) + { + $tmpNode = $xaml.CreateElement("Temp") + $tmpNode.InnerXml = $node.OuterXml + $xaml.Window.'Window.Resources'.ResourceDictionary.AppendChild($tmpNode.Style) | Out-Null + } + $global:window = [Windows.Markup.XamlReader]::Load((New-Object System.Xml.XmlNodeReader $xaml)) + } + catch + { + Write-LogError "Failed to initialize main window" $_.Exception + return + } + + # ToDo: Convert to a list for data binding + Add-XamlEvent $window "mnuSettings" "Add_Click" -scriptBlock ([scriptblock]{ Show-SettingsForm }) + Add-XamlEvent $window "mnuAbout" "Add_Click" -scriptBlock ([scriptblock]{ Show-AboutDialog }) + Add-XamlEvent $window "mnuExit" "Add_Click" -scriptBlock ([scriptblock]{ + if([System.Windows.MessageBox]::Show("Are you sure you want to exit?", "Exit?", "YesNo", "Question") -eq "Yes") + { + $window.Close() + } + } + ) + + Add-XamlVariables $xaml $window + + $lstMenuItems.Add_SelectionChanged({ + if($global:currentViewObject.ViewInfo.ItemChanged) + { + & $global:currentViewObject.ViewInfo.ItemChanged + } + }) + + $global:grdPopup.add_MouseLeftButtonDown( { Hide-Popup } ) + + # ToDo: !!! Intune should not be default icon... + $iconFile = "$($global:AppRootFolder)\Intune.ico" + if([io.File]::Exists($iconFile)) + { + $Window.Icon = $iconFile + } + + $window.Add_Closed({ + }) + + $window.add_Loaded({ + $global:SplashScreen.Hide() + $global:window.Activate() + [System.Windows.Forms.Application]::DoEvents() + #$global:window.Topmost = $true + #$global:window.Topmost = $false + #$global:window.Focus() + + $global:MainAppStarted = $true + + if($global:FirstTimeRunning) + { + $script:welcomeForm = Get-XamlObject ($global:AppRootFolder + "\Xaml\Welcome.xaml") -AddVariables + + Add-XamlEvent $script:welcomeForm "gitHubLink" "Add_RequestNavigate" ({ [System.Diagnostics.Process]::Start($_.Uri.AbsoluteUri); $_.Handled = $true }) + Add-XamlEvent $script:welcomeForm "licenseLink" "Add_RequestNavigate" ({ [System.Diagnostics.Process]::Start($_.Uri.AbsoluteUri); $_.Handled = $true }) + + Add-XamlEvent $script:welcomeForm "chkAcceptConditions" "add_click" { + $global:btnAcceptConditions.IsEnabled = ($this.IsChecked -eq $true) + } + + Add-XamlEvent $script:welcomeForm "btnAcceptConditions" "add_click" { + Save-Setting "" "LicenseAccepted" "True" + Save-Setting "" "FirstTimeRunning" "False" + Show-ModalObject + + if($global:currentViewObject.ViewInfo.Authentication.ShowErrors) + { + & $global:currentViewObject.ViewInfo.Authentication.ShowErrors + } + } + + Add-XamlEvent $script:welcomeForm "btnCancel" "add_click" { + if([System.Windows.MessageBox]::Show("Conditions not accepted`n`nDo you want to close the application?", "Close App?", "YesNo", "Warning") -eq "Yes") + { + $window.Close() + } + } + + Show-ModalForm $window.Title $script:welcomeForm -HideButtons + } + }) + + foreach($view in $global:viewObjects) + { + $subItem = [System.Windows.Controls.MenuItem]::new() + $subItem.Header = $view.ViewInfo.Title + $subItem.Tag = $view.ViewInfo.Id + $subItem.Add_Click({ + if($this.Tag) + { + Show-View $this.Tag + } + }) + $global:mnuViews.AddChild($subItem) | Out-Null + } +} + +#endregion + +#region Module functions +function Invoke-ModuleFunction +{ + param($function) + + Write-Log "Trigger function $function" + + foreach($module in $global:loadedModules) + { + # Get command with ExportedFunctions instead of Get-Command + $cmd = $module.ExportedFunctions[$function] + if($cmd) + { + Write-Log "Trigger $function in $($module.Name)" + Invoke-Command -ScriptBlock $cmd.ScriptBlock + } + else + { + #Write-Log "$function not found in $($module.Name)" 2 + } + } +} + +#endregion + +#region JWTToken + +### See JWT token documentation for more info: https://tools.ietf.org/html/rfc7519 +### AccessToken documentation https://docs.microsoft.com/en-us/azure/active-directory/develop/access-tokens +function Get-JWTtoken +{ + param($token) + + if(-not $token -or -not $token.StartsWith("eyJ")) { Write-Log "Invalid token" 3; return } + + # First part is the header. Second part is the payload. Third part is the signature + $arr = $token.Split(".") + + if($arr.Count -lt 2) { Write-Log "Invalid token" 3; return } + + $header = $arr[0].Replace('-', '+').Replace('_', '/') # change base64url to base64 + while ($header.Length % 4) { $header += "=" } # Add padding to match required length + + $payload = $arr[1].Replace('-', '+').Replace('_', '/') # change base64url to base64 + while ($payload.Length % 4) { $payload += "=" } # Add padding to match required length + + return (New-Object PSObject -Property @{ + Header=(([System.Text.Encoding]::ASCII.GetString(([System.Convert]::FromBase64String($header)))) | ConvertFrom-Json) + Payload=(([System.Text.Encoding]::ASCII.GetString(([System.Convert]::FromBase64String($payload)))) | ConvertFrom-Json) + }) +} +#endregion + +function AddGridObject +{ + param($grid, $obj) + + $rd = [System.Windows.Controls.RowDefinition]::new() + $rd.Height = [double]::NaN + $obj.SetValue([System.Windows.Controls.Grid]::RowProperty,$grid.RowDefinitions.Count) | Out-Null + $grid.RowDefinitions.Add($rd) | Out-Null + $grid.Children.Add($obj) | Out-Null +} + +function Get-IsAdmin +{ + (New-Object Security.Principal.WindowsPrincipal ([Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator) +} + +New-Alias -Name ?? -value Invoke-Coalesce +New-Alias -Name ?: -value Invoke-IfTrue +Export-ModuleMember -alias * -function * \ No newline at end of file diff --git a/Extensions/AppProtection.psm1 b/Extensions/AppProtection.psm1 deleted file mode 100644 index 7fedeff..0000000 --- a/Extensions/AppProtection.psm1 +++ /dev/null @@ -1,332 +0,0 @@ -######################################################## -# -# Common module functions -# -######################################################## -function Add-ModuleMenuItems -{ - Add-MenuItem (New-Object PSObject -Property @{ - Title = (Get-AppProtectionName) - MenuID = "IntuneGraphAPI" - Script = [ScriptBlock]{Get-AppProtections} - }) -} - -function Get-SupportedImportObjects -{ - $global:importObjects += (New-Object PSObject -Property @{ - Selected = $true - Title = (Get-AppProtectionName) - Script = [ScriptBlock]{ - param($rootFolder) - - Write-Status "Import all app protection/configuration policies" - Import-AllAppProtectionObjects (Join-Path $rootFolder (Get-AppProtectionFolderName)) - } - }) -} - -function Get-SupportedExportObjects -{ - $global:exportObjects += (New-Object PSObject -Property @{ - Selected = $true - Title = (Get-AppProtectionName) - Script = [ScriptBlock]{ - param($rootFolder) - - Write-Status "Export all app protection/configuration policies" - Get-AppProtectionObjects | ForEach-Object { Export-SingleAppProtection $PSItem.Object (Join-Path $rootFolder (Get-AppProtectionFolderName)) } - } - }) -} - -function Export-AllObjects -{ - param($addObjectSubfolder) - - $subFolder = "" - if($addObjectSubfolder) { $subFolder = Get-AppProtectionFolderName } -} - -######################################################## -# -# Object specific functions -# -######################################################## -function Get-AppProtectionName -{ - return "App Protection/Configuration" -} - -function Get-AppProtectionFolderName -{ - return "AppProtection" -} - -function Get-AppProtections -{ - Write-Status "Loading app protections and configurations" - $dgObjects.ItemsSource = @(Get-AppProtectionObjects) - - #Scriptblocks that will perform the export tasks. empty by default - $script:exportParams = @{} - $script:exportParams.Add("ExportAllScript", [ScriptBlock]{ - Export-AllAppProtections $global:txtExportPath.Text - Set-ObjectGrid - Write-Status "" - }) - - $script:exportParams.Add("ExportSelectedScript", [ScriptBlock]{ - Export-SelectedAppProtection $global:txtExportPath.Text - Set-ObjectGrid - Write-Status "" - }) - - #Scriptblock that will perform the import all files - $script:importAll = [ScriptBlock]{ - Import-AllAppProtectionObjects $global:txtImportPath.Text - Set-ObjectGrid - } - - #Scriptblock that will perform the import of selected files - $script:importSelected = [ScriptBlock]{ - Import-AppProtectionObjects $global:lstFiles.ItemsSource -Selected - Set-ObjectGrid - } - - #Scriptblock that will read json files - $script:getImportFiles = [ScriptBlock]{ - Show-FileListBox - $global:lstFiles.ItemsSource = @(Get-JsonFileObjects $global:txtImportPath.Text -Exclude "*_Settings.json") - } - - Add-DefaultObjectButtons -export ([scriptblock]{Show-DefaultExportGrid @script:exportParams}) -import ([scriptblock]{Show-DefaultImportGrid -ImportAll $script:importAll -ImportSelected $script:importSelected -GetFiles $script:getImportFiles}) -copy ([scriptblock]{Copy-AppProtection}) -ViewFullObject ([scriptblock]{Get-AppProtectionObject $global:dgObjects.SelectedItem.Object}) -ForceFullObject -} - -function Get-AppProtectionObjects -{ - Get-GraphObjects -Url "/deviceAppManagement/managedAppPolicies" -} - -function Get-AppProtectionObject -{ - param($object, $additional = "") - - if(-not $object.id) { return } - - $objType = Get-AppProtectionObjectType $object."@odata.type" - - $expand = "" - if($objType -eq "targetedManagedAppConfigurations") - { - $expand = "?`$expand=Apps" - } - - if($objType) - { - Invoke-GraphRequest -Url "/deviceAppManagement/$objType/$($object.id)$($expand)" - } -} - -function Export-AllAppProtections -{ - param($path = "$env:Temp") - - if(-not (Test-Path $path)) { mkdir -Path $path -Force -ErrorAction SilentlyContinue | Out-Null } - - if(Test-Path $path) - { - foreach($objTmp in ($global:dgObjects.ItemsSource)) - { - Export-SingleAppProtection $objTmp.Object $path - } - } -} - -function Export-SelectedAppProtection -{ - param($path = "$env:Temp") - - Export-SingleAppProtection $global:dgObjects.SelectedItem.Object $path -} - -function Export-SingleAppProtection -{ - param($psObj, $path = "$env:Temp") - - if(-not $psObj) { return } - - if($global:runningBulkExport -ne $true) - { - if($global:chkAddCompanyName.IsChecked) { $path = Join-Path $path $global:organization.displayName } - if($global:chkAddObjectType.IsChecked) { $path = Join-Path $path (Get-AppProtectionFolderName) } - } - - if(-not (Test-Path $path)) { mkdir -Path $path -Force -ErrorAction SilentlyContinue | Out-Null } - - if(Test-Path $path) - { - Write-Status "Export $($psObj.displayName)" - - $obj = Get-AppProtectionObjectForExport $psObj - - if($obj) - { - $fileName = "$path\$((Remove-InvalidFileNameChars $obj.displayName)).json" - ConvertTo-Json $obj -Depth 5 | Out-File $fileName -Force - - Add-MigrationInfo $obj.assignments - } - $global:exportedObjects++ - } -} - -function Get-AppProtectionObjectType -{ - param($odataType) - - if($odataType -like "*targetedManagedAppConfiguration*") - { - "targetedManagedAppConfigurations" - - } - elseif($odataType -like "*iosManagedAppProtection*") - { - "iosManagedAppProtections" - } - elseif($odataType -like "*androidManagedAppProtection*") - { - "androidManagedAppProtections" - } - elseif($odataType -like "*mdmWindowsInformationProtectionPolicy*") - { - # Win 10 - With enrollment e.g. Intune enrolled Win 10 devices - "mdmWindowsInformationProtectionPolicies" - } - elseif($odataType -like "*windowsInformationProtectionPolicy*") - { - # Win 10 - Without enrollment e.g. MAM polices for Win 10 - "WindowsInformationProtectionPolicies" - } -} - -function Get-AppProtectionObjectForExport -{ - param($obj) - - $objType = Get-AppProtectionObjectType $obj."@odata.type" - - $expand = "?`$expand=assignments" - if($objType -eq "targetedManagedAppConfigurations") - { - $expand += ",Apps" - } - - if($objType) - { - Invoke-GraphRequest -Url "/deviceAppManagement/$objType/$($obj.id)$($expand)" - } -} - -function Copy-AppProtection -{ - if(-not $dgObjects.SelectedItem) - { - [System.Windows.MessageBox]::Show("No object selected`n`nSelect app protection/configuration item you want to copy", "Error", "OK", "Error") - return - } - - $ret = Show-InputDialog "Copy app protection/configuration" "Select name for the new policy" "$($dgObjects.SelectedItem.displayName) - Copy" - - if($ret) - { - # Export profile - Write-Status "Export $($dgObjects.SelectedItem.displayName)" - $obj = Get-AppProtectionObjectForExport $dgObjects.SelectedItem.Object - if($obj) - { - # Remove assignment properties - Remove-ObjectProperty $obj "assignments" - Remove-ObjectProperty $obj "assignments@odata.context" - - # Import new profile - $obj.displayName = $ret - Import-AppProtection $obj | Out-Null - - $dgObjects.ItemsSource = @(Get-AppProtectionObjects) - } - Write-Status "" - } - $dgObjects.Focus() -} - -function Import-AppProtection -{ - param($obj) - - if(($obj | GM -MemberType NoteProperty -Name "Apps")) - { - $apps = $obj.Apps - } - Start-PreImport $obj -RemoveProperties @("apps","apps@odata.context") - - Write-Status "Import $($obj.displayName)" - - $objType = Get-AppProtectionObjectType $obj."@odata.context" - - if($objType) - { - #Import the app configuration policy - $response = Invoke-GraphRequest -Url "/deviceAppManagement/$objType" -Content (ConvertTo-Json $obj -Depth 5) -HttpMethod POST - if($response -and $apps) - { - # Import targeted apps - $response2 = Invoke-GraphRequest -Url "/deviceAppManagement/$objType/$($response.Id)/targetApps" -Content "{ apps: $(ConvertTo-Json $apps -Depth 5)}" -HttpMethod POST - } - $response - } -} - -function Import-AllAppProtectionObjects -{ - param($path = "$env:Temp") - - Import-AppProtectionObjects (Get-JsonFileObjects $path) -} - -function Import-AppProtectionObjects -{ - param( - $Objects, - - [switch] - $Selected - ) - - Write-Status "Import app protection/configuration policies" - - foreach($obj in $objects) - { - if($Selected -and $obj.Selected -ne $true) { continue } - - Write-Log "Import App Protection/Configuration: $($obj.Object.displayName)" - - $assignments = Get-GraphAssignmentsObject $obj.Object ($obj.FileInfo.DirectoryName + "\" + $obj.FileInfo.BaseName + "_assignments.json") - - $response = Import-AppProtection $obj.Object - - if($response) - { - $global:importedObjects++ - - $dataType = Get-AppProtectionObjectType $response."@odata.context" - - if($dataType) - { - Import-GraphAssignments $assignments "assignments" "/deviceAppManagement/$dataType/$($response.Id)/assign" - } - } - } - $dgObjects.ItemsSource = @(Get-AppProtectionObjects) - Write-Status "" -} \ No newline at end of file diff --git a/Extensions/AutoPilot.psm1 b/Extensions/AutoPilot.psm1 deleted file mode 100644 index 20c4409..0000000 --- a/Extensions/AutoPilot.psm1 +++ /dev/null @@ -1,249 +0,0 @@ -######################################################## -# -# Common module functions -# -######################################################## -function Add-ModuleMenuItems -{ - Add-MenuItem (New-Object PSObject -Property @{ - Title = (Get-AutoPilotName) - MenuID = "IntuneGraphAPI" - Script = [ScriptBlock]{Get-AutoPilots} - }) -} - -function Get-SupportedImportObjects -{ - $global:importObjects += (New-Object PSObject -Property @{ - Selected = $true - Title = (Get-AutoPilotName) - Script = [ScriptBlock]{ - param($rootFolder) - - Write-Status "Import all AutoPilot policies" - Import-AllAutoPilotObjects (Join-Path $rootFolder (Get-AutoPilotFolderName)) - } - }) -} - -function Get-SupportedExportObjects -{ - $global:exportObjects += (New-Object PSObject -Property @{ - Selected = $true - Title = (Get-AutoPilotName) - Script = [ScriptBlock]{ - param($rootFolder) - - Write-Status "Export all AutoPilot policies" - Get-AutoPilotObjects | ForEach-Object { Export-SingleAutoPilotObject $PSItem.Object (Join-Path $rootFolder (Get-AutoPilotFolderName)) } - } - }) -} - -function Export-AllObjects -{ - param($addObjectSubfolder) - - $subFolder = "" - if($addObjectSubfolder) { $subFolder = Get-AutoPilotFolderName } -} - -######################################################## -# -# Object specific functions -# -######################################################## -function Get-AutoPilotName -{ - (Get-AutoPilotFolderName) -} - -function Get-AutoPilotFolderName -{ - "AutoPilot" -} - -function Get-AutoPilots -{ - Write-Status "Loading AutoPilot profiles" - $dgObjects.ItemsSource = @(Get-AutoPilotObjects) - - #Scriptblocks that will perform the export tasks. empty by default - $script:exportParams = @{} - $script:exportParams.Add("ExportAllScript", [ScriptBlock]{ - Export-AllAutoPilots $global:txtExportPath.Text - Set-ObjectGrid - Write-Status "" - }) - - $script:exportParams.Add("ExportSelectedScript", [ScriptBlock]{ - Export-SelectedAutoPilotObject $global:txtExportPath.Text - Set-ObjectGrid - Write-Status "" - }) - - #Scriptblock that will perform the import all files - $script:importAll = [ScriptBlock]{ - Import-AllAutoPilotObjects $global:txtImportPath.Text - Set-ObjectGrid - } - - #Scriptblock that will perform the import of selected files - $script:importSelected = [ScriptBlock]{ - Import-AutoPilotObjects $global:lstFiles.ItemsSource -Selected - Set-ObjectGrid - } - - #Scriptblock that will read json files - $script:getImportFiles = [ScriptBlock]{ - Show-FileListBox - $global:lstFiles.ItemsSource = @(Get-JsonFileObjects $global:txtImportPath.Text -Exclude "*_Settings.json") - } - - Add-DefaultObjectButtons -export ([scriptblock]{Show-DefaultExportGrid @script:exportParams}) -import ([scriptblock]{Show-DefaultImportGrid -ImportAll $script:importAll -ImportSelected $script:importSelected -GetFiles $script:getImportFiles}) -copy ([scriptblock]{Copy-AutoPilot}) -ViewFullObject ([scriptblock]{Get-AutoPilotObject $global:dgObjects.SelectedItem.Object}) -} - -function Get-AutoPilotObjects -{ - Get-GraphObjects -Url "/deviceManagement/windowsAutopilotDeploymentProfiles" -} - -function Get-AutoPilotObject -{ - param($object, $additional = "") - - if(-not $Object.id) { return } - - Invoke-GraphRequest -Url "/deviceManagement/windowsAutopilotDeploymentProfiles/$($Object.id)$additional" -} - -function Export-AllAutoPilots -{ - param($path = "$env:Temp") - - if(-not (Test-Path $path)) { mkdir -Path $path -Force -ErrorAction SilentlyContinue | Out-Null } - - if(Test-Path $path) - { - foreach($objTmp in ($global:dgObjects.ItemsSource)) - { - Export-SingleAutoPilotObject $objTmp.Object $path - } - } -} - -function Export-SelectedAutoPilotObject -{ - param($path = "$env:Temp") - - Export-SingleAutoPilotObject $global:dgObjects.SelectedItem.Object $path -} - -function Export-SingleAutoPilotObject -{ - param($psObj, $path = "$env:Temp") - - if(-not $psObj) { return } - - if($global:runningBulkExport -ne $true) - { - if($global:chkAddCompanyName.IsChecked) { $path = Join-Path $path $global:organization.displayName } - if($global:chkAddObjectType.IsChecked) { $path = Join-Path $path (Get-AutoPilotFolderName) } - } - - if(-not (Test-Path $path)) { mkdir -Path $path -Force -ErrorAction SilentlyContinue | Out-Null } - - if(Test-Path $path) - { - Write-Status "Export $($psObj.displayName)" - $obj = Invoke-GraphRequest -Url "/deviceManagement/windowsAutopilotDeploymentProfiles/$($psObj.id)?`$expand=assignments" - if($obj) - { - $fileName = "$path\$((Remove-InvalidFileNameChars $obj.displayName)).json" - ConvertTo-Json $obj -Depth 5 | Out-File $fileName -Force - - Add-MigrationInfo $obj.assignments - } - $global:exportedObjects++ - } -} - -function Copy-AutoPilot -{ - if(-not $dgObjects.SelectedItem) - { - [System.Windows.MessageBox]::Show("No object selected`n`nSelect AutoPilot item you want to copy", "Error", "OK", "Error") - return - } - - $ret = Show-InputDialog "Copy AutoPilot" "Select name for the new object" "$($dgObjects.SelectedItem.displayName) Copy" - - if($ret) - { - # Export profile - Write-Status "Export $($dgObjects.SelectedItem.displayName)" - # Convert to Json and back to clone the object - $obj = ConvertTo-Json $dgObjects.SelectedItem.Object -Depth 5 | ConvertFrom-Json - if($obj) - { - # Import new profile - $obj.displayName = Remove-InvalidFileNameChars $ret - Import-AutoPilot $obj | Out-Null - - $dgObjects.ItemsSource = @(Get-AutoPilotObjects) - } - Write-Status "" - } - $dgObjects.Focus() -} - -function Import-AutoPilot -{ - param($obj) - - Write-Status "Import $($obj.displayName)" - - Start-PreImport $obj - - Invoke-GraphRequest -Url "/deviceManagement/windowsAutopilotDeploymentProfiles" -Content (ConvertTo-Json $obj -Depth 5) -HttpMethod POST -} - -function Import-AllAutoPilotObjects -{ - param( - $path = "$env:Temp" - ) - - Import-AutoPilotObjects (Get-JsonFileObjects $path) -} - -function Import-AutoPilotObjects -{ - param( - $Objects, - - [switch] - $Selected - ) - - Write-Status "Import AutoPilot profiles" - - foreach($obj in $objects) - { - if($Selected -and $obj.Selected -ne $true) { continue } - - Write-Log "Import AutoPilot profile: $($obj.Object.displayName)" - - $assignments = Get-GraphAssignmentsObject $obj.Object ($obj.FileInfo.DirectoryName + "\" + $obj.FileInfo.BaseName + "_assignments.json") - - $response = Import-AutoPilot $obj.Object - - if($response) - { - $global:importedObjects++ - Import-GraphAssignments2 $assignments "/deviceManagement/windowsAutopilotDeploymentProfiles/$($response.Id)/assignments" - } - } - $dgObjects.ItemsSource = @(Get-AutoPilotObjects) - Write-Status "" -} \ No newline at end of file diff --git a/Extensions/AzureBranding.psm1 b/Extensions/AzureBranding.psm1 deleted file mode 100644 index a43e64d..0000000 --- a/Extensions/AzureBranding.psm1 +++ /dev/null @@ -1,319 +0,0 @@ -######################################################## -# -# Common module functions -# -######################################################## -function Add-ModuleMenuItems -{ - Add-MenuItem (New-Object PSObject -Property @{ - Title = (Get-AZBrandingName) - MenuID = "IntuneGraphAPI" - Script = [ScriptBlock]{Get-AZBrandings} - }) -} - -function Get-SupportedImportObjects -{ - $global:importObjects += (New-Object PSObject -Property @{ - Selected = $true - Title = (Get-AZBrandingName) - Script = [ScriptBlock]{ - param($rootFolder) - - Write-Status "Import all Azure branding" - Import-AllAZBrandingObjects (Join-Path $rootFolder (Get-AZBrandingFolderName)) - } - }) -} - -function Get-SupportedExportObjects -{ - $global:exportObjects += (New-Object PSObject -Property @{ - Selected = $true - Title = (Get-AZBrandingName) - Script = [ScriptBlock]{ - param($rootFolder) - - Write-Status "Export all Azure branding" - Get-AZBrandingObjects | ForEach-Object { Export-SingleAZBranding $PSItem.Object (Join-Path $rootFolder (Get-AZBrandingFolderName)) } - } - }) -} - -function Export-AllObjects -{ - param($addObjectSubfolder) - - $subFolder = "" - if($addObjectSubfolder) { $subFolder = Get-AZBrandingFolderName } -} - -######################################################## -# -# Object specific functions -# -######################################################## -function Get-AZBrandingName -{ - return "Azure Branding" -} - -function Get-AZBrandingFolderName -{ - return "AZBranding" -} - -function Get-AZBrandings -{ - Write-Status "Loading Azure brandings" - $dgObjects.ItemsSource = @(Get-AZBrandingObjects) - - #Scriptblocks that will perform the export tasks. empty by default - $script:exportParams = @{} - $script:exportParams.Add("ExportAllScript", [ScriptBlock]{ - Export-AllAZBrandings $global:txtExportPath.Text - Set-ObjectGrid - Write-Status "" - }) - - $script:exportParams.Add("ExportSelectedScript", [ScriptBlock]{ - Export-SelectedAZBranding $global:txtExportPath.Text - Set-ObjectGrid - Write-Status "" - }) - - $script:exportParams.Add("DisplayColumn", "localeDisplayName") - - #Scriptblock that will perform the import all files - $script:importAll = [ScriptBlock]{ - Import-AllAZBrandingObjects $global:txtImportPath.Text - Set-ObjectGrid - } - - #Scriptblock that will perform the import of selected files - $script:importSelected = [ScriptBlock]{ - Import-AZBrandingObjects $global:lstFiles.ItemsSource -Selected - Set-ObjectGrid - } - - #Scriptblock that will read json files - $script:getImportFiles = [ScriptBlock]{ - Show-FileListBox - $global:lstFiles.ItemsSource = @(Get-JsonFileObjects $global:txtImportPath.Text -Exclude "*_Settings.json") - } - - Add-DefaultObjectButtons -export ([scriptblock]{Show-DefaultExportGrid @script:exportParams}) -import ([scriptblock]{Show-DefaultImportGrid -ImportAll $script:importAll -ImportSelected $script:importSelected -GetFiles $script:getImportFiles}) -ViewFullObject ([scriptblock]{Get-AZBrandingObject $global:dgObjects.SelectedItem.Object}) -} - -function Get-AZBrandingObjects -{ - $response = Get-AzureNativeObjects "LoginTenantBrandings" -property @('locale', 'localeDisplayName') - if($response) - { - $response | Where { $_.Object.isConfigured -eq $true } - } -} - -function Get-AZBrandingObject -{ - param($object, $additional = "") - - if(-not $Object.locale) { return } - - Invoke-AzureNativeRequest "LoginTenantBrandings/$($Object.locale)$additional" -} - -function Export-AllAZBrandings -{ - param($path = "$env:Temp") - - if(-not (Test-Path $path)) { mkdir -Path $path -Force -ErrorAction SilentlyContinue | Out-Null } - - if(Test-Path $path) - { - foreach($objTmp in ($global:dgObjects.ItemsSource)) - { - Export-SingleAZBranding $objTmp.Object $path - } - } -} - -function Export-SelectedAZBranding -{ - param($path = "$env:Temp") - - Export-SingleAZBranding $global:dgObjects.SelectedItem.Object $path -} - -function Export-SingleAZBranding -{ - param($psObj, $path = "$env:Temp") - - if(-not $psObj) { return } - - if($global:runningBulkExport -ne $true) - { - if($global:chkAddCompanyName.IsChecked) { $path = Join-Path $path $global:organization.displayName } - if($global:chkAddObjectType.IsChecked) { $path = Join-Path $path (Get-AZBrandingFolderName) } - } - - if(-not (Test-Path $path)) { mkdir -Path $path -Force -ErrorAction SilentlyContinue | Out-Null } - - if(Test-Path $path) - { - Write-Status "Export $($psObj.localeDisplayName)" - $obj = Invoke-AzureNativeRequest "LoginTenantBrandings/$($psObj.locale)" - if($obj) - { - $fileName = "$path\$((Remove-InvalidFileNameChars $obj.localeDisplayName)).json" - ConvertTo-Json $obj -Depth 5 | Out-File $fileName -Force - - Save-AzureBrandingFile $obj "tileLogoUrl" $path - Save-AzureBrandingFile $obj "bannerLogoUrl" $path - Save-AzureBrandingFile $obj "illustrationUrl" $path - Save-AzureBrandingFile $obj "squareLogoDarkUrl" $path - - $global:exportedObjects++ - } - Set-ObjectPath $global:txtExportPath.Text - } -} - -function Save-AzureBrandingFile -{ - param($obj, $prop, $path) - - if(-not $obj.$prop) { return } - - $arr=$obj.$prop.Split('.') - if($arr.Length -ne 1) - { - return - } - $fileType = "jpg" # Assume...not OK. $arr[0] contains information about what kind of file it is - - $fileName = "$path\$((Remove-InvalidFileNameChars "$($obj.localeDisplayName).$prop.$fileType"))" - try - { - if(Test-Path $fileName) - { - Remove-Item -Path $fileName -Force - } - [IO.File]::WriteAllBytes($fileName, [System.Convert]::FromBase64String($arr[1])) - } - catch {} -} - -function Import-AZBranding -{ - param($obj) - - if($global:runningBulkImport -eq $true) - { - # Update Default and create the rest... - $createNew = $obj.locale -ne 0 - } - else - { - $curObj = $global:lstFiles.ItemsSource | Where { $_.Object.locale -eq $obj.locale } - - if($curObj -and $obj.locale -ne 0) - { - return # Do not update existing object except default - } - elseif(-not $curObj) - { - $createNew = $true - } - else - { - $createNew = $false - } - } - - $json = "{" - - if($createNew) { $json += "`"locale`":`"$($obj.locale)`"," } - - if($obj.signInUserIdLabel) { $json += "`"userIdLabel`": `"$($obj.signInUserIdLabel)`"," } - if($obj.signInPageText) { $json += "`"boilerPlateText`": `"$($obj.signInPageText)`"," } - if($obj.signInBackColor) { $json += "`"backgroundColor`": `"$($obj.signInBackColor)`"," } - if($obj.tileLogoUrl) { $json += "`"tileLogoUrl`": `"$($obj.tileLogoUrl)`"," } - if($obj.bannerLogoUrl) { $json += "`"bannerLogoUrl`": `"$($obj.bannerLogoUrl)`"," } - if($obj.illustrationUrl) { $json += "`"illustrationUrl`": `"$($obj.illustrationUrl)`"," } - if($obj.squareLogoDarkUrl) { $json += "`"squareLogoDarkUrl`": `"$($obj.squareLogoDarkUrl)`"," } - - if($obj.hideKeepMeSignedIn -and $obj.locale -eq 0) { $json += "`"keepMeSignedInDisabled`": $($obj.hideKeepMeSignedIn.ToString().ToLower())," } - - if($createNew) - { - if($curObj.bannerLogoUrl -ne $curObj.bannerLogoUrl) - { - $json += "`"isTileLogoUpdated`":true," - } - - if($curObj.illustrationUrl -ne $curObj.illustrationUrl) - { - $json += "`"isIllustrationImageUpdated`":true," - } - - if($curObj.squareLogoDarkUrl -ne $curObj.squareLogoDarkUrl) - { - $json += "`"isSquareDarkLogoUpdated`":true," - } - - if($curObj.bannerLogoUrl -ne $curObj.bannerLogoUrl) - { - $json += "`"isBannerLogoUpdated`":true," - } - } - - $json = $json.TrimEnd(',') - $json += "}" - - Write-Status "Import $($obj.localeDisplayName)" - - if($createNew) - { - Invoke-AzureNativeRequest "LoginTenantBrandings" -Method POST -Body $json | Out-Null - } - else - { - Invoke-AzureNativeRequest "LoginTenantBrandings/$($obj.locale)" -Method PATCH -Body $json | Out-Null - } -} - -function Import-AllAZBrandingObjects -{ - param($path = "$env:Temp") - - Import-AZBrandingObjects (Get-JsonFileObjects $path) -} - -function Import-AZBrandingObjects -{ - param( - $Objects, - - [switch] - $Selected - ) - - Write-Status "Import Azure brandings" - - foreach($obj in $objects) - { - if($Selected -and $obj.Selected -ne $true) { continue } - - Write-Log "Import Azure branding" - - $response = Import-AZBranding $obj.Object - - if($response) - { - $global:importedObjects++ - } - } - $dgObjects.ItemsSource = @(Get-AZBrandingObjects) - Write-Status "" -} \ No newline at end of file diff --git a/Extensions/AzureNative.psm1 b/Extensions/AzureNative.psm1 deleted file mode 100644 index 9f61227..0000000 --- a/Extensions/AzureNative.psm1 +++ /dev/null @@ -1,221 +0,0 @@ -#Requires -module Az.Accounts - -function Invoke-InitializeModule -{ - if(-not $global:AzToken) - { - # Only allow re-logging if it failed the first time - $global:AuthenticatedToAzure = $false - - } - #!!! - Used for testing login - #Disconnect-AzAccount -Username admin@delematelab2.onmicrosoft.com -} - -function Connect-AzureNative -{ - <# - .SYNOPSIS - Tries to connect to Azure with existing token - Uses Connect-AZAccount if no token found in cache - #> - - param($user) - - Write-Log "Authenticate to Azure (Az module). Try from cache with user $user" - - $Context = (Get-AzContext -ListAvailable | Where { $_.Account.Id -eq $user } | select -first 1) - - if (-not $Context) - { - $user | Clip # Copy login id to clipboard - - # Run Connect-AZAccount in a separate runspace or it will hang - $Runspace = [runspacefactory]::CreateRunspace() - $PowerShell = [powershell]::Create() - $PowerShell.Runspace = $Runspace - $Runspace.Open() - $PowerShell.AddScript({Connect-AZAccount}) - $PowerShell.Invoke() - - [System.Windows.Forms.Application]::DoEvents() - - $Context = (Get-AzContext -ListAvailable | Where { $_.Account.Id -eq $user } | select -first 1) - } - $global:AzToken = "" - try - { - $Resource = '74658136-14ec-4630-ad9b-26e160ff0fc6' - $global:AzToken = [Microsoft.Azure.Commands.Common.Authentication.AzureSession]::Instance.AuthenticationFactory.Authenticate($context.Account, $context.Environment, $context.Tenant.Id, $null, "Never", $null, $Resource) - } - catch - { - Write-LogError "Failed to authenticate with Instance.AuthenticationFactory.Authenticate" $_.Exception - } - - if(-not $global:AzToken) - { - Write-Log "Failed to authenticate" 3 - } - else - { - Write-Log "Authenticated as $($global:AzToken.UserId)" - } - $global:AuthenticatedToAzure = $true - - Set-MainTitle -} - -# Invoke-AzureNativeRequest is based on the following project -# https://github.com/JustinGrote/Az.PortalAPI/tree/master/Az.PortalAPI -# -# Some small changes: -# - Get-AzContext is based on the same user as Intune user -# - Renamed Invoke-Request to Invoke-AzureNativeRequest -# - Added support for HTTP Method PATCH -# - Added support for paging with nextLink (Lazy solution...not fully tested but looks like it is working) -# - Removed Token parameter. Created the Connect-AzureNative to get token -# - Removed Context parameter - -function Invoke-AzureNativeRequest { - <# - .SYNOPSIS - Runs a command against the Azure Portal API - #> - - [CmdletBinding(SupportsShouldProcess)] - param ( - #The target of your request. This is appended to the Portal API URI. Example: Permissions - [Parameter(Mandatory)]$Target, - - #The command you wish to execute. Example: GetUserSystemRoleTemplateIds - [Parameter()]$Action, - - #The body of your request. This is usually in JSON format - $Body, - - #Specify the HTTP Method you wish to use. Defaults to GET - [ValidateSet("GET","POST","OPTIONS","DELETE","PATCH")] - $Method = "GET", - - #The base URI for the Portal API. Typically you don't need to change this - [Uri]$baseURI = 'https://main.iam.ad.ext.azure.com/api/', - - [URI]$requestOrigin = 'https://iam.hosting.portal.azure.net', - - #The request ID for the session. You can generate one with [guid]::NewGuid().guid. - #Typically you only specify this if you're trying to retry an operation and don't want to duplicate the request, such as for a POST operation - $requestID = [guid]::NewGuid().guid, - - [switch]$allowPaging - ) - - if(-not $global:AzToken -and $global:AuthenticatedToAzure -eq $false) - { - Connect-AzureNative $global:me.userPrincipalName - } - - if(-not $global:AzToken) - { - return - } - - #Combine the BaseURI and Target - [String]$ApiAction = $Target.TrimStart('/') - - if ($Action) { - $ApiAction = $ApiAction + '/' + $Action - } - - $uriStr = "$baseURI$ApiAction" - - if($allowPaging) - { - $uri = [Uri]::New("$uriStr&nextLink=null") - } - else - { - $uri = [Uri]::New($baseURI,$ApiAction) - } - - if(-not $global:AzToken.AccessToken.tostring()) - { - Write-Log "No access token available" 3 - return - } - - $InvokeRestMethodParams = @{ - Uri = $uri - Method = $Method - Header = [ordered]@{ - Authorization = 'Bearer ' + $global:AzToken.AccessToken.tostring() - 'Content-Type' = 'application/json' - 'x-ms-client-request-id' = $requestID - 'Host' = $baseURI.Host - 'Origin' = 'https://iam.hosting.portal.azure.net' - } - Body = $Body - } - - $max = 100 - $cur = 0 - - $retObj = Invoke-RestMethod @InvokeRestMethodParams - if(($retObj | GM -MemberType NoteProperty -Name "nextLink")) - { - while($retObj.nextLink) - { - # Get more objects - $InvokeRestMethodParams["Uri"] = [Uri]::New($uriStr + "&nextLink=" + $retObj.nextLink) - $retObj = Invoke-RestMethod @InvokeRestMethodParams - - if($cur -ge $max) { break } - $cur++ # Loop gets stuck if nextLink=null is added to the command line so make sure it doesn't hang forever - } - } - - $retObj -} - -function Get-AzureNativeObjects -{ - param( - [Array] - $Target, - [Array] - $property, - [Array] - $exclude, - $SortProperty = "", - [switch]$allowPaging) - - $objects = @() - $nativeObjects = Invoke-AzureNativeRequest $Target -allowPaging:($allowPaging -eq $true) - - if(($nativeObjects | GM -Name "items")) - { - $objectList = $nativeObjects.Items - } - else - { - $objectList = $nativeObjects - } - - foreach($nativeObject in $objectList) - { - $params = @{} - if($property) { $params.Add("Property", $property) } - if($exclude) { $params.Add("ExcludeProperty", $exclude) } - foreach($objTmp in ($nativeObject | select @params)) - { - $objTmp | Add-Member -NotePropertyName "Object" -NotePropertyValue $nativeObject - $objects += $objTmp - } - } - - if($objects.Count -gt 0 -and $SortProperty -and ($objects[0] | GM -MemberType NoteProperty -Name $SortProperty)) - { - $objects = $objects | sort -Property $SortProperty - } - $objects -} \ No newline at end of file diff --git a/Extensions/Baseline.psm1 b/Extensions/Baseline.psm1 deleted file mode 100644 index 0b12afc..0000000 --- a/Extensions/Baseline.psm1 +++ /dev/null @@ -1,306 +0,0 @@ -######################################################## -# -# Common module functions -# -######################################################## -function Add-ModuleMenuItems -{ - Add-MenuItem (New-Object PSObject -Property @{ - Title = (Get-BaselineTemplatesName) - MenuID = "IntuneGraphAPI" - Script = [ScriptBlock]{Get-BaselineTemplates} - }) - - Add-MenuItem (New-Object PSObject -Property @{ - Title = (Get-BaselineName) - MenuID = "IntuneGraphAPI" - Script = [ScriptBlock]{Get-BaselineProfiles} - }) - -} - -function Get-SupportedImportObjects -{ - $global:importObjects += (New-Object PSObject -Property @{ - Selected = $true - Title = (Get-AppProtectionName) - Script = [ScriptBlock]{ - param($rootFolder) - - Write-Status "Import all baseline policies" - Import-AllBaselineProfileObjects (Join-Path $rootFolder (Get-BaselineFolderName)) - } - }) -} - -function Get-SupportedExportObjects -{ - $global:exportObjects += (New-Object PSObject -Property @{ - Selected = $true - Title = (Get-AppProtectionName) - Folder = (Get-BaselineFolderName) - Script = [ScriptBlock]{ - param($rootFolder) - - Write-Status "Import all baseline policies" - Get-BaselineProfileObjects | ForEach-Object { Export-SingleBaselineProfile $PSItem.Object (Join-Path $rootFolder (Get-BaselineFolderName)) } - } - }) -} - -function Export-AllObjects -{ - param($addObjectSubfolder) - - $subFolder = "" - if($addObjectSubfolder) { $subFolder = Get-BaselineFolderName } - -} -######################################################## -# -# Object specific functions -# -######################################################## -function Get-BaselineTemplatesName -{ - return "Baseline Templates" -} - - -function Get-BaselineName -{ - return "Baseline Profiles" -} - -function Get-BaselineFolderName -{ - return "Baseline" -} - -function Get-BaselineTemplates -{ - Write-Status "Loading baseline templates" -SkipLog - $dgObjects.ItemsSource = @(Get-BaselineTemplateObjects) -} - -function Get-BaselineTemplateObjects -{ - Get-GraphObjects -Url "/deviceManagement/templates" -} - -function Get-BaselineProfiles -{ - Write-Status "Loading banding profiles" -SkipLog - $dgObjects.ItemsSource = @(Get-BaselineProfileObjects) - - #Scriptblocks that will perform the export tasks. empty by default - $script:exportParams = @{} - $script:exportParams.Add("ExportAllScript", [ScriptBlock]{ - Export-AllBaselineProfiles $global:txtExportPath.Text - Set-ObjectGrid - Write-Status "" - }) - - $script:exportParams.Add("ExportSelectedScript", [ScriptBlock]{ - Export-SelectedBaselineProfile $global:txtExportPath.Text - Set-ObjectGrid - Write-Status "" - }) - - #Scriptblock that will perform the import all files - $script:importAll = [ScriptBlock]{ - Import-AllBaselineProfileObjects $global:txtImportPath.Text - Set-ObjectGrid - } - - #Scriptblock that will perform the import of selected files - $script:importSelected = [ScriptBlock]{ - Import-BaselineProfileObjects $global:lstFiles.ItemsSource -Selected - Set-ObjectGrid - } - - #Scriptblock that will read json files - $script:getImportFiles = [ScriptBlock]{ - Show-FileListBox - $global:lstFiles.ItemsSource = @(Get-JsonFileObjects $global:txtImportPath.Text -Exclude @("*_Settings.json","*_assignments.json")) - } - - Add-DefaultObjectButtons -export ([scriptblock]{Show-DefaultExportGrid @script:exportParams}) -import ([scriptblock]{Show-DefaultImportGrid -ImportAll $script:importAll -ImportSelected $script:importSelected -GetFiles $script:getImportFiles}) -copy ([scriptblock]{Copy-BaselineProfile}) -ViewFullObject ([scriptblock]{Get-BaselineProfileObject $global:dgObjects.SelectedItem.Object}) -} - -function Get-BaselineProfileObjects -{ - Get-GraphObjects -Url "/deviceManagement/intents" -} - -function Get-BaselineProfileObject -{ - param($object, $additional = "") - - if(-not $Object.id) { return } - - $profile = Invoke-GraphRequest -Url "/deviceManagement/intents/$($Object.id)" - $settings = Invoke-GraphRequest -Url "/deviceManagement/intents/$($Object.id)/Settings" - - @($profile, $settings) -} - -function Export-AllBaselineProfiles -{ - param($path = "$env:Temp") - - if(-not (Test-Path $path)) { mkdir -Path $path -Force -ErrorAction SilentlyContinue | Out-Null } - - if(Test-Path $path) - { - foreach($objTmp in ($global:dgObjects.ItemsSource)) - { - Export-SingleBaselineProfile $objTmp.Object $path - } - } -} - -function Export-SelectedBaselineProfile -{ - param($path = "$env:Temp") - - Export-SingleBaselineProfile $global:dgObjects.SelectedItem.Object $path -} - -function Export-SingleBaselineProfile -{ - param($psObj, $path = "$env:Temp") - - if(-not $psObj) { return } - - if($global:runningBulkExport -ne $true) - { - if($global:chkAddCompanyName.IsChecked) { $path = Join-Path $path $global:organization.displayName } - if($global:chkAddObjectType.IsChecked) { $path = Join-Path $path (Get-BaselineFolderName) } - } - - if(-not (Test-Path $path)) { mkdir -Path $path -Force -ErrorAction SilentlyContinue | Out-Null } - - if(Test-Path $path) - { - Write-Status "Export $($psObj.displayName)" - $obj = $psObj - if($obj) - { - $fileName = "$path\$((Remove-InvalidFileNameChars $obj.displayName)).json" - ConvertTo-Json $obj -Depth 5 | Out-File $fileName -Force - $settings = Invoke-GraphRequest -Url "/deviceManagement/intents/$($obj.id)/settings" - ConvertTo-Json $settings.value -Depth 5 | Out-File "$path\$($obj.displayName)_Settings.json" -Force - } - $assignments = Invoke-GraphRequest -Url "/deviceManagement/intents/$($obj.id)/assignments" - if(($assignments.Value | measure).Count -gt 0) - { - ConvertTo-Json $assignments.value -Depth 5| Out-File "$path\$($obj.displayName)_assignments.json" -Force - } - $global:exportedObjects++ - } -} - -function Copy-BaselineProfile -{ - if(-not $dgObjects.SelectedItem) - { - [System.Windows.MessageBox]::Show("No object selected`n`nSelect baseline profile you want to copy", "Error", "OK", "Error") - return - } - - $ret = Show-InputDialog "Copy baseline profiles" "Select name for the new object" "$($dgObjects.SelectedItem.displayName) - Copy" - - if($ret) - { - # Export profile - Write-Status "Export $($dgObjects.SelectedItem.displayName)" - # Convert to Json and back to clone the object - $obj = ConvertTo-Json $dgObjects.SelectedItem.Object -Depth 5 | ConvertFrom-Json - $settings = Invoke-GraphRequest -Url "/deviceManagement/intents/$($obj.id)/settings" - $intentSettings = ConvertTo-Json $settings.value -Depth 5 - - if($obj) - { - # Import new profile - $obj.displayName = $ret - Import-BaselineProfile $obj $intentSettings | Out-null - - $dgObjects.ItemsSource = @(Get-BaselineProfileObjects) - } - Write-Status "" - } - $dgObjects.Focus() -} - -function Import-BaselineProfile -{ - param($obj, $intentSettings, $templateId) - -$json = @" - { - "displayName": "$($obj.displayName)", - "description": "$($obj.description)", - "settingsDelta": - $($intentSettings) - - } -"@ - - if($templateId) - { - $tempId = $templateId - } - else - { - $tempId = $obj.templateId - } - - Write-Status "Import $($obj.displayName)" - - return Invoke-GraphRequest -Url "/deviceManagement/templates/$($tempId)/createInstance" -Content $json -HttpMethod POST -} - -function Import-AllBaselineProfileObjects -{ - param( - $path = "$env:Temp" - ) - - Import-BaselineProfileObjects (Get-JsonFileObjects $path) -} - -function Import-BaselineProfileObjects -{ - param( - $Objects, - - [switch] - $Selected - ) - - Write-Status "Import terms and conditions" - - foreach($obj in $objects) - { - if($Selected -and $obj.Selected -ne $true) { continue } - - Write-Log "Import security baseline: $($obj.Object.displayName)" - - $assignments = Get-GraphAssignmentsObject $obj.Object ($obj.FileInfo.DirectoryName + "\" + $obj.FileInfo.BaseName + "_assignments.json") - - $settingsFile = $obj.FileInfo.DirectoryName + "\" + $obj.FileInfo.BaseName + "_settings.json" - if(-not (Test-Path $settingsFile)) { continue } - - $intentSettings = Get-Content $settingsFile -Raw - - $response = Import-BaselineProfile $obj.Object $intentSettings - if($response) - { - $global:importedObjects++ - Import-GraphAssignments $assignments "assignments" "/deviceManagement/intents/$($response.Id)/assign" - } - } - $dgObjects.ItemsSource = @(Get-BaselineProfileObjects) - Write-Status "" -} diff --git a/Extensions/Branding.psm1 b/Extensions/Branding.psm1 deleted file mode 100644 index 70120fa..0000000 --- a/Extensions/Branding.psm1 +++ /dev/null @@ -1,236 +0,0 @@ -######################################################## -# -# Common module functions -# -######################################################## -function Add-ModuleMenuItems -{ - Add-MenuItem (New-Object PSObject -Property @{ - Title = (Get-IntuneBrandingName) - MenuID = "IntuneGraphAPI" - Script = [ScriptBlock]{Get-IntuneBrandings} - }) -} - -function Get-SupportedImportObjects -{ - $global:importObjects += (New-Object PSObject -Property @{ - Selected = $true - Title = (Get-IntuneBrandingName) - Script = [ScriptBlock]{ - param($rootFolder) - - Write-Status "Import all Intune branding objects" - Import-AllIntuneBrandingObjects (Join-Path $rootFolder (Get-IntuneBrandingFolderName)) - } - }) -} - -function Get-SupportedExportObjects -{ - $global:exportObjects += (New-Object PSObject -Property @{ - Selected = $true - Title = (Get-IntuneBrandingName) - Script = [ScriptBlock]{ - param($rootFolder) - - Write-Status "Export all Intune branding objects" - Get-IntuneBrandingObjects | ForEach-Object { Export-SingleIntuneBranding $PSItem.Object (Join-Path $rootFolder (Get-IntuneBrandingFolderName)) } - } - }) -} - -function Export-AllObjects -{ - param($addObjectSubfolder) - - $subFolder = "" - if($addObjectSubfolder) { $subFolder = Get-IntuneBrandingFolderName } -} - -######################################################## -# -# Object specific functions -# -######################################################## -function Get-IntuneBrandingName -{ - return "Intune Branding" -} - -function Get-IntuneBrandingFolderName -{ - return "IntuneBranding" -} - -function Get-IntuneBrandings -{ - Write-Status "Loading banding profiles" - $dgObjects.ItemsSource = @(Get-IntuneBrandingObjects) - - #Scriptblocks that will perform the export tasks. empty by default - $script:exportParams = @{} - $script:exportParams.Add("ExportAllScript", [ScriptBlock]{ - Export-AllIntuneBrandings $global:txtExportPath.Text - Set-ObjectGrid - Write-Status "" - }) - - # Same as ExportAllScript since only one object is supported - $script:exportParams.Add("ExportSelectedScript", [ScriptBlock]{ - Export-AllIntuneBrandings $global:txtExportPath.Text - Set-ObjectGrid - Write-Status "" - }) - - #Scriptblock that will perform the import all files - $script:importAll = [ScriptBlock]{ - Import-AllIntuneBrandingObjects $global:txtImportPath.Text - Set-ObjectGrid - } - - #Scriptblock that will perform the import of selected files - $script:importSelected = [ScriptBlock]{ - Import-IntuneBrandingObjects $global:lstFiles.ItemsSource -Selected - Set-ObjectGrid - } - - #Scriptblock that will read json files - $script:getImportFiles = [ScriptBlock]{ - Show-FileListBox - $global:lstFiles.ItemsSource = @(Get-JsonFileObjects $global:txtImportPath.Text -Exclude "*_Settings.json") - } - - Add-DefaultObjectButtons -export ([scriptblock]{Show-DefaultExportGrid @script:exportParams}) -import ([scriptblock]{Show-DefaultImportGrid -ImportAll $script:importAll -ImportSelected $script:importSelected -GetFiles $script:getImportFiles}) -} - -function Get-IntuneBrandingObjects -{ - Get-GraphObjects -Url "/deviceManagement/intuneBrand" -property @("displayName") -} - -function Export-AllIntuneBrandings -{ - param($path = "$env:Temp") - - if(-not (Test-Path $path)) { mkdir -Path $path -Force -ErrorAction SilentlyContinue | Out-Null } - - if(Test-Path $path) - { - foreach($objTmp in ($global:dgObjects.ItemsSource)) - { - Export-SingleIntuneBranding $objTmp.Object $path - } - } -} - -function Export-SingleIntuneBranding -{ - param($psObj, $path = "$env:Temp") - - if(-not $psObj) { return } - - if($global:runningBulkExport -ne $true) - { - if($global:chkAddCompanyName.IsChecked) { $path = Join-Path $path $global:organization.displayName } - if($global:chkAddObjectType.IsChecked) { $path = Join-Path $path (Get-IntuneBrandingFolderName) } - } - - if(-not (Test-Path $path)) { mkdir -Path $path -Force -ErrorAction SilentlyContinue | Out-Null } - - if(Test-Path $path) - { - Write-Status "Export $($psObj.displayName)" - - $obj = $psObj - if($obj) - { - $fileName = "$path\$((Remove-InvalidFileNameChars $obj.displayName)).json" - ConvertTo-Json $obj -Depth 5 | Out-File $fileName -Force - Save-IntuneBrandingFile $obj "lightBackgroundLogo" $path - Save-IntuneBrandingFile $obj "darkBackgroundLogo" $path - Save-IntuneBrandingFile $obj "landingPageCustomizedImage" $path - } - $global:exportedObjects++ - } -} - -function Save-IntuneBrandingFile -{ - param($obj, $prop, $path) - - if(-not $obj.$prop.type) { return } - - $arr=$obj.$prop.type.Split('/') - if($arr.Length -gt 1) - { - $fileType = $arr[1] - } - else - { - $fileType = ".jpg" # assume... - } - - $fileName = "$path\$((Remove-InvalidFileNameChars "$($obj.displayName).$prop.$fileType"))" - try - { - if(Test-Path $fileName) - { - Remove-Item -Path $fileName -Force - } - [IO.File]::WriteAllBytes($fileName, [System.Convert]::FromBase64String($obj.$prop.value)) - } - catch {} -} - - -function Import-IntuneBranding -{ - param($obj) - - Start-PreImport $obj -RemoveProperties @("@odata.context") - - $newObject = @" -{ - "intuneBrand":$((ConvertTo-Json $obj -Depth 5)) -} - -"@ - Write-Status "Import $($obj.displayName)" - - # Note: Branding is imported to deviceManagement with JSON parent object intuneBrand - Invoke-GraphRequest -Url "$URL/deviceManagement" -Content $newObject -HttpMethod PATCH -} - -function Import-AllIntuneBrandingObjects -{ - param($path = "$env:Temp") - - Import-IntuneBrandingObjects (Get-JsonFileObjects $path) -} - -function Import-IntuneBrandingObjects -{ - param( - $Objects, - - [switch] - $Selected - ) - - Write-Status "Import Intune branding" - - foreach($obj in $objects) - { - if($Selected -and $obj.Selected -ne $true) { continue } - - Write-Log "Import Intune branding" - - $response = Import-IntuneBranding $obj.Object - - # Note: No assignments for branding. This is default branding for everyone - - } - $dgObjects.ItemsSource = @(Get-IntuneBrandingObjects) - Write-Status "" -} \ No newline at end of file diff --git a/Extensions/CompliancePolicies.psm1 b/Extensions/CompliancePolicies.psm1 deleted file mode 100644 index fa2a53b..0000000 --- a/Extensions/CompliancePolicies.psm1 +++ /dev/null @@ -1,255 +0,0 @@ -######################################################## -# -# Common module functions -# -######################################################## -function Add-ModuleMenuItems -{ - Add-MenuItem (New-Object PSObject -Property @{ - Title = (Get-CompliancePolicyName) - MenuID = "IntuneGraphAPI" - Script = [ScriptBlock]{Get-CompliancePolicies} - }) -} - -function Get-SupportedImportObjects -{ - $global:importObjects += (New-Object PSObject -Property @{ - Selected = $true - Title = (Get-CompliancePolicyName) - Script = [ScriptBlock]{ - param($rootFolder) - - Write-Status "Import all Intune compliance policies" - Import-AllCompliancePolicyObjects (Join-Path $rootFolder (Get-CompliancePolicyFolderName)) - } - }) -} - -function Get-SupportedExportObjects -{ - $global:exportObjects += (New-Object PSObject -Property @{ - Selected = $true - Title = (Get-CompliancePolicyName) - Script = [ScriptBlock]{ - param($rootFolder) - - Write-Status "Export all compliance policies" - Get-CompliancePolicyObjects | ForEach-Object { Export-SingleCompliancePolicy $PSItem.Object (Join-Path $rootFolder (Get-CompliancePolicyFolderName)) } - } - }) -} - -function Export-AllObjects -{ - param($addObjectSubfolder) - - $subFolder = "" - if($addObjectSubfolder) { $subFolder = Get-CompliancePolicyFolderName } -} - -######################################################## -# -# Object specific functions -# -######################################################## -function Get-CompliancePolicyName -{ - return "Compliance Policies" -} - -function Get-CompliancePolicyFolderName -{ - return "CompliancePolicies" -} - -function Get-CompliancePolicies -{ - Write-Status "Loading compliance policies" - $dgObjects.ItemsSource = @(Get-CompliancePolicyObjects) - - #Scriptblocks that will perform the export tasks. empty by default - $script:exportParams = @{} - $script:exportParams.Add("ExportAllScript", [ScriptBlock]{ - Export-AllCompliancePolicies $global:txtExportPath.Text - Set-ObjectGrid - Write-Status "" - }) - - $script:exportParams.Add("ExportSelectedScript", [ScriptBlock]{ - Export-SelectedCompliancePolicy $global:txtExportPath.Text - Set-ObjectGrid - Write-Status "" - }) - - #Scriptblock that will perform the import all files - $script:importAll = [ScriptBlock]{ - Import-AllCompliancePolicyObjects $global:txtImportPath.Text - Set-ObjectGrid - } - - #Scriptblock that will perform the import of selected files - $script:importSelected = [ScriptBlock]{ - Import-CompliancePolicyObjects $global:lstFiles.ItemsSource -Selected - Set-ObjectGrid - } - - #Scriptblock that will read json files - $script:getImportFiles = [ScriptBlock]{ - Show-FileListBox - $global:lstFiles.ItemsSource = @(Get-JsonFileObjects $global:txtImportPath.Text -Exclude "*_Settings.json") - } - - Add-DefaultObjectButtons -export ([scriptblock]{Show-DefaultExportGrid @script:exportParams}) -import ([scriptblock]{Show-DefaultImportGrid -ImportAll $script:importAll -ImportSelected $script:importSelected -GetFiles $script:getImportFiles}) -copy ([scriptblock]{Copy-CompliancePolicy}) -ViewFullObject ([scriptblock]{Get-CompliancePolicyObject $global:dgObjects.SelectedItem.Object}) -} - -function Get-CompliancePolicyObjects -{ - Get-GraphObjects -Url "/deviceManagement/deviceCompliancePolicies" -} - -function Get-CompliancePolicyObject -{ - param($object, $additional = "") - - if(-not $Object.id) { return } - - Invoke-GraphRequest -Url "/deviceManagement/deviceCompliancePolicies/$($Object.id)$additional" -} - -function Export-AllCompliancePolicies -{ - param($path = "$env:Temp") - - if(-not (Test-Path $path)) { mkdir -Path $path -Force -ErrorAction SilentlyContinue | Out-Null } - - if(Test-Path $path) - { - foreach($objTmp in ($global:dgObjects.ItemsSource)) - { - Export-SingleCompliancePolicy $objTmp.Object $path - } - } -} - -function Export-SelectedCompliancePolicy -{ - param($path = "$env:Temp") - - Export-SingleCompliancePolicy $global:dgObjects.SelectedItem.Object $path -} - -function Export-SingleCompliancePolicy -{ - param($psObj, $path = "$env:Temp") - - if(-not $psObj) { return } - - if($global:runningBulkExport -ne $true) - { - if($global:chkAddCompanyName.IsChecked) { $path = Join-Path $path $global:organization.displayName } - if($global:chkAddObjectType.IsChecked) { $path = Join-Path $path (Get-CompliancePolicyFolderName) } - } - - if(-not (Test-Path $path)) { mkdir -Path $path -Force -ErrorAction SilentlyContinue | Out-Null } - - if(Test-Path $path) - { - Write-Status "Export $($psObj.displayName)" - $obj = Invoke-GraphRequest -Url "/deviceManagement/deviceCompliancePolicies/$($psObj.id)?`$expand=assignments" - if($obj) - { - $fileName = "$path\$((Remove-InvalidFileNameChars $obj.displayName)).json" - ConvertTo-Json $obj -Depth 5 | Out-File $fileName -Force - - Add-MigrationInfo $obj.assignments - } - $global:exportedObjects++ - } -} - -function Copy-CompliancePolicy -{ - if(-not $dgObjects.SelectedItem) - { - [System.Windows.MessageBox]::Show("No object selected`n`nSelect compliance policy item you want to copy", "Error", "OK", "Error") - return - } - - $ret = Show-InputDialog "Copy compliance policy" "Select name for the new object" "$($dgObjects.SelectedItem.displayName) - Copy" - - if($ret) - { - # Export profile - Write-Status "Export $($dgObjects.SelectedItem.displayName)" - # Convert to Json and back to clone the object - $obj = ConvertTo-Json $dgObjects.SelectedItem.Object -Depth 5 | ConvertFrom-Json - if($obj) - { - # Import new profile - $obj.displayName = $ret - Import-CompliancePolicy $obj | Out-null - - $dgObjects.ItemsSource = @(Get-CompliancePolicyObjects) - } - Write-Status "" - } - $dgObjects.Focus() -} - -function Import-CompliancePolicy -{ - param($obj) - - Start-PreImport $obj - - $json = ConvertTo-Json $obj -Depth 5 - $json = $json.Trim().TrimEnd('}').Trim() - $json += @" -, - "scheduledActionsForRule":[{"ruleName":"PasswordRequired","scheduledActionConfigurations":[{"actionType":"block","gracePeriodHours":0,"notificationTemplateId":"","notificationMessageCCList":[]}]}] -} - -"@ - - Write-Status "Import $($obj.displayName)" - - Invoke-GraphRequest -Url "/deviceManagement/deviceCompliancePolicies" -Content $json -HttpMethod POST -} - -function Import-AllCompliancePolicyObjects -{ - param($path = "$env:Temp") - - Import-CompliancePolicyObjects (Get-JsonFileObjects $path) -} - -function Import-CompliancePolicyObjects -{ - param( - $Objects, - - [switch] - $Selected - ) - - Write-Status "Import compliance policies" - - foreach($obj in $objects) - { - if($Selected -and $obj.Selected -ne $true) { continue } - - Write-Log "Import Compliance Policy: $($obj.Object.displayName)" - - $assignments = Get-GraphAssignmentsObject $obj.Object ($obj.FileInfo.DirectoryName + "\" + $obj.FileInfo.BaseName + "_assignments.json") - - $response = Import-CompliancePolicy $obj.Object - if($response) - { - $global:importedObjects++ - Import-GraphAssignments $assignments "assignments" "/deviceManagement/deviceCompliancePolicies/$($response.Id)/assign" - } - } - $dgObjects.ItemsSource = @(Get-CompliancePolicyObjects) - Write-Status "" -} \ No newline at end of file diff --git a/Extensions/ConditionalAccess.psm1 b/Extensions/ConditionalAccess.psm1 deleted file mode 100644 index e285d1c..0000000 --- a/Extensions/ConditionalAccess.psm1 +++ /dev/null @@ -1,291 +0,0 @@ -######################################################## -# -# Common module functions -# -######################################################## -function Add-ModuleMenuItems -{ - Add-MenuItem (New-Object PSObject -Property @{ - Title = (Get-ConditionalAccessName) - MenuID = "IntuneGraphAPI" - Script = [ScriptBlock]{Get-ConditionalAccess} - }) -} - -function Get-SupportedImportObjects -{ - $global:importObjects += (New-Object PSObject -Property @{ - Selected = $true - Title = (Get-ConditionalAccessName) - Script = [ScriptBlock]{ - param($rootFolder) - - Write-Status "Import all conditional access policies" - Import-AllConditionalAccessObjects (Join-Path $rootFolder (Get-ConditionalAccessFolderName)) - } - }) -} - -function Get-SupportedExportObjects -{ - $global:exportObjects += (New-Object PSObject -Property @{ - Selected = $true - Title = (Get-ConditionalAccessName) - Script = [ScriptBlock]{ - param($rootFolder) - - Write-Status "Export all conditional access policies" - Get-ConditionalAccessObjects | ForEach-Object { Export-SingleConditionalAccess $PSItem.Object (Join-Path $rootFolder (Get-ConditionalAccessFolderName)) } - } - }) -} - -function Export-AllObjects -{ - param($addObjectSubfolder) - - $subFolder = "" - if($addObjectSubfolder) { $subFolder = Get-ConditionalAccessFolderName } -} - -######################################################## -# -# Object specific functions -# -######################################################## -function Get-ConditionalAccessName -{ - return "Conditional Access" -} - -function Get-ConditionalAccessFolderName -{ - return "ConditionalAccess" -} - -function Get-ConditionalAccess -{ - Write-Status "Loading conditional access objects" - $dgObjects.ItemsSource = @(Get-ConditionalAccessObjects) - - #Scriptblocks that will perform the export tasks. empty by default - $script:exportParams = @{} - $script:exportParams.Add("ExportAllScript", [ScriptBlock]{ - Export-AllConditionalAccess $global:txtExportPath.Text - Set-ObjectGrid - Write-Status "" - }) - - $script:exportParams.Add("ExportSelectedScript", [ScriptBlock]{ - Export-SelectedConditionalAccess $global:txtExportPath.Text - Set-ObjectGrid - Write-Status "" - }) - #Scriptblock that will perform the import all files - $script:importAll = [ScriptBlock]{ - Import-AllConditionalAccessObjects $global:txtImportPath.Text - Set-ObjectGrid - } - - #Scriptblock that will perform the import of selected files - $script:importSelected = [ScriptBlock]{ - Import-ConditionalAccessObjects $global:lstFiles.ItemsSource -Selected - Set-ObjectGrid - } - - #Scriptblock that will read json files - $script:getImportFiles = [ScriptBlock]{ - Show-FileListBox - $global:lstFiles.ItemsSource = @(Get-JsonFileObjects $global:txtImportPath.Text -Exclude "*_Settings.json") - } - - Add-DefaultObjectButtons -export ([scriptblock]{Show-DefaultExportGrid @script:exportParams}) -import ([scriptblock]{Show-DefaultImportGrid -ImportAll $script:importAll -ImportSelected $script:importSelected -GetFiles $script:getImportFiles}) -ViewFullObject ([scriptblock]{Get-ConditionalAccessObject $global:dgObjects.SelectedItem.Object}) -} - -function Get-ConditionalAccessObjects -{ - #https://main.iam.ad.ext.azure.com/api/Policies/Policies?top=10&nextLink=null&appId=&includeBaseline=true - Get-AzureNativeObjects "Policies/Policies?top=10&appId=&includeBaseline=true" -property @('policyName') -allowPaging -} - -function Get-ConditionalAccessObject -{ - param($object, $additional = "") - - if(-not $Object.policyId) { return } - - if($Object.baselineType -eq 0) - { - Invoke-AzureNativeRequest "Policies/$($Object.policyId)$additional" - } - else - { - Invoke-AzureNativeRequest "BaselinePolicies/$($Object.policyId)$additional" - } -} - -function Export-AllConditionalAccess -{ - param($path = "$env:Temp") - - if(-not (Test-Path $path)) { mkdir -Path $path -Force -ErrorAction SilentlyContinue | Out-Null } - - if(Test-Path $path) - { - foreach($objTmp in ($global:dgObjects.ItemsSource)) - { - Export-SingleConditionalAccess $objTmp.Object $path - } - } -} - -function Export-SelectedConditionalAccess -{ - param($path = "$env:Temp") - - Export-SingleConditionalAccess $global:dgObjects.SelectedItem.Object $path -} - -function Export-SingleConditionalAccess -{ - param($psObj, $path = "$env:Temp") - - if(-not $psObj) { return } - - if($global:runningBulkExport -ne $true) - { - if($global:chkAddCompanyName.IsChecked) { $path = Join-Path $path $global:organization.displayName } - if($global:chkAddObjectType.IsChecked) { $path = Join-Path $path (Get-ConditionalAccessFolderName) } - } - - if(-not (Test-Path $path)) { mkdir -Path $path -Force -ErrorAction SilentlyContinue | Out-Null } - - if(Test-Path $path) - { - Write-Status "Export $($psObj.policyName)" - - if($psObj.baselineType -eq 0) - { - $obj = Invoke-AzureNativeRequest "Policies/$($psObj.policyId)" - } - else - { - $obj = Invoke-AzureNativeRequest "BaselinePolicies/$($psObj.policyId)" - } - - if($obj) - { - $fileName = "$path\$((Remove-InvalidFileNameChars $psObj.policyName)).json" - ConvertTo-Json $obj -Depth 5 | Out-File $fileName -Force - } - - if($jsonObj.usersV2.included.groupIds) - { - $jsonObj.usersV2.included.groupIds | ForEach-Object { Add-GroupMigrationObject $PSItem } - } - - if($jsonObj.usersV2.excluded.groupIds) - { - $jsonObj.usersV2.excluded.groupIds | ForEach-Object { Add-GroupMigrationObject $PSItem } - } - - if($jsonObj.usersV2.included.userIds -or $jsonObj.usersV2.excluded.userIds) - { - Write-Log "Users are specified in $($psObj.policyName). User are not supported in this version. This conditional access policy might not be imported" 2 - } - - if($jsonObj.usersV2.included.roleIds -or $jsonObj.usersV2.excluded.roleIds) - { - Write-Log "Roles are specified in $($psObj.policyName). Roles are not supported in this version. This conditional access policy might not be imported" 2 - } - - if($jsonObj.conditions.namedNetworks.includedNetworkIds -or $jsonObj.conditions.namedNetworks.excludedNetworkIds) - { - Write-Log "Networks are specified in $($psObj.policyName). Named networks are not supported in this version. This conditional access policy might not be imported" 2 - } - - # There might be a lot more to check here... - - $global:exportedObjects++ - } -} - -function Import-ConditionalAccess -{ - param($obj) - - Start-PreImport $obj - - $json = Update-JsonForEnvironment $json - - if($obj.baselineType -eq 0) - { - $obj.policyId = "" - $obj.isAllProtocolsEnabled = $true - $json = ConvertTo-Json $obj -Depth 10 - $json = Update-JsonForEnvironment $json - - if((Invoke-AzureNativeRequest "Policies/Validate" -Method POST -Body $json) -eq 11) - { - Invoke-AzureNativeRequest "Policies" -Method POST -Body $json | Out-Null - } - else - { - Write-Log "Policy validation of json data failed" 3 - } - } - else - { - Write-Log "Conditional Access Baseline Policies does not support import" - #Invoke-AzureNativeRequest "BaselinePolicies/$($obj.id)" -Method PUT -Body (ConvertTo-Json $obj -Depth 5) | Out-Null - } -} - -function Import-AllConditionalAccessObjects -{ - param($path = "$env:Temp") - - Import-ConditionalAccessObjects (Get-JsonFileObjects $path) -} - -function Import-ConditionalAccessObjects -{ - param( - $Objects, - - [switch] - $Selected - ) - - Write-Status "Import conditional access policies" - - foreach($obj in $objects) - { - if($Selected -and $obj.Selected -ne $true) { continue } - - Write-Log "Import Conditional Access: $($obj.Object.policyName)" - - $response = Import-ConditionalAccess $obj.Object - - if($response) - { - $global:importedObjects++ - } - # No additionl assignments on conditional access policies - } - $dgObjects.ItemsSource = @(Get-ConditionalAccessObjects) - Write-Status "" -} - -<# - # Get all networks - Get-AzureNativeObjects "NamedNetworksV2" - - # Network example - #{"networkName":"Australia","cidrIpRanges":[],"categories":[],"applyToUnknownCountry":false,"countryIsoCodes":["AU"],"isTrustedLocation":false,"namedLocationsType":2} - - Get-AzureNativeObjects "NamedNetworksV2" -Method POST -Body $json | Out-Nul - - # Get all contry codes - NamedNetworksV2/CountryCodes -#> \ No newline at end of file diff --git a/Extensions/ConfigurationItems.psm1 b/Extensions/ConfigurationItems.psm1 deleted file mode 100644 index 7443aa0..0000000 --- a/Extensions/ConfigurationItems.psm1 +++ /dev/null @@ -1,251 +0,0 @@ -######################################################## -# -# Common module functions -# -######################################################## -function Add-ModuleMenuItems -{ - Add-MenuItem (New-Object PSObject -Property @{ - Title = (Get-DeviceConfigurationName) - MenuID = "IntuneGraphAPI" - Script = [ScriptBlock]{Get-DeviceConfigurations} - }) -} - -function Get-SupportedImportObjects -{ - $global:importObjects += (New-Object PSObject -Property @{ - Selected = $true - Title = (Get-DeviceConfigurationName) - Script = [ScriptBlock]{ - param($rootFolder) - - Write-Status "Import all device configuration objects" - Import-AllDeviceConfigurationObjects (Join-Path $rootFolder (Get-DeviceConfigurationFolderName)) - } - }) -} - -function Get-SupportedExportObjects -{ - $global:exportObjects += (New-Object PSObject -Property @{ - Selected = $true - Title = (Get-DeviceConfigurationName) - Script = [ScriptBlock]{ - param($rootFolder) - - Write-Status "Export all device configuration objects" - Get-DeviceConfigurationObjects | ForEach-Object { Export-SingleDeviceConfiguration $PSItem.Object (Join-Path $rootFolder (Get-DeviceConfigurationFolderName)) } - } - }) -} - -function Export-AllObjects -{ - param($addObjectSubfolder) - - $subFolder = "" - if($addObjectSubfolder) { $subFolder = Get-DeviceConfigurationFolderName } -} - -######################################################## -# -# Object specific functions -# -######################################################## -function Get-DeviceConfigurationName -{ - return "Device Configurations" -} - -function Get-DeviceConfigurationFolderName -{ - return "DeviceConfigurations" -} - -function Get-DeviceConfigurations -{ - Write-Status "Loading device configurations" - $dgObjects.ItemsSource = @(Get-DeviceConfigurationObjects) - - #Scriptblocks that will perform the export tasks. empty by default - $script:exportParams = @{} - $script:exportParams.Add("ExportAllScript", [ScriptBlock]{ - Export-AllDeviceConfigurations $global:txtExportPath.Text - Set-ObjectGrid - Write-Status "" - }) - - $script:exportParams.Add("ExportSelectedScript", [ScriptBlock]{ - Export-SelectedDeviceConfiguration $global:txtExportPath.Text - Set-ObjectGrid - Write-Status "" - }) - #Scriptblock that will perform the import all files - $script:importAll = [ScriptBlock]{ - Import-AllDeviceConfigurationObjects $global:txtImportPath.Text - Set-ObjectGrid - } - - #Scriptblock that will perform the import of selected files - $script:importSelected = [ScriptBlock]{ - Import-DeviceConfigurationObjects $global:lstFiles.ItemsSource -Selected - Set-ObjectGrid - } - - #Scriptblock that will read json files - $script:getImportFiles = [ScriptBlock]{ - Show-FileListBox - $global:lstFiles.ItemsSource = @(Get-JsonFileObjects $global:txtImportPath.Text -Exclude "*_Settings.json") - } - - Add-DefaultObjectButtons -export ([scriptblock]{Show-DefaultExportGrid @script:exportParams}) -import ([scriptblock]{Show-DefaultImportGrid -ImportAll $script:importAll -ImportSelected $script:importSelected -GetFiles $script:getImportFiles}) -copy ([scriptblock]{Copy-DeviceConfiguration}) -ViewFullObject ([scriptblock]{Get-DeviceConfigurationObject $global:dgObjects.SelectedItem.Object}) -} - -function Get-DeviceConfigurationObjects -{ - Get-GraphObjects -Url "/deviceManagement/deviceConfigurations"#,"/deviceManagement/groupPolicyConfigurations" -} - -function Get-DeviceConfigurationObject -{ - param($object, $additional = "") - - if(-not $Object.id) { return } - - Invoke-GraphRequest -Url "/deviceManagement/deviceConfigurations/$($Object.id)$additional" -} - -function Export-AllDeviceConfigurations -{ - param($path = "$env:Temp") - - if(-not (Test-Path $path)) { mkdir -Path $path -Force -ErrorAction SilentlyContinue | Out-Null } - - if(Test-Path $path) - { - foreach($objTmp in ($global:dgObjects.ItemsSource)) - { - Export-SingleDeviceConfiguration $objTmp.Object $path - } - } -} - -function Export-SelectedDeviceConfiguration -{ - param($path = "$env:Temp") - - Export-SingleDeviceConfiguration $global:dgObjects.SelectedItem.Object $path -} - -function Export-SingleDeviceConfiguration -{ - param($psObj, $path = "$env:Temp") - - if(-not $psObj) { return } - - if($global:runningBulkExport -ne $true) - { - if($global:chkAddCompanyName.IsChecked) { $path = Join-Path $path $global:organization.displayName } - if($global:chkAddObjectType.IsChecked) { $path = Join-Path $path (Get-DeviceConfigurationFolderName) } - } - - if(-not (Test-Path $path)) { mkdir -Path $path -Force -ErrorAction SilentlyContinue | Out-Null } - - if(Test-Path $path) - { - Write-Status "Export $($psObj.displayName)" - $obj = Invoke-GraphRequest -Url "/deviceManagement/deviceConfigurations/$($psObj.id)?`$expand=assignments" - if($obj) - { - $fileName = "$path\$((Remove-InvalidFileNameChars $obj.displayName)).json" - ConvertTo-Json $obj -Depth 5 | Out-File $fileName -Force - if($script:chkExportScript.IsChecked) - { - $fileName = "$path\$($obj.FileName)" - [System.Text.Encoding]::ASCII.GetString([System.Convert]::FromBase64String($obj.scriptContent)) | Out-File $fileName -Force - } - - Add-MigrationInfo $obj.assignments - - $global:exportedObjects++ - } - } -} - -function Copy-DeviceConfiguration -{ - if(-not $dgObjects.SelectedItem) - { - [System.Windows.MessageBox]::Show("No object selected`n`nSelect device configuration item you want to copy", "Error", "OK", "Error") - return - } - - $ret = Show-InputDialog "Copy device configuration" "Select name for the new object" "$($dgObjects.SelectedItem.displayName) - Copy" - - if($ret) - { - # Export profile - Write-Status "Export $($dgObjects.SelectedItem.displayName)" - # Convert to Json and back to clone the object - $obj = ConvertTo-Json $dgObjects.SelectedItem.Object -Depth 5 | ConvertFrom-Json - if($obj) - { - # Import new profile - $obj.displayName = $ret - Import-DeviceConfiguration $obj | Out-Null - - $dgObjects.ItemsSource = @(Get-DeviceConfigurationObjects) - } - Write-Status "" - } - $dgObjects.Focus() -} - -function Import-DeviceConfiguration -{ - param($obj) - - Write-Status "Import $($obj.displayName)" - - Start-PreImport $obj - - Invoke-GraphRequest -Url "/deviceManagement/deviceConfigurations" -Content (ConvertTo-Json $obj -Depth 5) -HttpMethod POST -} - -function Import-AllDeviceConfigurationObjects -{ - param($path = "$env:Temp") - - Import-DeviceConfigurationObjects (Get-JsonFileObjects $path) -} - -function Import-DeviceConfigurationObjects -{ - param( - $Objects, - - [switch] - $Selected - ) - - Write-Status "Import device configuration profiles" - - foreach($obj in $objects) - { - if($Selected -and $obj.Selected -ne $true) { continue } - - Write-Log "Import device configuration policy: $($obj.Object.displayName)" - - $assignments = Get-GraphAssignmentsObject $obj.Object ($obj.FileInfo.DirectoryName + "\" + $obj.FileInfo.BaseName + "_assignments.json") - - $response = Import-DeviceConfiguration $obj.Object - if($response) - { - $global:importedObjects++ - Import-GraphAssignments $assignments "assignments" "/deviceManagement/deviceConfigurations/$($response.Id)/assign" - } - } - $dgObjects.ItemsSource = @(Get-DeviceConfigurationObjects) - Write-Status "" -} \ No newline at end of file diff --git a/Extensions/EndpointManager.psm1 b/Extensions/EndpointManager.psm1 new file mode 100644 index 0000000..4175424 --- /dev/null +++ b/Extensions/EndpointManager.psm1 @@ -0,0 +1,1595 @@ +<# +.SYNOPSIS +Module for managing Intune objects + +.DESCRIPTION +This module is for the Endpoint Manager/Intune View. It manages Export/Import/Copy of Intune objects + +.NOTES + Author: Mikael Karlsson +#> +function Get-ModuleVersion +{ + '3.0.0' +} + +function Invoke-InitializeModule +{ + #Add settings + $global:appSettingSections += (New-Object PSObject -Property @{ + Title = "Endpoint Manager/Intune" + Id = "EndpointManager" + Values = @() + Priority = 10 + }) + + Add-SettingsObject (New-Object PSObject -Property @{ + Title = "Application" + Key = "EMAzureApp" + Type = "List" + ItemsSource = $global:MSGraphGlobalApps + DefaultValue = "" + SubPath = "EndpointManager" + }) "EndpointManager" + + Add-SettingsObject (New-Object PSObject -Property @{ + Title = "Application Id" + Key = "EMCustomAppId" + Type = "String" + DefaultValue = "" + SubPath = "EndpointManager" + }) "EndpointManager" + + Add-SettingsObject (New-Object PSObject -Property @{ + Title = "Redirect URL" + Key = "EMCustomAppRedirect" + Type = "String" + DefaultValue = "" + SubPath = "EndpointManager" + }) "EndpointManager" + + Add-SettingsObject (New-Object PSObject -Property @{ + Title = "Tenant Id" + Key = "EMCustomTenantId" + Type = "String" + DefaultValue = "" + SubPath = "EndpointManager" + }) "EndpointManager" + + Add-SettingsObject (New-Object PSObject -Property @{ + Title = "Authority" + Key = "EMCustomAuthority" + Type = "String" + DefaultValue = "" + SubPath = "EndpointManager" + }) "EndpointManager" + + Add-SettingsObject (New-Object PSObject -Property @{ + Title = "App packages folder" + Key = "EMIntuneAppPackages" + Type = "Folder" + Description = "Root folder where intune app packages are located" + SubPath = "EndpointManager" + }) "EndpointManager" + + $viewPanel = Get-XamlObject ($global:AppRootFolder + "\Xaml\EndpointManagerPanel.xaml") -AddVariables + + Set-EMViewPanel $viewPanel + + #Add menu group and items + $global:EMViewObject = (New-Object PSObject -Property @{ + Title = "Intune Manager" + Description = "Manages Intune environments. This view can be used for copying objects in an Intune environment. It can also be used for backing up an entire Intune environment and cloning the Intune environment into another tenant." + ID="IntuneGraphAPI" + ViewPanel = $viewPanel + ItemChanged = { Show-GraphObjects; Write-Status ""} + Deactivating = { Invoke-EMDeactivateView } + Activating = { Invoke-EMActivatingView } + Authentication = (Get-MSALAuthenticationObject) + Authenticate = { Invoke-EMAuthenticateToMSAL } + AppInfo = (Get-GraphAppInfo "EMAzureApp" "d1ddf0e4-d672-4dae-b554-9d5bdfd93547") + SaveSettings = { Invoke-EMSaveSettings } + }) + + Add-ViewObject $global:EMViewObject + + Add-ViewItem (New-Object PSObject -Property @{ + Title = "Device Configuration" + Id = "DeviceConfiguration" + ViewID = "IntuneGraphAPI" + API = "/deviceManagement/deviceConfigurations" + QUERYLIST = "`$filter=not%20isof(%27microsoft.graph.windowsUpdateForBusinessConfiguration%27)%20and%20not%20isof(%27microsoft.graph.iosUpdateConfiguration%27)" + #ExportFullObject = $false + Permissons=@("DeviceManagementConfiguration.ReadWrite.All") + }) + + Add-ViewItem (New-Object PSObject -Property @{ + Title = "Conditional Access" + Id = "ConditionalAccess" + ViewID = "IntuneGraphAPI" + API = "/identity/conditionalAccess/policies" + Permissons=@("Policy.Read.All","Policy.ReadWrite.ConditionalAccess","Application.Read.All") + Dependencies = @("NamedLocations","Applications") + }) + + Add-ViewItem (New-Object PSObject -Property @{ + Title = "Named Locations" + Id = "NamedLocations" + ViewID = "IntuneGraphAPI" + API = "/identity/conditionalAccess/namedLocations" + Permissons=@("Policy.ReadWrite.ConditionalAccess") + ImportOrder = 50 + }) + + Add-ViewItem (New-Object PSObject -Property @{ + Title = "Endpoint Security" + Id = "EndpointSecurity" + ViewID = "IntuneGraphAPI" + API = "/deviceManagement/intents" + PropertiesToRemove = @('Settings','@OData.Type') + PreImportCommand = { Start-PreImportEndpointSecurity @args } + PostListCommand = { Start-PostListEndpointSecurity @args } + PostExportCommand = { Start-PostExportEndpointSecurity @args } + PostFileImportCommand = { Start-PostFileImportEndpointSecurity @args } + #PreCopyCommand = { Start-PreCopyEndpointSecurity @args } + PostCopyCommand = { Start-PostCopyEndpointSecurity @args } + Permissons=@("DeviceManagementConfiguration.ReadWrite.All") + }) + + Add-ViewItem (New-Object PSObject -Property @{ + Title = "Compliance Policies" + Id = "CompliancePolicies" + ViewID = "IntuneGraphAPI" + Expand = "scheduledActionsForRule(`$expand=scheduledActionConfigurations)" + API = "/deviceManagement/deviceCompliancePolicies" + Permissons=@("DeviceManagementConfiguration.ReadWrite.All") + Dependencies = @("Locations","Notifications") + PostExportCommand = { Start-PostExportCompliancePolicies @args } + }) + + Add-ViewItem (New-Object PSObject -Property @{ + Title = "Intune Branding" + Id = "IntuneBranding" + API = "/deviceManagement/intuneBrandingProfiles" + ViewID = "IntuneGraphAPI" + NameProperty = "profileName" + ViewProperties = @("profileName", "displayName", "description", "id","isDefaultProfile") + PreImportCommand = { Start-PreImportIntuneBranding @args } + PostImportCommand = { Start-PostImportIntuneBranding @args } + PostGetCommand = { Start-PostGetIntuneBranding @args } + PostExportCommand = { Start-PostExportIntuneBranding @args } + Permissons=@("DeviceManagementApps.ReadWrite.All") + Icon = "Branding" + SkipRemoveProperties = @('Id') # Id is removed by PreImport. Required for default profile + }) + + <# + # BUG in Graph? Cannot create default branding. Can only create it when importing another object + # Header required Accept-Language: sv-SE + # Documentation says to use Content-Language but that doesn't work + + # Could work with https://main.iam.ad.ext.azure.com/api/LoginTenantBrandings + + + Add-ViewItem (New-Object PSObject -Property @{ + Title = "Azure Branding" + Id = "AzureBranding" + API = "/organization/%OrganizationId%/branding/localizations" + ViewID = "IntuneGraphAPI" + ViewProperties = @("Id") + PreImportCommand = { Start-PreImportAzureBranding @args } + PostListCommand = { Start-PostListAzureBranding @args } + NameProperty = "Id" + Permissons=@("Organization.ReadWrite.All") + Icon = "Branding" + SkipRemoveProperties = @('Id') + }) + #> + + Add-ViewItem (New-Object PSObject -Property @{ + Title = "Enrollment Status Page" + Id = "EnrollmentStatusPage" + API = "/deviceManagement/deviceEnrollmentConfigurations" + ViewID = "IntuneGraphAPI" + PreImportCommand = { Start-PreImportESP @args } + PostExportCommand = { Start-PostExportESP @args } + QUERYLIST = "`$filter=endsWith(id,'Windows10EnrollmentCompletionPageConfiguration')" + Permissons=@("DeviceManagementServiceConfig.ReadWrite.All") + SkipRemoveProperties = @('Id') + AssignmentsType = "enrollmentConfigurationAssignments" + }) + + Add-ViewItem (New-Object PSObject -Property @{ + Title = "Enrollment Restrictions" + Id = "EnrollmentRestrictions" + API = "/deviceManagement/deviceEnrollmentConfigurations" + ViewID = "IntuneGraphAPI" + QUERYLIST = "`$filter=not endsWith(id,'Windows10EnrollmentCompletionPageConfiguration')" + PostExportCommand = { Start-PostExportEnrollmentRestrictions @args } + PreImportCommand = { Start-PreImportEnrollmentRestrictions @args } + Permissons=@("DeviceManagementServiceConfig.ReadWrite.All") + SkipRemoveProperties = @('Id') + AssignmentsType = "enrollmentConfigurationAssignments" + }) + + Add-ViewItem (New-Object PSObject -Property @{ + Title = "Administrative Templates" + Id = "AdministrativeTemplates" + API = "/deviceManagement/groupPolicyConfigurations" + ViewID = "IntuneGraphAPI" + PostExportCommand = { Start-PostExportAdministrativeTemplate @args } + PostCopyCommand = { Start-PostCopyAdministrativeTemplate @args } + PostFileImportCommand = { Start-PostFileImportAdministrativeTemplate @args } + Permissons=@("DeviceManagementConfiguration.ReadWrite.All") + Icon="DeviceConfiguration" + }) + + Add-ViewItem (New-Object PSObject -Property @{ + Title = "Scripts" + Id = "Scripts" + API = "/deviceManagement/deviceManagementScripts" + ViewID = "IntuneGraphAPI" + DetailExtension = { Add-ScriptExtensions @args } + ExportExtension = { Add-ScriptExportExtensions @args } + PostExportCommand = { Start-PostExportScripts @args } + Permissons=@("DeviceManagementManagedDevices.ReadWrite.All") + AssignmentsType = "deviceManagementScriptAssignments" + }) + + Add-ViewItem (New-Object PSObject -Property @{ + Title = "Terms and Conditions" + Id = "TermsAndConditions" + API = "/deviceManagement/termsAndConditions" + ViewID = "IntuneGraphAPI" + Permissons=@("DeviceManagementServiceConfig.ReadWrite.All") + ExpandAssignments = $false # Not supported for this object type + PostExportCommand = { Start-PostExportTermsAndConditions @args } + PreImportAssignmentsCommand = { Start-PreImportAssignmentsTermsAndConditions @args } + }) + + Add-ViewItem (New-Object PSObject -Property @{ + Title = "App Protection" + Id = "AppProtection" + API = "/deviceAppManagement/managedAppPolicies" + ViewID = "IntuneGraphAPI" + PreGetCommand = { Start-GetAppProtection @args } + PostListCommand = { Start-PostListAppProtection @args } + PreImportCommand = { Start-PreImportAppProtection @args } + PostImportCommand = { Start-PostImportAppProtection @args } + PreImportAssignmentsCommand = { Start-PreImportAssignmentsAppProtection @args } + ExportFullObject = $true + Permissons=@("DeviceManagementApps.ReadWrite.All") + Dependencies = @("Applications") + }) + + # These are also included in the managedAppPolicies API + # So all custom commands will be handled by the same functions as App Protection + Add-ViewItem (New-Object PSObject -Property @{ + Title = "App Configuration (App)" + Id = "AppConfigurationManagedApp" + API = "/deviceAppManagement/targetedManagedAppConfigurations" + ViewID = "IntuneGraphAPI" + PreGetCommand = { Start-GetAppProtection @args } + PreImportCommand = { Start-PreImportAppProtection @args } + PostImportCommand = { Start-PostImportAppProtection @args } + PreImportAssignmentsCommand = { Start-PreImportAssignmentsAppProtection @args } + Permissons=@("DeviceManagementApps.ReadWrite.All") + Dependencies = @("Applications") + Icon = "AppConfiguration" + }) + + Add-ViewItem (New-Object PSObject -Property @{ + Title = "App Configuration (Device)" + Id = "AppConfigurationManagedDevice" + API = "/deviceAppManagement/mobileAppConfigurations" + QUERYLIST = "`$filter=microsoft.graph.androidManagedStoreAppConfiguration/appSupportsOemConfig%20eq%20false%20or%20isof(%27microsoft.graph.androidManagedStoreAppConfiguration%27)%20eq%20false" + ViewID = "IntuneGraphAPI" + Permissons=@("DeviceManagementApps.ReadWrite.All") + Dependencies = @("Applications") + PreImportAssignmentsCommand = { Start-PreImportAssignmentsAppConfiguration @args } + #PostExportCommand = { Start-PostExportAppConfiguration @args } + Icon = "AppConfiguration" + }) + + Add-ViewItem (New-Object PSObject -Property @{ + Title = "Applications" + Id = "Applications" + API = "/deviceAppManagement/mobileApps" + ViewID = "IntuneGraphAPI" + PropertiesToRemove = @('uploadState','publishingState','isAssigned','dependentAppCount','supersedingAppCount','supersededAppCount','committedContentVersion','isFeatured','size') + QUERYLIST = "`$filter=(microsoft.graph.managedApp/appAvailability%20eq%20null%20or%20microsoft.graph.managedApp/appAvailability%20eq%20%27lineOfBusiness%27%20or%20isAssigned%20eq%20true)&`$orderby=displayName" + Permissons=@("DeviceManagementApps.ReadWrite.All") + AssignmentsType="mobileAppAssignments" + AssignmentProperties = @("@odata.type","target","settings","intent") + AssignmentTargetProperties = @("@odata.type","groupId","deviceAndAppManagementAssignmentFilterId","deviceAndAppManagementAssignmentFilterType") + ImportOrder = 60 + PostFileImportCommand = { Start-PostFileImportApplications @args } + }) + + Add-ViewItem (New-Object PSObject -Property @{ + Title = "AutoPilot" + Id = "AutoPilot" + API = "/deviceManagement/windowsAutopilotDeploymentProfiles" + ViewID = "IntuneGraphAPI" + CopyDefaultName = "%displayName% Copy" # '-' is not allowed in the name + Permissons=@("DeviceManagementServiceConfig.ReadWrite.All") + PreImportAssignmentsCommand = { Start-PreImportAssignmentsAutoPilot @args } + }) + + Add-ViewItem (New-Object PSObject -Property @{ + Title = "Policy Sets" + Id = "PolicySets" + API = "/deviceAppManagement/policySets" + ViewID = "IntuneGraphAPI" + Expand = "Items" + PreImportAssignmentsCommand = { Start-PreImportAssignmentsPolicySets @args } + PreImportCommand = { Start-PreImportPolicySets @args } + Permissons=@("DeviceManagementConfiguration.ReadWrite.All") + ImportOrder = 2000 # Policy Sets reference other objects so make sure it is imported last + Dependencies = @("Applications","AppConfiguration","AppProtection","AutoPilot","EnrollmentRestrictions","EnrollmentStatusPage","DeviceConfiguration","AdministrativeTemplates","SettingsCatalog","CompliancePolicies") + }) + + Add-ViewItem (New-Object PSObject -Property @{ + Title = "Update Policies" + Id = "UpdatePolicies" + ViewID = "IntuneGraphAPI" + API = "/deviceManagement/deviceConfigurations" + QUERYLIST = "`$filter=isof(%27microsoft.graph.windowsUpdateForBusinessConfiguration%27)%20or%20isof(%27microsoft.graph.iosUpdateConfiguration%27)" + #ExportFullObject = $false + Permissons=@("DeviceManagementConfiguration.ReadWrite.All") + }) + + Add-ViewItem (New-Object PSObject -Property @{ + Title = "Feature Updates" + Id = "FeatureUpdates" + ViewID = "IntuneGraphAPI" + API = "/deviceManagement/windowsFeatureUpdateProfiles" + Permissons=@("DeviceManagementConfiguration.ReadWrite.All") + }) + + # Locations are not FULLY supported + # They will be imported but Compliance Policies will not be updated with new Location object after import + # ToDo: Add support Export/Import Location Settings + # Location object - Only used by Android Device Admins Compliance Policies + # - These should probably be migrated to Android Enterprise anyway. That is the recommendation by Google + # Property that needs to be updated on the Compliance Policy + # deviceManagement/managementConditionStatements/$obj.conditionStatementId + + Add-ViewItem (New-Object PSObject -Property @{ + Title = "Locations" + Id = "Locations" + ViewID = "IntuneGraphAPI" + API = "/deviceManagement/managementConditions" + Permissons=@("DeviceManagementConfiguration.ReadWrite.All") + PreImportCommand = { Start-PreImportLocations @args } + ImportOrder = 30 + }) + + Add-ViewItem (New-Object PSObject -Property @{ + Title = "Settings Catalog" + Id = "SettingsCatalog" + ViewID = "IntuneGraphAPI" + API = "/deviceManagement/configurationPolicies" + PropertiesToRemove = @('settingCount') + Permissons=@("DeviceManagementConfiguration.ReadWrite.All") + NameProperty = "Name" + ViewProperties = @("name","description","Id") + Expand="Settings" + Icon="DeviceConfiguration" + PostExportCommand = { Start-PostExportSettingsCatalog @args } + }) + + Add-ViewItem (New-Object PSObject -Property @{ + Title = "Role Definitions" + Id = "RoleDefinitions" + ViewID = "IntuneGraphAPI" + API = "/deviceManagement/roleDefinitions" + QUERYLIST = "`$filter=isBuiltIn%20eq%20false" + PostExportCommand = { Start-PostExportRoleDefinitions @args } + PreImportCommand = { Start-PreImportRoleDefinitions @args } + PostFileImportCommand = { Start-PostFileImportRoleDefinitions @args } + Permissons=@("DeviceManagementRBAC.ReadWrite.All") + ImportOrder = 20 + #expand=roleassignments + }) + + Add-ViewItem (New-Object PSObject -Property @{ + Title = "Scope (Tags)" + Id = "ScopeTags" + ViewID = "IntuneGraphAPI" + API = "/deviceManagement/roleScopeTags" + QUERYLIST = "`$filter=isBuiltIn%20eq%20false" + Permissons=@("DeviceManagementRBAC.ReadWrite.All") + PostExportCommand = { Start-PostExportScopeTags @args } + ImportOrder = 10 + }) + + Add-ViewItem (New-Object PSObject -Property @{ + Title = "Notifications" + Id = "Notifications" + ViewID = "IntuneGraphAPI" + API = "/deviceManagement/notificationMessageTemplates" + Permissons=@("DeviceManagementServiceConfig.ReadWrite.All") + ImportOrder = 40 + Expand = "localizedNotificationMessages" + PreImportCommand = { Start-PreImportNotifications @args } + PostFileImportCommand = { Start-PostFileImportNotifications @args } + PostCopyCommand = { Start-PostCopyNotifications @args } + + }) + + # This has some pre-reqs for working! + # Import is tested and verified in a tenant with Googple Play connection configured + # And the OEM app was dpwnloaded e.g. Knox Service Plugin + # Import failed in a tenant where Google Play was NOT configured + Add-ViewItem (New-Object PSObject -Property @{ + Title = "Android OEM Config" + Id = "AndroidOEMConfig" + ViewID = "IntuneGraphAPI" + QUERYLIST = "`$filter=microsoft.graph.androidManagedStoreAppConfiguration/appSupportsOemConfig%20eq%20true" + API = "/deviceAppManagement/mobileAppConfigurations" + PreImportAssignmentsCommand = { Start-PreImportAssignmentsAppConfiguration @args } + Permissons=@("DeviceManagementConfiguration.ReadWrite.All") + Icon="DeviceConfiguration" + Dependencies = @("Applications") + }) + + # Copy/Export/Import not verified! + Add-ViewItem (New-Object PSObject -Property @{ + Title = "Apple Enrollment Types" + Id = "AppleEnrollmentTypes" + ViewID = "IntuneGraphAPI" + API = "/deviceManagement/appleUserInitiatedEnrollmentProfiles" + Permissons=@("DeviceManagementServiceConfig.ReadWrite.All") + }) +} + +function Invoke-EMAuthenticateToMSAL +{ + $global:EMViewObject.AppInfo = Get-GraphAppInfo "EMAzureApp" "d1ddf0e4-d672-4dae-b554-9d5bdfd93547" + Set-MSALCurrentApp $global:EMViewObject.AppInfo + & $global:msalAuthenticator.Login -Account (?? $global:MSALToken.Account.UserName (Get-Setting "" "LastLoggedOnUser")) +} + +function Invoke-EMDeactivateView +{ + $tmp = $mnuMain.Items | Where Name -eq "EMBulk" + if($tmp) { $mnuMain.Items.Remove($tmp) } +} + +function Invoke-EMActivatingView +{ + Show-MSALError + + # Refresh values in case they have changed + $global:EMViewObject.AppInfo = (Get-GraphAppInfo "EMAzureApp" "d1ddf0e4-d672-4dae-b554-9d5bdfd93547") + if(-not $global:EMViewObject.Authentication) + { + $global:EMViewObject.Authentication = Get-MSALAuthenticationObject + } + + # Add View specific menus + Add-GraphBulkMenu +} + +function Invoke-EMSaveSettings +{ + $tmpApp = Get-GraphAppInfo "EMAzureApp" "d1ddf0e4-d672-4dae-b554-9d5bdfd93547" + + if($global:appObj.ClientID -ne $tmpApp.ClientId -and $global:MSALToken) + { + # The app has changed. Need to authenticate to the new app + Write-Status "Logging in to $((?? $global:appObj.Name "selected application"))" + $global:EMViewObject.AppInfo = $tmpApp + Set-MSALCurrentApp $global:EMViewObject.AppInfo + Clear-MSALCurentUserVaiables + Connect-MSALUser -Account $global:MSALToken.Account.Username + Write-Status "" + } +} + +function Set-EMViewPanel +{ + param($panel) + # ToDo: Create View specific pannel and move this to graph + Add-XamlEvent $panel "btnView" "Add_Click" -scriptBlock ([scriptblock]{ + Show-GraphObjectInfo + }) + + Add-XamlEvent $panel "btnCopy" "Add_Click" -scriptBlock ([scriptblock]{ + Copy-GraphObject + }) + + Add-XamlEvent $panel "btnExport" "Add_Click" -scriptBlock ([scriptblock]{ + Show-GraphExportForm + }) + + Add-XamlEvent $panel "btnImport" "Add_Click" -scriptBlock ([scriptblock]{ + Show-GraphImportForm + }) + + Add-XamlEvent $panel "chkSelectAll" "Add_Click" -scriptBlock ([scriptblock]{ + foreach($item in $global:dgObjects.ItemsSource) + { + $item.IsSelected = $this.IsChecked + } + $global:dgObjects.Items.Refresh() + }) + + + Add-XamlEvent $panel "txtFilter" "Add_LostFocus" ({ #param($obj, $e) + Invoke-FiterBoxChanged $this + #$e.Handled = $true + }) + + Add-XamlEvent $panel "txtFilter" "Add_GotFocus" ({ + if($this.Tag -eq "1" -and $this.Text -eq "Filter") { $this.Text = "" } + Invoke-FiterBoxChanged $this + }) + + Add-XamlEvent $panel "txtFilter" "Add_TextChanged" ({ + Invoke-FiterBoxChanged $this + }) + + Invoke-FiterBoxChanged ($panel.FindName("txtFilter")) + + + $global:dgObjects.add_selectionChanged({ + Set-XamlProperty $this.Parent "btnView" "IsEnabled" (?: ($global:dgObjects.SelectedItem -eq $null) $false $true) + Set-XamlProperty $this.Parent "btnCopy" "IsEnabled" (?: ($global:dgObjects.SelectedItem -eq $null) $false $true) + }) + + # ToDo: Move this to the view object + $dpd = [System.ComponentModel.DependencyPropertyDescriptor]::FromProperty([System.Windows.Controls.ItemsControl]::ItemsSourceProperty, [System.Windows.Controls.DataGrid]) + if($dpd) + { + $dpd.AddValueChanged($global:dgObjects, { + Set-XamlProperty $global:dgObjects.Parent "txtFilter" "Text" "" + $enabled = (?: ($this.ItemsSource -eq $null -or ($this.ItemsSource | measure).Count -eq 0) $false $true) + Set-XamlProperty $global:dgObjects.Parent "btnImport" "IsEnabled" $true # Always all Import if ObjectType allows it + Set-XamlProperty $global:dgObjects.Parent "btnExport" "IsEnabled" $enabled + Set-XamlProperty $global:dgObjects.Parent "chkSelectAll" "IsEnabled" $enabled + Set-XamlProperty $global:dgObjects.Parent "chkSelectAll" "IsChecked" $false + }) + } + + $btnRefresh = Get-XamlObject ($global:AppRootFolder + "\Xaml\RefreshButton.xaml") + if($btnRefresh) + { + $btnRefresh.SetValue([System.Windows.Controls.Grid]::ColumnProperty,$grdTitle.ColumnDefinitions.Count - 1) + $btnRefresh.Margin = "0,0,5,3" + $btnRefresh.Cursor = "Hand" + $btnRefresh.Focusable = $false + $grdTitle.Children.Add($btnRefresh) | Out-Null + + $tooltip = [System.Windows.Controls.ToolTip]::new() + $tooltip.Content = "Refresh" + [System.Windows.Controls.ToolTipService]::SetToolTip($btnRefresh, $tooltip) + + $btnRefresh.Add_Click({ + # ToDo: Move this to view view object + $txtFilter = $this.Parent.FindName("txtFilter") + if($txtFilter) { $txtFilter.Text = "" } + Show-GraphObjects + Write-Status "" + }) + } +} + +function Invoke-FiterBoxChanged +{ + param($txtBox) + + $filter = $null + + if($txtBox.Text.Trim() -eq "" -and $txtBox.IsFocused -eq $false) + { + $txtBox.FontStyle = "Italic" + $txtBox.Tag = 1 + $txtBox.Text = "Filter" + $txtBox.Foreground="Lightgray" + } + else + { + if($txtBox.Tag -eq "1" -and $txtBox.Text -eq "Filter" -and $txtBox.IsFocused -eq $false) { return } + $txtBox.FontStyle = "Normal" + $txtBox.Tag = $null + $txtBox.Foreground="Black" + $txtBox.Background="White" + + if($txtBox.Text) + { + $filter = { + param ($item) + foreach($prop in ($item.PSObject.Properties | Where {$_.Name -notin @("IsSelected","Object")})) + { + if($prop.Value -match $txtBox.Text) { return $true } + } + $false + } + } + } + + if($dgObjects.ItemsSource -is [System.Windows.Data.ListCollectionView]) + { + # This causes odd behaviour with focus e.g. and item has to be clicked twice to be selected + $dgObjects.ItemsSource.Filter = $filter + $dgObjects.ItemsSource.Refresh() + } +} +#region Endpoint Security (Intents) functions + +function Start-PreImportEndpointSecurity +{ + param($obj, $objectType) + + @{ + "API"="deviceManagement/templates/$($obj.templateId)/createInstance" + } +} + +function Start-PostListEndpointSecurity +{ + param($objList, $objectType) + + if(-not $script:baseLineTemplates) + { + $script:baseLineTemplates = (Invoke-GraphRequest -Url "/deviceManagement/templates").Value + } + if(-not $script:baseLineTemplates) { return } + + foreach($obj in $objList) + { + if(-not $obj.Object.templateId) { continue } + if($obj.Object.templateId -ne $baseLineTepmlate.Id) + { + $baseLineTepmlate = $script:baseLineTemplates | Where Id -eq $obj.Object.templateId + } + if($baseLineTepmlate) + { + $obj | Add-Member -MemberType NoteProperty -Name "Type" -Value $baseLineTepmlate.displayName + $obj | Add-Member -MemberType NoteProperty -Name "Category" -Value (?: ($baseLineTepmlate.templateSubtype -eq "none") $baseLineTepmlate.templateType $baseLineTepmlate.templateSubtype) + } + } + $objList +} + +function Start-PostExportEndpointSecurity +{ + param($obj, $objectType, $path) + + $settings = Invoke-GraphRequest -Url "$($objectType.API)/$($obj.id)/settings" + $settingsJson = "{ `"settings`": $((ConvertTo-Json $settings.value -Depth 10 ))`n}" + $settingsJson | Out-File "$path\$((Get-GraphObjectName $obj $objectType))_Settings.json" -Force +} + +function Start-PostFileImportEndpointSecurity +{ + param($obj, $objectType, $file) + + $settings = Get-EMSettingsObject $obj $objectType $file + if($settings) + { + Start-GraphPreImport $settings + Invoke-GraphRequest -Url "$($objectType.API)/$($obj.id)/updateSettings" -Body ($settings | ConvertTo-Json -Depth 10) -Method "POST" + } +} + +function Start-PreCopyEndpointSecurity +{ + param($obj, $objectType, $newName) + + $false + + # Intents has a createCopy method. Use "manual" copy to have one standard and making sure Copy works the same as Export/Import + # These objects supports duplicate in the portal + # Keep for reference + # + # $objData = "{`"displayName`":`"$($newName)`"}" + # + #Invoke-GraphRequest -Url "/deviceManagement/intents/$($obj.Id)/createCopy" -Content $objData -HttpMethod "POST" | Out-Null + #$true +} + +function Start-PostCopyEndpointSecurity +{ + param($objCopyFrom, $objNew, $objectType) + + $settings = Invoke-GraphRequest -Url "$($objectType.API)/$($objCopyFrom.id)/settings" -ODataMetadata "Skip" + if($settings) + { + $settingsObj = New-object PSObject @{ "Settings" = $settings.Value } + Invoke-GraphRequest -Url "$($objectType.API)/$($objNew.id)/updateSettings" -Body ($settingsObj | ConvertTo-Json -Depth 10) -Method "POST" + } +} + +#endregion + +#region Compliance Policy +function Start-PostExportCompliancePolicies +{ + param($obj, $objectType, $exportPath) + + foreach($scheduledActionsForRule in $obj.scheduledActionsForRule) + { + foreach($scheduledActionConfiguration in $scheduledActionsForRule.scheduledActionConfigurations) + { + foreach($notificationMessageCCGroup in $scheduledActionConfiguration.notificationMessageCCList) + { + Add-GroupMigrationObject $notificationMessageCCGroup + } + } + } +} +#endregion + +#region Intune Branding functions +function Start-PreImportIntuneBranding +{ + param($obj, $objectType) + + $ret = @{} + $global:brandingClone = $null + + if($obj.isDefaultProfile) + { + + # Looks like the ID is the same for all tenants so skip this for now + <# + $defObj = (Invoke-GraphRequest -Url "/deviceManagement/intuneBrandingProfiles?`$filter=isDefaultProfile eq true&`$select=id,displayName").Value[0] + if($defObj) + { + $obj.Id = $defObj.Id + } + #> + + $ret.Add("API",($objectType.API + "/" + $obj.Id)) + $ret.Add("Method","PATCH") # Default profile always exists so update it + + foreach($prop in @("profileName","isDefaultProfile","disableClientTelemetry","profileDescription")) + { + Remove-Property $obj $prop + } + + $ret + } + else + { + # Create new Branding profile does not support images data in the json + # Workaround: (as done by the portal) + # Create a new profile with basic info + # Patch the profile with all the info + + $global:brandingClone = $obj | ConvertTo-Json -Depth 10 | ConvertFrom-Json + + foreach($prop in ($obj.PSObject.Properties | Where {$_.Name -notin @("profileName","profileDescription","roleScopeTagIds")})) #"customPrivacyMessage" + { + Remove-Property $obj $prop.Name + } + } + Remove-Property $obj "Id" +} + +function Start-PostImportIntuneBranding +{ + param($obj, $objectType) + + if($obj.isDefaultProfile -or -not $global:brandingClone) { return } + + foreach($prop in @("Id","isDefaultProfile","customPrivacyMessage","disableClientTelemetry")) #"isDefaultProfile","disableClientTelemetry" + { + Remove-Property $global:brandingClone $prop + } + $json = ($global:brandingClone | ConvertTo-Json -Depth 10) + $ret = Invoke-GraphRequest -Url "$($objectType.API)/$($obj.Id)" -Body $json -Method "PATCH" +} + +function Start-PostGetIntuneBranding +{ + param($obj, $objectType) + + foreach($imgType in @("themeColorLogo","lightBackgroundLogo","landingPageCustomizedImage")) + { + Write-LogDebug "Get $imgType for $($obj.profileName)" + $imgJson = Invoke-GraphRequest -Url "$($objectType.API)/$($obj.Id)/$imgType" + if($imgJson.Value) + { + $obj.Object.$imgType = $imgJson + } + } +} + +function Start-PostExportIntuneBranding +{ + param($obj, $objectType, $path) + + foreach($imgType in @("themeColorLogo","lightBackgroundLogo","landingPageCustomizedImage")) + { + if($obj.$imgType.Value) + { + $fileName = "$path\$((Remove-InvalidFileNameChars (Get-GraphObjectName $obj $objectType)))_$imgType.jpg" + [IO.File]::WriteAllBytes($fileName, [System.Convert]::FromBase64String($obj.$imgType.Value)) + } + } +} + + +#endregion + +#region Azure Branding functions +function Start-PreImportAzureBranding +{ + param($obj, $objectType) + + Remove-Property $obj "@odata.Type" + + $ret = @{} + if($obj.Id -eq "0") + { + #$ret.Add("Method","PATCH") # Default profile always exists so update it + #$ret.Add("API",($objectType.API + "/0")) + } + + $ret.Add("API",($objectType.API + "/$($global:Organization.Id)/branding/localizations")) + + # This is NOT wat the documentation says + # Documentation says to use Content-Language + # Any place the documentation states to use Accept-Language is for Get operation + # https://docs.microsoft.com/en-us/graph/api/organizationalbrandingproperties-get?view=graph-rest-beta&tabs=http#request-headers + $ret.Add("AdditionalHeaders", @{ "Accept-Language" = $obj.Id }) + + $ret +} + +function Start-PostListAzureBranding +{ + param($objList, $objectType) + + foreach($obj in $objList) + { + if(-not $obj.Object.id) { continue } + try + { + if($obj.Object.id -eq "0") + { + $language = "Default" + } + else + { + $language = ([cultureinfo]::GetCultureInfo($obj.Object.id)).DisplayName + } + + $obj | Add-Member -MemberType NoteProperty -Name "Language" -Value $language + } + catch{} + } + $objList +} + +#endregion + +#region Script functions +function Add-ScriptExtensions +{ + param($form, $buttonPanel, $index = 0) + + $btnDownload = New-Object System.Windows.Controls.Button + $btnDownload.Content = 'Download' + $btnDownload.Name = 'btnDownload' + $btnDownload.Margin = "0,0,5,0" + $btnDownload.Width = "100" + + $btnDownload.Add_Click({ + Invoke-DownloadScript + }) + + $tmp = $form.FindName($buttonPanel) + if($tmp) + { + $tmp.Children.Insert($index, $btnDownload) + } +} + +function Add-ScriptExportExtensions +{ + param($form, $buttonPanel, $index = 0) + + $xaml = @" + + +"@ + $label = [Windows.Markup.XamlReader]::Parse($xaml) + + $global:chkExportScript = [System.Windows.Controls.CheckBox]::new() + $global:chkExportScript.IsChecked = $true + $global:chkExportScript.VerticalAlignment = "Center" + + @($label, $global:chkExportScript) +} + +function Start-PostExportScripts +{ + param($obj, $objectType, $exportPath) + + if($obj.scriptContent -and $global:chkExportScript.IsChecked) + { + Write-Log "Export script $($obj.FileName)" + $fileName = [IO.Path]::Combine($exportPath, $obj.FileName) + [System.Text.Encoding]::ASCII.GetString([System.Convert]::FromBase64String($obj.scriptContent)) | Out-File $fileName -Force + } +} + +function Invoke-DownloadScript +{ + if(-not $global:dgObjects.SelectedItem.Object.id) { return } + + $obj = (Get-GraphObject $global:dgObjects.SelectedItem $global:curObjectType).Object + Write-Status "" + + if($obj.scriptContent) + { + Write-Log "Download PowerShell script '$($obj.FileName)' from $($obj.displayName)" + + $dlgSave = New-Object -Typename System.Windows.Forms.SaveFileDialog + $dlgSave.InitialDirectory = Get-SettingValue "IntuneRootFolder" $env:Temp + $dlgSave.FileName = $obj.FileName + if($dlgSave.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK -and $dlgSave.Filename) + { + [System.Text.Encoding]::ASCII.GetString([System.Convert]::FromBase64String($obj.scriptContent)) | Out-File $dlgSave.Filename -Force + } + } +} + +#endregion + +#region Terms and Conditions +function Start-PostExportTermsAndConditions +{ + param($obj, $objectType, $path) + + Add-EMAssignmentsToExportFile $obj $objectType $path +} + +function Start-PreImportAssignmentsTermsAndConditions +{ + param($obj, $objectType, $file, $assignments) + + Add-EMAssignmentsToObject $obj $objectType $file $assignments +} +#endregion + +#region App Protection functions + +function Start-GetAppProtection +{ + param($obj, $objectType) + + if(-not $obj."@odata.type") { return } + + Get-GraphMetaData + + $objectClass = $null + if($global:metaDataXML) + { + try + { + $tmp = $obj."@odata.type".Split('.')[-1] + $objectClass = Get-GraphObjectClassName $tmp + } + catch + { + + } + + if($objectClass) + { + @{"API"="/deviceAppManagement/$objectClass/$($obj.Id)"} + } + } +} + +function Start-PostListAppProtection +{ + param($objList, $objectType) + + # App Configurations for Managed Apps are included in App Protections e.g. the /deviceAppManagement/managedAppPolicies API + # For some reason, the some $filter options is not supported to filter out these objects + # e.g. not isof(...) to excluded the type, not startsWith(id, 'A_') to exlude based on Id + # These filters generates a request error so fiter them out manually in this function instead + # The portal is probably doing the same thing since these are included in the return but not in the UI + $objList | Where { $_.Object.'@OData.Type' -ne '#microsoft.graph.targetedManagedAppConfiguration' } +} + +function Start-PreImportAppProtection +{ + param($obj, $objectType) + + if(($obj.Apps | measure).Count -gt 0) + { + $global:ImportObjectInfo = @{ Apps=$obj.Apps } + } + else + { + $global:ImportObjectInfo = $null + } + + $global:ImportObjectClass = $null + if($obj."@odata.type") + { + try + { + $global:ImportObjectClass = Get-GraphObjectClassName ($obj."@odata.type".Split('.')[-1]) + } + catch {} + } + + Remove-Property $obj "apps" + Remove-Property $obj "apps@odata.context" + + try + { + $tmp = $obj."@odata.type".Split('.')[-1] + $objectClass = Get-GraphObjectClassName $tmp + if($objectClass) + { + @{"API"="/deviceAppManagement/$objectClass"} + } + } + catch {} +} + +function Start-PostImportAppProtection +{ + param($obj, $objectType, $file) + + if($global:ImportObjectInfo.Apps) + { + # No "@odata.type" on the created object so reload new object + #$newObject = (Invoke-GraphRequest "$($objectType.API)?`$filter=id eq '$($obj.Id)'").Value + $newObject = Invoke-GraphRequest "$($objectType.API)/$($obj.Id)" + if($newObject) + { + try + { + $tmp = $newObject."@odata.type".Split('.')[-1] + $objectClass = Get-GraphObjectClassName $tmp + + $response = Invoke-GraphRequest -Url "/deviceAppManagement/$objectClass/$($obj.Id)/targetApps" -Content "{ apps: $(ConvertTo-Json $global:ImportObjectInfo.Apps -Depth 10)}" -HttpMethod POST + } + catch {} + } + } + $global:ImportObjectInfo = $null +} + +function Start-PreImportAssignmentsAppProtection +{ + param($obj, $objectType, $file, $assignments) + + if($global:ImportObjectClass) + { + @{"API"="/deviceAppManagement/$($global:ImportObjectClass)/$($obj.Id)/assign"} + } +} +#endregion + +#region App Configuration +function Start-PostExportAppConfiguration +{ + param($obj, $objectType, $path) + + Add-EMAssignmentsToExportFile $obj $objectType $path +} + +function Start-PreImportAssignmentsAppConfiguration +{ + param($obj, $objectType, $file, $assignments) + + @{"API"="/deviceAppManagement/mobileAppConfigurations/$($obj.Id)/microsoft.graph.managedDeviceMobileAppConfiguration/assign"} +} +#endregon + +#region Applications +function Start-PostFileImportApplications +{ + param($obj, $objectType, $file) + + $tmpObj = Get-Content $file | ConvertFrom-Json + + if(-not $tmpObj.'@odata.type') { return } + + $pkgPath = Get-SettingValue "EMIntuneAppPackages" + + if(-not $pkgPath -or [IO.Directory]::Exists($pkgPath) -eq $false) + { + Write-LogDebug "Package source directory is either missing or does not exist" 2 + return + } + + $packageFile = "$($pkgPath)\$($obj.fileName)" + + if([IO.File]::Exists($packageFile) -eq $false) + { + Write-LogDebug "Package source file $packageFile not found" 2 + return + } + + Write-Status "Import appliction package file $($obj.fileName)" + Write-Log "Import application file '$($packageFile)' for $($obj.displayName)" + + if(-not ($obj.PSObject.Properties | Where Name -eq '@odata.type')) + { + # Add @odata.type property if it is missing. Required by app package import + $obj | Add-Member -MemberType NoteProperty -Name '@odata.type' -Value $tmpObj.'@odata.type' + } + + $appType = $tmpObj.'@odata.type'.Trim('#') + + if($appType -eq "microsoft.graph.win32LobApp") + { + Copy-Win32LOBPackage $packageFile $obj + } + elseif($appType -eq "microsoft.graph.windowsMobileMSI") + { + Copy-MSILOB $packageFile $obj + } + elseif($appType -eq "microsoft.graph.iosLOBApp") + { + Copy-iOSLOB $packageFile $obj + } + elseif($appType -eq "microsoft.graph.androidLOBApp") + { + Copy-AndroidLOB $packageFile $obj + } + else + { + Write-Log "Unsupported application type $appType. File will not be uploaded" 2 + } +} +#endregion + +#region Group Policy/Administrative Template functions +function Get-GPOObjectSettings +{ + param($GPOObj) + + $gpoSettings = @() + + # Get all configured policies in the Administrative Templates profile + $GPODefinitionValues = Invoke-GraphRequest -Url "/deviceManagement/groupPolicyConfigurations/$($GPOObj.id)/definitionValues?`$expand=definition" -ODataMetadata "skip" + foreach($definitionValue in $GPODefinitionValues.value) + { + # Get presentation values for the current settings (with presentation object included) + $presentationValues = Invoke-GraphRequest -Url "/deviceManagement/groupPolicyConfigurations/$($GPOObj.id)/definitionValues/$($definitionValue.id)/presentationValues?`$expand=presentation" -ODataMetadata "skip" + + # Set base policy settings + $obj = @{ + "enabled" = $definitionValue.enabled + "definition@odata.bind" = "$($global:graphURL)/deviceManagement/groupPolicyDefinitions('$($definitionValue.definition.id)')" + } + + if($presentationValues.value) + { + # Policy presentation values set e.g. a drop down list, check box, text box etc. + $obj.presentationValues = @() + + foreach ($presentationValue in $presentationValues.value) + { + # Add presentation@odata.bind property that links the value to the presentation object + $presentationValue | Add-Member -MemberType NoteProperty -Name "presentation@odata.bind" -Value "$($global:graphURL)/deviceManagement/groupPolicyDefinitions('$($definitionValue.definition.id)')/presentations('$($presentationValue.presentation.id)')" + + #Remove presentation object so it is not included in the export + Remove-ObjectProperty $presentationValue "presentation" + + #Optional removes. Import will igonre them + Remove-ObjectProperty $presentationValue "id" + Remove-ObjectProperty $presentationValue "lastModifiedDateTime" + Remove-ObjectProperty $presentationValue "createdDateTime" + + # Add presentation value to the list + $obj.presentationValues += $presentationValue + } + } + $gpoSettings += $obj + } + $gpoSettings +} + +function Import-GPOSetting +{ + param($obj, $settings) + + if($obj) + { + Write-Status "Import settings for $($obj.displayName)" + + foreach($setting in $settings) + { + Start-GraphPreImport $setting + + # Import each setting for the Administrative Template profile + Invoke-GraphRequest -Url "/deviceManagement/groupPolicyConfigurations/$($obj.id)/definitionValues" -Content (ConvertTo-Json $setting -Depth 10) -HttpMethod POST | Out-Null + } + } +} + +function Start-PostExportAdministrativeTemplate +{ + param($obj, $objectType, $path) + + # Collect and save all the settings of the Administrative Templates profile + $settings = Get-GPOObjectSettings $obj + ConvertTo-Json $settings -Depth 10 | Out-File "$path\$((Get-GraphObjectName $obj $objectType))_Settings.json" -Force +} + +function Start-PostCopyAdministrativeTemplate +{ + param($objCopyFrom, $objNew, $objectType) + + $settings = Get-GPOObjectSettings $objCopyFrom + if($settings) + { + Import-GPOSetting $objNew $settings + } +} + +function Start-PostFileImportAdministrativeTemplate +{ + param($obj, $objectType, $file) + + $settings = Get-EMSettingsObject $obj $objectType $file + if($settings) + { + Import-GPOSetting $obj $settings + } +} + +#endregion + +#region Policy Sets function + +function Start-PreImportAssignmentsPolicySets +{ + param($obj, $objectType, $file, $assignments) + + @{"API"="$($objectType.API)/$($obj.Id)/Update"} +} + +function Start-PreImportPolicySets +{ + param($obj, $objectType) + + @("items@odata.context","status","errorCode") | foreach { Remove-Property $obj $_ } + + # Properties to keep for items + $keepProperties = @("@odata.type","payloadId","intent","settings") + foreach($item in $obj.Items) + { + foreach($prop in ($item.PSObject.Properties | Where {$_.Name -notin $keepProperties})) + { + #if($prop.Name -in $keepProperties) { continue } + Remove-Property $item $prop.Name + } + #@("itemType","displayName","status","errorCode") | foreach { Remove-Property $item $_ } + } +} +#endregion + +#endregion Locations +function Start-PreImportLocations +{ + param($obj, $objectType) + + if($obj.uniqueName) + { + $arr = $obj.uniqueName.Split('_') + if($arr.Length -ge 3) + { + # Locations requires a unique name so generate a new guid and change the uniqueName property + $obj.uniqueName = ($obj.uniqueName.Substring(0,$obj.uniqueName.Length-$arr[-1].Length) + [Guid]::NewGuid().Tostring("n")) + } + } +} +#endregion + +#region RoleDefinitions +function Start-PostExportRoleDefinitions +{ + param($obj, $objectType, $path) + + $fileName = "$path\$((Get-GraphObjectName $obj $objectType)).json" + $tmpObj = Get-Content $fileName | ConvertFrom-Json + + if(($tmpObj.RoleAssignments | measure).Count -gt 0) + { + $roleAssignmentsArr = @() + foreach($roleAssignment in $tmpObj.RoleAssignments) + { + $raObj = Invoke-GraphRequest -Url "/deviceManagement/roleAssignments/$($roleAssignment.Id)?`$expand=microsoft.graph.deviceAndAppManagementRoleAssignment/roleScopeTags" -ODataMetadata "Minimal" + if($raObj) + { + foreach($groupId in $raObj.resourceScopes) { Add-GroupMigrationObject $groupId } + foreach($groupId in $raObj.members) { Add-GroupMigrationObject $groupId } + $roleAssignmentsArr += $raObj + } + } + + if($roleAssignmentsArr.Count -gt 0) + { + $tmpObj.RoleAssignments = $roleAssignmentsArr + $tmpObj | ConvertTo-Json -Depth 10 | Out-File $fileName + } + } +} + +function Start-PreImportRoleDefinitions +{ + param($obj, $objectType) + + Remove-Property $obj "RoleAssignments" + Remove-Property $obj "RoleAssignments@odata.context" +} + +function Start-PostFileImportRoleDefinitions +{ + param($obj, $objectType, $file) + + $tmpObj = Get-Content $file | ConvertFrom-Json + + $loadedScopeTags = $global:LoadedDependencyObjects["ScopeTags"] + if(($tmpObj.RoleAssignments | measure).Count -gt 0 -and ($loadedScopeTags | measure).Count -gt 0) + { + # Documentation way did not work so use the same way as the portal + # Should be created with /deviceManagement/roleDefinitions/{roleDefinitionId}/roleAssignments + foreach($roleAssignment in $tmpObj.RoleAssignments) + { + $roleAssignmentObj = New-object PSObject @{ + "description" = $roleAssignment.Description + "displayName"= $roleAssignment.DisplayName + "members" = $roleAssignment.members + "resourceScopes" = $roleAssignment.resourceScopes + "roleDefinition@odata.bind" = "https://graph.microsoft.com/beta/deviceManagement/roleDefinitions('$($obj.Id)')" + "roleScopeTags@odata.bind" = @() + } + + foreach($scopeTag in $roleAssignment.roleScopeTags) + { + $scopeMigObj = $loadedScopeTags | Where OriginalId -eq $scopeTag.Id + if(-not $scopeMigObj.Id) { continue } + $roleAssignmentObj."roleScopeTags@odata.bind" += "https://graph.microsoft.com/beta/deviceManagement/roleScopeTags('$($scopeMigObj.Id)')" + } + + # This will update GroupIds + $json = Update-JsonForEnvironment (ConvertTo-Json $roleAssignmentObj -Depth 10) + + Write-Log "Import Role Assignments" + Invoke-GraphRequest -Url "/deviceManagement/roleAssignments" -Body $json -Method "POST" + } + } +} +#endregion + +#region SettingsCatalog +function Start-PostExportSettingsCatalog +{ + param($obj, $objectType, $path) + + Add-EMAssignmentsToExportFile $obj $objectType $path +} + +#endregion + +#region Notification functions +function Start-PreImportNotifications +{ + param($obj, $objectType) + + Remove-Property $obj "defaultLocale" + Remove-Property $obj "localizedNotificationMessages" + Remove-Property $obj "localizedNotificationMessages@odata.context" +} + +function Start-PostFileImportNotifications +{ + param($obj, $objectType, $file) + + $tmpObj = Get-Content $file | ConvertFrom-Json + + foreach($localizedNotificationMessage in $tmpObj.localizedNotificationMessages) + { + Start-GraphPreImport $localizedNotificationMessage $objectType + Invoke-GraphRequest -Url "$($objectType.API)/$($obj.id)/localizedNotificationMessages" -Body ($localizedNotificationMessage | ConvertTo-Json -Depth 10) -Method "POST" + } +} + +function Start-PostCopyNotifications +{ + param($objCopyFrom, $objNew, $objectType) + + foreach($localizedNotificationMessage in $objCopyFrom.localizedNotificationMessages) + { + Start-GraphPreImport $localizedNotificationMessage $objectType + Invoke-GraphRequest -Url "$($objectType.API)/$($objNew.id)/localizedNotificationMessages" -Body ($localizedNotificationMessage | ConvertTo-Json -Depth 10) -Method "POST" + } +} +#endregion + +#region Enrollment Status Page functions +function Start-PreImportESP +{ + param($obj, $objectType) + + if($obj.Priority -eq 0) + { + $ret = @{} + $ret.Add("API","$($objectType.API)/$($obj.Id)") + $ret.Add("Method","PATCH") # Default profile always exists so update them + $ret + } + else + { + Remove-Property $obj "Id" + } +} + +function Start-PostExportESP +{ + param($obj, $objectType, $path) + + if($obj.Priority -eq 0) + { + Save-EMDefaultPolicy $obj $objectType $path + } +} +#endregion + +#region Enrollment Restriction functions + +function Start-PostExportEnrollmentRestrictions +{ + param($obj, $objectType, $path) + + if($obj.Priority -eq 0) + { + Save-EMDefaultPolicy $obj $objectType $path + } +} + +function Start-PreImportEnrollmentRestrictions +{ + param($obj, $objectType) + + if($obj.Priority -eq 0) + { + $ret = @{} + $ret.Add("API","$($objectType.API)/$($obj.Id)") + $ret.Add("Method","PATCH") # Default profile always exists so update them + $ret + } + else + { + Remove-Property $obj "Id" + } +} +#endregion + +#region ScopeTags +function Start-PostExportScopeTags +{ + param($obj, $objectType, $path) + + Add-EMAssignmentsToExportFile $obj $objectType $path +} +#endregion + +#region AutoPilot +function Start-PreImportAssignmentsAutoPilot +{ + param($obj, $objectType, $file, $assignments) + + Add-EMAssignmentsToObject $obj $objectType $file $assignments +} +#endregion + +#region Generic functions + +function Save-EMDefaultPolicy +{ + param($obj, $objectType, $path) + + if($obj.Priority -eq 0) + { + try + { + $fileName = $obj.Id.Split('_')[1] + + if($fileName) + { + $oldFile = "$path\$((Get-GraphObjectName $obj $objectType)).json" + try { [IO.File]::Delete($oldFile) | Out-Null } Catch {} + + $obj | ConvertTo-Json -Depth 10 | Out-File "$path\$fileName.json" + } + } + catch {} + } +} +function Get-EMSettingsObject +{ + param($obj, $objectType, $file) + + $fi = [IO.FileInfo]$file + $settingsFile = $fi.DirectoryName + "\" + $fi.BaseName + "_Settings.json" + $fiSettings = [IO.FileInfo]$settingsFile + if($fiSettings.Exists -eq $false) + { + Write-Log "Settings file '$($fiSettings.FullName)' was not found" 2 + return + } + + (Get-Content $fiSettings.FullName) | ConvertFrom-Json +} + +function Add-EMAssignmentsToExportFile +{ + param($obj, $objectType, $path, $Url = "") + + $fileName = "$path\$((Get-GraphObjectName $obj $objectType)).json" + $tmpObj = Get-Content $fileName | ConvertFrom-Json + + if(-not $url) + { + $url = "$($objectType.API)/$($obj.id)/assignments" + } + $assignments = (Invoke-GraphRequest -Url $url -ODataMetadata "Minimal").Value + if($assignments) + { + if(-not ($tmpObj.PSObject.Properties | Where Name -eq "assignments")) + { + $tmpObj | Add-Member -MemberType NoteProperty -Name "assignments" -Value $assignments + } + else + { + $tmpObj.Assignments = $assignments + } + ConvertTo-Json $tmpObj -Depth 10 | Out-File $fileName -Force + } +} + +function Add-EMAssignmentsToObject +{ + param($obj, $objectType, $file, $assignments) + + # AutoPilot and TaC are using assignments and not assign like other object types + $api = "$($objectType.API)/$($obj.Id)/assignments" + + # These profiles don't support importing of multiple assignments with { "assignment" [...]} + # Each assignment must be imported separately + + foreach($assignment in $assignments) + { + if($assignment.Source -and $assignment.Source -ne "direct") { continue } + + foreach($prop in $assignment.PSObject.Properties) + { + if($prop.Name -in @("Target")) { continue } + Remove-Property $assignment $prop.Name + } + + foreach($prop in $assignment.target.PSObject.Properties) + { + if($prop.Name -in @("@odata.type","groupId")) { continue } + Remove-Property $assignment.target $prop.Name + } + + $json = Update-JsonForEnvironment ($assignment | ConvertTo-Json -Depth 10) + Invoke-GraphRequest -Url $api -Body $json -Method "POST" | Out-Null + } + @{"Import"=$false} +} + +#endregion + +Export-ModuleMember -alias * -function * \ No newline at end of file diff --git a/Extensions/EndpointManagerInfo.psm1 b/Extensions/EndpointManagerInfo.psm1 new file mode 100644 index 0000000..7b8ccc8 --- /dev/null +++ b/Extensions/EndpointManagerInfo.psm1 @@ -0,0 +1,104 @@ +<# +.SYNOPSIS +Module for read-only Intune objects + +.DESCRIPTION +This module is for the Endpoint Info View. It shows read-only objects in Intune + +.NOTES + Author: Mikael Karlsson +#> +function Get-ModuleVersion +{ + '3.0.0' +} + +function Invoke-InitializeModule +{ + #Add menu group and items + $global:EMInfoViewObject = (New-Object PSObject -Property @{ + Title = "Intune Info" + Description = "Displays read-only information in Intune." + ID = "EMInfoGraphAPI" + ViewPanel = $viewPanel + ItemChanged = { Show-GraphObjects; Write-Status ""} + Activating = { Invoke-EMInfoActivatingView } + Authentication = (Get-MSALAuthenticationObject) + Authenticate = { Invoke-EMInfoAuthenticateToMSAL } + AppInfo = (Get-GraphAppInfo "EM" "d1ddf0e4-d672-4dae-b554-9d5bdfd93547") + SaveSettings = { Invoke-EMSaveSettings } + }) + + Add-ViewObject $global:EMInfoViewObject + + Add-ViewItem (New-Object PSObject -Property @{ + Title = "Baseline Templates" + Id = "BaselineTemplates" + ViewID = "EMInfoGraphAPI" + API = "/deviceManagement/templates" + ShowButtons = @("View") + Permissons=@("DeviceManagementConfiguration.ReadWrite.All") + Icon="EndpointSecurity" + }) + + Add-ViewItem (New-Object PSObject -Property @{ + Title = "Android Google Play" + Id = "AndroidGooglePlay" + ViewID = "EMInfoGraphAPI" + ViewProperties = @("bindStatus", "lastAppSyncDateTime", "ownerUserPrincipalName") + API = "/deviceManagement/androidManagedStoreAccountEnterpriseSettings" + ShowButtons = @("View") + Permissons=@("DeviceManagementConfiguration.ReadWrite.All") + }) + + Add-ViewItem (New-Object PSObject -Property @{ + Title = "Android Enrolment Profiles" + Id = "AndroidEnrolmentProfiles" + ViewID = "EMInfoGraphAPI" + API = "deviceManagement/androidDeviceOwnerEnrollmentProfiles" + ShowButtons = @("View") + Permissons=@("DeviceManagementConfiguration.ReadWrite.All") + Icon = "AndroidCOWP" + }) + + Add-ViewItem (New-Object PSObject -Property @{ + Title = "Apple VPP Tokens" + Id = "AppleVPPTokens" + ViewID = "EMInfoGraphAPI" + ViewProperties = @("appleId", "state", "appleId", "id") + API = "/deviceAppManagement/vppTokens" + ShowButtons = @("View") + Permissons=@("DeviceManagementConfiguration.ReadWrite.All") + }) + + Add-ViewItem (New-Object PSObject -Property @{ + Title = "Apple Enrollment Tokens" + Id = "AppleEnrollmentTokens" + ViewID = "EMInfoGraphAPI" + ViewProperties = @("tokenName", "appleIdentifier", "tokenExpirationDateTime", "id") + API = "/deviceManagement/depOnboardingSettings/?`$top=100" + ShowButtons = @("View") + Permissons=@("DeviceManagementServiceConfig.ReadWrite.All") + }) + +} + +function Invoke-EMInfoActivatingView +{ + if(-not $global:EMInfoViewObject.ViewPanel) + { + # Use the same view panel as Intune Manager + $global:EMInfoViewObject.ViewPanel = $global:EMViewObject.ViewPanel + } +} + +function Invoke-EMInfoAuthenticateToMSAL +{ + $global:EMInfoViewObject.AppInfo = Get-GraphAppInfo "EMAzureApp" "d1ddf0e4-d672-4dae-b554-9d5bdfd93547" + Set-MSALCurrentApp $global:EMInfoViewObject.AppInfo + $usr = (?? $global:MSALToken.Account.UserName (Get-Setting "" "LastLoggedOnUser")) + if($usr) + { + & $global:msalAuthenticator.Login -Account $usr + } +} \ No newline at end of file diff --git a/Extensions/EnrollmentStatusPage.psm1 b/Extensions/EnrollmentStatusPage.psm1 deleted file mode 100644 index 0c85d51..0000000 --- a/Extensions/EnrollmentStatusPage.psm1 +++ /dev/null @@ -1,288 +0,0 @@ -######################################################## -# -# Common module functions -# -######################################################## -function Add-ModuleMenuItems -{ - Add-MenuItem (New-Object PSObject -Property @{ - Title = (Get-ESPName) - MenuID = "IntuneGraphAPI" - Script = [ScriptBlock]{Get-ESPs} - }) -} - -function Get-SupportedImportObjects -{ - $global:importObjects += (New-Object PSObject -Property @{ - Selected = $true - Title = (Get-ESPName) - Script = [ScriptBlock]{ - param($rootFolder) - - Write-Status "Import all enrollment status page settings" - Import-AllESPObjects (Join-Path $rootFolder (Get-ESPFolderName)) - } - }) -} - -function Get-SupportedExportObjects -{ - $global:exportObjects += (New-Object PSObject -Property @{ - Selected = $true - Title = (Get-ESPName) - Script = [ScriptBlock]{ - param($rootFolder) - - Write-Status "Export all enrollment status page settings" - Get-ESPObjects | ForEach-Object { Export-SingleESP $PSItem.Object (Join-Path $rootFolder (Get-ESPFolderName)) } - } - }) -} - -function Export-AllObjects -{ - param($addObjectSubfolder) - - $subFolder = "" - if($addObjectSubfolder) { $subFolder = Get-ESPFolderName } -} - -######################################################## -# -# Object specific functions -# -######################################################## -function Get-ESPName -{ - return "Enrollment Status Page" -} - - -function Get-ESPFolderName -{ - return "EnrollmentStatusPage" -} - -function Get-ESPs -{ - Write-Status "Loading enrollment status page objects" - $dgObjects.ItemsSource = @(Get-ESPObjects) - - #Scriptblocks that will perform the export tasks. empty by default - $script:exportParams = @{} - $script:exportParams.Add("ExportAllScript", [ScriptBlock]{ - Export-AllESPs $global:txtExportPath.Text - Set-ObjectGrid - Write-Status "" - }) - - $script:exportParams.Add("ExportSelectedScript", [ScriptBlock]{ - Export-SelectedESP $global:txtExportPath.Text - Set-ObjectGrid - Write-Status "" - }) - #Scriptblock that will perform the import all files - $script:importAll = [ScriptBlock]{ - Import-AllESPObjects $global:txtExportPath.Text - Set-ObjectGrid - } - - #Scriptblock that will perform the import of selected files - $script:importSelected = [ScriptBlock]{ - Import-ESPObjects $global:lstFiles.ItemsSource -Selected - Set-ObjectGrid - } - - #Scriptblock that will read json files - $script:getImportFiles = [ScriptBlock]{ - Show-FileListBox - $global:lstFiles.ItemsSource = @(Get-JsonFileObjects $global:txtImportPath.Text -Exclude "*_Settings.json") - } - - Add-DefaultObjectButtons -export ([scriptblock]{Show-DefaultExportGrid @script:exportParams}) -import ([scriptblock]{Show-DefaultImportGrid -ImportAll $script:importAll -ImportSelected $script:importSelected -GetFiles $script:getImportFiles}) -copy ([scriptblock]{Copy-ESP}) -ViewFullObject ([scriptblock]{Get-ESPObject $global:dgObjects.SelectedItem.Object}) -} - -function Get-ESPObjects -{ - Get-GraphObjects -Url "/deviceManagement/deviceEnrollmentConfigurations" -} - -function Get-ESPObject -{ - param($object, $additional = "") - - if(-not $Object.id) { return } - - Invoke-GraphRequest -Url "/deviceManagement/deviceEnrollmentConfigurations/$($Object.id)$additional" -} - -function Export-AllESPs -{ - param($path = "$env:Temp") - - if(-not (Test-Path $path)) { mkdir -Path $path -Force -ErrorAction SilentlyContinue | Out-Null } - - if(Test-Path $path) - { - foreach($objTmp in ($global:dgObjects.ItemsSource)) - { - Export-SingleESP $objTmp.Object $path - } - } -} - -function Export-SelectedESP -{ - param($path = "$env:Temp") - - Export-SingleESP $global:dgObjects.SelectedItem.Object $path -} - -function Export-SingleESP -{ - param($psObj, $path = "$env:Temp") - - if(-not $psObj) { return } - - if($global:runningBulkExport -ne $true) - { - if($global:chkAddCompanyName.IsChecked) { $path = Join-Path $path $global:organization.displayName } - if($global:chkAddObjectType.IsChecked) { $path = Join-Path $path (Get-ESPFolderName) } - } - - if(-not (Test-Path $path)) { mkdir -Path $path -Force -ErrorAction SilentlyContinue | Out-Null } - - if(Test-Path $path) - { - Write-Status "Export $($psObj.displayName)" - $obj = Invoke-GraphRequest -Url "/deviceManagement/deviceEnrollmentConfigurations/$($psObj.id)" #?`$expand=assignments" - if($obj) - { - if($obj.id -like "*_default*") - { - $idx = $obj.id.ToLower().IndexOf("_default") - $baseName = "Default_" + $obj.id.SubString($idx + "_default".Length) - } - else - { - # ?`$expand=assignments is not working so get assignments - $assignments = Invoke-GraphRequest -Url "/deviceManagement/deviceEnrollmentConfigurations/$($obj.id)/assignments" - if($assignments.value) - { - $obj | Add-Member -NotePropertyName "assignments" -NotePropertyValue $assignments.value - } - $baseName = Remove-InvalidFileNameChars $obj.displayName - } - $fileName = "$path\$baseName.json" - ConvertTo-Json $obj -Depth 5 | Out-File $fileName -Force - - Add-MigrationInfo $obj.assignments - } - $global:exportedObjects++ - } -} - -function Copy-ESP -{ - if(-not $dgObjects.SelectedItem) - { - [System.Windows.MessageBox]::Show("No object selected`n`nSelect enrollment status page item you want to copy", "Error", "OK", "Error") | Out-Null - return - } - - if($dgObjects.SelectedItem.Object.id -like "*_default*") - { - [System.Windows.MessageBox]::Show("You cannot copy default items`n`nSelect custom entrollment status page item", "Error", "OK", "Error") | Out-Null - return - } - - $ret = Show-InputDialog "Copy enrollment status page" "Select name for the new object" "$($dgObjects.SelectedItem.displayName) - Copy" - - if($ret) - { - # Export profile - Write-Status "Export $($dgObjects.SelectedItem.displayName)" - # Convert to Json and back to clone the object - $obj = ConvertTo-Json $dgObjects.SelectedItem.Object -Depth 5 | ConvertFrom-Json - if($obj) - { - # Import new profile - $obj.displayName = $ret - Import-ESP $obj | Out-Null - - $dgObjects.ItemsSource = @(Get-ESPObjects) - } - Write-Status "" - } - $dgObjects.Focus() -} - -function Import-ESP -{ - param($obj) - - Start-PreImport $obj - - if($obj.id -like "*_default*") - { - Write-Status "Update $($obj.displayName)" - - Invoke-GraphRequest -Url "/deviceManagement/deviceEnrollmentConfigurations/$($obj.id)" -Content (ConvertTo-Json $obj -Depth 5) -HttpMethod PATCH - } - else - { - Write-Status "Import $($obj.displayName)" - - Invoke-GraphRequest -Url "/deviceManagement/deviceEnrollmentConfigurations" -Content (ConvertTo-Json $obj -Depth 5) -HttpMethod POST - } -} - -function Import-AllESPObjects -{ - param($path = "$env:Temp") - - Import-ESPObjects (Get-JsonFileObjects $path) -} - -function Import-ESPObjects -{ - param( - $Objects, - - [switch] - $Selected - ) - - Write-Status "Import enrollment status page" - - foreach($obj in $Objects) - { - if($Selected -and $obj.Selected -ne $true) { continue } - - if($obj.Object.id -like "*_default*") - { - $idx = $obj.Object.id.ToLower().IndexOf("_default") - $extInfo = " ($($obj.Object.id.SubString($idx + "_default".Length)))" - } - else - { - $extInfo = "" - } - - Write-Log "Import Enrollment Status Page: $($obj.Object.displayName)$extInfo" - - $assignments = Get-GraphAssignmentsObject $obj.Object ($obj.FileInfo.DirectoryName + "\" + $obj.FileInfo.BaseName + "_assignments.json") - - $response = Import-ESP $obj.Object - - if($response) - { - $global:importedObjects++ - Import-GraphAssignments $assignments "enrollmentConfigurationAssignments" "/deviceManagement/deviceEnrollmentConfigurations/$($response.Id)/assign" - } - } - - $dgObjects.ItemsSource = @(Get-ESPObjects) - Write-Status "" -} \ No newline at end of file diff --git a/Extensions/GroupPolicy.psm1 b/Extensions/GroupPolicy.psm1 deleted file mode 100644 index 5211b1a..0000000 --- a/Extensions/GroupPolicy.psm1 +++ /dev/null @@ -1,335 +0,0 @@ -######################################################## -# -# Common module functions -# -######################################################## -function Add-ModuleMenuItems -{ - Add-MenuItem (New-Object PSObject -Property @{ - Title = (Get-GPOSettingName) - MenuID = "IntuneGraphAPI" - Script = [ScriptBlock]{Get-GPOSettings} - }) -} - -function Get-SupportedImportObjects -{ - $global:importObjects += (New-Object PSObject -Property @{ - Selected = $true - Title = (Get-GPOSettingName) - Script = [ScriptBlock]{ - param($rootFolder) - - Write-Status "Import all administrative templates" - Import-AllGPOSettingObjects (Join-Path $rootFolder (Get-GPOSettingFolderName)) - } - }) -} - -function Get-SupportedExportObjects -{ - $global:exportObjects += (New-Object PSObject -Property @{ - Selected = $true - Title = (Get-GPOSettingName) - Script = [ScriptBlock]{ - param($rootFolder) - - Write-Status "Export all administrative templates" - Get-GPOSettingObjects | ForEach-Object { Export-SingleGPOSetting $PSItem.Object (Join-Path $rootFolder (Get-GPOSettingFolderName)) } - } - }) -} - -function Export-AllObjects -{ - param($addObjectSubfolder) - - $subFolder = "" - if($addObjectSubfolder) { $subFolder = Get-GPOSettingFolderName } -} - -######################################################## -# -# Object specific functions -# -######################################################## -function Get-GPOSettingName -{ - return "Administrative Templates" -} - - -function Get-GPOSettingFolderName -{ - return "AdministrativeTemplates" -} - -function Get-GPOSettings -{ - Write-Status "Loading administrative templates" - $dgObjects.ItemsSource = @(Get-GPOSettingObjects) - - #Scriptblocks that will perform the export tasks. empty by default - $script:exportParams = @{} - $script:exportParams.Add("ExportAllScript", [ScriptBlock]{ - Export-AllGPOSettings $global:txtExportPath.Text - Set-ObjectGrid - Write-Status "" - }) - - $script:exportParams.Add("ExportSelectedScript", [ScriptBlock]{ - Export-SelectedGPOSetting $global:txtExportPath.Text - Set-ObjectGrid - Write-Status "" - }) - - #Scriptblock that will perform the import all files - $script:importAll = [ScriptBlock]{ - Import-AllGPOSettingObjects $global:txtImportPath.Text - Set-ObjectGrid - } - - #Scriptblock that will perform the import of selected files - $script:importSelected = [ScriptBlock]{ - Import-GPOSettingObjects $global:lstFiles.ItemsSource -Selected - Set-ObjectGrid - } - - #Scriptblock that will read json files - $script:getImportFiles = [ScriptBlock]{ - Show-FileListBox - $global:lstFiles.ItemsSource = @(Get-JsonFileObjects $global:txtImportPath.Text -Exclude "*_Settings.json") - } - - Add-DefaultObjectButtons -export ([scriptblock]{Show-DefaultExportGrid @script:exportParams}) -import ([scriptblock]{Show-DefaultImportGrid -ImportAll $script:importAll -ImportSelected $script:importSelected -GetFiles $script:getImportFiles}) -copy ([scriptblock]{Copy-GPOSetting}) -ViewFullObject ([scriptblock]{Get-GPOSettingObject $global:dgObjects.SelectedItem.Object}) -} - -function Get-GPOSettingObjects -{ - Get-GraphObjects -Url "/deviceManagement/groupPolicyConfigurations" -} - -function Get-GPOSettingObject -{ - param($object, $additional = "") - - if(-not $Object.id) { return } - - @((Invoke-GraphRequest -Url "/deviceManagement/groupPolicyConfigurations/$($Object.id)$additional"),(Get-GPOObjectSettings $Object)) -} - -function Export-AllGPOSettings -{ - param($path = "$env:Temp") - - if(-not (Test-Path $path)) { mkdir -Path $path -Force -ErrorAction SilentlyContinue | Out-Null } - - if(Test-Path $path) - { - foreach($objTmp in ($global:dgObjects.ItemsSource)) - { - Export-SingleGPOSetting $objTmp.Object $path - } - } -} - -function Export-SelectedGPOSetting -{ - param($path = "$env:Temp") - - Export-SingleGPOSetting $global:dgObjects.SelectedItem.Object $path -} - -function Get-GPOObjectSettings -{ - param($GPOObj) - - $gpoSettings = @() - - # Get all configured policies in the Administrative Templates profile - $GPODefinitionValues = Invoke-GraphRequest -Url "/deviceManagement/groupPolicyConfigurations/$($GPOObj.id)/definitionValues?`$expand=definition" - foreach($definitionValue in $GPODefinitionValues.value) - { - # Get presentation values for the current settings (with presentation object included) - $presentationValues = Invoke-GraphRequest -Url "/deviceManagement/groupPolicyConfigurations/$($GPOObj.id)/definitionValues/$($definitionValue.id)/presentationValues?`$expand=presentation" - - # Set base policy settings - $obj = @{ - "enabled" = $definitionValue.enabled - "definition@odata.bind" = "$($global:graphURL)/deviceManagement/groupPolicyDefinitions('$($definitionValue.definition.id)')" - } - - if($presentationValues.value) - { - # Policy presentation values set e.g. a drop down list, check box, text box etc. - $obj.presentationValues = @() - - $presentations = $null - foreach ($presentationValue in $presentationValues.value) - { - # Add presentation@odata.bind property that links the value to the presentation object - $presentationValue | Add-Member -MemberType NoteProperty -Name "presentation@odata.bind" -Value "$($global:graphURL)/deviceManagement/groupPolicyDefinitions('$($definitionValue.definition.id)')/presentations('$($presentationValue.presentation.id)')" - - #Remove presentation object so it is not included in the export - Remove-ObjectProperty $presentationValue "presentation" - - #Optional removes. Import will igonre them - Remove-ObjectProperty $presentationValue "id" - Remove-ObjectProperty $presentationValue "lastModifiedDateTime" - Remove-ObjectProperty $presentationValue "createdDateTime" - - # Add presentation value to the list - $obj.presentationValues += $presentationValue - } - } - $gpoSettings += $obj - } - $gpoSettings -} - -function Export-SingleGPOSetting -{ - param($psObj, $path = "$env:Temp") - - if(-not $psObj) { return } - - if($global:runningBulkExport -ne $true) - { - if($global:chkAddCompanyName.IsChecked) { $path = Join-Path $path $global:organization.displayName } - if($global:chkAddObjectType.IsChecked) { $path = Join-Path $path (Get-GPOSettingFolderName) } - } - - if(-not (Test-Path $path)) { mkdir -Path $path -Force -ErrorAction SilentlyContinue | Out-Null } - - if(Test-Path $path) - { - Write-Status "Export $($psObj.displayName)" - $obj = Invoke-GraphRequest -Url "deviceManagement/groupPolicyConfigurations/$($psObj.Id)?`$expand=assignments" - - if($obj) - { - # Save Administrative Templates profile - ConvertTo-Json $obj -Depth 5 | Out-File "$path\$((Remove-InvalidFileNameChars $obj.displayName)).json" -Force - - # Collect and save all the settings of the Administrative Templates profile - $gpoSettings = Get-GPOObjectSettings $obj - ConvertTo-Json $gpoSettings -Depth 5 | Out-File "$path\$($obj.displayName)_Settings.json" -Force - - # Export assignment info - Add-MigrationInfo $obj.assignments - } - $global:exportedObjects++ - } -} - -function Copy-GPOSetting -{ - if(-not $dgObjects.SelectedItem) - { - [System.Windows.MessageBox]::Show("No object selected`n`nSelect administrative templates profile you want to copy", "Error", "OK", "Error") - return - } - - $ret = Show-InputDialog "Copy administrative template" "Select name for the new profile" "$($dgObjects.SelectedItem.displayName) - Copy" - - if($ret) - { - # Export profile - Write-Status "Export $($dgObjects.SelectedItem.displayName)" - # Convert to Json and back to clone the object - $obj = ConvertTo-Json $dgObjects.SelectedItem.Object -Depth 5 | ConvertFrom-Json - if($obj) - { - # Get the settings of the profile - $gpoSettings = Get-GPOObjectSettings $obj - - # Import the new profile - $obj.displayName = $ret - Import-GPOSetting $obj $gpoSettings | Out-Null - - #Reload objects - $dgObjects.ItemsSource = @(Get-GPOSettingObjects) - } - Write-Status "" - } - $dgObjects.Focus() -} - -function Import-GPOSetting -{ - param($obj, $settings) - - Write-Status "Import $($obj.displayName)" - - Start-PreImport $obj - - # Import Administrative Template profile - $response = Invoke-GraphRequest -Url "/deviceManagement/groupPolicyConfigurations" -Content (ConvertTo-Json $obj -Depth 5) -HttpMethod POST - - if($response) - { - foreach($setting in $settings) - { - Start-PreImport $setting - - # Import each setting for the Administrative Template profile - $response2 = Invoke-GraphRequest -Url "/deviceManagement/groupPolicyConfigurations/$($response.id)/definitionValues" -Content (ConvertTo-Json $setting -Depth 5) -HttpMethod POST - } - } - - $response -} - -function Import-AllGPOSettingObjects -{ - param($path = "$env:Temp") - - # Read json files and import all objects - # Note: Each json file must match the object type being imported - Import-GPOSettingObjects (Get-JsonFileObjects $path) -} - -function Import-GPOSettingObjects -{ - param( - $Objects, - - [switch] - $Selected - ) - - Write-Status "Import administrative template profile" - - foreach($obj in $objects) - { - if($Selected -and $obj.Selected -ne $true) { continue } - - Write-Log "Import Administrative Template: $($obj.Object.displayName)" - - $gpoSettings = $null - - # Load settings from the _settings.json file - $settingsFile = ($obj.FileInfo.DirectoryName + "\" + $obj.FileInfo.BaseName + "_settings.json") - if(Test-Path $settingsFile) - { - $gpoSettings = (ConvertFrom-Json (Get-Content $settingsFile -Raw)) - } - - # Get assignment settings - $assignments = Get-GraphAssignmentsObject $obj.Object ($obj.FileInfo.DirectoryName + "\" + $obj.FileInfo.BaseName + "_assignments.json") - - # Import Administrative Template object - $response = Import-GPOSetting $obj.Object $gpoSettings - - if($response) - { - $global:importedObjects++ - # Import assignments - Import-GraphAssignments $assignments "assignments" "/deviceManagement/groupPolicyConfigurations/$($response.Id)/assign" - } - } - - #Reload list of objects - $dgObjects.ItemsSource = @(Get-GPOSettingObjects) - Write-Status "" -} \ No newline at end of file diff --git a/Extensions/Apps.psm1 b/Extensions/IntuneAppManagement.psm1 similarity index 59% rename from Extensions/Apps.psm1 rename to Extensions/IntuneAppManagement.psm1 index 52b15b1..340b716 100644 --- a/Extensions/Apps.psm1 +++ b/Extensions/IntuneAppManagement.psm1 @@ -1,923 +1,576 @@ -######################################################## -# -# Common module functions -# -######################################################## -function Add-ModuleMenuItems -{ - Add-MenuItem (New-Object PSObject -Property @{ - Title = (Get-ApplicationName) - MenuID = "IntuneGraphAPI" - Script = [ScriptBlock]{Get-Applications} - }) -} - -function Get-SupportedImportObjects -{ - $global:importObjects += (New-Object PSObject -Property @{ - Selected = $true - Title = (Get-ApplicationName) - Script = [ScriptBlock]{ - param($rootFolder) - - Write-Status "Import all applications" - Import-AllApplicationObjects (Join-Path $rootFolder (Get-ApplicationFolderName)) - } - }) -} - -function Get-SupportedExportObjects -{ - $global:exportObjects += (New-Object PSObject -Property @{ - Selected = $true - Title = (Get-ApplicationName) - Script = [ScriptBlock]{ - param($rootFolder) - - Write-Status "Export all applications" - Get-ApplicationObjects | ForEach-Object { Export-SingleApplication $PSItem.Object (Join-Path $rootFolder (Get-ApplicationFolderName)) } - } - }) -} - -function Export-AllObjects -{ - param($addObjectSubfolder) - - $subFolder = "" - if($addObjectSubfolder) { $subFolder = Get-ApplicationFolderName } -} - -######################################################## -# -# Object specific functions -# -######################################################## -function Get-ApplicationName -{ - (Get-ApplicationFolderName) -} - -function Get-ApplicationFolderName -{ - "Applications" -} - -function Get-Applications -{ - Write-Status "Loading applications" - $dgObjects.ItemsSource = @(Get-ApplicationObjects) - - #Scriptblocks that will perform the export tasks. empty by default - $script:exportParams = @{} - $script:exportParams.Add("ExportAllScript", [ScriptBlock]{ - Export-AllApplications $global:txtExportPath.Text - Set-ObjectGrid - Write-Status "" - }) - - $script:exportParams.Add("ExportSelectedScript", [ScriptBlock]{ - Export-SelectedApplication $global:txtExportPath.Text - Set-ObjectGrid - Write-Status "" - }) - - #Scriptblock that will perform the import all files - $script:importAll = [ScriptBlock]{ - Import-AllApplicationObjects $global:txtImportPath.Text - Set-ObjectGrid - } - - #Scriptblock that will perform the import of selected files - $script:importSelected = [ScriptBlock]{ - Import-ApplicationObjects $global:lstFiles.ItemsSource -Selected - Set-ObjectGrid - } - - #Scriptblock that will read json files - $script:getImportFiles = [ScriptBlock]{ - Show-FileListBox - $global:lstFiles.ItemsSource = @(Get-JsonFileObjects $global:txtImportPath.Text -Exclude "*_Settings.json") - } - - $importExtension = (New-Object PSObject -Property @{ - Xaml = @" - - - - - - - - - - - - - - - - - - - - - - - - -"@ - Script = [ScriptBlock]{ - param($form) - $script:txtPackagePath = $form.FindName("txtPackagePath") - $btnBrowsePackagePath = $form.FindName("btnBrowsePackagePath") - $script:txtPackagePath.Text = Get-SettingValue "IntuneAppPackages" - - $btnBrowsePackagePath.Tag = $script:txtPackagePath - $btnBrowsePackagePath.Add_Click({ - $folder = Get-Folder $this.Tag.Text - if($folder) { $this.Tag.Text = $folder } - }) - } - }) - - $script:importParams = @{} - $script:importParams.Add("Extension", $importExtension) - - Add-DefaultObjectButtons -export ([scriptblock]{Show-DefaultExportGrid @script:exportParams}) -import ([scriptblock]{Show-DefaultImportGrid -ImportAll $script:importAll -ImportSelected $script:importSelected -GetFiles $script:getImportFiles @script:importParams}) -ViewFullObject ([scriptblock]{Get-ApplicationObject $global:dgObjects.SelectedItem.Object}) -} - -function Get-ApplicationObjects -{ - Get-GraphObjects -Url "/deviceAppManagement/mobileApps?`$filter=(microsoft.graph.managedApp/appAvailability%20eq%20null%20or%20microsoft.graph.managedApp/appAvailability%20eq%20%27lineOfBusiness%27%20or%20isAssigned%20eq%20true)&`$orderby=displayName" -} - -function Get-ApplicationObject -{ - param($object, $additional = "") - - if(-not $Object.id) { return } - - Invoke-GraphRequest -Url "/deviceAppManagement/mobileApps/$($Object.id)$additional" -} - -function Export-AllApplications -{ - param($path = "$env:Temp") - - if(-not (Test-Path $path)) { mkdir -Path $path -Force -ErrorAction SilentlyContinue | Out-Null } - - if(Test-Path $path) - { - foreach($objTmp in ($global:dgObjects.ItemsSource)) - { - Export-SingleApplication $objTmp.Object $path - } - } -} - -function Export-SelectedApplication -{ - param($path = "$env:Temp") - - Export-SingleApplication $global:dgObjects.SelectedItem.Object $path -} - -function Export-SingleApplication -{ - param($psObj, $path = "$env:Temp") - - if(-not $psObj) { return } - - if($global:runningBulkExport -ne $true) - { - if($global:chkAddCompanyName.IsChecked) { $path = Join-Path $path $global:organization.displayName } - if($global:chkAddObjectType.IsChecked) { $path = Join-Path $path (Get-ApplicationFolderName) } - } - - if(-not (Test-Path $path)) { mkdir -Path $path -Force -ErrorAction SilentlyContinue | Out-Null } - - if(Test-Path $path) - { - Write-Status "Export $($psObj.displayName)" - $obj = Invoke-GraphRequest -Url "/deviceAppManagement/mobileApps/$($psObj.id)?`$expand=assignments" - if($obj) - { - $fileName = "$path\$((Remove-InvalidFileNameChars $obj.displayName)).json" - ConvertTo-Json $obj -Depth 5 | Out-File $fileName -Force - - Add-MigrationInfo $obj.assignments - } - $global:exportedObjects++ - } -} - -function Copy-Application -{ - if(-not $dgObjects.SelectedItem) - { - [System.Windows.MessageBox]::Show("No object selected`n`nSelect application item you want to copy", "Error", "OK", "Error") - return - } - - $ret = Show-InputDialog "Copy application" "Select name for the new object" "$($dgObjects.SelectedItem.displayName) - Copy" - - if($ret) - { - # Export profile - Write-Status "Export $($dgObjects.SelectedItem.displayName)" - # Convert to Json and back to clone the object - $obj = ConvertTo-Json $dgObjects.SelectedItem.Object -Depth 5 | ConvertFrom-Json - if($obj) - { - # Import new profile - $obj.displayName = $ret - Import-Application $obj | Out-Null - - $dgObjects.ItemsSource = @(Get-ApplicationObjects) - } - Write-Status "" - } - $dgObjects.Focus() -} - -function Import-Application -{ - param($obj) - - Start-PreImport $obj -RemoveProperties @("uploadState","publishingState","isAssigned","roleScopeTagIds","dependentAppCount","committedContentVersion","id","isFeatured","size") - - Write-Status "Import $($obj.displayName)" - - Invoke-GraphRequest -Url "/deviceAppManagement/mobileApps" -Content (ConvertTo-Json $obj -Depth 5) -HttpMethod POST -} - -function Import-AllApplicationObjects -{ - param($path = "$env:Temp") - - Import-ApplicationObjects (Get-JsonFileObjects $path) -} - -function Import-ApplicationObjects -{ - param( - $Objects, - - [switch] - $Selected - ) - - Write-Status "Import applications" - - foreach($obj in $objects) - { - if($Selected -and $obj.Selected -ne $true) { continue } - - if($global:runningBulkImport) - { - $pkgPath = Get-SettingValue "IntuneAppPackages" - } - else - { - $pkgPath = $script:txtPackagePath.Text - } - $appFile = "$($pkgPath)\$($obj.Object.fileName)" - - if(Test-Path $appFile) - { - Write-Log "Import Application: $($obj.Object.displayName) ($($obj.Object."@odata.type"))" - - $assignments = Get-GraphAssignmentsObject $obj.Object ($obj.FileInfo.DirectoryName + "\" + $obj.FileInfo.BaseName + "_assignments.json") - $response = Import-Application $obj.Object - - if($response) - { - $global:importedObjects++ - Copy-AppPackageToIntune $appFile $response - - Import-GraphAssignments $assignments "mobileAppAssignments" "/deviceAppManagement/mobileApps/$($response.Id)/assign" "#microsoft.graph.mobileAppAssignment" - } - } - else - { - Write-Log "Application file $appFile not found. Skipping app $($obj.Object.displayName)" 3 - } - } - $dgObjects.ItemsSource = @(Get-ApplicationObjects) - Write-Status "" -} - -function Start-DownloadAppContent -{ - param($obj, $path) - # Not use but kept for reference. File can be download but it will be encrypted - - $appId = $obj.Id - - $appId = "b2b79110-31f7-40bd-923b-228415c92cdb" - - $appInfo = Invoke-GraphRequest -Url "$($global:graphURL)/deviceAppManagement/mobileApps/$appId" - - $appType = $appInfo.'@odata.type'.Trim('#') - - $contentVersions = Invoke-GraphRequest -Url "$($global:graphURL)/deviceAppManagement/mobileApps/$appId/$appType/contentVersions" - - $contentVerId = $contentVersions.Value[0].id - - $contentFiles = Invoke-GraphRequest "$($global:graphURL)/deviceAppManagement/mobileApps/$appId/$appType/contentVersions/$contentVerId/files" - - foreach($tmpFile in $contentFiles) - { - $contentFile = Invoke-GraphRequest -Url "$($global:graphURL)/deviceAppManagement/mobileApps/$appId/$appType/contentVersions/$contentVerId/files/$($tmpFile.Id)" - $downloadUrl = $contentFile.azureStorageUri - } -} - -function Copy-AppPackageToIntune -{ - param($packageFile, $appObj) - - $appType = $appObj.'@odata.type'.Trim('#') - - if($appType -eq "microsoft.graph.win32LobApp") - { - Copy-Win32LOBPackage $packageFile $appObj - } - elseif($appType -eq "microsoft.graph.windowsMobileMSI") - { - Copy-MSILOB $packageFile $appObj - } - elseif($appType -eq "microsoft.graph.iosLOBApp") - { - Copy-iOSLOB $packageFile $appObj - } - elseif($appType -eq "microsoft.graph.androidLOBApp") - { - Copy-AndroidLOB $packageFile $appObj - } -} - -######################################################################################### -# -# Upload file functions are based on the following scripts -# https://github.com/microsoftgraph/powershell-intune-samples/tree/master/LOB_Application -# -######################################################################################### - -function Export-IntunewinFileObject -{ - param($intunewinFile, $objectName, $toFile) - - Add-Type -Assembly System.IO.Compression.FileSystem - - $zip = [IO.Compression.ZipFile]::OpenRead($intunewinFile) - - $zip.Entries | where { $_.Name -like $objectName } | foreach { - - [System.IO.Compression.ZipFileExtensions]::ExtractToFile($_, $toFile, $true) - } - - $zip.Dispose() -} - -function Get-MSIFileInformation -{ - param($MSIFile, $Properties) - - $values = @{} - - try - { - $wiObj = New-Object -ComObject WindowsInstaller.Installer - $MSIDb = $wiObj.GetType().InvokeMember("OpenDatabase", "InvokeMethod", $null, $wiObj, @($MSIFile, 0)) - - foreach($prop in $Properties) - { - $Query = "SELECT Value FROM Property WHERE Property = '$($prop)'" - $View = $MSIDb.GetType().InvokeMember("OpenView", "InvokeMethod", $null, $MSIDb, ($Query)) - $View.GetType().InvokeMember("Execute", "InvokeMethod", $null, $View, $null) | Out-Null - $Record = $View.GetType().InvokeMember("Fetch", "InvokeMethod", $null, $View, $null) - $values.Add($prop, $Record.GetType().InvokeMember("StringData", "GetProperty", $null, $Record, 1).ToString().Trim()) - } - - $MSIDb.GetType().InvokeMember("Commit", "InvokeMethod", $null, $MSIDb, $null) | Out-Null - $View.GetType().InvokeMember("Close", "InvokeMethod", $null, $View, $null) | Out-Null - $MSIDb = $null - $View = $null - } - catch - { - Write-Log "Failed to get MSI info from $MSIFile. $($_.Exception.Message)" 3 - } - finally - { - [System.Runtime.Interopservices.Marshal]::ReleaseComObject($wiObj) | Out-Null - [System.GC]::Collect() | Out-Null - } - - $values -} - -function Copy-MSILOB -{ - param($msiFile, $appObj) - - if(-not $msiFile -or (Test-Path $msiFile) -eq $false) - { - return - } - - $appId = $appObj.Id - $appType = $appObj.'@odata.type'.Trim('#') - - $tmpFile = [IO.Path]::GetTempFileName() - - $msiInfo = Get-MSIFileInformation $msiFile @("ProductName", "ProductCode", "ProductVersion", "ProductLanguage") - - if(-not $msiInfo) { return } - - $fileEncryptionInfo = New-IntuneEncryptedFile $msiFile $tmpFile - - [xml]$manifestXML = '' - $manifestXML.MobileMsiData.MsiUpgradeCode = $msiInfo["ProductCode"] - - $appFileBody = @{ - "@odata.type" = "#microsoft.graph.mobileAppContentFile" - name = [IO.Path]::GetFileName($msiFile) - size = (Get-Item $msiFile).Length - sizeEncrypted = (Get-Item $tmpFile).Length - manifest = [Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($manifestXML.OuterXml)) - } - - Add-FileToIntuneApp $appId $appType $tmpFile $appFileBody - - Remove-Item $tmpFile -Force -} - -function Copy-iOSLOB -{ - param($pkgFile, $appObj) - - if(-not $pkgFile -or (Test-Path $pkgFile) -eq $false) - { - return - } - - $appId = $appObj.Id - $appType = $appObj.'@odata.type'.Trim('#') - - $tmpFile = [IO.Path]::GetTempFileName() - - $fileEncryptionInfo = New-IntuneEncryptedFile $pkgFile $tmpFile - - [string]$manifestStr = 'itemsassetskindsoftware-packageurl{UrlPlaceHolder}metadataAppRestrictionPolicyTemplate http://management.microsoft.com/PolicyTemplates/AppRestrictions/iOS/v1AppRestrictionTechnologyWindows Intune Application Restrictions Technology for iOSIntuneMAMVersionCFBundleSupportedPlatformsiPhoneOSMinimumOSVersion9.0bundle-identifierbundleidbundle-versionbundleversionkindsoftwaresubtitleLaunchMeSubtitletitlebundletitle' - - $manifestStr = $manifestStr.replace("bundleid", $appObj.bundleId) - $manifestStr = $manifestStr.replace("bundleversion",$appObj.identityVersion) - $manifestStr = $manifestStr.replace("bundletitle",$appObj.$displayName) - - $appFileBody = @{ - "@odata.type" = "#microsoft.graph.mobileAppContentFile" - name = [IO.Path]::GetFileName($pkgFile) - size = (Get-Item $pkgFile).Length - sizeEncrypted = (Get-Item $tmpFile).Length - manifest = [Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($manifestStr)) - } - - Add-FileToIntuneApp $appId $appType $tmpFile $appFileBody - - Remove-Item $tmpFile -Force -} - -function Copy-AndroidLOB -{ - param($pkgFile, $appObj) - - if(-not $pkgFile -or (Test-Path $pkgFile) -eq $false) - { - return - } - - $appId = $appObj.Id - $appType = $appObj.'@odata.type'.Trim('#') - - $tmpFile = [IO.Path]::GetTempFileName() - - $fileEncryptionInfo = New-IntuneEncryptedFile $pkgFile $tmpFile - - [xml]$manifestXML = 'com.leadapps.android.radio.ncp101.0.5.4A_Online_Radio_1.0.5.4.apk3' - - $manifestXML.AndroidManifestProperties.Package = $appObj.identityName - $manifestXML.AndroidManifestProperties.PackageVersionCode = $appObj.versionCode - $manifestXML.AndroidManifestProperties.PackageVersionName = $appObj.versionName - $manifestXML.AndroidManifestProperties.ApplicationName = [IO.Path]::GetFileName($pkgFile) - - $appFileBody = @{ - "@odata.type" = "#microsoft.graph.mobileAppContentFile" - name = [IO.Path]::GetFileName($pkgFile) - size = (Get-Item $pkgFile).Length - sizeEncrypted = (Get-Item $tmpFile).Length - manifest = [Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($manifestXML.OuterXml)) - } - - Add-FileToIntuneApp $appId $appType $tmpFile $appFileBody - - Remove-Item $tmpFile -Force -} - -function Copy-Win32LOBPackage -{ - param($intunewinFile, $appObj) - - if(-not $intunewinFile -or (Test-Path $intunewinFile) -eq $false) - { - return - } - - $appId = $appObj.Id - $appType = $appObj.'@odata.type'.Trim('#') - - #Extract the detection.xml from the intunewin file - - $tmpFile = [IO.Path]::GetTempFileName() - - Export-IntunewinFileObject $intunewinFile "detection.xml" $tmpFile - - [xml]$DetectionXML = Get-Content $tmpFile - - Remove-Item -Path $tmpFile - - # Get encryption info from detection.xml and build encryptionInfo object - - $encryptionInfo = @{} - $encryptionInfo.encryptionKey = $DetectionXML.ApplicationInfo.EncryptionInfo.EncryptionKey - $encryptionInfo.macKey = $DetectionXML.ApplicationInfo.EncryptionInfo.macKey - $encryptionInfo.initializationVector = $DetectionXML.ApplicationInfo.EncryptionInfo.initializationVector - $encryptionInfo.mac = $DetectionXML.ApplicationInfo.EncryptionInfo.mac - $encryptionInfo.profileIdentifier = "ProfileVersion1" - $encryptionInfo.fileDigest = $DetectionXML.ApplicationInfo.EncryptionInfo.fileDigest - $encryptionInfo.fileDigestAlgorithm = $DetectionXML.ApplicationInfo.EncryptionInfo.fileDigestAlgorithm - - $tmpIntunewinPath = ([IO.Path]::GetTempPath() + [Guid]::NewGuid().ToString("n")) - mkdir $tmpIntunewinPath | Out-Null - $tmpIntunewinFile = $tmpIntunewinPath + "\" + $DetectionXML.ApplicationInfo.FileName - - # Extract the encrypted file from the intunewin file - Export-IntunewinFileObject $intunewinFile $DetectionXML.ApplicationInfo.FileName $tmpIntunewinFile - - # Create mobileAppContentFile object for the file - $fileEncryptionInfo = @{} - $fileEncryptionInfo.fileEncryptionInfo = $encryptionInfo - - $fileBody = @{ - "@odata.type" = "#microsoft.graph.mobileAppContentFile" - name = $DetectionXML.ApplicationInfo.FileName - size = [int64]$DetectionXML.ApplicationInfo.UnencryptedContentSize - sizeEncrypted = (Get-Item $tmpIntunewinFile).Length - manifest = $null - isDependency = $false - } - - Add-FileToIntuneApp $appId $appType $tmpIntunewinFile $fileBody - - # Remove extracted inintunewin file - Remove-Item $tmpIntunewinPath -Force -Recurse -} - -function Add-FileToIntuneApp -{ - param($appId, $appType, $appFile, $fileBody) - - $contentVersion = Invoke-GraphRequest -Url "/deviceAppManagement/mobileApps/$appId/$appType/contentVersions" - $contentVersionId = $contentVersion.value[0].id - $fileObj = Invoke-GraphRequest -Url "/deviceAppManagement/mobileApps/$appId/$appType/contentVersions/$contentVersionId/files" -HttpMethod POST -Content (ConvertTo-Json $fileBody -Depth 5) - - if(-not $fileObj) - { - return - } - - # Wait for Azure storage URI - $fileObj = Wait-IntuneFileState "/deviceAppManagement/mobileApps/$appId/$appType/contentVersions/$contentVersionId/files/$($fileObj.Id)" "AzureStorageUriRequest" - if(-not $fileObj) - { - return - } - - # Upload file - Send-IntuneFileToAzureStorage $fileObj.azureStorageUri $appFile "/deviceAppManagement/mobileApps/$appId/$appType/contentVersions/$contentVersionId/files/$($fileObj.Id)" - - # Commit the file - $reponse = Invoke-GraphRequest -Url "/deviceAppManagement/mobileApps/$appId/$appType/contentVersions/$contentVersionId/files/$($fileObj.Id)/commit" -HttpMethod POST -Content (ConvertTo-Json $fileEncryptionInfo -Depth 5) - - Wait-IntuneFileState "/deviceAppManagement/mobileApps/$appId/$appType/contentVersions/$contentVersionId/files/$($fileObj.Id)" "CommitFile" - - # Commit the content version - $commitAppBody = @{ - "@odata.type" = "#$appType" - committedContentVersion = $contentVersionId - } - - $reponse = Invoke-GraphRequest -Url "/deviceAppManagement/mobileApps/$appId" -HttpMethod PATCH -Content (ConvertTo-Json $commitAppBody -Depth 5) -} - -function Wait-IntuneFileState -{ - param($fileUri, $state, $maxWait = 60) - - Write-Status "Wait for state $state" - - $endWait = (Get-Date).AddMinutes($maxWait) - - $successState = "$($state)Success" - $pendingState = "$($state)Pending" - $failedState = "$($state)Failed" - $timedOutState = "$($state)TimedOut" - - $file = $null - $succes = $false - - while ((Get-Date) -lt $endWait) - { - $file = Invoke-GraphRequest -Url $fileUri - - if ($file.uploadState -eq $successState) - { - $succes = $true - break - } - elseif ($file.uploadState -ne $pendingState) - { - Write-Log "Failed to upload file. State: $($file.uploadState)" 3 - return - } - - Start-Sleep -s 5 - } - - if($succes -eq $false) - { - Write-Log "Wait for state operation timed out" 3 - return - } - - $file -} - -function Send-IntuneFileToAzureStorage -{ - param($sasUri, $filepath, $fileUri) - - try - { - $chunkSizeInBytes = 5MB - - # Start the timer for SAS URI renewal. - $sasRenewalTimer = [System.Diagnostics.Stopwatch]::StartNew() - - # Find the file size and open the file. - $fileSize = (Get-Item $filepath).length - $chunks = [Math]::Ceiling($fileSize / $chunkSizeInBytes) - $reader = New-Object System.IO.BinaryReader([System.IO.File]::Open($filepath, [System.IO.FileMode]::Open)) - $position = $reader.BaseStream.Seek(0, [System.IO.SeekOrigin]::Begin) - - # Upload each chunk. Check whether a SAS URI renewal is required after each chunk is uploaded and renew if needed. - $ids = @() - - for ($chunk = 0; $chunk -lt $chunks; $chunk++) - { - - $id = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($chunk.ToString("0000"))) - $ids += $id - - $start = $chunk * $chunkSizeInBytes - $length = [Math]::Min($chunkSizeInBytes, $fileSize - $start) - $bytes = $reader.ReadBytes($length) - - $currentChunk = $chunk + 1 - - Write-Status "Uploading file to Azure Storage`n`nUploading chunk $currentChunk of $chunks ($(($currentChunk / $chunks*100))%)" - - Write-AzureStorageChunk $sasUri $id $bytes - - if ($currentChunk -lt $chunks -and $sasRenewalTimer.ElapsedMilliseconds -ge 450000) - { - Request-RenewAzureStorageUpload $fileUri - $sasRenewalTimer.Restart() - } - } - $reader.Close() - } - finally - { - if ($reader -ne $null) { $reader.Dispose() } - } - - # Finalize the upload. - $uploadResponse = Set-FinalizeAzureStorageUpload $sasUri $ids -} - -function Request-RenewAzureStorageUpload -{ - param($fileUri) - - $fileObj = Invoke-GraphRequest -Url "$fileUri/renewUpload" -HttpMethod POST - - $file = Wait-IntuneFileState $fileUri "AzureStorageUriRenewal" $azureStorageRenewSasUriBackOffTimeInSeconds -} - -function Set-FinalizeAzureStorageUpload -{ - param($sasUri, $ids) - - $uri = "$sasUri&comp=blocklist" - - if(($uri -notmatch "^http://|^https://")) - { - $uri = $global:graphURL + "/" + $uri.TrimStart('/') - } - - $request = "PUT $uri" - - $xml = '' - foreach ($id in $ids) - { - $xml += "$id" - } - $xml += '' - - try - { - Invoke-RestMethod $uri -Method Put -Body $xml - } - catch - { - Write-Log "Failed to finilize upload. $($_.Exception.Message)" 3 - } -} - -function Write-AzureStorageChunk -{ - param($sasUri, $id, $body) - - $uri = "$sasUri&comp=block&blockid=$id" - - if(($uri -notmatch "^http://|^https://")) - { - $uri = $global:graphURL + "/" + $uri.TrimStart('/') - } - - $request = "PUT $uri" - - $iso = [System.Text.Encoding]::GetEncoding("iso-8859-1") - $encodedBody = $iso.GetString($body) - $headers = @{ - "x-ms-blob-type" = "BlockBlob" - } - - try - { - $response = Invoke-WebRequest $uri -Method Put -Headers $headers -Body $encodedBody - } - catch - { - Write-Log "Failed to upload file chunk. $($_.Exception.Message)" 3 - } -} - -function Get-IntuneKey -{ - try - { - $aes = [System.Security.Cryptography.Aes]::Create() - $aesProvider = New-Object System.Security.Cryptography.AesCryptoServiceProvider - $aesProvider.GenerateKey() - $aesProvider.Key - } - finally - { - if ($aesProvider -ne $null) { $aesProvider.Dispose() } - if ($aes -ne $null) { $aes.Dispose() } - } -} - -function Get-IntuneKeyIV -{ - - try - { - $aes = [System.Security.Cryptography.Aes]::Create() - $aes.IV - } - finally - { - if ($aes -ne $null) { $aes.Dispose() } - } -} - -function Start-EncryptFileWithIV -{ - param($sourceFile, $targetFile, $encryptionKey, $hmacKey, $initializationVector) - - $bufferBlockSize = 1024 * 4 - $computedMac = $null - - try - { - $aes = [System.Security.Cryptography.Aes]::Create() - $hmacSha256 = New-Object System.Security.Cryptography.HMACSHA256 - $hmacSha256.Key = $hmacKey - $hmacLength = $hmacSha256.HashSize / 8 - - $buffer = New-Object byte[] $bufferBlockSize - $bytesRead = 0 - - $targetStream = [System.IO.File]::Open($targetFile, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write, [System.IO.FileShare]::Read) - $targetStream.Write($buffer, 0, $hmacLength + $initializationVector.Length) - - try - { - $encryptor = $aes.CreateEncryptor($encryptionKey, $initializationVector) - $sourceStream = [System.IO.File]::Open($sourceFile, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read) - $cryptoStream = New-Object System.Security.Cryptography.CryptoStream -ArgumentList @($targetStream, $encryptor, [System.Security.Cryptography.CryptoStreamMode]::Write) - - $targetStream = $null - while (($bytesRead = $sourceStream.Read($buffer, 0, $bufferBlockSize)) -gt 0) - { - $cryptoStream.Write($buffer, 0, $bytesRead) - $cryptoStream.Flush() - } - $cryptoStream.FlushFinalBlock() - } - finally - { - if ($cryptoStream -ne $null) { $cryptoStream.Dispose() } - if ($sourceStream -ne $null) { $sourceStream.Dispose() } - if ($encryptor -ne $null) { $encryptor.Dispose() } - } - - try - { - $finalStream = [System.IO.File]::Open($targetFile, [System.IO.FileMode]::Open, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::Read) - - $finalStream.Seek($hmacLength, [System.IO.SeekOrigin]::Begin) > $null - $finalStream.Write($initializationVector, 0, $initializationVector.Length) - $finalStream.Seek($hmacLength, [System.IO.SeekOrigin]::Begin) > $null - - $hmac = $hmacSha256.ComputeHash($finalStream) - $computedMac = $hmac - - $finalStream.Seek(0, [System.IO.SeekOrigin]::Begin) > $null - $finalStream.Write($hmac, 0, $hmac.Length) - } - finally - { - if ($finalStream -ne $null) { $finalStream.Dispose() } - } - } - finally - { - if ($targetStream -ne $null) { $targetStream.Dispose() } - if ($aes -ne $null) { $aes.Dispose() } - } - - $computedMac -} - -function New-IntuneEncryptedFile -{ - param($sourceFile, $targetFile) - - $encryptionKey = Get-IntuneKey - $hmacKey = Get-IntuneKey - $initializationVector = Get-IntuneKeyIV - - # Create the encrypted target file and compute the HMAC value. - $mac = Start-EncryptFileWithIV $sourceFile $targetFile $encryptionKey $hmacKey $initializationVector - - # Compute the SHA256 hash of the source file and convert the result to bytes. - $fileDigest = (Get-FileHash $sourceFile -Algorithm SHA256).Hash - $fileDigestBytes = New-Object byte[] ($fileDigest.Length / 2) - for ($i = 0; $i -lt $fileDigest.Length; $i += 2) - { - $fileDigestBytes[$i / 2] = [System.Convert]::ToByte($fileDigest.Substring($i, 2), 16) - } - - # Return an object that will serialize correctly to the file commit Graph API. - $encryptionInfo = @{} - $encryptionInfo.encryptionKey = [System.Convert]::ToBase64String($encryptionKey) - $encryptionInfo.macKey = [System.Convert]::ToBase64String($hmacKey) - $encryptionInfo.initializationVector = [System.Convert]::ToBase64String($initializationVector) - $encryptionInfo.mac = [System.Convert]::ToBase64String($mac) - $encryptionInfo.profileIdentifier = "ProfileVersion1" - $encryptionInfo.fileDigest = [System.Convert]::ToBase64String($fileDigestBytes) - $encryptionInfo.fileDigestAlgorithm = "SHA256" - - $fileEncryptionInfo = @{} - $fileEncryptionInfo.fileEncryptionInfo = $encryptionInfo - - $fileEncryptionInfo +<# +.SYNOPSIS +Module for Intune Applications + +.DESCRIPTION +This module manages Application objects in Intune e.g. uploading application files + +.NOTES + Author: Mikael Karlsson +#> +function Get-ModuleVersion +{ + '3.0.0' +} + +######################################################################################### +# +# Upload file functions are based on the following scripts +# https://github.com/microsoftgraph/powershell-intune-samples/tree/master/LOB_Application +# +######################################################################################### + +function Export-IntunewinFileObject +{ + param($intunewinFile, $objectName, $toFile) + + Add-Type -Assembly System.IO.Compression.FileSystem + + $zip = [IO.Compression.ZipFile]::OpenRead($intunewinFile) + + $zip.Entries | where { $_.Name -like $objectName } | foreach { + + [System.IO.Compression.ZipFileExtensions]::ExtractToFile($_, $toFile, $true) + } + + $zip.Dispose() +} + +function Get-MSIFileInformation +{ + param($MSIFile, $Properties) + + $values = @{} + + try + { + $wiObj = New-Object -ComObject WindowsInstaller.Installer + $MSIDb = $wiObj.GetType().InvokeMember("OpenDatabase", "InvokeMethod", $null, $wiObj, @($MSIFile, 0)) + + foreach($prop in $Properties) + { + $Query = "SELECT Value FROM Property WHERE Property = '$($prop)'" + $View = $MSIDb.GetType().InvokeMember("OpenView", "InvokeMethod", $null, $MSIDb, ($Query)) + $View.GetType().InvokeMember("Execute", "InvokeMethod", $null, $View, $null) | Out-Null + $Record = $View.GetType().InvokeMember("Fetch", "InvokeMethod", $null, $View, $null) + $values.Add($prop, $Record.GetType().InvokeMember("StringData", "GetProperty", $null, $Record, 1).ToString().Trim()) + } + + $MSIDb.GetType().InvokeMember("Commit", "InvokeMethod", $null, $MSIDb, $null) | Out-Null + $View.GetType().InvokeMember("Close", "InvokeMethod", $null, $View, $null) | Out-Null + $MSIDb = $null + $View = $null + } + catch + { + Write-Log "Failed to get MSI info from $MSIFile. $($_.Exception.Message)" 3 + } + finally + { + [System.Runtime.Interopservices.Marshal]::ReleaseComObject($wiObj) | Out-Null + [System.GC]::Collect() | Out-Null + } + + $values +} + +function Copy-MSILOB +{ + param($msiFile, $appObj) + + if(-not $msiFile -or (Test-Path $msiFile) -eq $false) + { + return + } + + $appId = $appObj.Id + $appType = $appObj.'@odata.type'.Trim('#') + + $tmpFile = [IO.Path]::GetTempFileName() + + $msiInfo = Get-MSIFileInformation $msiFile @("ProductName", "ProductCode", "ProductVersion", "ProductLanguage") + + if(-not $msiInfo) { return } + + $fileEncryptionInfo = New-IntuneEncryptedFile $msiFile $tmpFile + + [xml]$manifestXML = '' + $manifestXML.MobileMsiData.MsiUpgradeCode = $msiInfo["ProductCode"] + + $appFileBody = @{ + "@odata.type" = "#microsoft.graph.mobileAppContentFile" + name = [IO.Path]::GetFileName($msiFile) + size = (Get-Item $msiFile).Length + sizeEncrypted = (Get-Item $tmpFile).Length + manifest = [Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($manifestXML.OuterXml)) + } + + Add-FileToIntuneApp $appId $appType $tmpFile $appFileBody + + Remove-Item $tmpFile -Force +} + +function Copy-iOSLOB +{ + param($pkgFile, $appObj) + + if(-not $pkgFile -or (Test-Path $pkgFile) -eq $false) + { + return + } + + $appId = $appObj.Id + $appType = $appObj.'@odata.type'.Trim('#') + + $tmpFile = [IO.Path]::GetTempFileName() + + $fileEncryptionInfo = New-IntuneEncryptedFile $pkgFile $tmpFile + + [string]$manifestStr = 'itemsassetskindsoftware-packageurl{UrlPlaceHolder}metadataAppRestrictionPolicyTemplate http://management.microsoft.com/PolicyTemplates/AppRestrictions/iOS/v1AppRestrictionTechnologyWindows Intune Application Restrictions Technology for iOSIntuneMAMVersionCFBundleSupportedPlatformsiPhoneOSMinimumOSVersion9.0bundle-identifierbundleidbundle-versionbundleversionkindsoftwaresubtitleLaunchMeSubtitletitlebundletitle' + + $manifestStr = $manifestStr.replace("bundleid", $appObj.bundleId) + $manifestStr = $manifestStr.replace("bundleversion",$appObj.identityVersion) + $manifestStr = $manifestStr.replace("bundletitle",$appObj.$displayName) + + $appFileBody = @{ + "@odata.type" = "#microsoft.graph.mobileAppContentFile" + name = [IO.Path]::GetFileName($pkgFile) + size = (Get-Item $pkgFile).Length + sizeEncrypted = (Get-Item $tmpFile).Length + manifest = [Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($manifestStr)) + } + + Add-FileToIntuneApp $appId $appType $tmpFile $appFileBody + + Remove-Item $tmpFile -Force +} + +function Copy-AndroidLOB +{ + param($pkgFile, $appObj) + + if(-not $pkgFile -or (Test-Path $pkgFile) -eq $false) + { + return + } + + $appId = $appObj.Id + $appType = $appObj.'@odata.type'.Trim('#') + + $tmpFile = [IO.Path]::GetTempFileName() + + $fileEncryptionInfo = New-IntuneEncryptedFile $pkgFile $tmpFile + + [xml]$manifestXML = 'com.leadapps.android.radio.ncp101.0.5.4A_Online_Radio_1.0.5.4.apk3' + + $manifestXML.AndroidManifestProperties.Package = $appObj.identityName + $manifestXML.AndroidManifestProperties.PackageVersionCode = $appObj.versionCode + $manifestXML.AndroidManifestProperties.PackageVersionName = $appObj.versionName + $manifestXML.AndroidManifestProperties.ApplicationName = [IO.Path]::GetFileName($pkgFile) + + $appFileBody = @{ + "@odata.type" = "#microsoft.graph.mobileAppContentFile" + name = [IO.Path]::GetFileName($pkgFile) + size = (Get-Item $pkgFile).Length + sizeEncrypted = (Get-Item $tmpFile).Length + manifest = [Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($manifestXML.OuterXml)) + } + + Add-FileToIntuneApp $appId $appType $tmpFile $appFileBody + + Remove-Item $tmpFile -Force +} + +function Copy-Win32LOBPackage +{ + param($intunewinFile, $appObj) + + if(-not $intunewinFile -or (Test-Path $intunewinFile) -eq $false) + { + return + } + + $appId = $appObj.Id + $appType = $appObj.'@odata.type'.Trim('#') + + #Extract the detection.xml from the intunewin file + + $tmpFile = [IO.Path]::GetTempFileName() + + Export-IntunewinFileObject $intunewinFile "detection.xml" $tmpFile + + [xml]$DetectionXML = Get-Content $tmpFile + + Remove-Item -Path $tmpFile + + # Get encryption info from detection.xml and build encryptionInfo object + + $encryptionInfo = @{} + $encryptionInfo.encryptionKey = $DetectionXML.ApplicationInfo.EncryptionInfo.EncryptionKey + $encryptionInfo.macKey = $DetectionXML.ApplicationInfo.EncryptionInfo.macKey + $encryptionInfo.initializationVector = $DetectionXML.ApplicationInfo.EncryptionInfo.initializationVector + $encryptionInfo.mac = $DetectionXML.ApplicationInfo.EncryptionInfo.mac + $encryptionInfo.profileIdentifier = "ProfileVersion1" + $encryptionInfo.fileDigest = $DetectionXML.ApplicationInfo.EncryptionInfo.fileDigest + $encryptionInfo.fileDigestAlgorithm = $DetectionXML.ApplicationInfo.EncryptionInfo.fileDigestAlgorithm + + $tmpIntunewinPath = ([IO.Path]::GetTempPath() + [Guid]::NewGuid().ToString("n")) + mkdir $tmpIntunewinPath | Out-Null + $tmpIntunewinFile = $tmpIntunewinPath + "\" + $DetectionXML.ApplicationInfo.FileName + + # Extract the encrypted file from the intunewin file + Export-IntunewinFileObject $intunewinFile $DetectionXML.ApplicationInfo.FileName $tmpIntunewinFile + + # Create mobileAppContentFile object for the file + $fileEncryptionInfo = @{} + $fileEncryptionInfo.fileEncryptionInfo = $encryptionInfo + + $fileBody = @{ + "@odata.type" = "#microsoft.graph.mobileAppContentFile" + name = $DetectionXML.ApplicationInfo.FileName + size = [int64]$DetectionXML.ApplicationInfo.UnencryptedContentSize + sizeEncrypted = (Get-Item $tmpIntunewinFile).Length + manifest = $null + isDependency = $false + } + + Add-FileToIntuneApp $appId $appType $tmpIntunewinFile $fileBody + + # Remove extracted inintunewin file + Remove-Item $tmpIntunewinPath -Force -Recurse +} + +function Add-FileToIntuneApp +{ + param($appId, $appType, $appFile, $fileBody) + + $contentVersion = Invoke-GraphRequest -Url "/deviceAppManagement/mobileApps/$appId/$appType/contentVersions" + $contentVersionId = $contentVersion.value[0].id + $fileObj = Invoke-GraphRequest -Url "/deviceAppManagement/mobileApps/$appId/$appType/contentVersions/$contentVersionId/files" -HttpMethod POST -Content (ConvertTo-Json $fileBody -Depth 5) + + if(-not $fileObj) + { + return + } + + # Wait for Azure storage URI + $fileObj = Wait-IntuneFileState "/deviceAppManagement/mobileApps/$appId/$appType/contentVersions/$contentVersionId/files/$($fileObj.Id)" "AzureStorageUriRequest" + if(-not $fileObj) + { + return + } + + # Upload file + Send-IntuneFileToAzureStorage $fileObj.azureStorageUri $appFile "/deviceAppManagement/mobileApps/$appId/$appType/contentVersions/$contentVersionId/files/$($fileObj.Id)" + + # Commit the file + $reponse = Invoke-GraphRequest -Url "/deviceAppManagement/mobileApps/$appId/$appType/contentVersions/$contentVersionId/files/$($fileObj.Id)/commit" -HttpMethod POST -Content (ConvertTo-Json $fileEncryptionInfo -Depth 5) + + Wait-IntuneFileState "/deviceAppManagement/mobileApps/$appId/$appType/contentVersions/$contentVersionId/files/$($fileObj.Id)" "CommitFile" + + # Commit the content version + $commitAppBody = @{ + "@odata.type" = "#$appType" + committedContentVersion = $contentVersionId + } + + $reponse = Invoke-GraphRequest -Url "/deviceAppManagement/mobileApps/$appId" -HttpMethod PATCH -Content (ConvertTo-Json $commitAppBody -Depth 5) +} + +function Wait-IntuneFileState +{ + param($fileUri, $state, $maxWait = 60) + + Write-Status "Wait for state $state" + + $endWait = (Get-Date).AddMinutes($maxWait) + + $successState = "$($state)Success" + $pendingState = "$($state)Pending" + $failedState = "$($state)Failed" + $timedOutState = "$($state)TimedOut" + + $file = $null + $succes = $false + + while ((Get-Date) -lt $endWait) + { + $file = Invoke-GraphRequest -Url $fileUri + + if ($file.uploadState -eq $successState) + { + $succes = $true + break + } + elseif ($file.uploadState -ne $pendingState) + { + Write-Log "Failed to upload file. State: $($file.uploadState)" 3 + return + } + + Start-Sleep -s 5 + } + + if($succes -eq $false) + { + Write-Log "Wait for state operation timed out" 3 + return + } + + $file +} + +function Send-IntuneFileToAzureStorage +{ + param($sasUri, $filepath, $fileUri) + + try + { + $chunkSizeInBytes = 5MB + + # Start the timer for SAS URI renewal. + $sasRenewalTimer = [System.Diagnostics.Stopwatch]::StartNew() + + # Find the file size and open the file. + $fileSize = (Get-Item $filepath).length + $chunks = [Math]::Ceiling($fileSize / $chunkSizeInBytes) + $reader = New-Object System.IO.BinaryReader([System.IO.File]::Open($filepath, [System.IO.FileMode]::Open)) + $position = $reader.BaseStream.Seek(0, [System.IO.SeekOrigin]::Begin) + + # Upload each chunk. Check whether a SAS URI renewal is required after each chunk is uploaded and renew if needed. + $ids = @() + + for ($chunk = 0; $chunk -lt $chunks; $chunk++) + { + + $id = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($chunk.ToString("0000"))) + $ids += $id + + $start = $chunk * $chunkSizeInBytes + $length = [Math]::Min($chunkSizeInBytes, $fileSize - $start) + $bytes = $reader.ReadBytes($length) + + $currentChunk = $chunk + 1 + + Write-Status "Uploading file to Azure Storage`n`nUploading chunk $currentChunk of $chunks ($(($currentChunk / $chunks*100))%)" + + Write-AzureStorageChunk $sasUri $id $bytes + + if ($currentChunk -lt $chunks -and $sasRenewalTimer.ElapsedMilliseconds -ge 450000) + { + Request-RenewAzureStorageUpload $fileUri + $sasRenewalTimer.Restart() + } + } + $reader.Close() + } + finally + { + if ($reader -ne $null) { $reader.Dispose() } + } + + # Finalize the upload. + $uploadResponse = Set-FinalizeAzureStorageUpload $sasUri $ids +} + +function Request-RenewAzureStorageUpload +{ + param($fileUri) + + $fileObj = Invoke-GraphRequest -Url "$fileUri/renewUpload" -HttpMethod POST + + $file = Wait-IntuneFileState $fileUri "AzureStorageUriRenewal" $azureStorageRenewSasUriBackOffTimeInSeconds +} + +function Set-FinalizeAzureStorageUpload +{ + param($sasUri, $ids) + + $uri = "$sasUri&comp=blocklist" + + if(($uri -notmatch "^http://|^https://")) + { + $uri = $global:graphURL + "/" + $uri.TrimStart('/') + } + + $request = "PUT $uri" + + $xml = '' + foreach ($id in $ids) + { + $xml += "$id" + } + $xml += '' + + try + { + Invoke-RestMethod $uri -Method Put -Body $xml + } + catch + { + Write-Log "Failed to finilize upload. $($_.Exception.Message)" 3 + } +} + +function Write-AzureStorageChunk +{ + param($sasUri, $id, $body) + + $uri = "$sasUri&comp=block&blockid=$id" + + if(($uri -notmatch "^http://|^https://")) + { + $uri = $global:graphURL + "/" + $uri.TrimStart('/') + } + + $request = "PUT $uri" + + $iso = [System.Text.Encoding]::GetEncoding("iso-8859-1") + $encodedBody = $iso.GetString($body) + $headers = @{ + "x-ms-blob-type" = "BlockBlob" + } + + try + { + $response = Invoke-WebRequest $uri -Method Put -Headers $headers -Body $encodedBody + } + catch + { + Write-Log "Failed to upload file chunk. $($_.Exception.Message)" 3 + } +} + +function Get-IntuneKey +{ + try + { + $aes = [System.Security.Cryptography.Aes]::Create() + $aesProvider = New-Object System.Security.Cryptography.AesCryptoServiceProvider + $aesProvider.GenerateKey() + $aesProvider.Key + } + finally + { + if ($aesProvider -ne $null) { $aesProvider.Dispose() } + if ($aes -ne $null) { $aes.Dispose() } + } +} + +function Get-IntuneKeyIV +{ + + try + { + $aes = [System.Security.Cryptography.Aes]::Create() + $aes.IV + } + finally + { + if ($aes -ne $null) { $aes.Dispose() } + } +} + +function Start-EncryptFileWithIV +{ + param($sourceFile, $targetFile, $encryptionKey, $hmacKey, $initializationVector) + + $bufferBlockSize = 1024 * 4 + $computedMac = $null + + try + { + $aes = [System.Security.Cryptography.Aes]::Create() + $hmacSha256 = New-Object System.Security.Cryptography.HMACSHA256 + $hmacSha256.Key = $hmacKey + $hmacLength = $hmacSha256.HashSize / 8 + + $buffer = New-Object byte[] $bufferBlockSize + $bytesRead = 0 + + $targetStream = [System.IO.File]::Open($targetFile, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write, [System.IO.FileShare]::Read) + $targetStream.Write($buffer, 0, $hmacLength + $initializationVector.Length) + + try + { + $encryptor = $aes.CreateEncryptor($encryptionKey, $initializationVector) + $sourceStream = [System.IO.File]::Open($sourceFile, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read) + $cryptoStream = New-Object System.Security.Cryptography.CryptoStream -ArgumentList @($targetStream, $encryptor, [System.Security.Cryptography.CryptoStreamMode]::Write) + + $targetStream = $null + while (($bytesRead = $sourceStream.Read($buffer, 0, $bufferBlockSize)) -gt 0) + { + $cryptoStream.Write($buffer, 0, $bytesRead) + $cryptoStream.Flush() + } + $cryptoStream.FlushFinalBlock() + } + finally + { + if ($cryptoStream -ne $null) { $cryptoStream.Dispose() } + if ($sourceStream -ne $null) { $sourceStream.Dispose() } + if ($encryptor -ne $null) { $encryptor.Dispose() } + } + + try + { + $finalStream = [System.IO.File]::Open($targetFile, [System.IO.FileMode]::Open, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::Read) + + $finalStream.Seek($hmacLength, [System.IO.SeekOrigin]::Begin) > $null + $finalStream.Write($initializationVector, 0, $initializationVector.Length) + $finalStream.Seek($hmacLength, [System.IO.SeekOrigin]::Begin) > $null + + $hmac = $hmacSha256.ComputeHash($finalStream) + $computedMac = $hmac + + $finalStream.Seek(0, [System.IO.SeekOrigin]::Begin) > $null + $finalStream.Write($hmac, 0, $hmac.Length) + } + finally + { + if ($finalStream -ne $null) { $finalStream.Dispose() } + } + } + finally + { + if ($targetStream -ne $null) { $targetStream.Dispose() } + if ($aes -ne $null) { $aes.Dispose() } + } + + $computedMac +} + +function New-IntuneEncryptedFile +{ + param($sourceFile, $targetFile) + + $encryptionKey = Get-IntuneKey + $hmacKey = Get-IntuneKey + $initializationVector = Get-IntuneKeyIV + + # Create the encrypted target file and compute the HMAC value. + $mac = Start-EncryptFileWithIV $sourceFile $targetFile $encryptionKey $hmacKey $initializationVector + + # Compute the SHA256 hash of the source file and convert the result to bytes. + $fileDigest = (Get-FileHash $sourceFile -Algorithm SHA256).Hash + $fileDigestBytes = New-Object byte[] ($fileDigest.Length / 2) + for ($i = 0; $i -lt $fileDigest.Length; $i += 2) + { + $fileDigestBytes[$i / 2] = [System.Convert]::ToByte($fileDigest.Substring($i, 2), 16) + } + + # Return an object that will serialize correctly to the file commit Graph API. + $encryptionInfo = @{} + $encryptionInfo.encryptionKey = [System.Convert]::ToBase64String($encryptionKey) + $encryptionInfo.macKey = [System.Convert]::ToBase64String($hmacKey) + $encryptionInfo.initializationVector = [System.Convert]::ToBase64String($initializationVector) + $encryptionInfo.mac = [System.Convert]::ToBase64String($mac) + $encryptionInfo.profileIdentifier = "ProfileVersion1" + $encryptionInfo.fileDigest = [System.Convert]::ToBase64String($fileDigestBytes) + $encryptionInfo.fileDigestAlgorithm = "SHA256" + + $fileEncryptionInfo = @{} + $fileEncryptionInfo.fileEncryptionInfo = $encryptionInfo + + $fileEncryptionInfo } \ No newline at end of file diff --git a/Extensions/MDM_MAM.psm1 b/Extensions/MDM_MAM.psm1 deleted file mode 100644 index 37b4701..0000000 --- a/Extensions/MDM_MAM.psm1 +++ /dev/null @@ -1,224 +0,0 @@ -######################################################## -# -# Common module functions -# -######################################################## -function Add-ModuleMenuItems -{ - Add-MenuItem (New-Object PSObject -Property @{ - Title = (Get-MDMMAMName) - MenuID = "IntuneGraphAPI" - Script = [ScriptBlock]{Get-MDMMAM} - }) -} - -function Get-SupportedImportObjects -{ - $global:importObjects += (New-Object PSObject -Property @{ - Selected = $true - Title = (Get-MDMMAMName) - Script = [ScriptBlock]{ - param($rootFolder) - - Write-Status "Import all MDM/MAM setting" - Import-AllMDMMAMObjects (Join-Path $rootFolder (Get-MDMMAMFolderName)) - } - }) -} - -function Get-SupportedExportObjects -{ - $global:exportObjects += (New-Object PSObject -Property @{ - Selected = $true - Title = (Get-MDMMAMName) - Script = [ScriptBlock]{ - param($rootFolder) - - Write-Status "Export all MDM/MAM settings" - Get-MDMMAMObjects | ForEach-Object { Export-SingleMDMMAM $PSItem.Object (Join-Path $rootFolder (Get-MDMMAMFolderName)) } - } - }) -} - -function Export-AllObjects -{ - param($addObjectSubfolder) - - $subFolder = "" - if($addObjectSubfolder) { $subFolder = Get-MDMMAMFolderName } -} - -######################################################## -# -# Object specific functions -# -######################################################## -function Get-MDMMAMName -{ - return "MDM/MAM" -} - -function Get-MDMMAMFolderName -{ - return "MDMMAM" -} - -function Get-MDMMAM -{ - Write-Status "Loading MDM/MAM object" - $dgObjects.ItemsSource = @(Get-MDMMAMObjects) - - #Scriptblocks that will perform the export tasks. empty by default - $script:exportParams = @{} - $script:exportParams.Add("ExportAllScript", [ScriptBlock]{ - Export-AllMDMMAM $global:txtExportPath.Text - Set-ObjectGrid - Write-Status "" - }) - - $script:exportParams.Add("ExportSelectedScript", [ScriptBlock]{ - Export-SelectedMDMMAM $global:txtExportPath.Text - Set-ObjectGrid - Write-Status "" - }) - #Scriptblock that will perform the import all files - $script:importAll = [ScriptBlock]{ - Import-AllMDMMAMObjects $global:txtImportPath.Text - Set-ObjectGrid - } - - #Scriptblock that will perform the import of selected files - $script:importSelected = [ScriptBlock]{ - Import-MDMMAMObjects $global:lstFiles.ItemsSource -Selected - Set-ObjectGrid - } - - #Scriptblock that will read json files - $script:getImportFiles = [ScriptBlock]{ - Show-FileListBox - $global:lstFiles.ItemsSource = @(Get-JsonFileObjects $global:txtImportPath.Text -Exclude "*_Settings.json") - } - - Add-DefaultObjectButtons -export ([scriptblock]{Show-DefaultExportGrid @script:exportParams}) -import ([scriptblock]{Show-DefaultImportGrid -ImportAll $script:importAll -ImportSelected $script:importSelected -GetFiles $script:getImportFiles}) -ViewFullObject ([scriptblock]{Get-MDMMAMObject $global:dgObjects.SelectedItem.Object}) -} - -function Get-MDMMAMObjects -{ - Get-AzureNativeObjects "MdmApplications" -property @('appDisplayName') -} - -function Get-MDMMAMObject -{ - param($object, $additional = "") - - if(-not $Object.objectId) { return } - - Invoke-AzureNativeRequest "/MdmApplications/$($Object.objectId)$additional" -} - -function Export-AllMDMMAM -{ - param($path = "$env:Temp") - - if(-not (Test-Path $path)) { mkdir -Path $path -Force -ErrorAction SilentlyContinue | Out-Null } - - if(Test-Path $path) - { - foreach($objTmp in ($global:dgObjects.ItemsSource)) - { - Export-SingleMDMMAM $objTmp.Object $path - } - } -} - -function Export-SelectedMDMMAM -{ - param($path = "$env:Temp") - - Export-SingleMDMMAM $global:dgObjects.SelectedItem.Object $path -} - -function Export-SingleMDMMAM -{ - param($psObj, $path = "$env:Temp") - - if(-not $psObj) { return } - - if($global:runningBulkExport -ne $true) - { - if($global:chkAddCompanyName.IsChecked) { $path = Join-Path $path $global:organization.displayName } - if($global:chkAddObjectType.IsChecked) { $path = Join-Path $path (Get-MDMMAMFolderName) } - } - - if(-not (Test-Path $path)) { mkdir -Path $path -Force -ErrorAction SilentlyContinue | Out-Null } - - if(Test-Path $path) - { - Write-Status "Export $($psObj.appDisplayName)" - - $obj = Invoke-AzureNativeRequest "MdmApplications/$($psObj.objectId)" - - if($obj) - { - $fileName = "$path\$((Remove-InvalidFileNameChars $obj.appDisplayName)).json" - ConvertTo-Json $obj -Depth 5 | Out-File $fileName -Force - } - - if($obj.mdmAppliesToGroups) - { - $obj.mdmAppliesToGroups | ForEach-Object { Add-GroupMigrationObject $PSItem.objectId } - } - - if($obj.mamAppliesToGroups) - { - $obj.mamAppliesToGroups | ForEach-Object { Add-GroupMigrationObject $PSItem.objectId } - } - - $global:exportedObjects++ - } -} - -function Import-MDMMAM -{ - param($obj) - - $argStr = "?" - if($obj.enrollmentUrl) { $argStr += "mdmAppliesToChanged=true" } - else{ $argStr += "mdmAppliesToChanged=false" } - if($obj.mamEnrollmentUrl) { $argStr += "&mamAppliesToChanged=true" } - else{ $argStr += "&mamAppliesToChanged=false" } - - $response = Invoke-AzureNativeRequest "MdmApplications/$($obj.objectId)$argStr" -Method PUT -Body (Update-JsonForEnvironment (ConvertTo-Json $obj -Depth 5)) -} - -function Import-AllMDMMAMObjects -{ - param($path = "$env:Temp") - - Import-MDMMAMObjects (Get-JsonFileObjects $path) -} - -function Import-MDMMAMObjects -{ - param( - $Objects, - - [switch] - $Selected - ) - - Write-Status "Import MDM/MAM settings" - - foreach($obj in $objects) - { - if($Selected -and $obj.Selected -ne $true) { continue } - - Write-Log "Import MDM/MAM app settings: $($obj.Object.appDisplayName)" - - Import-MDMMAM $obj.Object - - # No assignments for MDM/MAM - } - $dgObjects.ItemsSource = @(Get-MDMMAMObjects) - Write-Status "" -} \ No newline at end of file diff --git a/Extensions/MSALAuthentication.psm1 b/Extensions/MSALAuthentication.psm1 new file mode 100644 index 0000000..6295e15 --- /dev/null +++ b/Extensions/MSALAuthentication.psm1 @@ -0,0 +1,1361 @@ +<# +.SYNOPSIS +Module for Authentication + +.DESCRIPTION +This module manages Authentication for the application with MSAL. It is also responsible for displaying the Profile Picture control of he logged in user. + +.NOTES + Author: Mikael Karlsson +#> +function Get-ModuleVersion +{ + '3.0.0' +} + +$global:msalAuthenticator = $null +function Invoke-InitializeModule +{ + $script:MSALAllApps = @() + $global:MSALToken = $null + $global:MSALAuthority = $null + $script:AccessableTenants = $null + + $global:appSettingSections += (New-Object PSObject -Property @{ + Title = "MSAL" + Id = "MSAL" + Values = @() + Priority = 8 + }) + + Add-SettingsObject (New-Object PSObject -Property @{ + Title = "MSAL Library File" + Key = "MSALDLL" + Type = "File" + Description = "Full path to the Microsoft.Identity.Client.dll file" + }) "MSAL" + + Add-SettingsObject (New-Object PSObject -Property @{ + Title = "Remember Login" + Key = "CacheMSALToken" + Type = "Boolean" + DefaultValue = $true + Description = "Store the MSAL token in an encrypted file an automatically log on at next logon. Store in the users profile and can only be decrypted by the user that created it. Note: Requires restart" + }) "MSAL" + + Add-SettingsObject (New-Object PSObject -Property @{ + Title = "Get Tenant List" + Key = "GetTenantList" + Type = "Boolean" + DefaultValue = $false + Description = "Get a list of all tenants the current user has access to. Only used when the user has access to multiple tenants. This may cause duplicate login/consent prompts first time" + }) "MSAL" + + Add-SettingsObject (New-Object PSObject -Property @{ + Title = "Use Default Permissions" + Key = "UseDefaultPermissions" + Type = "Boolean" + DefaultValue = $false + Description = "Default permissions of the selected app will be used when logging on. Some objects might not be accessable" + }) "MSAL" + + Add-MSALPrereq + + #$script:MSALDLLMissing = $true #!!!! +} + +function Get-MSALAuthenticationObject +{ + if(-not $global:msalAuthenticator) + { + $global:msalAuthenticator = New-Object PSObject -Property @{ + Title = "MSAL" + SilentLogin = { Connect-MSALUser -Silent @args; } + Login = { Connect-MSALUser @args } + Logout = { Disconnect-MSALUser } + ProfilePicture = { Get-MSALProfileEllipse @args } + ShowErrors = { Show-MSALError } + } + } + + $global:msalAuthenticator +} + +function Clear-MSALCurentUserVaiables +{ + $global:MSALAuthority = $null +} + +function Get-MSALCurrentApp +{ + $global:appObj +} + +function Set-MSALCurrentApp +{ + param($appInfoObj) + + $global:appObj = $appInfoObj +} + +function Get-MSALUserInfo +{ + if($global:MSALToken) + { + Write-Log "Get current user" + $tmpMe = MSGraph\Invoke-GraphRequest -Url "ME" -SkipAuthentication + if($tmpMe.creationType -ne "Invitation") + { + ### Only get user info from home tenant + $global:Me = $tmpMe + Write-Log "Get profile picture" + $global:profilePhoto = "$($env:LOCALAPPDATA)\CloudAPIPowerShellManagement\$($global:Me.Id).jpeg" + MSGraph\Invoke-GraphRequest "me/photos/48x48/`$value" -OutFile $global:profilePhoto -SkipAuthentication | Out-Null + } + + Write-Log "Get organization info" + $global:Organization = (MSGraph\Invoke-GraphRequest -Url "Organization" -SkipAuthentication).Value + } + else + { + $global:Me = $null + $global:profilePhoto = $null + $global:Organization = $null + } + Show-AuthenticationInfo +} + +function Show-MSALError +{ + if($script:MSALDLLMissing -ne $true) { return } + + $script:msalPreReqForm = Get-XamlObject ($global:AppRootFolder + "\Xaml\MSALPreReqForm.xaml") -AddVariables + + $isAdmin = Get-IsAdmin + + if($isAdmin) + { + Set-XamlProperty $script:msalPreReqForm "chkCurrentUser" "IsChecked" $false + } + + $powerShellGet = Get-Module "PowerShellGet" -ListAvailable -ErrorAction SilentlyContinue | Sort -Property Version -Descending | Select -First 1 + + if($powerShellGet -and $powerShellGet.Version -gt [Version]"2.0.0") + { + $global:installPowerShellGet = $false + Write-Log "PowerShellGet $($powerShellGet.Version) detected. No need to install package" + Set-XamlProperty $script:msalPreReqForm "spPowerShellGet" "Visibility" "Collapsed" + } + else + { + if($powerShellGet) + { + Write-Log "PowerShellGet $($powerShellGet.Version) detected. Module needs to be updated" 2 + } + else + { + Write-Log "PowerShellGet is missing. It needs to be installed" 2 + } + + $global:installPowerShellGet = $true + } + + $pkgNuGet = Get-PackageProvider | Where Name -eq "NuGet" + + if($pkgNuGet -and $pkgNuGet.Version -ge [Version]"2.8.5.201") + { + Write-Log "NuGet $($pkgNuGet.Version) detected. No need to install package" + Set-XamlProperty $script:msalPreReqForm "spNuGet" "Visibility" "Collapsed" + $global:installNuGet = $false + } + else + { + $global:installNuGet = $true + + if($isAdmin) + { + Set-XamlProperty $script:msalPreReqForm "txtNotAdmin" "Visibility" "Collapsed" + } + else + { + Set-XamlProperty $script:msalPreReqForm "chkInstallNuGet" "Visibility" "Collapsed" + Set-XamlProperty $script:msalPreReqForm "btnInstallMSALPS" "IsEnabled" $false + Set-XamlProperty $script:msalPreReqForm "btnInstallAz" "IsEnabled" $false + } + + if($pkgNuGet) + { + Write-Log "NuGet $($pkgNuGet.Version) detected. Pakage needs to be updated" 2 + } + else + { + Write-Log "NuGet is missing. It needs to be installed" 2 + } + } + + Add-XamlEvent $script:msalPreReqForm "btnInstallMSALPS" "add_click" { + Install-MSALDependencyModule "MSAL.PS" $this + } + + Add-XamlEvent $script:msalPreReqForm "btnInstallAz" "add_click" { + Install-MSALDependencyModule "Az" $this + } + Show-ModalForm "MSAL Errors" $script:msalPreReqForm +} + +function Install-MSALDependencyModule +{ + param($moduleToInstall, $button) + + $forceUserInstallation = $false + + if($global:chkCurrentUser.IsChecked -eq $false -and (Get-IsAdmin) -eq $false) + { + if([System.Windows.MessageBox]::Show("Module will be install for system but current user is not Admin`n`nDo you want to install modules as user instead?`n`nNo will abort the installation", "Not admin!", "YesNo", "Warning") -eq "Yes") + { + $forceUserInstallation = $true + } + else + { + return + } + + } + + $installExtra = "" + if($global:installNuGet) + { + $installExtra += "`nNuGet will also be installed" + } + + if($global:installPowerShellGet) + { + $installExtra += "`nPowerShellGet module will also be installed" + } + if($installExtra) { $installExtra = "`nAdditional installs:`n$($installExtra)" } + + if([System.Windows.MessageBox]::Show("Are you sure you want to install the $moduleToInstall module?$($installExtra)", "Install module?", "YesNo", "Question") -ne "Yes") { return } + + # Force TLS 1.2 + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + + if($global:installNuGet) + { + Write-Status "Install NuGet package provider" + try + { + Install-PackageProvider -Name NuGet -Force #-MinimumVersion 2.8.5.201 + if($? -eq $false) + { + throw $global:error[0] + } + } + catch + { + Write-Log "Failed to install package provider NuGet. Error: $($_.Exception.Message)" 3 + [System.Windows.MessageBox]::Show("Failed to install Nuget`n`nThe $moduleToInstall module cannot be installed.`n`nTry installing the module manually and then restart the appliaction.", "Failed!", "OK", "Error") | Out-Null + return + } + } + + if($global:installPowerShellGet) + { + Write-Status "Install PowerShellGet module" + try + { + $params = @{} + if($global:chkCurrentUser.IsChecked -or $forceUserInstallation) { $params.Add("Scope", "CurrentUser") } + + Install-Module -Name PowerShellGet -Force -AllowClobber @params #-MinimumVersion 2.8.5.201 + + if($? -eq $false) + { + throw $global:error[0] + } + } + catch + { + Write-Log "Failed to install PowerShellGet module. Error: $($_.Exception.Message)" 3 + [System.Windows.MessageBox]::Show("Failed to install PowerShellGet module`n`nThe $moduleToInstall module cannot be installed.`n`nTry installing the module manually and then restart the appliaction.", "Failed!", "OK", "Error") | Out-Null + return + } + } + + $params = @{} + $params.Add("Name", $moduleToInstall) + $params.Add("Force", $true) + + if($global:chkAllowClobber.IsChecked) { $params.Add("AllowClobber", $true) } + if($global:chkCurrentUser.IsChecked -or $forceUserInstallation) { $params.Add("Scope", "CurrentUser") } + if($global:chkSkipPublisherCheck.IsChecked) { $params.Add("SkipPublisherCheck", $true) } + #if($global:chkAcceptLicense.IsChecked) { $params.Add("AcceptLicense", $true) } + $installError = "" + Write-Status "Install module $moduleToInstall" + try + { + $mod = Install-Module @params -ErrorAction SilentlyContinue #-PassThru + if($? -eq $false) + { + throw $global:error[0] + } + } + catch + { + $installError = "Error: $($_.Exception.Message)`n`n" + Write-Log "Failed to install module. Error: $($_.Exception.Message)" 3 + } + + $checkModule = ?: ($moduleToInstall -eq "Az") "Az.Accounts" $moduleToInstall + + if(-not $mod) { $mod = Get-Module $checkModule -ListAvailable } + + Write-Status "" + + if($mod) + { + $script:MSALDLLMissing = $false + Add-MSALPrereq + } + + if(-not $mod) + { + [System.Windows.MessageBox]::Show("Failed to install the $moduleToInstall module`n`n$($installError)Try installing the module manually and then restart the appliaction.", "Failed!", "OK", "Error") | Out-Null + } + elseif($mod -and $script:MSALDLLMissing) + { + [System.Windows.MessageBox]::Show("The $moduleToInstall module was installed successfully`n`nBut the app failed to load the MSAL DLL.`n`nPlease reastart the app and try again", "Failed!", "OK", "Warning") | Out-Null + } + else + { + [System.Windows.MessageBox]::Show("The $moduleToInstall was installed successfully!`n", "Success!", "OK", "Info") | Out-Null + } + Show-ModalObject +} + +function Add-MSALPrereq +{ + $msalPath = "" + + # Path stored in settings + $msalPath = (Get-SettingValue "MSALDLL") + if($msalPath -and ([IO.File]::Exists($msalPath)) -eq $false -and ([IO.Path]::GetFileName($msalPath)) -ne "Microsoft.Identity.Client.dll") + { + Write-Log "Microsoft.Identity.Client.dll file is either missing or pointing to the wrong file name" + $msalPath = "" + } + + # Check if located in app folder + $tmpPath = "$($global:AppRootFolder)\Microsoft.Identity.Client.dll" + if(-not $msalPath -and ([IO.File]::Exists($tmpPath))) + { + $msalPath = $tmpPath + } + + # Check Az module + if(-not $msalPath) + { + $module = Get-Module Az.Accounts -ListAvailable + if($module) + { + # Use the latest version and first path in case it is install for both the user and device + $module = $module | Sort -Property Version -Descending | Select -First 1 + $tmpPath = (([IO.Path]::GetDirectoryName(($module.Path | Select -First 1 ))) + "\PreloadAssemblies\Microsoft.Identity.Client.dll") + if(([IO.File]::Exists($tmpPath))) + { + $msalPath = $tmpPath + } + } + } + + # Check MSAL.PS module + if(-not $msalPath) + { + $module = Get-Module MSAL.PS -ListAvailable + $module = $module | Sort -Property Version -Descending | Select -First 1 + $folderMSAL = Get-ChildItem -Path ([IO.Path]::GetDirectoryName($module.Path)) -Filter "Microsoft.Identity.Client*" | ?{ $_.PSIsContainer } | Sort -Property Version -Descending | Select -First 1 + if([IO.File]::Exists(($folderMSAL.FullName + "\net45\Microsoft.Identity.Client.dll"))) + { + $msalPath = ($folderMSAL.FullName + "\net45\Microsoft.Identity.Client.dll") + } + } + + if(-not $msalPath) + { + $script:MSALDLLMissing = $true + Write-Log "Could not find Microsoft.Identity.Client.dll. Install the latest Az or MSAL.PS module or download MSAL library" 3 + return + } + + $fi = [IO.FileInfo]$msalPath + [void] [System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms") + Write-Log "Using MSAL file $msalPath. Version: $($fi.VersionInfo.FileVersion)" + [void][System.Reflection.Assembly]::LoadFile($msalPath) + + [System.Collections.Generic.List[string]] $RequiredAssemblies = New-Object System.Collections.Generic.List[string] + $RequiredAssemblies.Add($msalPath) + $RequiredAssemblies.Add('System.Security.dll') + + Add-Type -Path ($global:AppRootFolder + "\CS\TokenCacheHelperEx.cs") -ReferencedAssemblies $RequiredAssemblies +} + +function Get-MsalAuthenticationToken +{ + param($aquireTokenObj) + + $script:authenticationFailure = $null + $script:errorInfo = $null + $authResult = $null + try + { + $tokenSource = New-Object System.Threading.CancellationTokenSource + $taskAuthenticationResult = $aquireTokenObj.ExecuteAsync($tokenSource.Token) + try + { + while (!$taskAuthenticationResult.IsCompleted) + { + # Login hung on rare occations + # Workaround: Added DoEvents + [System.Windows.Forms.Application]::DoEvents() + Start-Sleep -Seconds 1 + } + } + finally + { + if (-not $taskAuthenticationResult.IsCompleted) + { + $tokenSource.Cancel() + } + $tokenSource.Dispose() + } + + ## Parse task results + if ($taskAuthenticationResult.IsFaulted) + { + # ToDo Check if: $taskAuthenticationResult.Exception.InnerException -is [Microsoft.Identity.Client.MsalUiRequiredException] + if($taskAuthenticationResult.Exception.InnerException.ResponseBody) + { + try + { + $script:errorInfo = $taskAuthenticationResult.Exception.InnerException.ResponseBody | ConvertFrom-Json + } + catch { } + } + $script:authenticationFailure = ?? $taskAuthenticationResult.Exception.InnerException $taskAuthenticationResult.Exception + if($script:errorInfo.error_description) + { + Write-LogError "Failed to login. Error: $($script:errorInfo.error). Description: $($script:errorInfo.error_description)" 3 + } + else + { + Write-LogError "Failed to login" (?? $taskAuthenticationResult.Exception.InnerException $taskAuthenticationResult.Exception) + } + } + if ($taskAuthenticationResult.IsCanceled) + { + Write-Log "The login was canceled" 2 + } + else + { + $authResult = $taskAuthenticationResult.Result + } + } + catch + { + $script:authenticationFailure = ?? $_.Exception.InnerException $_.Exception + Write-LogError "Failed to authenticate" (?? $_.Exception.InnerException $_.Exception) + } + $authResult +} + +function Get-MSALApp +{ + param($appInfo) + + $msalApp = $script:MSALAllApps | Where { $_.ClientId -eq $appInfo.ClientID -and (-not $appInfo.RedirectUri -or $_.AppConfig.RedirectUri -eq $appInfo.RedirectUri)} + + if(-not $msalApp) + { + Write-Log "Add MSAL App $($appInfo.ClientID) $((?? $appInfo.TenantId $appInfo.Authority))" + $appBuilder = [Microsoft.Identity.Client.PublicClientApplicationBuilder]::Create($appInfo.ClientID) + + if($appInfo.TenantId) { [void]$appBuilder.WithAuthority("https://login.microsoftonline.com/$($appInfo.TenantId)/") } + elseif ($appInfo.Authority) { [void]$appBuilder.WithAuthority($appInfo.Authority) } + if($appInfo.RedirectUri) { [void]$appBuilder.WithRedirectUri($appInfo.RedirectUri) } + + [void] $appBuilder.WithClientName("CloudAPIPowerShellManagement") + [void] $appBuilder.WithClientVersion($PSVersionTable.PSVersion) + + $msalApp = $appBuilder.Build() + + if((Get-SettingValue "CacheMSALToken")) + { + [TokenCacheHelperEx]::EnableSerialization($msalApp.UserTokenCache, "%LOCALAPPDATA%\CloudAPIPowerShellManagement\msalcahce.bin3") + } + $script:MSALAllApps += $msalApp + } + return $msalApp +} + +function Connect-MSALUser +{ + param( + #[Parameter(Mandatory = $false, ParameterSetName = 'Silent')] + [switch] + $Silent, + + #[Parameter(Mandatory = $false, ParameterSetName = 'Silent')] + [switch] + $ForceRefresh, + + #[Parameter(Mandatory = $false, ParameterSetName = 'Interactive')] + [switch] + $Interactive, + + $Account + ) + + # No login during first time the app is started + if($global:FirstTimeRunning -and $global:MainAppStarted -eq $false) { return } + + Write-LogDebug "Authenticate" + + if(-not $global:appObj.ClientId) + { + Write-Log "Application id is missing. Cannot authenticate" 3 + return + } + + if(-not $global:appObj.TenantId -and -not $global:appObj.Authority) + { + Write-Log "Tenant id/Authority is missing. Cannot authenticate" 3 + return + } + + if (-not ("TokenCacheHelperEx" -as [type])) + { + Add-MSALPrereq + } + + if (-not ("TokenCacheHelperEx" -as [type])) + { + Write-Log "Failed to compile TokenCacheHelperEx class" + return + } + + $curTicks = $global:MSALToken.ExpiresOn.LocalDateTime.Ticks + + $currentLoggedInUserApp = ($global:MSALToken.Account.HomeAccountId.Identifier + $global:MSALToken.TenantId + $global:MSALApp.ClientId) + $currentLoggedInUserId = $global:MSALToken.Account.HomeAccountId.Identifier + if($Interactive -eq $true) + { + Clear-MSALCurentUserVaiables + $global:MSALToken = $null + } + + if((Get-SettingValue "UseDefaultPermissions") -eq $true) + { + [string[]] $Scopes = "https://graph.microsoft.com/.default" + $useDefaultPermissions = $true + } + else + { + $Scopes = [string[]]$global:PermissionScope + $useDefaultPermissions = $false + } + + $global:MSALApp = Get-MSALApp $global:appObj + $loginHint = "" + + $global:MSALAccounts = $global:MSALApp.GetAccountsAsync().GetAwaiter().GetResult() + if($Account) + { + $loginHint = $global:MSALAccounts | Where UserName -eq $Account + if($global:MSALToken -and $global:MSALToken.Account.UserName -ne $Account) + { + # We're logging in with someone else... + Clear-MSALCurentUserVaiables + } + } + + # If we force interactive login the skip setting loginHint to force the user select account + if(-not $loginHint -and $Interactive -ne $true) + { + if($global:MSALAccounts) + { + if($global:MSALToken) + { + # Make sure we are logging in with the current user + $loginHint = $global:MSALAccounts | Where { $_.HomeAccountId.Identifier -eq $global:MSALToken.Account.HomeAccountId.Identifier } + } + else + { + $lastUser = Get-Setting "" "LastLoggedOnUser" + if($lastUser) + { + $loginHint = $global:MSALAccounts | Where { $_.HomeAccountId.Identifier -eq $lastUser } + } + if(-not $loginHint) + { + # Try with the first user in the list + $loginHint = $global:MSALAccounts | Select -First 1 + } + } + } + } + + $prompConsent = $false + $authResult = $null + $tenantId = $global:appObj.TenantId + $authority = ?? $global:MSALAuthority $global:appObj.Authority + + try + { + ######################################################################################################### + ### Silent Login + ######################################################################################################### + if($loginHint -and $Interactive -ne $true) + { + $aquireTokenObj = $global:MSALApp.AcquireTokenSilent($Scopes, $loginHint) + if($ForceRefresh) { [void]$aquireTokenObj.WithForceRefresh($ForceRefresh) } + if ($tenantId) { [void] $aquireTokenObj.WithAuthority("https://login.microsoftonline.com/$($TenantId)/") } + if ($authority) { [void]$aquireTokenObj.WithAuthority($authority) } + + $authResult = Get-MsalAuthenticationToken $aquireTokenObj + + if($script:authenticationFailure -and $script:authenticationFailure -isnot [Microsoft.Identity.Client.MsalUiRequiredException]) + { + Write-Log "Authentication failed but not with UI required. Skipping futher authentication" + # Force the user to click Login + # Is this the best way to handle this? Could happen when connection is lost etc. + $global:MSALToken = $null + Get-MSALUserInfo + Clear-MSALCurentUserVaiables + return + } + + if($authResult -and $authResult.ExpiresOn.LocalDateTime.Ticks -ne $curTicks) + { + Write-Log "$($authResult.Account.UserName) authenticated successfully (Silent). CorrelationId: $($global:MSALToken.CorrelationId)" + } + else + { + Write-LogDebug "$($authResult.Account.UserName) authenticated successfully (Silent). CorrelationId: $($global:MSALToken.CorrelationId)" + } + + #AADSTS65001 + if($script:authenticationFailure.Classification -eq "ConsentRequired") + { + $Scopes = [string[]]$global:PermissionScope + } + else + { + if($useDefaultPermissions -eq $false -and $authResult -and ($global:PermissionScope | measure).Count -gt 0 -and $global:promptConsentRequested -notcontains $authResult.TenantId) + { + $missingScopes = @() + foreach($scope in $global:PermissionScope) + { + $tmpScope = $scope.Split('/')[-1] + if($tmpScope -eq ".default") { continue } + if($authResult.Scopes -contains $tmpScope) { continue } + $missingScopes += $tmpScope + Write-LogDebug "Adding missing scope $tmpScope" + } + + if($missingScopes) + { + # This will prompt for consent for the missing scopes + $prompConsent = $true + $global:promptConsentRequested += $authResult.TenantId + $Scopes = $authResult.Scopes + $extraScopesToConsent = [string[]]$missingScopes + $loginHint = $authResult.Account + } + } + } + } + } + catch {} + + # Interactive login is only allowed once the app has started. Skip if silent login failed during startup + if($global:MainAppStarted -and ((-not $authResult -and $Silent -ne $true) -or $prompConsent)) + { + ######################################################################################################### + ### Interactive Login + ######################################################################################################### + Write-Log "Initiate interactive logon" + Write-Log "Scopes: $(($Scopes -join ","))" + $loginHintName = (?? $loginHint.Username $loginHint) + + $aquireTokenObj = $global:MSALApp.AcquireTokenInteractive($Scopes) + + if ($tenantId) + { + Write-Log "Tenant id: $tenantId" + [void] $aquireTokenObj.WithAuthority("https://login.microsoftonline.com/$tenantId)/") + } + elseif ($authority) + { + Write-Log "Authority: $authority" + [void]$aquireTokenObj.WithAuthority($authority) + } + + if($loginHintName) + { + Write-Log "Login hint: $loginHintName" + [void]$AquireTokenObj.WithLoginHint($loginHintName) + } + + if($script:authenticationFailure.Claims) + { + Write-Log "Login claims: $($script:authenticationFailure.Claims))" + [void]$AquireTokenObj.WithClaims($script:authenticationFailure.Claims) + } + + [IntPtr]$ParentWindow = [System.Diagnostics.Process]::GetCurrentProcess().MainWindowHandle + if ($ParentWindow) + { + [void]$aquireTokenObj.WithParentActivityOrWindow($ParentWindow) + } + + # If we need a consent (e.g. App is not approved in the environment), prompt for consent with all reqruied permissions + # Extra scopes to consent is only required when new perissions should be added + if ($script:authenticationFailure.Classification -eq "ConsentRequired") + { + Write-Log "Interactive login with Consent prompt" + [void]$aquireTokenObj.WithPrompt([Microsoft.Identity.Client.Prompt]::Consent) + } + elseif ($extraScopesToConsent) + { + Write-Log "Interactive login with extra scopes consent prompt: $(($extraScopesToConsent -join ","))" + [void] $aquireTokenObj.WithExtraScopesToConsent($extraScopesToConsent) + } + + $authResult = Get-MsalAuthenticationToken $aquireTokenObj + if($authResult) + { + Write-Log "$($authResult.Account.UserName) authenticated successfully (Interactively). CorrelationId: $($authResult.CorrelationId)" + } + } + + if($currentLoggedInUserId -ne $authResult.Account.HomeAccountId.Identifier) + { + $script:AccessableTenants = $null + if($authResult -and (Get-Setting "" "GetTenantList" $false) -eq $true) + { + ######################################################################################################### + ### Get tenant list + ######################################################################################################### + try + { + Write-Log "Get tennant list" + + # Can we reuse the app used for login? + $appBuilder = [Microsoft.Identity.Client.PublicClientApplicationBuilder]::Create($global:appObj.ClientID) + if($tenantId) { [void]$appBuilder.WithAuthority("https://login.microsoftonline.com/$($tenantId)") } + elseif ($authority) { [void]$appBuilder.WithAuthority($authority) } + if($global:appObj.RedirectUri) { [void]$appBuilder.WithRedirectUri($global:appObj.RedirectUri) } + $app = $appBuilder.Build() + + if((Get-SettingValue "CacheMSALToken")) + { + [TokenCacheHelperEx]::EnableSerialization($app.UserTokenCache, "%LOCALAPPDATA%\CloudAPIPowerShellManagement\msalcahce.bin3") + } + + ### Silent login + $tmpScope = [string[]]"https://management.azure.com/user_impersonation" + $tmpResults = Get-MsalAuthenticationToken ($app.AcquireTokenSilent($tmpScope, $authResult.Account)) + if(-not $tmpResults -and $global:MainAppStarted -and $script:authenticationFailure -and $script:authenticationFailure -is [Microsoft.Identity.Client.MsalUiRequiredException]) + { + ### Interactive login + $AquireTokenObj = $app.AcquireTokenInteractive($tmpScope) + #[void]$AquireTokenObj.WithAccount($authResult.Account) + [void]$AquireTokenObj.WithLoginHint($authResult.Account.Username) + $tmpResults = Get-MsalAuthenticationToken $AquireTokenObj + } + + if($tmpResults) + { + $Headers = @{ + 'Content-Type' = 'application/json' + 'Authorization' = "Bearer " + $tmpResults.AccessToken + 'ExpiresOn' = $tmpResults.ExpiresOn + } + + $ret = Invoke-RestMethod "https://management.azure.com/tenants?api-version=2020-01-01" -Headers $Headers + if($ret) + { + $script:AccessableTenants = $ret.Value + } + } + } + catch { } + } + } + + Write-LogDebug "Authentication finished $($authResult.Account.UserName)" + + $global:MSALToken = $authResult + + if($currentLoggedInUserApp -ne ($global:MSALToken.Account.HomeAccountId.Identifier + $global:MSALToken.TenantId + $global:MSALApp.ClientId)) + { + if($authResult) + { + Save-Setting "" "LastLoggedOnUser" $authResult.Account.UserName + } + + Write-LogDebug "User, tenant or app has changed" + Get-MSALUserInfo + } +} + +function Disconnect-MSALUser +{ + param($user, [switch]$force) + + if(-not $user) + { + if(-not $global:MSALToken.Account) { return } + $user = $global:MSALToken.Account # Logout current user + $global:MSALToken = $null + Clear-MSALCurentUserVaiables # Only clear variables for current user + } + + # ToDo: Clear browser cache + + if($user -and $global:MSALApp -and (Get-SettingValue "CacheMSALToken")) + { + if($force -eq $true -or [System.Windows.MessageBox]::Show("Do you want to remove the token from the cache?", "Clear cache?", "YesNo", "Question") -eq "Yes") + { + try + { + [void]$global:MSALApp.RemoveAsync($user).GetAwaiter().GetResult() + } + catch + { + Write-LogError "Failed to remove token from cache" $_.Exception + } + } + } + Get-MSALUserInfo +} + +function Get-MSALProfileEllipse +{ + param($size = 32, $fontSize = 20, $Color = "Blue", [Switch]$Popup, $AuthenticationProvider) + + Write-LogDebug "Create Profile Ellipse" + + if(-not $global:MSALToken -or -not $global:me) + { + ######################################################################################################### + ### Build login button when no user is logged on + ######################################################################################################### + + Write-LogDebug "Add login button" + $grd = [System.Windows.Controls.Border]::new() + $icon = Get-XamlObject ($global:AppRootFolder + "\Xaml\Icons\Logon.xaml") + $icon.Width = $size + $icon.Height = $size + $grd.Background = "#01000000" + $grd.Child = $icon + + $lnkButton = [System.Windows.Controls.Button]::new() + $lnkButton.Content = $grd + $lnkButton.Cursor = "Hand" + $lnkButton.Style = $window.TryFindResource("ContentButton") + $lnkButton.add_Click({ + if($script:MSALDLLMissing) + { + Show-MSALError + return + } + if(($global:MSALAccounts | measure).Count -eq 0) + { + Connect-MSALUser -Interactive + if($global:curObjectType) + { + Show-GraphObjects + } + Write-Status "" + } + else + { + $xaml = Get-Content ($global:AppRootFolder + "\Xaml\LoginPanel.Xaml") + $loginPanel = [Windows.Markup.XamlReader]::Parse($xaml) + $otherLogins = $loginPanel.FindName("grdAccounts") + foreach($account in $global:MSALAccounts) + { + try + { + ######################################################################################################### + ### Add cached users to the list of logins + ######################################################################################################### + $grdAccount = [System.Windows.Controls.Grid]::new() + $cd = [System.Windows.Controls.ColumnDefinition]::new() + $grdAccount.ColumnDefinitions.Add($cd) + $cd = [System.Windows.Controls.ColumnDefinition]::new() + $cd.Width = [double]::NaN + $grdAccount.ColumnDefinitions.Add($cd) + + $icon = Get-XamlObject ($global:AppRootFolder + "\Xaml\Icons\LoggedOnUser.xaml") + $icon.Width = 24 + $icon.Height = 24 + $icon.Margin = "0,0,5,0" + $grdAccount.Children.Add($icon) | Out-Null + + $lbObj = [Windows.Markup.XamlReader]::Parse("$($account.UserName)$($account.HomeAccountId.TenantId)") + $lbObj.SetValue([System.Windows.Controls.Grid]::ColumnProperty,1) + $grdAccount.Children.Add($lbObj) | Out-Null + + $lnkButton = [System.Windows.Controls.Button]::new() + $lnkButton.Content = $grdAccount + $lnkButton.Style = $window.TryFindResource("LinkButton") + $lnkButton.Margin = "0,5,0,0" + $lnkButton.Cursor = "Hand" + $lnkButton.Tag = $account + $lnkButton.add_Click({ + Write-Status "Logging in with $($this.Tag.UserName)" + Hide-Popup + Clear-MSALCurentUserVaiables + Connect-MSALUser -Account $this.Tag.UserName + + if($global:curObjectType) + { + Show-GraphObjects + } + Write-Status "" + }) + + AddGridObject $otherLogins $lnkButton + } + catch {} + } + + ######################################################################################################### + ### Add login button + ######################################################################################################### + $grdAccount = [System.Windows.Controls.Grid]::new() + $cd = [System.Windows.Controls.ColumnDefinition]::new() + $grdAccount.ColumnDefinitions.Add($cd) + $cd = [System.Windows.Controls.ColumnDefinition]::new() + $cd.Width = [double]::NaN + $grdAccount.ColumnDefinitions.Add($cd) + + $icon = Get-XamlObject ($global:AppRootFolder + "\Xaml\Icons\Logon.xaml") + $icon.Width = 24 + $icon.Height = 24 + $icon.Margin = "0,0,5,0" + $grdAccount.Children.Add($icon) | Out-Null + + $lbObj = [Windows.Markup.XamlReader]::Parse("Sign in with a different account") + $lbObj.SetValue([System.Windows.Controls.Grid]::ColumnProperty,1) + #$lbObj.Style = $window.TryFindResource("HoverUnderlineStyle") + $grdAccount.Children.Add($lbObj) | Out-Null + + $lnkButton = [System.Windows.Controls.Button]::new() + $lnkButton.Content = $grdAccount + $lnkButton.Style = $window.TryFindResource("LinkButton") + $lnkButton.Margin = "0,5,0,0" + $lnkButton.Cursor = "Hand" + $lnkButton.add_Click({ + Write-Status "Logging in..." + Hide-Popup + Connect-MSALUser -Interactive + if($global:curObjectType) + { + Show-GraphObjects + } + Write-Status "" + }) + + AddGridObject $otherLogins $lnkButton + + $loginPanel.Tag = $this.Content + + $loginPanel.Add_Loaded({param($obj, $e) + $point = $obj.Tag.TransformToAncestor($window).Transform([System.Windows.Point]::new(0,0)); + [System.Windows.Controls.Canvas]::SetLeft($obj,($point.X - $obj.ActualWidth + $obj.Tag.ActualWidth)) + [System.Windows.Controls.Canvas]::SetTop($obj,($point.Y + $obj.Tag.ActualHeight)) + }) + + Show-Popup $loginPanel + } + }) + + return $lnkButton + } + + ######################################################################################################### + ### Build the ellipse image for the Profile Info + ######################################################################################################### + + if($global:me.givenName -and $global:me.surname) + { + $initials = "$($global:me.givenName[0])$($global:me.surname[0])".ToUpper() + } + else + { + $initials = "$($global:me.userPrincipalName[0])".ToUpper() + } + + $grd = [System.Windows.Controls.Grid]::new() + + $ellipse = [System.Windows.Shapes.Ellipse]::new() + $ellipse.Width = $size + $ellipse.Height = $size + $ellipse.Fill = $Color + $ellipse.Stroke = "#FFFF00FF" + $ellipse.StrokeThickness = "0" + + $grd.Children.Add($ellipse) | Out-Null + + $tb = [System.Windows.Controls.TextBlock]::new() + $tb.FontSize = $fontSize + $tb.Foreground = "White" + #$tb.FontFamily="" + $tb.FontWeight = "Bold" + #$tb.TextLineBounds="Tight" + $tb.VerticalAlignment="Center" + $tb.HorizontalAlignment="Center" + #$tb.IsTextScaleFactorEnabled="False" + $tb.Text = $initials + + $grd.Children.Add($tb) | Out-Null + + if($global:profilePhoto -and [IO.File]::Exists($global:profilePhoto)) + { + Write-LogDebug "Create image" + $img = [System.Windows.Media.Imaging.BitmapImage]::new() + $img.BeginInit() + $img.CacheOption = [System.Windows.Media.Imaging.BitmapCacheOption]::OnLoad + $img.UriSource = [System.Uri]::new($global:profilePhoto) + $img.EndInit() + $ib = [System.Windows.Media.ImageBrush]::new() + $ib.ImageSource = $img + + $ellipse = [System.Windows.Shapes.Ellipse]::new() + $ellipse.Width = $size + $ellipse.Height = $size + $ellipse.FlowDirection="LeftToRight" + $ellipse.Fill = $ib + $grd.Children.Add($ellipse) | Out-Null + } + + if($Popup) + { + # Hide the popup when mouse button is clicked anywhere + $grd.add_MouseLeftButtonDown(({param($obj, $e) + if(-not $global:grdProfileInfo) { return } + Show-Popup $global:grdProfileInfo + })) + + try + { + ######################################################################################################### + ### Build Profile Info forcurrent user + ######################################################################################################### + + $global:grdProfileInfo = $null + $xaml = Get-Content ($global:AppRootFolder + "\Xaml\ProfileInfo.Xaml") + $global:grdProfileInfo = [Windows.Markup.XamlReader]::Parse($xaml) + $global:grdProfileInfo.Tag = $grd + $grd.Tag = $global:grdProfileInfo + Set-XamlProperty $global:grdProfileInfo "txtOrganization" "Text" $global:Organization.displayName + Set-XamlProperty $global:grdProfileInfo "txtUsername" "Text" $global:me.displayName + Set-XamlProperty $global:grdProfileInfo "txtLogonName" "Text" $global:me.userPrincipalName + + $global:tokenInfo = Get-JWTtoken $global:MSALToken.AccessToken + if($global:tokenInfo) + { + Write-LogDebug "App $($global:tokenInfo.Payload.app_displayname)" + Set-XamlProperty $global:grdProfileInfo "txtAppName" "Text" $global:tokenInfo.Payload.app_displayname + Set-XamlProperty $global:grdProfileInfo "txtAppId" "Text" $global:tokenInfo.Payload.appid + } + + $tmpObj = Get-MSALProfileEllipse -size 64 -fontSize 32 + $profileGrid = $global:grdProfileInfo.FindName("ProfileInfo") + if($tmpObj -and $profileGrid) + { + $tmpObj.SetValue([System.Windows.Controls.Grid]::RowProperty,1) + $tmpObj.SetValue([System.Windows.Controls.Grid]::RowSpanProperty,2) + } + + if($tmpObj) + { + $profileGrid.Children.Add($tmpObj) | Out-Null + } + + $global:grdProfileInfo.Add_Loaded({param($obj, $e) + $point = $obj.Tag.TransformToAncestor($window).Transform([System.Windows.Point]::new(0,0)); + [System.Windows.Controls.Canvas]::SetLeft($obj,($point.X - $obj.ActualWidth + $obj.Tag.ActualWidth)) + [System.Windows.Controls.Canvas]::SetTop($obj,($point.Y + $obj.Tag.ActualHeight)) + }) + + $otherLogins = $global:grdProfileInfo.FindName("grdAccountsAndTenants") + + ######################################################################################################### + ### Add cached users + ######################################################################################################### + + foreach($account in $global:MSALAccounts) + { + # Skip current logged on user + if($global:MSALToken.Account.Username -eq $Account.Username) { continue } + + try + { + $grdAccount = [System.Windows.Controls.Grid]::new() + $cd = [System.Windows.Controls.ColumnDefinition]::new() + $grdAccount.ColumnDefinitions.Add($cd) + $cd = [System.Windows.Controls.ColumnDefinition]::new() + $cd.Width = [double]::NaN + $grdAccount.ColumnDefinitions.Add($cd) + + $icon = Get-XamlObject ($global:AppRootFolder + "\Xaml\Icons\LoggedOnUser.xaml") + $icon.Width = 24 + $icon.Height = 24 + $icon.Margin = "0,0,5,0" + $grdAccount.Children.Add($icon) | Out-Null + + $lbObj = [Windows.Markup.XamlReader]::Parse("$($account.UserName)$($account.HomeAccountId.TenantId)") + $lbObj.SetValue([System.Windows.Controls.Grid]::ColumnProperty,1) + $grdAccount.Children.Add($lbObj) | Out-Null + + # Forget user + # Cannot be added to Grid since that is for logging on with user + # Need to rebuild the + #$icon = Get-XamlObject ($global:AppRootFolder + "\Xaml\Icons\Trash.xaml") + #$icon.Width = 24 + #$icon.Height = 24 + #$icon.Margin = "0,0,5,0" + #$icon.SetValue([System.Windows.Controls.Grid]::ColumnProperty,1) + #$icon.HorizontalAlignment = "Right" + #$grdAccount.Children.Add($icon) | Out-Null + + $lnkButton = [System.Windows.Controls.Button]::new() + $lnkButton.Content = $grdAccount + $lnkButton.Style = $window.TryFindResource("LinkButton") + $lnkButton.Margin = "0,5,0,0" + $lnkButton.Cursor = "Hand" + $lnkButton.Tag = $account + $lnkButton.add_Click({ + Write-Status "Logging in with $($this.Tag.UserName)" + Hide-Popup + Clear-MSALCurentUserVaiables + Connect-MSALUser -Account $this.Tag.UserName + + if($global:curObjectType) + { + Show-GraphObjects + } + Write-Status "" + }) + + AddGridObject $otherLogins $lnkButton + } + catch {} + } + + ######################################################################################################### + ### Add login with another user + ######################################################################################################### + $grdAccount = [System.Windows.Controls.Grid]::new() + $cd = [System.Windows.Controls.ColumnDefinition]::new() + $grdAccount.ColumnDefinitions.Add($cd) + $cd = [System.Windows.Controls.ColumnDefinition]::new() + $cd.Width = [double]::NaN + $grdAccount.ColumnDefinitions.Add($cd) + + $icon = Get-XamlObject ($global:AppRootFolder + "\Xaml\Icons\Logon.xaml") + $icon.Width = 24 + $icon.Height = 24 + $icon.Margin = "0,0,5,0" + $grdAccount.Children.Add($icon) | Out-Null + + $lbObj = [Windows.Markup.XamlReader]::Parse("Sign in with a different account") + $lbObj.SetValue([System.Windows.Controls.Grid]::ColumnProperty,1) + #$lbObj.Style = $window.TryFindResource("HoverUnderlineStyle") + $grdAccount.Children.Add($lbObj) | Out-Null + + $lnkButton = [System.Windows.Controls.Button]::new() + $lnkButton.Content = $grdAccount + $lnkButton.Style = $window.TryFindResource("LinkButton") + $lnkButton.Margin = "0,5,0,0" + $lnkButton.Cursor = "Hand" + $lnkButton.Tag = $account + $lnkButton.add_Click({ + Write-Status "Logging in..." + Hide-Popup + Connect-MSALUser -Interactive + if($global:curObjectType) + { + Show-GraphObjects + } + Write-Status "" + }) + + AddGridObject $otherLogins $lnkButton + + if(($script:AccessableTenants | measure).Count -gt 1) + { + ######################################################################################################### + ### Add switch to another tenant + ######################################################################################################### + $lbObj = [Windows.Markup.XamlReader]::Parse("Tenants:") + $lbObj.Margin = "0,5,0,0" + + AddGridObject $otherLogins $lbObj + foreach($tenant in $script:AccessableTenants) + { + try + { + $lbObj = [Windows.Markup.XamlReader]::Parse("$($tenant.DisplayName)$($tenant.defaultDomain)$($tenant.tenantId)") + + if($tenant.tenantId -ne $global:MSALToken.TenantId) + { + $lbObj.Style = $window.TryFindResource("HoverUnderlineStyleWithBackground") + $lbObj.HorizontalAlignment = "Stretch" + $lnkButton = [System.Windows.Controls.Button]::new() + $lnkButton.Content = $lbObj + $lnkButton.HorizontalAlignment = "Stretch" + $lnkButton.Style = $window.TryFindResource("ContentButton") + $lnkButton.Margin = "0,5,0,0" + $lnkButton.Cursor = "Hand" + $lnkButton.Tag = $tenant + $lnkButton.add_Click({ + Write-Status "Logging in to $($this.Tag.DisplayName)" + # Set authority to selected tenant + $global:MSALAuthority = "https://login.microsoftonline.com/$($this.Tag.tenantId)/" + Hide-Popup + Connect-MSALUser -Account $global:MSALToken.Account.Username + + if($global:curObjectType) + { + Show-GraphObjects + } + Write-Status "" + }) + AddGridObject $otherLogins $lnkButton + } + else + { + $lbObj.Background = $window.TryFindResource("SelectedRowBackgroundColor") + $lbObj.Margin = "0,5,0,0" + AddGridObject $otherLogins $lbObj + } + } + catch {} + } + } + + ######################################################################################################### + ### Add event handling + ######################################################################################################### + Add-XamlEvent $tmpObj "lnkTokeninfo" "add_Click" { + #Hide-Popup + $tokenArr = @() + foreach($prop in ($global:MSALToken | GM | Where MemberType -eq Property)) + { + if($prop.Name -in @("AccessToken", "IdToken")) { continue } + elseif($prop.Name -eq "Scopes") { $value = ($global:MSALToken.Scopes -join "`n")} + elseif($prop.Name -in @("ExpiresOn", "ExtendedExpiresOn")) { $value = $global:MSALToken."$($prop.Name)".LocalDateTime } + else { $value = $global:MSALToken."$($prop.Name)"} + + + $tokenArr += New-Object PSObject -Property @{ + Name=$prop.Name + Value=$value + } + } + + $dg = [System.Windows.Controls.DataGrid]::new() + $dg.ItemsSource = ($tokenArr | Select Name, Value) + Show-ModalForm "Token info" $dg + } + + Add-XamlEvent $tmpObj "lnkAccessTokenInfo" "add_Click" { + #Hide-Popup + Show-MSALDecodedToken (Get-JWTtoken $global:MSALToken.AccessToken) "Access Token Info" + } + + Add-XamlEvent $tmpObj "lnkIdTokenInfo" "add_Click" { + #Hide-Popup + Show-MSALDecodedToken (Get-JWTtoken $global:MSALToken.IdToken) "Id Token Info" + } + + Add-XamlEvent $tmpObj "lnkForceRefresh" "add_Click" { + Write-Status "Refreshing the token" + Connect-MSALUser -ForceRefresh + if(-not $global:MSALToken) + { + # Refresh failed. User was logged out + Show-GraphObjects + Hide-Popup + } + Write-Status "" + } + + Add-XamlEvent $tmpObj "lnkLogout" "add_Click" { + Hide-Popup + Disconnect-MSALUser + if($global:curObjectType) + { + Show-GraphObjects + } + } + } + catch { + Write-LogError "Failed to create profile information object. Error: " $_.Exception + } + } + + $grd +} + +function Show-MSALDecodedToken { + param ( + $tokenData, + $title + ) + $tokenArr = @() + foreach($prop in ($tokenData.Header | GM | Where MemberType -eq NoteProperty)) + { + $tokenArr += New-Object PSObject -Property @{ + Name=$prop.Name + Value=$tokenData.Header."$($prop.Name)" + } + } + + foreach($prop in ($tokenData.Payload | GM | Where MemberType -eq NoteProperty)) + { + if($prop.Name -in @("exp","iat","nbf","xms_tcdt")) + { + $value =[datetime]::new(1970, 1, 1, 0, 0, 0, 0, "UTC").AddSeconds(($tokenData.Payload."$($prop.Name)")).ToLocalTime() + } + elseif($prop.Name -in @("acrs","amr")) + { + $value = $tokenData.Payload."$($prop.Name)" -join ";" + } + elseif($prop.Name -in @("wids")) + { + $value = $tokenData.Payload."$($prop.Name)" -join "`n" + } + elseif($prop.Name -in @("scp")) + { + $value = $tokenData.Payload."$($prop.Name)" -replace " ","`n" + } + else + { + $value = $tokenData.Payload."$($prop.Name)" + } + $tokenArr += New-Object PSObject -Property @{ + Name=$prop.Name + Value=$value + } + } + $dg = [System.Windows.Controls.DataGrid]::new() + $dg.ItemsSource = ($tokenArr | Select Name, Value) + Show-ModalForm $title $dg +} + +Export-ModuleMember -alias * -function * \ No newline at end of file diff --git a/Extensions/MSGraph.psm1 b/Extensions/MSGraph.psm1 new file mode 100644 index 0000000..64f4b43 --- /dev/null +++ b/Extensions/MSGraph.psm1 @@ -0,0 +1,1952 @@ +<# +.SYNOPSIS +Module for MS Graph functions + +.DESCRIPTION +This module manages Microsoft Grap fuctions like calling APIs, managing graph objects etc. This is common for all view using MS Graph + +.NOTES + Author: Mikael Karlsson +#> +function Get-ModuleVersion +{ + '3.0.0' +} + +$global:MSGraphGlobalApps = @( + (New-Object PSObject -Property @{Name="";ClientId="";RedirectUri="";Authority=""}), + (New-Object PSObject -Property @{Name="Microsoft Intune PowerShell";ClientId="d1ddf0e4-d672-4dae-b554-9d5bdfd93547";RedirectUri="urn:ietf:wg:oauth:2.0:oob";Authority="https://login.microsoftonline.com/organizations/"}), + (New-Object PSObject -Property @{Name="Microsoft Graph PowerShell";ClientId="14d82eec-204b-4c2f-b7e8-296a70dab67e";RedirectUri="https://login.microsoftonline.com/common/oauth2/nativeclient";Authority="https://login.microsoftonline.com/organizations/"}) + ) + +function Invoke-InitializeModule +{ + $global:graphURL = "https://graph.microsoft.com/beta" + + $global:LoadedDependencyObject = $null + $global:MigrationTableCache = $null + + # Make sure MS Graph settings are added before exiting before App Id and Tenant Id is missing + Write-Log "Add settings and menu items" + + # Add settings + $global:appSettingSections += (New-Object PSObject -Property @{ + Title = "Import/Export" + Id = "ImportExport" + Values = @() + }) + + Add-SettingsObject (New-Object PSObject -Property @{ + Title = "Root folder" + Key = "RootFolder" + Type = "Folder" + Description = "Root folder for exporting/importing objects" + }) "ImportExport" + + Add-SettingsObject (New-Object PSObject -Property @{ + Title = "Add object type" + Key = "AddObjectType" + Type = "Boolean" + DefaultValue = $true + Description = "Default setting for adding object type to the export folder" + }) "ImportExport" + + Add-SettingsObject (New-Object PSObject -Property @{ + Title = "Add company name" + Key = "AddCompanyName" + Type = "Boolean" + DefaultValue = $true + Description = "Default setting for adding company name to the export folder" + }) "ImportExport" + + Add-SettingsObject (New-Object PSObject -Property @{ + Title = "Export Assignments" + Key = "ExportAssignments" + Type = "Boolean" + DefaultValue = $true + Description = "Default setting for exporting assignments" + }) "ImportExport" + + Add-SettingsObject (New-Object PSObject -Property @{ + Title = "Create groups" + Key = "CreateGroupOnImport" + Type = "Boolean" + DefaultValue = $true + Description = "Default setting for creating groups during import" + }) "ImportExport" + + Add-SettingsObject (New-Object PSObject -Property @{ + Title = "Convert synced groups" + Key = "ConvertSyncedGroupOnImport" + Type = "Boolean" + DefaultValue = $true + Description = "Convert AD synched groups to Azure AD group during import if the group does not exist" + }) "ImportExport" + + Add-SettingsObject (New-Object PSObject -Property @{ + Title = "Import Assignments" + Key = "ImportAssignments" + Type = "Boolean" + DefaultValue = $true + Description = "Import assignments when importing objects" + }) "ImportExport" +} + +function Get-GraphAppInfo +{ + param($settingId, $defaultAppId) + + $graphAppId = Get-SettingValue $settingId + + if($graphAppId) + { + # Check if an app in the list is selected + $appObj = $global:MSGraphGlobalApps | Where ClientId -eq $graphAppId + } + + if(-not $appObj) + { + # Set app info from custom settings + $appObj = New-Object PSObject -Property @{ + ClientId = Get-SettingValue "$($PreFix)CustomAppId" + TenantId = Get-SettingValue "$($PreFix)CustomTenantId" + RedirectUri = Get-SettingValue "$($PreFix)CustomAppRedirect" + Authority = Get-SettingValue "$($PreFix)CustomAuthority" + } + } + + if(-not $appObj.ClientId -and $defaultAppId) + { + # No app info found. Use default + $appObj = $global:MSGraphGlobalApps | Where ClientId -eq $defaultAppId + } + + $appObj +} + +function Invoke-GraphRequest +{ + param ( + [Parameter(Mandatory)] + $Url, + + [Alias("Body")] + $Content, + + $Headers, + + [ValidateSet("GET","POST","OPTIONS","DELETE", "PATCH")] + [Alias("Method")] + $HttpMethod = "GET", + + $AdditionalHeaders, + + [string]$Outfile = "", + + [Switch]$SkipAuthentication, + + $ODataMetadata = "full" # full, minimal, none or skip + ) + + if($SkipAuthentication -ne $true) + { + Connect-MSALUser + } + + $params = @{} + + $requestId = [Guid]::NewGuid().guid + + if(-not $Headers) + { + $Headers = @{ + 'Content-Type' = 'application/json; charset=utf-8' + 'Authorization' = "Bearer " + $global:MSALToken.AccessToken + 'ExpiresOn' = $global:MSALToken.ExpiresOn + 'x-ms-client-request-id' = $requestId + } + + if($ContentLanguage) + { + $Headers.Add("Content-Language",$ContentLanguage) + } + } + + if($HttpMethod -eq "GET" -and $ODataMetadata -ne "Skip") + { + # Note: odata.metadata=full in Accept + # @odata.type is not always included with default (minimum). + # That is required to identify the object type in some functions + # It does include a lot of info we don't need... + $Headers.Add("Accept","application/json;odata.metadata=$ODataMetadata") + } + #elseif($Content) + #{ + # # Upload content as UTF8 to support international and extended characters + # $Content = [System.Text.Encoding]::UTF8.GetBytes($Content) + #} + + if($AdditionalHeaders -is [HashTable]) + { + foreach($key in $AdditionalHeaders) + { + if($Headers.ContainsKey($key)) { continue } + + $Headers.Add($key, $AdditionalHeaders[$key]) + } + } + + if($Content) { $params.Add("Body", [System.Text.Encoding]::UTF8.GetBytes($Content)) } + if($Headers) { $params.Add("Headers", $Headers) } + if($Outfile) + { + $dirName = [IO.Path]::GetDirectoryName($Outfile) + try { + [IO.Directory]::CreateDirectory($dirName) + } + catch { + + } + if([IO.Directory]::Exists($dirName)) + { + $params.Add("OutFile", $OutFile) + } + else { + Write-Log "Failed to create directory for OutFile $Outfile" 3 + } + } + + if(($Url -notmatch "^http://|^https://")) + { + $Url = $global:graphURL + "/" + $Url.TrimStart('/') + $Url = $Url -replace "%OrganizationId%", $global:Organization.Id + } + + ### !!! + ### @odata.nextLink - ToDo: Support for paging + ### https://docs.microsoft.com/en-us/graph/paging + + $ret = $null + try + { + Write-LogDebug "Invoke graph API: $Url (Request ID: $requestId)" + $ret = Invoke-RestMethod -Uri $Url -Method $HttpMethod @params + if($? -eq $false) + { + throw $global:error[0] + } + } + catch + { + Write-LogError "Failed to invoke MS Graph with URL $Url (Request ID: $requestId). Status code: $($_.Exception.Response.StatusCode)" $_.Excption + } + + Write-Debug "$(($ret | Select *))" + + $ret +} + +function Get-GraphObjects +{ + param( + [Array] + $Url, + [Array] + $property = $null, + [Array] + $exclude, + $SortProperty = "displayName") + + $objects = @() + + if($property -isnot [Object[]]) { $property = @('displayName', 'description', 'id')} + + $graphObjects = Invoke-GraphRequest -Url $url + + if($graphObjects -and ($graphObjects | GM -Name Value -MemberType NoteProperty)) + { + $retObjects = $graphObjects.Value + } + else + { + $retObjects = $graphObjects + } + + foreach($graphObject in $retObjects) + { + $params = @{} + if($property) { $params.Add("Property", $property) } + if($exclude) { $params.Add("ExcludeProperty", $exclude) } + foreach($objTmp in ($graphObject | Select-Object @params)) + { + $objTmp | Add-Member -NotePropertyName "IsSelected" -NotePropertyValue $false + $objTmp | Add-Member -NotePropertyName "Object" -NotePropertyValue $graphObject + $objects += $objTmp + } + } + $property = "IsSelected",$property + + if($objects.Count -gt 0 -and $SortProperty -and ($objects[0] | GM -MemberType NoteProperty -Name $SortProperty)) + { + $objects = $objects | sort -Property $SortProperty + } + $objects +} + +function Show-GraphObjects +{ + $global:curObjectType = $global:lstMenuItems.SelectedItem + + Clear-GraphObjects + + if(-not $global:MSALToken) + { + $global:grdNotLoggedIn.Visibility = "Visible" + $global:grdData.Visibility = "Collapsed" + return + } + $global:grdNotLoggedIn.Visibility = "Collapsed" + $global:grdData.Visibility = "Visible" + + # Always show Import is an item is selected + $global:btnImport.IsEnabled = $global:lstMenuItems.SelectedItem -ne $null + + if(-not $global:lstMenuItems.SelectedItem) { return } + + Write-Status "Loading $($global:curObjectType.Title) objects" + + if($global:lstMenuItems.SelectedItem.ShowForm -ne $false) + { + $viewItem = $global:lstMenuItems.SelectedItem + if($viewItem.Icon -or [IO.File]::Exists(($global:AppRootFolder + "\Xaml\Icons\$($viewItem.Id).xaml"))) + { + $global:ccIcon.Content = Get-XamlObject ($global:AppRootFolder + "\Xaml\Icons\$((?? $viewItem.Icon $viewItem.Id)).xaml") + } + + $global:txtFormTitle.Text = $global:lstMenuItems.SelectedItem.Title + $global:grdTitle.Visibility = "Visible" + } + + $url = $global:curObjectType.API + if($global:curObjectType.QUERYLIST) + { + $url = "$($url.Trim())?$($global:curObjectType.QUERYLIST.Trim())" + } + + $graphObjects = @(Get-GraphObjects -Url $url -property $global:curObjectType.ViewProperties) + + if($global:curObjectType.PostListCommand) + { + $graphObjects = & $global:curObjectType.PostListCommand $graphObjects $global:curObjectType + } + + if(($graphObjects | measure).Count -eq 0) { return } + + $dgObjects.AutoGenerateColumns = $false + $dgObjects.Columns.Clear() + $tmpObj = $graphObjects | Select -First 1 + + $prop = $tmpObj.PSObject.Properties | Where Name -eq "IsSelected" + if($prop) + { + # Build the CheckBox column for IsSelected + $binding = [System.Windows.Data.Binding]::new($prop.Name) + $binding.UpdateSourceTrigger = [System.Windows.Data.UpdateSourceTrigger]::PropertyChanged + $column = [System.Windows.Controls.DataGridTemplateColumn]::new() + $fef = [System.Windows.FrameworkElementFactory]::new([System.Windows.Controls.CheckBox]) + $binding.Mode = [System.Windows.Data.BindingMode]::TwoWay + $fef.SetValue([System.Windows.Controls.CheckBox]::IsCheckedProperty,$binding) + $dt = [System.Windows.DataTemplate]::new() + $dt.VisualTree = $fef + $column.CellTemplate = $dt + #$header = [System.Windows.Controls.CheckBox]::new() + #$column.Header = $header + $dgObjects.Columns.Add($column) + } + + $tableColumns = @() + # Add other columns + foreach($prop in ($tmpObj.PSObject.Properties | Where {$_.Name -notin @("IsSelected","Object")})) + { + $binding = [System.Windows.Data.Binding]::new($prop.Name) + $column = [System.Windows.Controls.DataGridTextColumn]::new() + $column.Header = $prop.Name + $column.IsReadOnly = $true + $column.Binding = $binding + + $tableColumns += $prop.Name + $dgObjects.Columns.Add($column) + } + $ocList = [System.Collections.ObjectModel.ObservableCollection[object]]::new($graphObjects) + $dgObjects.ItemsSource = [System.Windows.Data.CollectionViewSource]::GetDefaultView($ocList) + + <# + $dt = New-Object System.Data.DataTable + [void]$dt.Columns.AddRange($tableColumns) + foreach ($graphObject in $graphObjects) + { + $rowValues = @() + Foreach ($prop in $tableColumns) + { + $rowValues += $graphObject.$prop + } + $dt.Rows.Add($rowValues) | Out-Null + } + $dgObjects.ItemsSource = $dt.DefaultView + #> + + # Show/Hide buttons based on object type + foreach($ctrl in $spSubMenu.Children) + { + if(-not $global:curObjectType.ShowButtons -or ($global:curObjectType.ShowButtons | Where-Object { $ctrl.Name -like "*$($_)" } )) + { + Write-LogDebug "Show $($ctrl.Name)" + $ctrl.Visibility = "Visible" + } + else + { + Write-LogDebug "Hide $($ctrl.Name)" + $ctrl.Visibility = "Collapsed" + } + } +} + +function Clear-GraphObjects +{ + $global:txtFormTitle.Text = "" + $global:grdTitle.Visibility = "Collapsed" + $global:grdObject.Children.Clear() + $global:dgObjects.ItemsSource = $null + Set-ObjectGrid + + [System.Windows.Forms.Application]::DoEvents() +} + +function Get-GraphObject +{ + param($obj, $objectType, [switch]$SkipAssignments) + + Write-Status "Loading $((Get-GraphObjectName $obj $objectType))" + + if($objectType.PreGetCommand) + { + $preConfig = & $objectType.PreGetCommand $obj $objectType + } + + if($preConfig -isnot [Hashtable]) { $preConfig = @{} } + + if($preConfig.ContainsKey("API") -and $preConfig["API"]) + { + $api = $preConfig["API"] + } + elseif(-not $objectType.APIGET) + { + $api = ("$($objectType.API)/$($obj.Id)") + } + else + { + $api = $graphObject.APIGET -replace "%id%", (Get-GraphObjectId $obj $objectType) + } + + $expand = @() + if($obj.'assignments@odata.navigationLink' -and $SkipAssignments -ne $true -and $objectType.ExpandAssignments -ne $false) + { + $expand += "assignments" + } + + if($obj.'apps@odata.navigationLink') + { + $expand += "apps" + } + + if($obj.'settings@odata.navigationLink') + { + $expand += "settings" + } + + if($obj.'roleAssignments@odata.navigationLink') + { + $expand += "roleAssignments" + } + + if($objectType.Expand) + { + foreach($objExpand in $objectType.Expand) + { + if($objExpand -notin $expand) { $expand += $objExpand} + } + } + + if($expand.Count -gt 0) + { + if($api.IndexOf('?') -eq -1) { $api = ($api + "?")} + else { $api = ($api + "&")} + $api = ($api + ("expand=" + ($expand -join ","))) + } + + $objInfo = Get-GraphObjects -Url $api -property $objectType.ViewProperties + + if($objInfo -and $objectType.PostGetCommand) + { + & $objectType.PostGetCommand $objInfo $objectType + } + $objInfo +} + +# Generic Pre-Import function for all imports +function Start-GraphPreImport +{ + param($obj, $objectType) + + if($objectType.SkipRemovingProperties -eq $true) { return } + + $removeProperties = $objectType.PropertiesToRemove + + if($removeProperties -isnot [Object[]]) + { + $removeProperties = @() + } + + if($removeProperties.Count -eq 0 -or $objectType.SkipRemoveDefaultProperties -ne $true) + { + # Default properties to delete + $removeProperties += @('lastModifiedDateTime','createdDateTime','supportsScopeTags','id','modifiedDateTime') + } + + # Remove OData properties + foreach($odataProp in ($obj.PSObject.Properties | Where { $_.Name -like "*@Odata*Link" -or $_.Name -like "*@odata.context" -or $_.Name -like "*@odata.id" -or ($_.Name -like "*@odata.type" -and $_.Name -ne "@odata.type")})) + { + $removeProperties += $odataProp.Name + } + + foreach($prop in $removeProperties) + { + # Allow override deleting default propeties e.g. some object types requires the Id property + if($objectType.SkipRemoveProperties -is [Object[]] -and $prop -in $objectType.SkipRemoveProperties) { continue } + Remove-Property $obj $prop + } + + if($objectType.SkipRemovingChildProperties -ne $true) + { + foreach($prop in ($obj.PSObject.Properties)) + { + if($obj."$($prop.Name)"."@odata.type") + { + foreach($childObj in ($obj."$($prop.Name)")) + { + Start-GraphPreImport $childObj $objectType + } + } + } + } +} + +function Get-GraphMetaData +{ + if(-not $global:metaDataXML) + { + $downloadSize = 0 + $url = "https://graph.microsoft.com/beta/`$metadata#deviceAppManagement" + try + { + $wr = [net.WebRequest]::Create($url) + try + { + $wrResponse = $wr.GetResponse() + $downloadSize = $wrResponse.ContentLength + } + catch + { + } + finally + { + $wrResponse.Close() + $wrResponse.Dispose() + } + $wr.Abort() + } + catch + { + + } + #ToDo: When do we update/re-download it? + $fileName = [Environment]::ExpandEnvironmentVariables("%LOCALAPPDATA%\GraphPowerShellManager\GraphMetaData.xml") + $fi = [IO.FileInfo]$fileName + if($fi.Exists -and $fi.Length -ne $downloadSize) + { + try + { + [xml]$global:metaDataXML = Get-Content $fileName + } + catch { } + } + + + if(-not $global:metaDataXML) + { + $ret = Invoke-WebRequest $url -UseBasicParsing + [xml]$global:metaDataXML = $ret.Content + try { $global:metaDataXML.Save($fileName) } catch {} + } + } +} + +function Get-GraphObjectClassName +{ + param($type) + + Get-GraphMetaData + + $objectClassName = $null + + $nodes = $global:metaDataXML.SelectNodes("//*[@Type='Collection(graph.$($type))']") + if($nodes -ne $null -and $nodes.Count -gt 0) + { + foreach($node in $nodes) + { + if($node.ParentNode.Name -eq "deviceAppManagement") + { + $objectClassName = $node.Name + break + } + } + } + + $objectClassName +} + +#region Export/Import dialogs + +function Show-GraphExportForm +{ + $script:exportForm = Get-XamlObject ($global:AppRootFolder + "\Xaml\ExportForm.xaml") -AddVariables + if(-not $script:exportForm) { return } + + Set-XamlProperty $script:exportForm "txtExportPath" "Text" (?? (Get-Setting "" "LastUsedRoot") (Get-SettingValue "RootFolder")) + Set-XamlProperty $script:exportForm "chkAddObjectType" "IsChecked" (Get-SettingValue "AddObjectType") + Set-XamlProperty $script:exportForm "chkAddCompanyName" "IsChecked" (Get-SettingValue "AddCompanyName") + + Set-XamlProperty $script:exportForm "btnExportSelected" "IsEnabled" ($global:dgObjects.SelectedItem -ne $null) + if(($global:dgObjects.ItemsSource | Where IsSelected).Count -gt 0) + { + Set-XamlProperty $script:exportForm "lblSelectedObject" "Content" "$(($global:dgObjects.ItemsSource | Where IsSelected).Count) selected object(s)" + } + elseif($global:dgObjects.SelectedItem) + { + Set-XamlProperty $script:exportForm "lblSelectedObject" "Content" "Selected object: $((Get-GraphObjectName $global:dgObjects.SelectedItem $global:curObjectType))" + } + Add-XamlEvent $script:exportForm "btnCancel" "add_click" { + $script:exportForm = $null + Show-ModalObject + } + + Add-XamlEvent $script:exportForm "btnExportAll" "add_click" { + + Export-GraphObjects + + $script:exportForm = $null + Show-ModalObject + } + + Add-XamlEvent $script:exportForm "btnExportSelected" "add_click" { + Export-GraphObjects -Selected + + $script:exportForm = $null + Show-ModalObject + } + + Add-XamlEvent $script:exportForm "browseExportPath" "add_click" { + $folder = Get-Folder (Get-XamlProperty $script:exportForm "txtExportPath" "Text") "Select root folder for export" + if($folder) + { + Set-XamlProperty $script:exportForm "txtExportPath" "Text" $folder + } + } + + Add-GraphExportExtensions $script:exportForm 1 + + Show-ModalForm "Export $($global:curObjectType.Title) objects" $script:exportForm -HideButtons +} + +function Show-GraphBulkExportForm +{ + $script:exportForm = Get-XamlObject ($global:AppRootFolder + "\Xaml\BulkExportForm.xaml") -AddVariables + if(-not $script:exportForm) { return } + + Set-XamlProperty $script:exportForm "txtExportPath" "Text" (?? (Get-Setting "" "LastUsedRoot") (Get-SettingValue "RootFolder")) + Set-XamlProperty $script:exportForm "chkAddCompanyName" "IsChecked" (Get-SettingValue "AddCompanyName") + + Add-XamlEvent $script:exportForm "browseExportPath" "add_click" ({ + $folder = Get-Folder (Get-XamlProperty $script:exportForm "txtExportPath" "Text") "Select root folder for export" + if($folder) + { + Set-XamlProperty $script:exportForm "txtExportPath" "Text" $folder + } + }) + + $script:exportObjects = @() + foreach($objType in $global:lstMenuItems.ItemsSource) + { + if(-not $objType.Title) { continue } + + if($objType.ShowButtons -is [Object[]] -and $objType.ShowButtons -notcontains "Export") { continue } + + $script:exportObjects += New-Object PSObject -Property @{ + Title = $objType.Title + Selected = (?? $objType.BulkExport $true) + ObjectType = $objType + } + } + + Add-GraphExportExtensions $script:exportForm 0 + + $script:lstObjectsToExport = $script:exportForm.FindName("lstObjectsToExport") + if($script:lstObjectsToExport) + { + $script:lstObjectsToExport.ItemsSource = $script:exportObjects + + Add-XamlEvent $script:exportForm "chkCheckAll" "add_click" ({ + foreach($item in $script:exportObjects) + { + $item.Selected = $this.IsChecked + } + $script:lstObjectsToExport.Items.Refresh() + }) + } + + Add-XamlEvent $script:exportForm "btnClose" "add_click" ({ + $script:exportForm = $null + Show-ModalObject + }) + + Add-XamlEvent $script:exportForm "btnExport" "add_click" ({ + Write-Status "Export objects" -Block + Write-Log "****************************************************************" + Write-Log "Start bulk export" + Write-Log "****************************************************************" + foreach($item in $script:exportObjects) + { + if($item.Selected -ne $true) { continue } + + Write-Log "----------------------------------------------------------------" + Write-Log "Export $($item.ObjectType.Title) objects" + Write-Log "----------------------------------------------------------------" + + $url = $item.ObjectType.API + if($item.ObjectType.QUERYLIST) + { + $url = "$($url.Trim())?$($item.ObjectType.QUERYLIST.Trim())" + } + + try + { + $folder = Get-GraphObjectFolder $item.ObjectType (Get-XamlProperty $script:exportForm "txtExportPath" "Text") (Get-XamlProperty $script:exportForm "chkAddObjectType" "IsChecked") (Get-XamlProperty $script:exportForm "chkAddCompanyName" "IsChecked") + + $objects = @(Get-GraphObjects -Url $url -property $objectType.ViewProperties) + foreach($obj in $objects) + { + Write-Status "Export $($item.Title): $((Get-GraphObjectName $obj))" -Force + Export-GraphObject $obj.Object $item.ObjectType $folder + } + Save-Setting "" "LastUsedFullPath" $folder + } + catch + { + Write-LogError "Failed when exporting $($item.Title) objects" $_.Exception + } + } + Save-Setting "" "LastUsedRoot" (Get-XamlProperty $script:exportForm "txtExportPath" "Text") + + Write-Log "****************************************************************" + Write-Log "Bulk export finished" + Write-Log "****************************************************************" + Write-Status "" + }) + + Show-ModalForm "Bulk Export" $script:exportForm -HideButtons +} + +function Show-GraphImportForm +{ + $script:importForm = Get-XamlObject ($global:AppRootFolder + "\Xaml\ImportForm.xaml") -AddVariables + if(-not $script:importForm) { return } + + $path = Get-Setting "" "LastUsedFullPath" + if($path) + { + $path = [IO.Path]::Combine([IO.Directory]::GetParent($path).FullName, $global:lstMenuItems.SelectedItem.Id) + if([IO.Directory]::Exists($path) -eq $false) + { + $path = Get-Setting "" "LastUsedRoot" + } + } + + Set-XamlProperty $script:importForm "txtImportPath" "Text" (?? $path (Get-SettingValue "RootFolder")) + + Add-XamlEvent $script:importForm "browseImportPath" "add_click" ({ + $folder = Get-Folder (Get-XamlProperty $script:importForm "txtImportPath" "Text") "Select root folder for import" + if($folder) + { + Set-XamlProperty $script:importForm "txtImportPath" "Text" $folder + $global:lstFiles.ItemsSource = @(Get-GraphFileObjects $folder) + Save-Setting "" "LastUsedFullPath" $folder + Set-XamlProperty $script:importForm "lblMigrationTableInfo" "Content" (Get-MigrationTableInfo) + } + }) + + Add-XamlEvent $script:importForm "btnCancel" "add_click" { + $script:importForm = $null + Show-ModalObject + } + + Add-XamlEvent $script:importForm "btnImportSelected" "add_click" { + Write-Status "Import objects" + Get-GraphDependencyDefaultObjects + foreach ($fileObj in ($global:lstFiles.ItemsSource | Where Selected -eq $true)) + { + Import-GraphFile $fileObj + } + Show-GraphObjects + Show-ModalObject + Write-Status "" + } + + Add-XamlEvent $script:importForm "chkCheckAll" "add_click" { + foreach($obj in $global:lstFiles.Items) + { + $obj.Selected = $global:chkCheckAll.IsChecked + } + $global:lstFiles.Items.Refresh() + } + + Add-XamlEvent $script:importForm "btnGetFiles" "add_click" { + # Used when the user manually updates the path and the press Get Files + $global:lstFiles.ItemsSource = @(Get-GraphFileObjects $global:txtImportPath.Text) + if([IO.Directory]::Exists($global:txtImportPath.Text)) + { + Save-Setting "" "LastUsedFullPath" $global:txtImportPath.Text + Set-XamlProperty $script:importForm "lblMigrationTableInfo" "Content" (Get-MigrationTableInfo) + } + } + + Add-GraphImportExtensions $script:importForm 1 + + if($global:txtImportPath.Text) + { + $global:lstFiles.ItemsSource = @(Get-GraphFileObjects $global:txtImportPath.Text) + Set-XamlProperty $script:importForm "lblMigrationTableInfo" "Content" (Get-MigrationTableInfo) + } + + Show-ModalForm "Import objects" $script:importForm -HideButtons +} + +function Show-GraphBulkImportForm +{ + $script:importForm = Get-XamlObject ($global:AppRootFolder + "\Xaml\BulkImportForm.xaml") -AddVariables + if(-not $script:importForm) { return } + + $path = Get-Setting "" "LastUsedFullPath" + if($path) + { + $path = [IO.Directory]::GetParent($path).FullName + } + + Set-XamlProperty $script:importForm "txtImportPath" "Text" (?? $path (Get-SettingValue "RootFolder")) + #Set-XamlProperty $script:importForm "chkAddCompanyName" "IsChecked" (Get-SettingValue "AddCompanyName") + + Add-XamlEvent $script:importForm "browseImportPath" "add_click" ({ + $folder = Get-Folder (Get-XamlProperty $script:importForm "txtImportPath" "Text") "Select root folder for import" + if($folder) + { + Set-XamlProperty $script:importForm "txtImportPath" "Text" $folder + Set-XamlProperty $script:importForm "lblMigrationTableInfo" "Content" (Get-MigrationTableInfo) + } + }) + + $script:importObjects = @() + foreach($objType in $global:lstMenuItems.ItemsSource) + { + if(-not $objType.Title) { continue } + + if($objType.ShowButtons -is [Object[]] -and $objType.ShowButtons -notcontains "Import") { continue } + + $script:importObjects += New-Object PSObject -Property @{ + Title = $objType.Title + Selected = (?? $objType.BulkImport $true) + ObjectType = $objType + } + } + + Add-GraphImportExtensions $script:importForm 0 + + $script:lstObjectsToImport = $script:importForm.FindName("lstObjectsToImport") + if($script:lstObjectsToImport) + { + $script:lstObjectsToImport.ItemsSource = $script:importObjects + + Add-XamlEvent $script:importForm "chkCheckAll" "add_click" ({ + foreach($item in $script:importObjects) + { + $item.Selected = $this.IsChecked + } + $script:lstObjectsToImport.Items.Refresh() + }) + } + + Add-XamlEvent $script:importForm "btnClose" "add_click" ({ + $script:importForm = $null + Show-ModalObject + }) + + Add-XamlEvent $script:importForm "btnImport" "add_click" ({ + Write-Status "Import objects" -Block + Write-Log "****************************************************************" + Write-Log "Start bulk import" + Write-Log "****************************************************************" + Get-GraphDependencyDefaultObjects + $importedObjects = 0 + + foreach($item in ($script:importObjects | where Selected -eq $true | sort-object -property @{e={$_.ObjectType.ImportOrder}})) + { + Write-Log "----------------------------------------------------------------" + Write-Log "Import $($item.ObjectType.Title) objects" + Write-Log "----------------------------------------------------------------" + $folder = Get-GraphObjectFolder $item.ObjectType (Get-XamlProperty $script:importForm "txtImportPath" "Text") (Get-XamlProperty $script:importForm "chkAddObjectType" "IsChecked") + + if([IO.Directory]::Exists($folder)) + { + foreach ($fileObj in @(Get-GraphFileObjects $folder -ObjectType $item.ObjectType)) + { + Import-GraphFile $fileObj + $importedObjects++ + } + Save-Setting "" "LastUsedFullPath" $folder + } + else + { + Write-Log "Folder $folder not found. Skipping import" 2 + } + } + + Write-Log "****************************************************************" + Write-Log "Bulk import finished" + Write-Log "****************************************************************" + Write-Status "" + if($importedObjects -eq 0) + { + [System.Windows.MessageBox]::Show("No objects were imported. Verify folder and exported files", "Error", "OK", "Error") + } + }) + + if((Get-XamlProperty $script:importForm "txtImportPath" "Text")) + { + Set-XamlProperty $script:importForm "lblMigrationTableInfo" "Content" (Get-MigrationTableInfo) + } + + Show-ModalForm "Bulk Import" $script:importForm -HideButtons +} + +function Add-GraphExportExtensions +{ + param($form, $buttonIndex = 0) + + if($global:curObjectType.ExportExtension) + { + $grid = $form.FindName("grdExportProperties") + $extraProperties = & $global:curObjectType.ExportExtension $global:curObjectType.ExportExtension $form "spExportSubMenu" 1 + for($i=0;($i + 1) -lt (($extraProperties) | measure).Count;$i ++) + { + $rd = [System.Windows.Controls.RowDefinition]::new() + $rd.Height = [double]::NaN + $grid.RowDefinitions.Add($rd) + $extraProperties[$i].SetValue([System.Windows.Controls.Grid]::RowProperty,$grid.RowDefinitions.Count) + $grid.Children.Add($extraProperties[$i]) + + $i++ + $extraProperties[$i].SetValue([System.Windows.Controls.Grid]::RowProperty,$grid.RowDefinitions.Count) + $extraProperties[$i].SetValue([System.Windows.Controls.Grid]::ColumnProperty,1) + $grid.Children.Add($extraProperties[$i]) + + } + } +} + +function Add-GraphImportExtensions +{ + param($form, $buttonIndex = 0) + + if($global:curObjectType.ImportExtension) + { + $grid = $form.FindName("grdImportProperties") + $extraProperties = & $global:curObjectType.ExportExtension $global:curObjectType.ExportExtension $form "spExportSubMenu" 1 + for($i=0;($i + 1) -lt (($extraProperties) | measure).Count;$i ++) + { + $rd = [System.Windows.Controls.RowDefinition]::new() + $rd.Height = [double]::NaN + $grid.RowDefinitions.Add($rd) + $extraProperties[$i].SetValue([System.Windows.Controls.Grid]::RowProperty,$grid.RowDefinitions.Count) + $grid.Children.Add($extraProperties[$i]) + + $i++ + $extraProperties[$i].SetValue([System.Windows.Controls.Grid]::RowProperty,$grid.RowDefinitions.Count) + $extraProperties[$i].SetValue([System.Windows.Controls.Grid]::ColumnProperty,1) + $grid.Children.Add($extraProperties[$i]) + + } + } +} + +function Get-GraphFileObjects +{ + param($path, $Exclude = @("*_settings.json","*_assignments.json"), $SelectedStatus = $true, $ObjectType = $global:curObjectType) + + if(-not $path -or (Test-Path $path) -eq $false) { return } + + $params = @{} + if($exclude) + { + $params.Add("Exclude", $exclude) + } + + $fileArr = @() + foreach($file in (Get-Item -path "$path\*.json" @params)) + { + $obj = New-Object PSObject -Property @{ + FileName = $file.Name + FileInfo = $file + Selected = $SelectedStatus + Object = (ConvertFrom-Json (Get-Content $file.FullName -Raw)) + ObjectType = $ObjectType + } + + $fileArr += $obj + } + + if(($fileArr | measure).Count -eq 1) + { + return @($fileArr) + } + return $fileArr +} + +function Import-GraphFile +{ + param($file, $objectType) + + if([IO.File]::Exists($file.FileInfo.FullName) -eq $false) + { + Write-Log "File '$($file.FileInfo.FullName)' not found. Cannot import object" 3 + return + } + + Get-GraphMigrationObjectsFromFile + + Get-GraphDependencyObjects $file.ObjectType + + try + { + # Clone the object to keep original values + $objClone = $file.Object | ConvertTo-Json -Depth 10 | ConvertFrom-Json + + if($objectType.PreFileImportCommand) + { + & $objectType.PreFileImportCommand $objectType $file + } + + Set-ScopeTags $file.Object + + # Never import with assignments. Add them if requested + Remove-Property $file.Object "Assignments" + + $newObj = Import-GraphObject $file.Object $file.ObjectType $file.FileInfo.FullName + + if($newObj -and $file.ObjectType.PostFileImportCommand) + { + & $file.ObjectType.PostFileImportCommand $newObj $file.ObjectType $file.FileInfo.FullName + } + + if($newObj -and $objClone.Assignments -and $global:chkImportAssignments.IsChecked -eq $true) + { + $preConfig = $null + if($file.ObjectType.PreImportAssignmentsCommand) + { + $preConfig = & $file.ObjectType.PreImportAssignmentsCommand $newObj $file.ObjectType $file.FileInfo.FullName $objClone.Assignments + } + + ###### Import Assignments ###### + + if($preConfig -isnot [Hashtable]) { $preConfig = @{} } + + if($preConfig["Import"] -eq $false) { return } # Assignment managed manually so skip further processing + + $api = ?? $preConfig["API"] "$($file.ObjectType.API)/$($newObj.Id)/assign" + + $method = ?? $preConfig["Method"] "POST" + + $keepProperties = ?? $file.ObjectType.AssignmentProperties @("target") + $keepTargetProperties = ?? $file.ObjectType.AssignmentTargetProperties @("@odata.type","groupId") + $ObjctAssignments = @() + foreach($assignment in $objClone.Assignments) + { + if($assignment.target.UserId -or ($assignment.Source -and $assignment.Source -ne "direct")) + { + # E.g. Source could be PolicySet...so should not be added here + continue + } + + $assignment.Id = "" + foreach($prop in $assignment.PSObject.Properties) + { + if($prop.Name -in $keepProperties) { continue } + Remove-Property $assignment $prop.Name + } + + foreach($prop in $assignment.target.PSObject.Properties) + { + if($prop.Name -in $keepTargetProperties) { continue } + Remove-Property $assignment.target $prop.Name + } + $ObjctAssignments += $assignment + } + + $objClone.Assignments = $ObjctAssignments + + if(($objClone.Assignments | measure).Count -gt 0) + { + $json = "{ `"$((?? $file.ObjectType.AssignmentsType "assignments"))`": " + $strAssign = "$((Update-JsonForEnvironment ($objClone.Assignments | ConvertTo-Json -Depth 10)))" + # Array characters [ ] is not included if there is only one assignment + # Added them if they are missing + if($strAssign.Trim().StartsWith("[") -eq $false) { $strAssign = (" [ " + $strAssign + " ] ") } + $json = ($json + $strAssign + "}") + + if($json) + { + $objAssign = Invoke-GraphRequest $api -HttpMethod $method -Content $json + } + } + + if($assignmentsProcessed -ne $true -and $file.ObjectType.PostImportAssignmentsCommand) + { + & $file.ObjectType.PostImportAssignmentsCommand $newObj $file.ObjectType $file.FileInfo.FullName $objAssign + } + } + } + catch + { + Write-LogError "Failed to import file '$($file.FileInfo.Name)'" $_.Exception + } +} + +#endregion + +#region Migration Info +######################################################################## +# +# Migration functions +# +######################################################################## +function Set-ScopeTags +{ + param($obj) + # ToDo: Get values from exported json files instead of MigrationTable? + + if(-not $obj.roleScopeTagIds) { return } + + $scopesIds = @() + $loadedScopeTags = $global:LoadedDependencyObjects["ScopeTags"] + $usingDefault = (($obj.roleScopeTagIds | measure).Count -eq 1 -and $obj.roleScopeTagIds[0] -eq "0") + if($loadedScopeTags -and $global:chkImportScopes.IsChecked -eq $true -and $usingDefault -eq $false -and $global:MigrationTableCache) + { + foreach($scopeId in $obj.roleScopeTagIds) + { + if($scopeId -eq 0) { $scopesIds += "0"; continue } # Add default + + $scopeMigObj = $loadedScopeTags | Where OriginalId -eq $scopeId + if($scopeMigObj -and $scopeMigObj.Id) + { + $scopesIds += "$($scopeMigObj.Id)" + } + elseif($scopeMigObj) + { + Write-Log "Could not find a ScopeTag for exported Id '$($obj.Id)' ($($scopeMigObj.Name)). Make sure all ScopeTags are imported into the environment" 2 + } + } + } + if($scopesIds.Count -eq 0) + { + $scopesIds += "0" # Import with Default ScopeTag as default. + } + $obj.roleScopeTagIds = $scopesIds +} + +# Called during export to add group info for assignments +# $objAssignments is specified for objects who don't support getting the assgnment info with expand=assignments +function Add-GraphMigrationInfo +{ + param($obj, $objAssignments) + + if(-not $obj) { return } + + $assignments = ?? $objAssignments $obj.Assignments + + foreach($assignment in $assignments) + { + foreach($objInfo in $assignment.target) + { + if(-not $objInfo."@odata.type") { continue } + + $objType = $objInfo."@odata.type" + + if($objType -eq "#microsoft.graph.groupAssignmentTarget" -or + $objType -eq "#microsoft.graph.exclusionGroupAssignmentTarget") + { + Add-GroupMigrationObject $objInfo.groupid + } + elseif($objType -eq "#microsoft.graph.allLicensedUsersAssignmentTarget" -or + $objType -eq "#microsoft.graph.allDevicesAssignmentTarget") + { + # No need to migrate All Users or All Devices + } + else + { + Write-Log "Unsupported migration object: $objType" 3 + } + } + } +} + +# Used during Import to display Migration Table info on the Import Form +function Get-MigrationTableInfo +{ + $fileName = Get-GraphMigrationTableForImport + + $str = $null + $sameTenant = $false + if($fileName -and [IO.File]::Exists($fileName)) + { + $migFileObj = ConvertFrom-Json (Get-Content $fileName -Raw) + if($migFileObj.TenantId -and $migFileObj.TenantId -eq $global:organization.Id) + { + $sameTenant = $true + $str = "Current tenant. Migration table will not be used" + } + elseif($migFileObj.Organization) + { + $str = "Objects exported from $($migFileObj.Organization) ($($migFileObj.TenantId))" + } + } + $chkReplaceDependencyIDs.IsEnabled = $sameTenant -eq $false + $chkReplaceDependencyIDs.IsChecked = $sameTenant -eq $false + + if(-not $str) + { + # Hide controls? + $str = "No migration table found" + } + $str +} + +function Get-GraphMigrationTableFile +{ + param($path) + + if(-not $path) + { + Write-Log "Export path not set" 3 + return + } + + if($global:chkAddCompanyName.IsChecked) + { + $path = Join-Path $path $global:organization.displayName + } + $path +} + +function Add-GroupMigrationObject +{ + param($groupId) + + if(-not $groupId) { return } + + $path = Get-GraphMigrationTableFile $global:txtExportPath.Text + + if(-not $path) { return } + + # Check if group is already processed + $groupObj = Get-GraphMigrationObject $groupId + if(-not $groupObj) + { + # Get group info + $groupObj = Invoke-GraphRequest "/groups/$groupId" -ODataMetadata "none" + } + + if($groupObj) + { + # Add group to cache + if($global:AADObjectCache.ContainsKey($groupId) -eq $false) { $global:AADObjectCache.Add($groupId, $groupObj) } + + # Add group to migration file + if((Add-GraphMigrationObject $groupObj $path "Group")) + { + # Export group info to json file for possible import + $grouspPath = Join-Path $path "Groups" + if(-not (Test-Path $grouspPath)) { mkdir -Path $grouspPath -Force -ErrorAction SilentlyContinue | Out-Null } + $fileName = "$grouspPath\$((Remove-InvalidFileNameChars $groupObj.displayName)).json" + ConvertTo-Json $groupObj -Depth 10 | Out-File $fileName -Force + } + } +} + +function Get-GraphMigrationObject +{ + param($objId) + + if(-not $global:AADObjectCache) + { + $global:AADObjectCache = @{} + } + + if($global:AADObjectCache.ContainsKey($objId)) { return $global:AADObjectCache[$objId] } +} + +# Adds an object to migration file if not added previously +function Add-GraphMigrationObject +{ + param($obj, $path, $objType) + + if(-not $objType) { $objType = $obj."@odata.type" } + + $migFileName = Join-Path $path "MigrationTable.json" + + if(-not $global:migFileObj) + { + if(-not ([IO.File]::Exists($migFileName))) + { + # Create new file + $global:migFileObj = (New-Object PSObject -Property @{ + TenantId = $global:organization.Id + Organization = $global:organization.displayName + Objects = @() + }) + } + else + { + # Add to existing file + $global:migFileObj = ConvertFrom-Json (Get-Content $migFileName -Raw) + } + } + + # Make sure Objects property actually exists + if(($global:migFileObj | GM -MemberType NoteProperty -Name "Objects") -eq $false) + { + $global:migFileObj | Add-Member -MemberType NoteProperty -Name "Objects" -Value (@()) + } + + # Get current object + $curObj = $global:migFileObj.Objects | Where { $_.Id -eq $obj.Id -and $_.Type -eq $objType } + + if($curObj) { return $false } # Existing object found so return false to tell that the object was not added + + $global:migFileObj.Objects += (New-Object PSObject -Property @{ + Id = $obj.Id + DisplayName = $obj.displayName + Type = $objType + }) + + if(-not (Test-Path $path)) { mkdir -Path $path -Force -ErrorAction SilentlyContinue | Out-Null } + ConvertTo-Json $global:migFileObj -Depth 10 | Out-File $migFileName -Force + + $true # New object was added +} + +function Get-GraphMigrationTableForImport +{ + $global:GraphMigrationTable = $null + # Migration table must be located in the root of the import path + $path = $global:txtImportPath.Text + + for($i = 0;$i -lt 2;$i++) + { + if($i -gt 0) + { + # Get parent directory + $path = [io.path]::GetDirectoryName($path) + } + + $migFileName = Join-Path $path "MigrationTable.json" + try + { + if([IO.File]::Exists($migFileName)) + { + $global:GraphMigrationTable = $migFileName + return $migFileName + } + } + catch {} + } + + Write-Log "Could not find migration table" 2 +} + +# Cache the migration table and create all missing groups +function Get-GraphMigrationObjectsFromFile +{ + if($global:MigrationTableCache) { return } + + $migFileName = Get-GraphMigrationTableForImport + if(-not $migFileName) { return } + + $global:MigrationTableCache = @() + + $migFileObj = ConvertFrom-Json (Get-Content $migFileName -Raw) + + # No need to translate migrated objects in the same environment as exported + if($migFileObj.TenantId -eq $global:organization.Id) { return } + + Write-Status "Loading migration objects" + + if($global:chkImportAssignments.IsChecked -eq $true) + { + # Only check groups if Assignments are imported + # This will CREATE the group if it doesn't exist in the target environment + foreach($migObj in $migFileObj.Objects) + { + if($migObj.Type -like "*group*") + { + $obj = (Invoke-GraphRequest "/groups?`$filter=displayName eq '$($migObj.DisplayName)'").Value + if(-not $obj) + { + $groupFi = $null + if($global:GraphMigrationTable) + { + $fi = [IO.FileInfo]$global:GraphMigrationTable + $groupFi = [IO.FileInfo]($fi.DirectoryName + "\Groups\$($migObj.DisplayName).json") + } + + if($groupFi.Exists -eq $true) + { + # ToDo: Create group from Json (could be a dynamic group) + # Warn if synched group + $groupObj = (Get-Content $groupFi.FullName) | ConvertFrom-Json + + #isAssignableToRole - For Role assignment groupd. + $keepProps = @("displayName","description","mailEnabled","mailNickname","securityEnabled","membershipRule","groupTypes", "membershipRuleProcessingState") + foreach($prop in $groupObj.PSObject.Properties) + { + if($prop.Name -in $keepProps) { continue } + + Remove-Property $groupObj $prop.Name + } + $groupJson = ConvertTo-Json $groupObj -Depth 10 + } + else + { + Write-Log "No group object found for $($migObj.DisplayName). Creating a cloud group with default settings" 2 + $groupJson = @" + { + "displayName": "$($migObj.DisplayName)", + "groupTypes": [ + ], + "mailEnabled": false, + "mailNickname" "NotSet" + "securityEnabled": true + } +"@ + } + Write-Log "Create AAD Group $($migObj.DisplayName)" + + $obj = Invoke-GraphRequest "/groups" -HttpMethod "POST" -Content $groupJson + } + $global:MigrationTableCache += (New-Object PSObject -Property @{ + OriginalId = $migObj.Id + Id = $obj.Id + Type = $migObj.Type + }) + } + } + } +} +function Update-JsonForEnvironment +{ + param($json) + + # Load MigrationTable file unless previously loaded + Get-GraphMigrationObjectsFromFile + + if($global:chkReplaceDependencyIDs.IsChecked -eq $true) + { + foreach($depObjType in $global:LoadedDependencyObjects.Keys) + { + foreach($depObj in $global:LoadedDependencyObjects[$depObjType]) + { + if(-not $depObj.Id -or -not $depObj.OriginalId) { continue } + if($depObj.OriginalId.Length -lt 36) { continue } # Skip non-guid IDs # ToDo: Verify... + $json = $json -replace $depObj.OriginalId,$depObj.Id + } + } + } + + if(-not $global:MigrationTableCache -or $global:MigrationTableCache.Count -eq 0) { return $json } + + # Enumerate all objects in the migration table and replace all exported Id's to Id's in the new environment + foreach($migInfo in ($global:MigrationTableCache | Where Type -like "*group*")) + { + if(-not $migInfo.Id -or -not $migInfo.OriginalId) { continue } + if($migInfo.OriginalId.Length -lt 36) { continue } # Skip non-guid IDs # ToDo: Verify... + $json = $json -replace $migInfo.OriginalId,$migInfo.Id + } + + #return updated json + $json +} + +#endregion + +#region Dependency Functions +function Get-GraphDependencyDefaultObjects +{ + Add-GraphDependencyObjects @("ScopeTags") +} + +function Get-GraphDependencyObjects +{ + param($objectType) + + if($global:chkReplaceDependencyIDs.IsChecked -ne $true -or -not $objectType -or -not $objectType.Dependencies -or (($objectType.Dependencies) | Measure).Count -eq 0) { return } + + $missingDeps = @() + foreach($dep in $objectType.Dependencies) + { + if($global:LoadedDependencyObjects -isnot [HashTable] -or $global:LoadedDependencyObjects.ContainsKey($dep) -eq $false) + { + $missingDeps += $dep + } + } + + if($missingDeps.Count -eq 0) { return } + + Add-GraphDependencyObjects $missingDeps +} + +function Add-GraphDependencyObjects +{ + param($DependencyIds) + + if($global:LoadedDependencyObjects -isnot [HashTable]) { $global:LoadedDependencyObjects = @{} } + + $importPath = $global:txtImportPath.Text + $parentPath = [IO.Path]::GetDirectoryName($importPath) + foreach($dep in $DependencyIds) + { + if($global:LoadedDependencyObjects.ContainsKey($dep)) { continue } + + $depObjectType = $global:currentViewObject.ViewItems | Where Id -eq $Dep + + if(-not $depObjectType) + { + Write-Log "No ViewItem found with Id $dep" 2 + continue + } + + if([IO.Directory]::Exists(($importPath + "\" + $dep))) + { + $path = ($importPath + "\" + $dep) + } + elseif([IO.Directory]::Exists(($parentPath + "\" + $dep))) + { + $path = ($parentPath + "\" + $dep) + } + else + { + Write-Log "Export folder for depndency $dep not found" 2 + continue + } + + $depFiles = Get-GraphFileObjects $path -ObjectType $depObjectType + + $url = ($depObjectType.API + "?`$select=$((?? $depObjectType.IdProperty "Id")),$((?? $depObjectType.NameProperty "displayName"))") + + if($depObjectType.QUERYLIST) + { + $url = "$($url.Trim())&$($depObjectType.QUERYLIST.Trim())" + } + + $depObjects = (Invoke-GraphRequest $url -ODataMetadata "none").Value + $arrDepObjects = @() + foreach($depObject in $depObjects) + { + $name = Get-GraphObjectName $depObject $depObjectType + + $fileObj = $depFiles | Where { (Get-GraphObjectName $_.Object $depObjectType) -eq $name } + if(-not $fileObj) + { + Write-Log "Could not find an exported '$($depObjectType.Title)' object with name $name" 2 + continue + } + if(($fileObj | measure).Count -gt 1) + { + $fileObj = $fileObj[0] + Write-Log "Multple files returned for object $name. Using first: $($fileObj.FileInfo.Name)" 2 + } + $arrDepObjects += New-Object PSObject -Property @{ + OriginalId = $fileObj.Object.Id + Name = $name + Id = Get-GraphObjectId $depObject $depObjectType + Type = $depObjectType.Id + } + } + + if($arrDepObjects.Count -gt 0) + { + $global:LoadedDependencyObjects.Add($depObjectType.Id,$arrDepObjects) + } + } +} + + +#endregion + +#region Import/Export/Copy functions + +function Export-GraphObjects +{ + param([switch]$Selected) + + $objectType = $global:curObjectType + Write-Status "Export $($objectType.Title)" + + $global:ExportRoot = (Get-XamlProperty $script:exportForm "txtExportPath" "Text") + $folder = Get-GraphObjectFolder $objectType $global:ExportRoot (Get-XamlProperty $script:exportForm "chkAddObjectType" "IsChecked") (Get-XamlProperty $script:exportForm "chkAddCompanyName" "IsChecked") + + $objectsToExport = @() + if($Selected -ne $true) + { + # Export all + $objectsToExport = $global:dgObjects.ItemsSource + } + elseif(($global:dgObjects.ItemsSource | Where IsSelected).Count -gt 0) + { + # Export checked items + $objectsToExport += ($global:dgObjects.ItemsSource | Where IsSelected) + } + elseif($global:dgObjects.SelectedItem) + { + # Export selected item + $objectsToExport += $global:dgObjects.SelectedItem + } + else + { + return + } + + foreach($obj in $objectsToExport) + { + Export-GraphObject $obj.Object $global:curObjectType $folder + } + + Save-Setting "" "LastUsedFullPath" $folder + Save-Setting "" "LastUsedRoot" $global:ExportRoot + + Write-Status "" +} + +function Export-GraphObject +{ + param($objToExport, + $objectType, + $exportFolder) + + if(-not $exportFolder) { return } + + Write-Status "Export $((Get-GraphObjectName $objToExport $objectType))" + + $obj = Get-GraphExportObject $objToExport $objectType + + if(-not $obj) + { + Write-Log "No object to export" 3 + return + } + + try + { + if([IO.Directory]::Exists($exportFolder) -eq $false) + { + [IO.Directory]::CreateDirectory($exportFolder) + } + + if($chkExportAssignments.IsChecked -ne $true -and $obj.Assignments) + { + ### ToDo: Fix full support for including Assignments. $extend=Assignments might not work + ### E.g. Check AutoPilot + Remove-Property $obj $Assignments + } + elseif($chkExportAssignments.IsChecked -eq $true -and -not $obj.Assignments) + { + + } + + $obj | ConvertTo-Json -Depth 10 | Out-File ([IO.Path]::Combine($exportFolder, (Remove-InvalidFileNameChars "$((Get-GraphObjectName $obj $objectType)).json"))) + + if($objectType.PostExportCommand) + { + & $objectType.PostExportCommand $obj $objectType $exportFolder + } + + Add-GraphMigrationInfo $obj + } + catch + { + Write-LogError "Failed to export object" $_.Exception + } +} + +function Get-GraphExportObject +{ + param($obj, $objectType) + + if($objectType.ExportFullObject -ne $false) + { + $exportObj = (Get-GraphObject $obj $objectType).Object + } + else + { + if($obj.Object) + { + $exportObj = $obj.Object + } + else + { + $exportObj = $obj + } + } + $exportObj +} + +function Import-GraphObject +{ + param($obj, + $objectType, + $fromFile) + + Write-Log "Import $($objectType.Title) object $((Get-GraphObjectName $obj $objectType))" + + # Clone the object before removing properties + $objClone = $obj | ConvertTo-Json -Depth 10 | ConvertFrom-Json + + Start-GraphPreImport $obj $objectType + + $params = @{} + $strAPI = (?? $objectType.APIPOST $objectType.API) + $method = "POST" + if($objectType.PreImportCommand) + { + $ret = & $objectType.PreImportCommand $obj $objectType $fromFile + if($ret -is [HashTable]) + { + if($ret.ContainsKey("Import") -and $ret["Import"] -eq $false) + { + # Import handled manually + return $false + } + + if($ret.ContainsKey("API")) + { + $strAPI = $ret["API"] + } + + if($ret.ContainsKey("Method")) + { + $method = $ret["Method"] + } + + if($ret.ContainsKey("AdditionalHeaders") -and $ret["AdditionalHeaders"] -is [HashTable]) + { + $params.Add("AdditionalHeaders",$ret["AdditionalHeaders"]) + } + } + } + + $json = ConvertTo-Json $obj -Depth 10 + if($fromFile) + { + # Call Update-JsonForEnvironment before importing the object + # E.g. PolicySets contains references, AppConfiguration policies reference apps etc. + $json = Update-JsonForEnvironment $json + } + + $newObj = (Invoke-GraphRequest -Url $strAPI -Content $json -HttpMethod $method @params) + + if($newObj -and $objectType.PostImportCommand) + { + & $objectType.PostImportCommand $newObj $objectType $fromFile + } + + $newObj +} + +function Copy-GraphObject +{ + if(-not $dgObjects.SelectedItem) + { + [System.Windows.MessageBox]::Show("No object selected`n`nSelect the $($global:curObjectType.Title) item you want to copy", "Error", "OK", "Error") + return + } + + $newName = "$((Get-GraphObjectName $dgObjects.SelectedItem $global:curObjectType)) - Copy" + if($global:curObjectType.CopyDefaultName) + { + $newName = $global:curObjectType.CopyDefaultName + $dgObjects.SelectedItem.PSObject.Properties | foreach { $newName = $newName -replace "%$($_.Name)%", $dgObjects.SelectedItem."$($_.Name)" } + } + $ret = Show-InputDialog "Copy $($global:curObjectType.Title)" "Select name for the new object" $newName + + if($ret) + { + # Export profile + Write-Status "Export $((Get-GraphObjectName $dgObjects.SelectedItem $global:curObjectType))" + if($global:curObjectType.PreCopyCommand) + { + if((& $global:curObjectType.PreCopyCommand $dgObjects.SelectedItem.Object $global:curObjectType $ret)) + { + Show-GraphObjects + Write-Status "" + return + } + } + + $exportObj = (Get-GraphObject $dgObjects.SelectedItem.Object $global:curObjectType -SkipAssignments).Object + + # Convert to Json and back to clone the object + $obj = ConvertTo-Json $exportObj -Depth 10 | ConvertFrom-Json + if($obj) + { + # Import new profile + Set-GraphObjectName $obj $global:curObjectType $ret + + $newObj = Import-GraphObject $obj $global:curObjectType + if($newObj) + { + if($global:curObjectType.PostCopyCommand) + { + & $global:curObjectType.PostCopyCommand $exportObj $newObj $global:curObjectType + } + Show-GraphObjects + } + else + { + [System.Windows.MessageBox]::Show("Failed to copy object. See log for more information", "Error", "OK", "Error") + } + } + Write-Status "" + } + $dgObjects.Focus() +} + +#endregion + +function Show-GraphObjectInfo +{ + param( + $FormTitle = "", + [switch]$NoLoadFull) + + if(-not $global:dgObjects.SelectedItem) { return } + if(-not $global:dgObjects.SelectedItem.Object) { return } + + $script:detailsForm = Get-XamlObject ($global:AppRootFolder + "\Xaml\ObjectDetails.xaml") + if(-not $script:detailsForm) { return } + + if(-not $FormTitle) { $FormTitle = $global:curObjectType.Title } + $objName = Get-GraphObjectName $global:dgObjects.SelectedItem.Object $global:curObjectType + if($objName) + { + $FormTitle = "$FormTitle - $objName" + } + + if($global:curObjectType.DetailExtension) + { + & $global:curObjectType.DetailExtension $script:detailsForm "pnlButtons" + } + + Set-XamlProperty $script:detailsForm "txtValue" "Text" (ConvertTo-Json $global:dgObjects.SelectedItem.Object -Depth 10) + + if($global:curObjectType.AllowFullDetails -eq $false) + { + Set-XamlProperty $script:detailsForm "btnFull" "Visibility" "Collapsed" + } + + Add-XamlEvent $script:detailsForm "btnCopy" "Add_Click" -scriptBlock ([scriptblock]{ + $tmp = $script:detailsForm.FindName("txtValue") + if($tmp.Text) { $tmp.Text | Clip } + }) + + Add-XamlEvent $script:detailsForm "btnFull" "Add_Click" -scriptBlock ([scriptblock]{ + + $obj = Get-GraphObject $global:dgObjects.SelectedItem.Object $global:curObjectType + if($obj.Object) + { + Set-XamlProperty $script:detailsForm "txtValue" "Text" (ConvertTo-Json $obj.Object -Depth 10) + Set-XamlProperty $script:detailsForm "btnFull" "IsEnabled" $false + } + Write-Status "" + }) + + Show-ModalForm $FormTitle $detailsForm +} + +function Get-GraphObjectName +{ + param($obj, $objectType) + + $obj."$((?? ($objectType.NameProperty) "displayName"))" +} + +function Set-GraphObjectName +{ + param($obj, $objectType, $value) + + $obj."$((?? ($objectType.NameProperty) "displayName"))" = $value +} + +function Get-GraphObjectId +{ + param($obj, + $objectType) + + $obj."$((?? ($objectType.IdProperty) "Id"))" +} +function Get-GraphObjectFolder +{ + param($objectType, + $rootFolder, + $addObjectType, + $addOrganization) + + $path = $rootFolder + + if($addOrganization) { $path = Join-Path $path $global:organization.displayName } + + if($addObjectType -and $objectType.Id) { $path = Join-Path $path $objectType.Id } + + $path +} + +function Add-GraphBulkMenu +{ + $menuItem = [System.Windows.Controls.MenuItem]::new() + $menuItem.Header = "_Bulk" + $menuItem.Name = "EMBulk" + $subItem = [System.Windows.Controls.MenuItem]::new() + $subItem.Header = "_Export" + $subItem.Add_Click({Show-GraphBulkExportForm}) + $menuItem.AddChild($subItem) | Out-Null + $subItem = [System.Windows.Controls.MenuItem]::new() + $subItem.Header = "_Import" + $subItem.Add_Click({Show-GraphBulkImportForm}) + $menuItem.AddChild($subItem) | Out-Null + + $mnuMain.Items.Insert(1,$menuItem) | Out-Null +} \ No newline at end of file diff --git a/Extensions/MSGraphIntune.psm1 b/Extensions/MSGraphIntune.psm1 deleted file mode 100644 index 51095af..0000000 --- a/Extensions/MSGraphIntune.psm1 +++ /dev/null @@ -1,1614 +0,0 @@ -function Invoke-InitializeModule -{ - $module = Get-Module -Name Microsoft.Graph.Intune -ListAvailable - if(-not $module) - { - $ret = [System.Windows.MessageBox]::Show("Intune PowerShell module not found!`n`nDo you want to install it as admin?`n`nYes = Install intune module as Admin (Requires admin or it will fail)`nNo = Install module for current user`nCancel = Quit", "Error", "YesNoCancel", "Error") - if($ret -eq "Cancel") - { - exit - } - - $params = @{} - if($ret -eq "No") - { - $params.Add("Scope", "CurrentUser") - } - - try - { - Install-Module -Name Microsoft.Graph.Intune -Force -ErrorAction SilentlyContinue @params - } - catch {} - - if(-not (Get-Module -Name Microsoft.Graph.Intune -ListAvailable -Refresh)) - { - [System.Windows.MessageBox]::Show("Failed to install Intune PowerShell module!`n`nRestart this as admin and try again`nor`nStart PowerShell as admin and run:`nInstall-Module -Name Microsoft.Graph.Intune", "Error", "OK", "Error") - exit - } - } - - if(-not $global:authentication) - { - if((Get-Command Connect-MSGraph -ErrorAction SilentlyContinue)) - { - $global:authentication = Connect-MSGraph -PassThru - } - } - - if(-not $global:authentication) - { - [System.Windows.MessageBox]::Show("Failed to connect to Azure with Intune PowerShell module!`n`nNo Intune extensions will be imported", "Error", "OK", "Error") - return - } - - Write-Log "Get current user" - $global:Me = Invoke-GraphRequest "ME" - - if(-not $global:Me) - { - [System.Windows.MessageBox]::Show("Failed to get information about current logged on Azure user!`n`nVerify connection and try again`n`nNo Intune modules will be imported!", "Error", "OK", "Error") - return - } - - Write-Log "Get organization info" - $global:Organization = (Invoke-GraphRequest "Organization").Value - - $global:graphURL = "https://graph.microsoft.com/beta" - - # Add settings - $global:appSettingSections += (New-Object PSObject -Property @{ - Title = "Intune" - Id = "IntuneAzure" - Values = @() - }) - - Write-Log "Add settings and menu items" - - Add-SettingsObject (New-Object PSObject -Property @{ - Title = "Root folder" - Key = "IntuneRootFolder" - Type = "Folder" - }) "IntuneAzure" - - Add-SettingsObject (New-Object PSObject -Property @{ - Title = "App packages folder" - Key = "IntuneAppPackages" - Type = "Folder" - }) "IntuneAzure" - - Add-SettingsObject (New-Object PSObject -Property @{ - Title = "Add object type" - Key = "AddObjectType" - Type = "Boolean" - DefaultValue = $true - Description = "Default setting for adding object type to the export folder" - }) "IntuneAzure" - - Add-SettingsObject (New-Object PSObject -Property @{ - Title = "Add company name" - Key = "AddCompanyName" - Type = "Boolean" - DefaultValue = $true - Description = "Default setting for adding company name to the export folder" - }) "IntuneAzure" - - Add-SettingsObject (New-Object PSObject -Property @{ - Title = "Export Assignments" - Key = "ExportIntuneAssignments" - Type = "Boolean" - DefaultValue = $true - Description = "Default setting for exporting assignments" - }) "IntuneAzure" - - Add-SettingsObject (New-Object PSObject -Property @{ - Title = "Create groups" - Key = "CreateIntuneGroupOnImport" - Type = "Boolean" - DefaultValue = $true - Description = "Default setting for creating groups during import" - }) "IntuneAzure" - - Add-SettingsObject (New-Object PSObject -Property @{ - Title = "Convert synced groups" - Key = "ConvertIntuneSyncedGroupOnImport" - Type = "Boolean" - DefaultValue = $true - Description = "Convert AD synched groups to Azure AD group during import if the group does not exist" - }) "IntuneAzure" - - Add-SettingsObject (New-Object PSObject -Property @{ - Title = "Import Assignments" - Key = "ImportAssignments" - Type = "Boolean" - DefaultValue = $true - }) "IntuneAzure" - - - #Add menu group and items - Add-MenuSection (New-Object PSObject -Property @{ Title = "Intune/Azure Objects"; ID="IntuneGraphAPI"; Order = 10}) - Add-MenuSection (New-Object PSObject -Property @{ Title = "Intune/Azure Management"; ID="IntuneGraphAPIEX"; Order = 20}) - - # Add default menu items - Add-MenuItem (New-Object PSObject -Property @{ - Title = 'Bulk Import' - MenuID = "IntuneGraphAPIEX" - Script = [ScriptBlock]{ Show-ImportAllForm } - }) - - # Add default menu items - Add-MenuItem (New-Object PSObject -Property @{ - Title = 'Bulk Export' - MenuID = "IntuneGraphAPIEX" - Script = [ScriptBlock]{ Show-ExportAllForm } - }) - - $global:UpdateJsonForMigration = $true -} - -function Show-ExportAllForm -{ - param($Extension) - -$xmlStr = @" - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - $($Extension.Xaml) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - $($Extension.Xaml) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -"@ - - $reader = (New-Object System.Xml.XmlNodeReader $xaml) - $script:inputBox = [Windows.Markup.XamlReader]::Load($reader) - - $script:txtValue = $script:inputBox.FindName("txtValue") - $btnOk = $script:inputBox.FindName("btnOk") - $btnCopy = $script:inputBox.FindName("btnCopy") - $btnFull = $script:inputBox.FindName("btnFull") - - $script:txtValue.Text = (ConvertTo-Json $Object -Depth 5) - - $btnOk.Add_Click({ - $script:inputBox.Close() - }) - - $btnCopy.Add_Click({ - $script:txtValue.Text | Clip - }) - - if($script:ViewFullObject -and $NoLoadFull -ne $true) - { - $btnFull.Visibility = "Visible" - $btnFull.Add_Click({ - Write-Status "Loading full object info" - $objFullInfo = Invoke-Command -ScriptBlock $script:ViewFullObject - Write-Status "" - if($objFullInfo) - { - $script:inputBox.Close() - Show-ObjectInfo -object $objFullInfo -NoLoadFull - } - }) - } - - $inputBox.ShowDialog() | Out-Null -} - -######################################################################## -# -# Export functions -# -######################################################################## - -function Show-DefaultExportGrid -{ - param( - [ScriptBlock]$ExportAllScript, - [ScriptBlock]$ExportSelectedScript, - $Extension, - $DisplayColumn) - - $exportGrid = [System.Windows.Markup.XamlReader]::Parse(@" - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - $($Extension.Xaml) - - - - - - - - - - - - - - - - - - - - $($Extension.Xaml) - - - - - -"@ - - $script:dlgAbout = [Windows.Markup.XamlReader]::Load((New-Object System.Xml.XmlNodeReader $xaml)) - - $btnOk = $dlgAbout.FindName("btnOk") - $lstModules = $dlgAbout.FindName("lstModules") - $linkSource = $dlgAbout.FindName("linkSource") - - - $lstModules.ItemsSource = Get-Module | Where { $_.ModuleBase -like "$($global:PSScriptRoot)*" } | Sort -Property Name - - $btnOk.Add_Click({ - $script:dlgAbout.Close() - }) - - $linkSource.Add_RequestNavigate({ - [System.Diagnostics.Process]::Start($_.Uri.AbsoluteUri) - $_.Handled = $true - }) - - $script:dlgAbout.ShowDialog() | Out-Null - - $global:menuObjects | ForEach-Object { - # Clear selection in all menu sections - So it can be pressed again - $PSItem.MenuListBox.SelectedItem = $null - } -} - -function global:Add-XamlVariables -{ - param($xaml) - - # Generate a global variable for each object with Name property set - # Ref: https://learn-powershell.net/2014/08/10/powershell-and-wpf-radio-button/ - $xaml.SelectNodes("//*[@*[contains(translate(name(.),'n','N'),'Name')]]") | ForEach { - New-Variable -Name $_.Name -Value $Window.FindName($_.Name) -Force -Scope Global - } -} - -function global:Remove-InvalidFileNameChars -{ - param($Name) - - $re = "[{0}]" -f [RegEx]::Escape(([IO.Path]::GetInvalidFileNameChars() -join '')) - - $Name = $Name -replace $re - $Name = $Name -replace "[]]", "" - $Name = $Name -replace "[[]", "" - - return $Name -} - -function global:Remove-ObjectProperty -{ - param($obj, $property) - - if(-not $obj -or -not $property) { return } - - if(($obj | GM -MemberType NoteProperty -Name $property)) - { - $obj.PSObject.Properties.Remove($property) - } -} - -function global:Show-InputDialog -{ - param( - $FormTitle = "Input", - $FormText, - $DefaultValue) - - [xml]$xaml = @" - - - - - - - - - - - - - - $DefaultValue - - - - - - - -"@ - $reader = (New-Object System.Xml.XmlNodeReader $xaml) - $script:inputBox = [Windows.Markup.XamlReader]::Load($reader) - - $script:txtValue = $script:inputBox.FindName("txtValue") - $btnOk = $script:inputBox.FindName("btnOk") - $btnCancel = $script:inputBox.FindName("btnCancel") - - $inputBox.Add_ContentRendered({ - $script:txtValue.SelectAll(); - $script:txtValue.Focus(); - }) - - $script:InputDialogValue = "" - - $btnOk.Add_Click({ - $script:inputBox.Close() - }) - - $btnCancel.Add_Click({ - $script:txtValue.Text ="" - $script:inputBox.Close() - }) - - $inputBox.ShowDialog() | Out-null - - return $script:txtValue.Text -} - -function global:Set-ObjectGrid -{ - param( $obj ) - - if($obj) - { - $grdObject.Children.Add($obj) - $grdObject.Visibility = "Visible" - } - else - { - $grdObject.Children.Clear() - $grdObject.Visibility = "Collapsed" - } - - [System.Windows.Forms.Application]::DoEvents() -} - -function global:Clear-Objects -{ - $global:txtFormTitle.Text = "" - $global:txtFormTitle.Visibility = "Collapsed" - $spSubMenu.Visibility = "Collapsed" - $spSubMenu.Children.Clear() - $grdObject.Children.Clear() - $dgObjects.ItemsSource = $null - Set-ObjectGrid - - [System.Windows.Forms.Application]::DoEvents() -} - -function global:Show-SubMenu -{ - $spSubMenu.Visibility = "Visible" - [System.Windows.Forms.Application]::DoEvents() -} - -function global:Get-Folder -{ - param($path = $env:temp) - - if($global:useDefaultFolderDialog -ne $true) - { - try - { - if($global:WindowsAPICodePackLoaded -eq $false) - { - - $apiCodec = Join-Path $PSScriptRoot "Microsoft.WindowsAPICodePack.Shell.dll" - if([IO.File]::Exists($apiCodec)) - { - Add-Type -Path $apiCodec | Out-Null - $global:WindowsAPICodePackLoaded = $true - } - else - { - } - } - else - { - } - $dlgCOFD = New-Object Microsoft.WindowsAPICodePack.Dialogs.CommonOpenFileDialog - } - catch { - } - } - - if($dlgCOFD -and $global:useDefaultFolderDialog -ne $true) - { - $dlgCOFD.EnsureReadOnly = $true - $dlgCOFD.IsFolderPicker = $true - $dlgCOFD.AllowNonFileSystemItems = $false - $dlgCOFD.Multiselect = $false - $dlgCOFD.Title = "Please select the destination directory" - - if($path -and (Test-Path $path)) - { - $dlgCOFD.InitialDirectory = $path - } - if($dlgCOFD.ShowDialog($window) = [Microsoft.WindowsAPICodePack.Dialogs.CommonFileDialogResult]::Ok) - { - $dlgCofd.FileName - } - } - else - { - $global:useDefaultFolderDialog = $true - [Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms") | Out-Null - [System.Windows.Forms.Application]::EnableVisualStyles() - $dlgFBD = New-Object System.Windows.Forms.FolderBrowserDialog - $dlgFBD.SelectedPath = "C:\" - $dlgFBD.ShowNewFolderButton = $false - $dlgFBD.Description = "Select a directory" - if($dlgFBD.ShowDialog() -eq "OK") - { - $dlgFBD.SelectedPath - } - $dlgFBD.Dispose() - } -} - -#region Reg functions -######################################################################## -# -# Reg functions -# -######################################################################## - -function global:Save-RegSetting -{ - param($SubPath, $Key, $Value, $Type = "String") - - $regPath = Get-RegPath $SubPath - if((Test-Path $regPath) -eq $false) - { - New-Item (Get-RegPath $SubPath) -ErrorAction SilentlyContinue - } - New-ItemProperty -Path $regPath -Name $Key -Value $Value -Type $Type -Force | Out-Null -} - -function global:Get-RegSetting -{ - param($SubPath, $Key, $defautValue) - - try - { - $val = Get-ItemPropertyValue -Path (Get-RegPath $SubPath) -Name $Key -ErrorAction SilentlyContinue - } - catch { } - if(-not $val) - { - $defautValue - } - else - { - $val - } -} - -function global:Get-RegPath -{ - param($SubPath) - - $path = "HKCU:\Software\IntunePSTools" - if($SubPath) - { - $path = $path + "\" + $SubPath - } - - $path -} -#endregion - -#region Setting functions - -######################################################################## -# -# Settings functions -# -######################################################################## - -function global:Add-SettingTextBox -{ - param($id, $value) - - $xaml = @" -$value -"@ - return [Windows.Markup.XamlReader]::Parse($xaml) -} - -function global:Add-SettingCheckBox -{ - param($id, $value) - - $tmpValue = ($value -eq $true -or $value -eq "true").ToString().ToLower() - - $xaml = @" - -"@ - return [Windows.Markup.XamlReader]::Parse($xaml) -} - -function global:Add-SettingFolder -{ - param($id, $value) - $xaml = @" - - - - - - - $value - - - -"@ - - $obj = [Windows.Markup.XamlReader]::Parse($xaml) - - $btnBrowse = $obj.FindName("browse_$($id)") - $txtObj = $obj.FindName($id) - if($btnBrowse) - { - $btnBrowse.Tag = $txtObj - $btnBrowse.Add_Click({ - $folder = Get-Folder $this.Tag.Text - if($folder) { $this.Tag.Text = $folder } - }) - } - return $obj -} - -function global:Add-SettingValue -{ - param($settingValue) - - $id = "id_" + [Guid]::NewGuid().ToString('n') - - $value = Get-SettingValue $settingValue.Key - - if($settingValue.Type -eq "folder") - { - $settingObj = Add-SettingFolder $id $value - } - elseif($settingValue.Type -eq "Boolean") - { - $settingObj = Add-SettingCheckBox $id $value - } - else - { - $settingObj = Add-SettingTextBox $id $value - } - - $descriptionInfo = "" - if($settingValue.Description) - { - $descriptionInfo = "" - } - - $xaml = @" - - - - - - - - - - - - - - $descriptionInfo - - - - - - -"@ - $newSetting = [Windows.Markup.XamlReader]::Parse($xaml) - - if($newSetting) - { - $spSettings.AddChild($newSetting) - - $tmpObj = $newSetting.FindName("border_$($id)") - $tmpObj.Child = $settingObj - - $ctrl = $settingObj.FindName($id) - $global:settingControls += $ctrl - - if(($settingValue | GM -MemberType NoteProperty -Name "Control")) - { - $settingValue.Control = $ctrl - } - else - { - $settingValue | Add-Member -MemberType NoteProperty -Name "Control" -Value $ctrl - } - } -} - -function global:Add-SettingTitle -{ - param($title, $marginTop = "0") - - $xaml = @" - -"@ - $global:spSettings.Children.Add([Windows.Markup.XamlReader]::Parse($xaml)) -} - -function global:Save-Setting -{ - foreach($ctrl in $global:settingControls) - { - Write-Host "$($ctrl.Text) $($ctrl.Tag)" - } -} - -function Show-SettingsForm -{ - $settingsStr = @" - - - - - - - - - - - - - - -"@ - - $global:settingControls = @() - - $settingsForm = [Windows.Markup.XamlReader]::Parse($settingsStr) - - $global:spSettings = $settingsForm.FindName("spSettings") - $btnSave = $settingsForm.FindName("btnSave") - $btnSave.Add_Click({ - Save-AllSettings - }) - - $tmp = $global:appSettingSections | Where Id -eq "General" - if($tmp.Values.Count -gt 0) - { - Add-SettingTitle $tmp.Title - foreach($settingObj in $tmp.Values) - { - Add-SettingValue $settingObj - } - } - - foreach($section in ($global:appSettingSections | Where Id -ne "General" | Sort -Property Title)) - { - if($section.Values.Count -eq 0) { continue } - Add-SettingTitle $section.Title 5 - foreach($settingObj in $section.Values) - { - Add-SettingValue $settingObj - } - } - - Set-ObjectGrid $settingsForm -} - -function global:Get-Setting -{ - foreach($ctrl in $global:settingControls) - { - Write-Host "$($ctrl.Text) $($ctrl.Tag)" - } -} - -function global:Add-DefaultSettings -{ - $global:appSettingSections = @() - - $global:appSettingSections += (New-Object PSObject -Property @{ - Title = "General" - Id = "General" - Values = @() - }) - - Add-SettingsObject (New-Object PSObject -Property @{ - Title = "Log file" - Key = "LogFile" - Type = "File" - }) "General" - - Add-SettingsObject (New-Object PSObject -Property @{ - Title = "Max log file size" - Key = "LogFileSize" - Type = "Int" - DefaultValue = 1024 - }) "General" - -} - -function global:Add-SettingsObject -{ - param($obj, $section) - - $section = $global:appSettingSections | Where Id -eq $section - if(-not $section) { return } - $section.Values += $obj -} - -function global:Save-AllSettings -{ - foreach($section in $global:appSettingSections) - { - foreach($settingObj in $section.Values) - { - if($settingObj.Control.GetType().Name -eq "TextBox") - { - $value = $settingObj.Control.Text - if($settingObj.Type -eq "Int") - { - try - { - $value = [int]$value - } - catch - { - # Log or set invalid - $value = $settingObj.Value - } - } - } - elseif($settingObj.Control.GetType().Name -eq "CheckBox") - { - $value = $settingObj.Control.IsChecked - } - - if($value) - { - Save-RegSetting $settingObj.SubPath $settingObj.Key $value - } - } - } -} - -function global:Get-SettingValue -{ - param($Key, $defaultValue) - - foreach($section in $global:appSettingSections) - { - $settingObj = $section.Values | Where Key -eq $Key - if($settingObj) { break } - } - if(-not $defaultValue) { $defaultValue = $settingObj.DefaultValue } - - $value = Get-RegSetting $settingObj.SubPath $settingObj.Key $defaultValue - if($value) - { - if($settingObj.Type -eq "Boolean") - { - $value = $value -eq $true -or $value -eq "true" - } - elseif($settingObj.Type -eq "Boolean") - { - try - { - $value = [int]$value - } - catch - { - if($settingObj.DefaultValue) - { - try - { - $value = [int]$settingObj.DefaultValue - } - catch { } - } - } - } - - # Keep last read value - if(($settingObj | GM -MemberType NoteProperty -Name "Value")) - { - $settingObj.Value = $value # Keep last read value - } - else - { - $settingObj | Add-Member -MemberType NoteProperty -Name "Value" -Value $value - } - } - $value -} - -#endregion - -#region Menu functions - -##################################################################################################### -# -# Menu functions -# -##################################################################################################### - -function global:Add-MenuSection -{ - param($menuSection) - - $id = [Guid]::NewGuid().ToString('n') - [xml]$menuXml = @" - - -"@ - - try - { - $objSection = [Windows.Markup.XamlReader]::Load((New-Object System.Xml.XmlNodeReader $menuXml)) - $lstBox = $objSection.FindName("Id_lb_$id") - if($menuSection.Order -gt 0) - { - $order = $menuSection.Order - } - else - { - $order = 90 - } - $global:menuObjects += New-Object PSObject -Property @{ ID = $id; MenuInfo = $menuSection; Object = $objSection; MenuItems = @(); MenuListBox = $lstBox; Order = $order } - if($objSection) - { - if($lstBox) - { - $lstBox.Add_SelectionChanged({ - - if(-not $this.SelectedItem) { return } - - $global:menuObjects | ForEach-Object { - if($PSItem.MenuListBox -and $this -ne $PSItem.MenuListBox) - { - # Clear selection in other menu sections - $PSItem.MenuListBox.SelectedItem = $null - } - } - if($this.SelectedItem.ShowForm -ne $false) - { - Clear-Objects - $global:txtFormTitle.Text = $this.SelectedItem.Title - $global:txtFormTitle.Visibility = "Visible" - - } - if($this.SelectedItem.Script) - { - Invoke-Command -ScriptBlock $this.SelectedItem.Script - } - Write-Status "" - }) - } - } - } - catch { Write-LogError "Failed to add menu section" $_.Exception } -} - -function global:Add-MenuItem -{ - param($menuItem) - - # Get the menu the item should be added to - $objSection = $global:menuObjects | Where { $_.MenuInfo.Id -eq $menuItem.MenuId } - if(-not $objSection) - { - if(($arrMenuInlcude -and $arrMenuInlcude -notcontains $menuItem.MenuId) -or ($arrMenuExlcude -and $arrMenuExlcude -contains $menuItem.MenuId)) { return } - - Write-Log "Could not find menu with id $($menuItem.MenuId). Item $($menuItem.Title) not added" 2 - return - } - - $objSection.MenuItems += $menuItem -} - -function global:Invoke-ModuleFunction -{ - param($function) - - Write-Log "Trigger function $function" - - foreach($module in $global:loadedModules) - { - # Get command with ExportedFunctions instead of Get-Command - $cmd = $module.ExportedFunctions[$function] - if($cmd) - { - Write-Log "Trigger $function in $($module.Name)" - Invoke-Command -ScriptBlock $cmd.ScriptBlock - } - else - { - #Write-Log "$function not found in $($module.Name)" 2 - } - } -} - -function global:Initialize-Menu -{ - # Add default menu section - Add-MenuSection (New-Object PSObject -Property @{ Title = "General"; ID="General"; Order = 1000; Sort = $false }) - - # Add default menu items - Add-MenuItem (New-Object PSObject -Property @{ - Title = 'Settings' - MenuID = "General" - Script = [ScriptBlock]{ Show-SettingsForm } - }) - - - Add-MenuItem (New-Object PSObject -Property @{ - Title = 'About' - MenuID = "General" - ShowForm = $false - Script = [ScriptBlock]{ Show-AboutDialog } - }) - - Add-MenuItem (New-Object PSObject -Property @{ - Title = 'Reload' - MenuID = "General" - ShowForm = $false - Script = [ScriptBlock]{ Start-Reload } - }) - - Add-MenuItem (New-Object PSObject -Property @{ - Title = 'Exit' - MenuID = "General" - ShowForm = $false - Script = [ScriptBlock]{ - if([System.Windows.MessageBox]::Show("Are you sure you want to exit?", "Exit?", "YesNo", "Question") -eq "Yes") - { - $window.Close() - } - $global:menuObjects | ForEach-Object { - # Clear selection in all menu sections - So it can be pressed again - $PSItem.MenuListBox.SelectedItem = $null - } - } - }) - - # Get all menu items - Invoke-ModuleFunction "Add-ModuleMenuItems" - - # Filter and sort menu sections based on order and title - # Add all the menu sections/menuitems to the menu - foreach($menuObj in ($global:menuObjects | Where { $_.MenuItems.Count -gt 0 } | Sort -Property Order)) - { - if($menuObj.MenuInfo.Sort -ne $false) - { - $menuObj.MenuItems = ($menuObj.MenuItems | Sort -Property Title) - } - - if($menuObj.MenuListBox) - { - $spMenu.Children.Add($menuObj.Object) | Out-Null - - $menuObj.MenuListBox.ItemsSource = @($menuObj.MenuItems) - } - } -} - -#endregion - -#region Console management functions - -# https://stackoverflow.com/questions/40617800/opening-powershell-script-and-hide-command-prompt-but-not-the-gui -Add-Type -Name Window -Namespace Console -MemberDefinition ' -[DllImport("Kernel32.dll")] -public static extern IntPtr GetConsoleWindow(); - -[DllImport("user32.dll")] -public static extern bool ShowWindow(IntPtr hWnd, Int32 nCmdShow); -' - -function global:Set-MainTitle -{ - if(-not $global:window) { return } - - Write-Log "Set main title" - - $mainTitle = $title - - try - { - if($global:Me.userPrincipalName) - { - $IntuneId = $global:Me.userPrincipalName - $mainTitle += " - IntuneGraph: $($global:Me.userPrincipalName)" - } - } - catch {} - - try - { - $ctx = Get-AzContext -ErrorAction SilentlyContinue - if($ctx.Account.Id) - { - $azureADId = $ctx.Account.Id - $mainTitle += " - AzureAD: $($ctx.Account.Id)" - } - } - catch {} - - $global:window.Title = $mainTitle -} - -function Show-Console -{ - $consolePtr = [Console.Window]::GetConsoleWindow() - - # Hide = 0, - # ShowNormal = 1, - # ShowMinimized = 2, - # ShowMaximized = 3, - # Maximize = 3, - # ShowNormalNoActivate = 4, - # Show = 5, - # Minimize = 6, - # ShowMinNoActivate = 7, - # ShowNoActivate = 8, - # Restore = 9, - # ShowDefault = 10, - # ForceMinimized = 11 - - [Console.Window]::ShowWindow($consolePtr, 4) -} - -function Hide-Console -{ - $consolePtr = [Console.Window]::GetConsoleWindow() - #0 hide - [Console.Window]::ShowWindow($consolePtr, 0) -} - -#endregion - -#region Module functions - -function Import-AllModules -{ - foreach($file in (Get-Item -path "$modulesPath\*.psm1")) - { - $module = Import-Module $file -PassThru -Force -ErrorAction SilentlyContinue - if($module) - { - $global:loadedModules += $module - Write-Host "Module $($module.Name) loaded successfully" - } - else - { - Write-Warning "Failed to load module $file" - } - } -} - -function Start-Reload -{ - if([System.Windows.MessageBox]::Show("Are you sure you want to reload all modules and settings?", "Exit?", "YesNo", "Question") -eq "No") - { - return - } - - Write-Status "Reloading modules" - - $global:menuObjects = @() - $tmpList = @() - $spMenu.Children.Clear() - - foreach($tmpModule in $global:loadedModules) - { - Remove-Module $tmpModule - - $module = Import-Module $tmpModule.Path -PassThru -Force -ErrorAction SilentlyContinue - if($module) - { - $tmpList += $module - Write-Host "Module $($module.Name) loaded successfully" - } - else - { - Write-Warning "Failed to load module $file" - } - } - $global:loadedModules = $tmpList - - Add-DefaultSettings - - Invoke-ModuleFunction "Invoke-InitializeModule" - - Initialize-Menu - - Write-Status "" - -} - - -#endregion - -##################################################################################################### -# -# Main -# -##################################################################################################### - -function global:Get-MainWindow -{ - $resources = @() - $themes = Join-Path $PSScriptRoot "Themes" - $themFile = Join-Path $themes "Default.xaml" - $resources += $themFile - $styles = Join-Path $themes "Styles.xaml" - $stylesStr = "" - if(Test-Path $styles) - { - try - { - [xml]$styleXml = Get-Content $styles - $stylesStr = $styleXml.FirstChild.InnerXml - } - catch {} - } - - [xml]$xaml = @" - - - - - - - - - $stylesStr - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -"@ - - $global:window = [Windows.Markup.XamlReader]::Load((New-Object System.Xml.XmlNodeReader $xaml)) - - $global:dgObjects = $window.FindName('dgObjects') - $global:grdData = $window.FindName('grdData') - $global:spMenu = $window.FindName('spMenu') - $global:spSubMenu = $window.FindName('spSubMenu') - $global:txtInfo = $window.FindName('txtInfo') - $global:grdStatus = $window.FindName('grdStatus') - $global:grdObject = $window.FindName('grdObject') - $global:txtFormTitle = $window.FindName('txtFormTitle') - - $global:dgObjects.Add_AutoGeneratingColumn({ - if($_.PropertyName -eq "Object") - { - $_.Cancel = $true - } - }) -} - -$global:wpfNS = "xmlns='http://schemas.microsoft.com/winfx/2006/xaml/presentation' xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'" - -Add-Type -AssemblyName PresentationFramework - -$global:useDefaultFolderDialog = $false -$global:WindowsAPICodePackLoaded = $false - -$global:loadedModules = @() -$global:menuObjects = @() - -# Load all modules in the Modules folder -$modulesPath = [IO.Path]::GetDirectoryName($PSCommandPath) + "\Extensions" - -if(Test-Path $modulesPath) -{ - Import-AllModules -} -else -{ - Write-Warning "Modules folder $modulesPath not found. Aborting..." 3 - exit 1 -} - -Add-DefaultSettings - -Invoke-ModuleFunction "Invoke-InitializeModule" - -#This will load the main window -Get-MainWindow - -Initialize-Menu - -if($ShowConsoleWindow -ne $true) -{ - Hide-Console -} - -Set-MainTitle - -# Show main window -# Workaround for ISE crash -# https://gist.github.com/altrive/6227237 -$async = $global:window.Dispatcher.InvokeAsync({ - $global:window.ShowDialog() | Out-Null -}) -$async.Wait() | Out-Null diff --git a/README.md b/README.md index f548f4a..c8d4b83 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,96 @@ # IntuneManagement with PowerShell and WPF UI -This PowerShell scripts are using Intune PowerShell module, Microsoft Graph APIs and AzureRM PowerShell module to manage objects in Intune and Azure. The scripts has a simple WPF UI and it supports operations like Export, Import, Copy and Download. +These PowerShell scripts are using Microsoft Authentication Library (MSAL), Microsoft Graph APIs and Azure Management APIs to manage objects in Intune and Azure. The scripts has a simple WPF UI and it supports operations like Export, Import, Copy and Download. -This makes it easy to backup or clone a complete Intune environment. The scripts will export and import assignments and support import/export between environments. The scripts will create a migration table during export and use that for importing in other environments. It will create groups if they are missing in the environment for import. +This makes it easy to backup or clone a complete Intune environment. The scripts can export and import objects including assignments and support import/export between tenants. The scripts will create a migration table during export and use that for importing assignments in other environments. It will create missing groups in the target environment during import. Group information like name, description and type will be imported based on the exported group e.g. dynamic groups are supported. There will be one json file for each group in the export folder. + +The script also support dependencies e.g. an App Protection is depending on an App, Policy Sets are depending on Compliance Policies, objects has Scope Tags etc. Dependency support requires exported json files and that the dependency objects are imported in the environment. The script uses the exported json files to get the Id and name's of the exported object and uses that information and updates Id's before import an object from a json file. The Bulk Import form shows the import order of the objects. The objects with the lowest order number will be imported first. ![Screenshot](/IntuneManagement.PNG?raw=true) -**Note:** The base PowerShell script is only a host for extensions. It is only used as a framework for basic UI, logging etc. The functionality is located in the extension modules which makes it easy to add/remove features. +This PowerShell application is based on the foundation modules CloudAPIPowerShellManagement and Core. These modules manages UI, settings, logging etc. The functionality for the application is located in the extension modules. This makes it easy to add/remove features, views etc. Additional features will be added... + +**Security note:** Since the scripts are not signed, a warning might be display when running it and files might be blocked. The script will unblock all files. This is to avoid issues that it fails to load the MSAL library etc. If there are any security concerns, the PowerShell code can be reviewed. The DLL files are downloaded from Microsoft repositories, see links below. These files can be downloaded and replaced. The DLL files *CAN* be removed but MSAL is a pre-requisite for login. The script will try to find the DLL in the Az or MSAL.PS module if not found in the script root directory. DLL files are included to reduce dependencies. ## Change log -**Version 2** +See [Change Log](ReleaseNotes.md) for more information -**Breaking changes** -* Removed support for AzureRM +## Authentication +See [MSAL Info](MSALInfo.md) for more information about authentication -**New features** -* Support for Az module for Azure objects (Conditional access, Company Branding and MDM/MAM settings) -* Reload - Reloads all modules - -**Fixes** -* Allow more than 9 Conditional Access policies. Issue [#5](https://github.com/Micke-K/IntuneManagement/issues/5) -* Include WIP policies. Issue [#7](https://github.com/Micke-K/IntuneManagement/issues/7) -* Import is not working. Issue [#6](https://github.com/Micke-K/IntuneManagement/issues/6) and [#4](https://github.com/Micke-K/IntuneManagement/issues/4) -* Intune module can now be install with scope user. Issue [#8](https://github.com/Micke-K/IntuneManagement/issues/8) - -## Intune objects -* Administrative Templates -* App Protection/Configuration policies +## Supported Intune objects +* App Configurations +* App Protection * Applications +* Apple Enrolment Types - NOT fully tested * Autopilot profiles * Baseline Security profiles * Compliance policies -* Configuration Items -* Enrollment Status Page profiles -* Intune Branding (Company Portal) -* PowerShell scripts (Supports download of PowerShell script) -* Terms and Conditions - -**Note:** The Intune PowerShell module are using the BETA version of the Graph API which might change at any time. - -## Azure objects * Conditional Access -* Company Branding -* MDM/MAM app settings +* Device Configuration (Administrative Templates, Configuration Policies, Android OEM Config, Settings Catalog) +* Endpoint Security (Account Protection, Disk Encryption, Firewall, Security Baselines etc.) +* Enrollment Restrictions +* Enrollment Status Page profiles +* Feature Updates +* Intune Branding (Company Portal) +* Locations +* Named Locations +* Policy Sets +* Role Definitions +* Scope Tags +* Scripts (Supports download of PowerShell script) +* Terms and Conditions +* Update Policies -**Note:** Azure objects are not using the Microsoft Graph API. They are using undocumented APIs which might not be supported and change at any time. -## Prerequisites +**Note:** The scripts are using the BETA version of the Graph API which might change at any time. + +## Azure Management APIs +* Tenants for the current user + +**Note:** Azure Management APIs are undocumented APIs which might not be supported and they might change at any time. + +## Pre-requisites * .Net 4.7 -* Intune PowerShell Module - * Install by running 'Install-Module -Name Microsoft.Graph.Intune' -* Az PowerShell Module - * Install by running 'Install-Module -Name Az -AllowClobber' -* Permissions in Azure to manage objects in Intune and Azure +* PowerShell 5.1 +* MSAL + * Microsoft.Identity.Client.dll version 4.29.0.0 is included in this version +* License and permissions in Azure to manage objects in Intune and Azure ## References * [Microsoft Graph API](https://docs.microsoft.com/en-us/graph/api/overview?toc=./ref/toc.json&view=graph-rest-beta) -* [Microsoft Intune PowerShell Module](https://github.com/microsoft/Intune-PowerShell-SDK) +* [Microsoft.Identity.Client](https://www.nuget.org/packages/Microsoft.Identity.Client/) (MSAL download) +* [MSAL.PS Module](https://github.com/AzureAD/MSAL.PS) * [Az PowerShellModule](https://docs.microsoft.com/en-us/powershell/azure/new-azureps-module-az) +* [Microsoft Intune PowerShell Module](https://github.com/microsoft/Intune-PowerShell-SDK) +* [Microsoft.WindowsAPICodePack](https://www.nuget.org/packages/Microsoft-WindowsAPICodePack-Core) and [Microsoft.WindowsAPICodePack.Shell](https://www.nuget.org/packages/Microsoft-WindowsAPICodePack-Shell) for Browse Folder dialogs ## Acknowledgments -The app enryption and upload is based on [PowerShell Intune Examples](https://github.com/microsoftgraph/powershell-intune-samples) +The app encryption and upload is based on [Graph PowerShell Intune Examples](https://github.com/microsoftgraph/powershell-intune-samples) +Some MSAL functionalities are based on [MSAL.PS Module](https://github.com/AzureAD/MSAL.PS) ## Known Issues -The scripts are using two separate PowerShell modules for accessing Intune and Azure. This can cause multiple logins since they are authenticating to two different apps in azure and the authentication token for Intune PowerShell module have no permissions on the Azure objects. -The support for import/export between environments is limited. Only groups in assignments are supported in this version. Additional objects like users, locations, notifications etc. will not be migrated and might cause the import to fail. +Device Configuration and App Configuration objects are split up in different object types. They are using different Graph APIs and each object type in the menu uses one API. This is also why all Endpoint Security objects are of the same object type. They use the same API but are separated based on the Baseline Template Id they us. -The script will create a group if it is missing in the destination environment. It will create a security group with manual assigned members. This might not always be the desired case e.g. original group was synched from AD or it was a dynamic group. +Android Store Apps are **not** imported. The create method is documented in Microsoft Graph but it's not working. Looks like these apps must be synched from Google Play. + +Using multiple tenants support causes multiple logins/consent prompts the first time if 'Microsoft Graph PowerShell' is used. Querying the API for tenant list uses a different scope that is not included by default in the 'Microsoft Graph PowerShell' app. + +Using multiple tenants support *might* cause and endless loop in the login screen and cause duplicate accounts in token cache. Actual cause is not found yet but it happens on rare occasions and it looks like it happens when a guest account is used. Workaround: Cancel the login, restart the script, logout and restart the script again. + +When multi tenant settings is Enabled/Disabled, the Profile Info is not updated until the account is changed or app is restarted. Profile Info popup is built after logon. + +The list applications API might not list an imported app immediately after the import. Click Refresh to reload the application objects. + +When using the filter box to search for items, the checkbox must be clicked twice to select an item. + +Logout will only clear the token from cache and not from the browser e.g. if login is triggered after a logout, the user will still be listed in the 'Select user' dialog. ## TIP -Download [Microsoft.WindowsAPICodePack](https://www.nuget.org/packages/WindowsAPICodePack-Core) and [Microsoft.WindowsAPICodePack.Shell](https://www.nuget.org/packages/WindowsAPICodePack-Shell) and copy the DLLs into the script folder to get a nicer folder dialog. +Check the log file for errors. The UI might not show errors why login failed etc. The log uses the Endpoint Configuration Manager (SCCM) format and it is best viewed with CMTrace. An old version can be downloaded [here](https://www.microsoft.com/en-us/download/confirmation.aspx?id=50012). ## License -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. \ No newline at end of file diff --git a/ReleaseNotes.md b/ReleaseNotes.md new file mode 100644 index 0000000..5c0ca5c --- /dev/null +++ b/ReleaseNotes.md @@ -0,0 +1,57 @@ +# Release Notes + +## 3.0.0 Beta 1 - 2021-04-01 + +**Breaking changes** + +- Dropped support for Azure Branding and MAM/MDM settings...for now +- Import might not work for items exported with previous versions. Some folders are renamed, import is depending on additional information. + +**New features** + +- Authentication managed by Microsoft Authentication Library (MSAL) + - Support for switching user + - Support for switching tenant. Multi tenant support must be enabled in Settings + - Token info, Profile picture info support etc. + - See [MSAL info](MSALInfo.md) for more information +- Support for multiple Views - Intune Management and Intune Info for now... + - Intune Management - Export/Import/Copy objects in Intune + - Intune Info - Show information about some objects in Intune +- Improved UI experience + - Support for resizing the Window + - Support for searching for objects + - Refresh objects in the list + - Scaled popup dialogs +- API management redeveloped from scratch to simplify support for new object types in the future +- Support for new object types (Settings Catalog, Named Locations, Scope Tags, Policy Sets etc.) +- Better support for migrating objects between environments + - Group migrations e.g. support for Dynamic Groups, different group types etc. + - Support for dependency objects e.g. Policy Sets reference other objects like Compliance Settings etc. The import of an object uses exported json files to identify dependent items and map old Id to the new Id in the target environment + - Support for migrating Scope Tags (Uses the dependency functionallity so Scope Tags must be Exported/Imported) + - Better support for migrating Assignments + +**Dependencies** + +- MSAL - **Microsoft.Identity.Client.dll**. This is included in Az / MSAL.PS modules or it can be installed separately. This release was developed and tested with MSAL version 4.21.0.0. + +## 2.0.0 - 2021-02-01 + +**Breaking changes** + +- Removed support for AzureRM + +**New features** + +- Support for Az module + +**Fixes** + +- Allow more than 9 Conditional Access policies. Issue [#5](https://github.com/Micke-K/IntuneManagement/issues/5) +- Include WIP policies. Issue [#7](https://github.com/Micke-K/IntuneManagement/issues/7) +- Import is not working. Issue #6 and [#4](https://github.com/Micke-K/IntuneManagement/issues/4) +- Intune module can now be install with scope user. Issue [#8](https://github.com/Micke-K/IntuneManagement/issues/8) + +## 1.0.0 + +- Intune Management with PowerShell +- Dependencies: Intune and AzureRM PowerShell modules diff --git a/Start-IntuneManagement.ps1 b/Start-IntuneManagement.ps1 new file mode 100644 index 0000000..012a929 --- /dev/null +++ b/Start-IntuneManagement.ps1 @@ -0,0 +1,7 @@ +[CmdletBinding(SupportsShouldProcess=$True)] +param( + [switch] + $ShowConsoleWindow +) +Import-Module ($PSScriptRoot + "\CloudAPIPowerShellManagement.psd1") -Force +Initialize-CloudAPIManagement -View "IntuneGraphAPI" -ShowConsoleWindow:($ShowConsoleWindow) \ No newline at end of file diff --git a/Start-WithConsole.cmd b/Start-WithConsole.cmd new file mode 100644 index 0000000..7ae4c51 --- /dev/null +++ b/Start-WithConsole.cmd @@ -0,0 +1,2 @@ +cmd /c powershell -ex bypass -file "%~DP0Start-IntuneManagement.ps1" -ShowConsoleWindow +pause \ No newline at end of file diff --git a/Start.cmd b/Start.cmd index dc0ea0e..e0b74bb 100644 --- a/Start.cmd +++ b/Start.cmd @@ -1 +1 @@ -cmd /c powershell -ex bypass "%~DP0PSExtensionsHost.ps1" \ No newline at end of file +cmd /c powershell -ex bypass -File "%~DP0Start-IntuneManagement.ps1" \ No newline at end of file diff --git a/Themes/Default.xaml b/Themes/Default.xaml index be95326..1b7300e 100644 --- a/Themes/Default.xaml +++ b/Themes/Default.xaml @@ -1,7 +1,19 @@ - + + + + + + + + + + 0.8 + 1.0 + 0 + 2 \ No newline at end of file diff --git a/Themes/Styles.xaml b/Themes/Styles.xaml index cb170f0..7c25cc7 100644 --- a/Themes/Styles.xaml +++ b/Themes/Styles.xaml @@ -21,4 +21,114 @@ + + + + + + + + + + + + + diff --git a/Xaml/AboutDialog.xaml b/Xaml/AboutDialog.xaml new file mode 100644 index 0000000..5955ba2 --- /dev/null +++ b/Xaml/AboutDialog.xaml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + See + + GitHub + for more information + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Xaml/BulkExportForm.xaml b/Xaml/BulkExportForm.xaml new file mode 100644 index 0000000..538f3a8 --- /dev/null +++ b/Xaml/BulkExportForm.xaml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +