repopack-output.txt
This file is a merged representation of the entire codebase, combining all repository files into a single document. Generated by Repopack on: 2024-11-14T13:25:30.377Z ================================================================ File Summary ================================================================ Purpose: -------- This file contains a packed representation of the entire repository's contents. It is designed to be easily consumable by AI systems for analysis, code review, or other automated processes. File Format: ------------ The content is organized as follows: 1. This summary section 2. Repository information 3. Repository structure 4. Multiple file entries, each consisting of: a. A separator line (================) b. The file path (File: path/to/file) c. Another separator line d. The full contents of the file e. A blank line Usage Guidelines: ----------------- - This file should be treated as read-only. Any changes should be made to the original repository files, not this packed version. - When processing this file, use the file path to distinguish between different files in the repository. - Be aware that this file may contain sensitive information. Handle it with the same level of security as you would the original repository. Notes: ------ - Some files may have been excluded based on .gitignore rules and Repopack's configuration. - Binary files are not included in this packed representation. Please refer to the Repository Structure section for a complete list of file paths, including binary files. Additional Info: ---------------- For more information about Repopack, visit: https://github.com/yamadashy/repopack ================================================================ Repository Structure ================================================================ .repopackignore JyskIT.Automation.psd1 JyskIT.Automation.psm1 Private/Authentication/ConvertFrom-JwtToken.ps1 Private/Authentication/Curanet/Get-CuranetAccessToken.ps1 Private/Authentication/Curanet/Get-CuranetCredentials.ps1 Private/Authentication/Microsoft/Get-PartnerAccessToken.ps1 Private/Authentication/Microsoft/Get-PartnerCredentials.ps1 Private/Authentication/Microsoft/Get-RefreshedToken.ps1 Private/Authentication/Microsoft/Initialize-TokenCache.ps1 Private/Authentication/Microsoft/TokenExceptions.ps1 Private/Consent/Set-ApplicationConsent.ps1 Private/GDAP/New-GDAPRelationship.ps1 Private/GDAP/Set-GDAPPermissions.ps1 Private/PartnerCenter/Invoke-PartnerCenterRequest.ps1 Private/PartnerMenu/Get-PartnerMenuHeader.ps1 Private/PartnerMenu/Start-PartnerMenuTenantCreation.ps1 Private/Utilities/Get-AutomationUpdates.ps1 Private/Utilities/Get-PSGalleryKey.ps1 Private/Utilities/Get-RandomPassword.ps1 Public/Configuration/Add-BaselineConfiguration.ps1 Public/Configuration/Add-CompanyBranding.ps1 Public/Configuration/Add-CustomerDomain.ps1 Public/Configuration/Baseline/Add-BaselinePolicy.ps1 Public/Configuration/Baseline/Add-EOPPolicies.ps1 Public/Configuration/Baseline/EOPPolicies/AntiPhishingPolicies/JyskIT-Baseline-EOP-AntiPhishing-Unlicensed.jx Public/Configuration/Baseline/Read-PolicyFile.ps1 Public/Configuration/ExchangeOnline/BitTitan/New-BitTitanAppRegistration.ps1 Public/Configuration/ExchangeOnline/BitTitan/Start-BitTitanPreperation.ps1 Public/Configuration/ExchangeOnline/Enable-Auditlogging.ps1 Public/Configuration/ExchangeOnline/Enable-CustomerDKIM.ps1 Public/Configuration/ExchangeOnline/Enable-CustomerDMARC.ps1 Public/Configuration/New-AdminUser.ps1 Public/Connect/Connect-CustomerExchange.ps1 Public/Connect/Connect-CustomerGraph.ps1 Public/Curanet/Customers/Get-CuranetCustomer.ps1 Public/Curanet/DNS/Get-CuranetDNSRecords.ps1 Public/Curanet/DNS/New-CuranetDNSRecord.ps1 Public/Curanet/DNS/Remove-CuranetDNSRecord.ps1 Public/Curanet/DNS/Update-CuranetDNSRecord.ps1 Public/Curanet/Invoicing/Get-CuranetInvoice.ps1 Public/Curanet/Invoke-CuranetAPI.ps1 Public/Curanet/M365/Get-CuranetM365AzureBilling.ps1 Public/Curanet/M365/Get-CuranetM365Licenses.ps1 Public/Curanet/M365/Get-CuranetM365OnboardingCredentials.ps1 Public/Curanet/Subscriptions/Get-CuranetCustomerSubscriptions.ps1 Public/Curanet/Subscriptions/Get-CuranetSubscriptionDetails.ps1 Public/Initialize-CustomerTenant.ps1 Public/PartnerCenter/Get-PartnerCustomer.ps1 Public/PartnerMenu/Start-PartnerMenu.ps1 Public/PartnerMenu/Start-PartnerMenuTenantSelection.ps1 Public/Utilities/Publish-JyskITAutomation.ps1 ================================================================ Repository Files ================================================================ ================ File: .repopackignore ================ /config *.json ================ File: JyskIT.Automation.psd1 ================ @{ # Script module or binary module file associated with this manifest. RootModule = 'JyskIT.Automation.psm1' # Version number of this module. ModuleVersion = '2.0.4' # ID used to uniquely identify this module GUID = '26d4c649-d11a-4aa9-bed1-9ab55f777b3f' # Author of this module Author = 'Jysk IT' # Company or vendor of this module CompanyName = 'Jysk IT' # Copyright statement for this module Copyright = '(c) Jysk IT. All rights reserved.' # Description of the functionality provided by this module Description = 'Provides different cmdlets for automating Microsoft 365 and related services.' # Minimum version of the PowerShell engine required by this module PowerShellVersion = '7.4.0' # Minimum version of the PowerShell host required by this module PowerShellHostVersion = '7.4.0' # Functions to export from this module FunctionsToExport = @( # Connection functions 'Connect-CustomerGraph', 'Connect-CustomerExchange', # Tenant management 'Initialize-CustomerTenant', 'Get-PartnerCustomer', # Configuration functions 'New-AdminUser', 'Add-CompanyBranding', 'Add-CustomerDomain', 'Add-BaselineConfiguration', 'Add-BaselinePolicy', 'New-BitTitanAppRegistration', 'Enable-AuditLogging', 'Enable-CustomerDKIM', 'Enable-CustomerDMARC', # DNS and domain functions 'Invoke-CuranetAPI', 'Get-CuranetCustomer', 'Get-CuranetCustomerSubscriptions', 'Get-CuranetCustomerSubscriptionDetails' 'Get-CuranetDNSRecords', 'New-CuranetDNSRecord', 'Remove-CuranetDNSRecord', 'Update-CuranetDNSRecord', 'Get-CuranetInvoice', 'Get-CuranetM365AzureBilling', 'Get-CuranetM365Licenses', 'GetCuranetM365OnboardingCredentials', # Menu system 'Start-PartnerMenu', 'Start-PartnerMenuTenantSelection', # Utility 'Publish-JyskITAutomation' ) AliasesToExport = @('back') } ================ File: JyskIT.Automation.psm1 ================ #Region Module Variables $script:ModuleRoot = $PSScriptRoot $script:Config = $null $script:CurrentGraphToken = $null $script:PartnerCredentials = $null # Token cache configuration $script:TokenCache = @{} $script:TokenCacheConfig = @{ MaxSize = 100 # Maximum number of tokens to cache RefreshBuffer = 5 # Minutes before expiry to trigger refresh # PersistencePath should be in a writable location even if the module is installed in Program Files, such as local app data PersistencePath = [System.IO.Path]::Combine([System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::LocalApplicationData), 'JyskIT.Automation\tokencache.xml') } . (Join-Path $PSScriptRoot 'Config\Baseline\PolicyTypeSettings.ps1') # Module default configuration $script:DefaultConfig = @{ CuranetBaseURL = 'https://reseller.curanet.dk' SpamfilterAPIBaseURL = 'https://spamfilter.io/api' KeyVaultName = 'KV-M365-SAM' SubscriptionName = 'internal-002-mpn' PartnerTenantId = 'b6a41db1-6b1a-4833-9b69-f8e363090e45' GDAP = Get-Content (Join-Path $PSScriptRoot 'Config\GDAP\default.json') | ConvertFrom-Json -AsHashtable PolicyTypeSettings = $PolicyTypeSettings } #Region Custom Exceptions class TokenOperationException : Exception { [string]$Operation [string]$DetailedMessage TokenOperationException([string]$operation, [string]$message, [Exception]$innerException) : base($message, $innerException) { $this.Operation = $operation $this.DetailedMessage = $message } } #Region Helper Functions $script:LogConfig = @{ LogPath = [System.IO.Path]::Combine([System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::LocalApplicationData), 'JyskIT.Automation\logs', "JyskIT.Automation-$(Get-Date -Format 'dd-MM-yyyy').log") MaxLogAgeDays = 30 LogLevel = 'Verbose' # Default log level: Error, Warning, Info, Debug, Verbose WriteToFile = $true WriteToHost = $true } function Write-ModuleLog { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Message, [Parameter()] [ValidateSet('Error', 'Warning', 'Info', 'Debug', 'Verbose')] [string]$Level = 'Info', [Parameter()] [System.Management.Automation.ErrorRecord]$ErrorRecord, [Parameter()] [string]$Component = 'General', [Parameter()] [string]$FunctionName = $MyInvocation.MyCommand.Name, [Parameter()] [switch]$NoOutput, [Parameter()] [switch]$ThrowError, [Parameter()] [string]$ErrorOperation, [Parameter()] [string]$ErrorMessage ) # Define log level weights for filtering $levelWeights = @{ Error = 1 Warning = 2 Info = 3 Debug = 4 Verbose = 5 } # Check if we should process this log level if ($levelWeights[$Level] -gt $levelWeights[$script:LogConfig.LogLevel]) { return } try { $timestamp = Get-Date -Format 'dd-MM-yyyy HH:mm:ss' $callStack = Get-PSCallStack | Select-Object -Skip 1 -First 1 $caller = if ($callStack) { $callStack.Command } else { 'Unknown' } # Build the log entry $logEntry = [PSCustomObject]@{ Timestamp = $timestamp Level = $Level.ToUpper() Component = $Component Function = $FunctionName Caller = $caller Message = $Message ErrorDetails = if ($ErrorRecord) { @{ Exception = $ErrorRecord.Exception.Message Category = $ErrorRecord.CategoryInfo.Category TargetObject = $ErrorRecord.TargetObject ScriptStackTrace = $ErrorRecord.ScriptStackTrace PositionMessage = $ErrorRecord.InvocationInfo.PositionMessage } | ConvertTo-Json -Compress } else { $null } } # Format the log message for output $consoleMessage = "$($logEntry.Timestamp) [$($logEntry.Level)] [$($logEntry.Component)] $($logEntry.Message)" if ($ErrorRecord) { $consoleMessage += "`nError: $($ErrorRecord.Exception.Message)" $consoleMessage += "`nStack: $($ErrorRecord.ScriptStackTrace)" } # Write to console if enabled if ($script:LogConfig.WriteToHost -and -not $NoOutput) { switch ($Level) { 'Error' { Write-Error $consoleMessage } 'Warning' { Write-Warning $consoleMessage } 'Info' { Write-Information $consoleMessage -InformationAction Continue } 'Debug' { Write-Debug $consoleMessage } 'Verbose' { Write-Verbose $consoleMessage } } } # Write to file if enabled if ($script:LogConfig.WriteToFile) { # Ensure log directory exists $logDir = Split-Path $script:LogConfig.LogPath -Parent if (-not (Test-Path $logDir)) { New-Item -Path $logDir -ItemType Directory -Force | Out-Null } # Convert log entry to JSON and append to file $logEntry | ConvertTo-Json -Compress | Add-Content -Path $script:LogConfig.LogPath -Encoding UTF8 } # Throw error if requested if ($ThrowError) { if ($ErrorRecord) { throw [TokenOperationException]::new( $ErrorOperation, $ErrorMessage ?? $Message, $ErrorRecord.Exception ) } else { throw [TokenOperationException]::new( $ErrorOperation, $ErrorMessage ?? $Message, $null ) } } } catch { # Fallback error handling - write directly to console $fallbackMessage = "Failed to write to log: $($_.Exception.Message)" Write-Error $fallbackMessage if ($ThrowError) { throw } } } function Initialize-ModuleLogging { [CmdletBinding()] param() try { # Create logs directory if it doesn't exist $logDir = Split-Path $script:LogConfig.LogPath -Parent if (-not (Test-Path $logDir)) { New-Item -Path $logDir -ItemType Directory -Force | Out-Null } # Clean up old log files Get-ChildItem -Path $logDir -Filter "JyskIT.Automation-*.log" | Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-$script:LogConfig.MaxLogAgeDays) } | ForEach-Object { Write-ModuleLog -Message "Removing old log file: $($_.Name)" -Level Debug -Component 'LogMaintenance' Remove-Item $_.FullName -Force } # Initialize new log file with header $moduleInfo = Get-Module JyskIT.Automation @" # JyskIT.Automation Module Log # Module Version: $($moduleInfo.Version) # Log Started: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') # PowerShell Version: $($PSVersionTable.PSVersion) # OS: $([System.Environment]::OSVersion.VersionString) "@ | Out-File -FilePath $script:LogConfig.LogPath -Encoding UTF8 Write-ModuleLog -Message "Module logging initialized" -Level Verbose -Component 'LogInitialization' } catch { Write-Error "Failed to initialize logging: $_" } } # Helper function to get log entries function Get-ModuleLog { [CmdletBinding()] param( [Parameter()] [DateTime]$StartTime, [Parameter()] [DateTime]$EndTime, [Parameter()] [ValidateSet('Error', 'Warning', 'Info', 'Debug', 'Verbose')] [string[]]$Level, [Parameter()] [string]$Component, [Parameter()] [switch]$Raw ) try { $logs = Get-Content -Path $script:LogConfig.LogPath | Where-Object { $_ -match '^{' } | ForEach-Object { $_ | ConvertFrom-Json } # Apply filters if ($StartTime) { $logs = $logs | Where-Object { [DateTime]$_.Timestamp -ge $StartTime } } if ($EndTime) { $logs = $logs | Where-Object { [DateTime]$_.Timestamp -le $EndTime } } if ($Level) { $logs = $logs | Where-Object { $_.Level -in $Level } } if ($Component) { $logs = $logs | Where-Object { $_.Component -eq $Component } } if ($Raw) { return $logs } else { return $logs | Format-Table -Property Timestamp, Level, Component, Message -AutoSize } } catch { Write-Error "Failed to retrieve logs: $_" } } function Initialize-ModuleDependencies { [CmdletBinding()] param() $requiredModules = @( @{Name = 'Microsoft.Graph'; MinimumVersion = '2.24.0'}, @{Name = 'Az.Accounts'; MinimumVersion = '3.0.5'}, @{Name = 'Az.KeyVault'; MinimumVersion = '6.2.0'}, @{Name = 'ExchangeOnlineManagement'; MinimumVersion = '3.6.0'}, @{Name = 'Microsoft.PowerShell.ConsoleGuiTools'; MinimumVersion = '0.7.7'} ) foreach ($module in $requiredModules) { $installed = Get-Module -ListAvailable -Name $module.Name | Where-Object { $_.Version -ge [version]$module.MinimumVersion } if (-not $installed) { try { Write-ModuleLog -Message "Installing $($module.Name) module (minimum version $($module.MinimumVersion))..." -Level Info -Component 'ModuleInitialization' Write-ModuleLog -Message "This might take a while, please be patient" -Level Info -Component 'ModuleInitialization' Install-Module -Name $module.Name -MinimumVersion $module.MinimumVersion -Force -AllowClobber -Scope CurrentUser Write-ModuleLog -Message "$($module.Name) module installed successfully" -Level Info -Component 'ModuleInitialization' } catch { Write-ModuleLog -Message "Failed to install $($module.Name) module: $_" -Level Error -Component 'ModuleInitialization' throw } } } } #Region Module Initialization try { Initialize-ModuleLogging } catch { Write-Error "Failed to initialize module logging: $_" throw } # Initialize required modules try { Initialize-ModuleDependencies } catch { Write-Error "Failed to initialize module dependencies: $_" throw } # Create necessary directories $directories = @( (Join-Path $PSScriptRoot 'cache'), (Join-Path $PSScriptRoot 'logs') ) foreach ($dir in $directories) { if (!(Test-Path $dir)) { New-Item -Path $dir -ItemType Directory -Force | Out-Null } } # Load config if exists $configPath = Join-Path $script:ModuleRoot 'Config\config.json' if (Test-Path $configPath) { try { $script:Config = Get-Content $configPath | ConvertFrom-Json -AsHashtable Write-Verbose "Loaded configuration from $configPath" } catch { Write-Warning "Failed to load configuration from $configPath, using defaults" $script:Config = $script:DefaultConfig } } else { $script:Config = $script:DefaultConfig Write-Verbose "No configuration file found, using defaults" } # Import functions $Public = @(Get-ChildItem -Path $PSScriptRoot\Public\*.ps1 -Recurse -ErrorAction SilentlyContinue) $Private = @(Get-ChildItem -Path $PSScriptRoot\Private\*.ps1 -Recurse -ErrorAction SilentlyContinue) foreach ($import in @($Private + $Public)) { try { Write-Verbose "Importing $($import.FullName)" . $import.FullName } catch { Write-Error -Message "Failed to import function $($import.FullName): $_" } } # Initialize token cache try { Write-Verbose "Initializing token cache" Initialize-TokenCache } catch { Write-Warning "Failed to initialize token cache: $_" } # Clean up on module removal $MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = { Write-Verbose "Cleaning up module resources..." try { Disconnect-MgGraph -ErrorAction SilentlyContinue Save-TokenCache } catch { Write-Warning "Cleanup error: $_" } } # Export public functions Export-ModuleMember -Function $Public.BaseName -Alias * ================ File: Private/Authentication/ConvertFrom-JwtToken.ps1 ================ function ConvertFrom-JwtToken { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Token ) if (-not $Token.Contains(".") -or -not $Token.StartsWith("eyJ")) { Write-ModuleLog -Message "Invalid token format detected" -Level Error -Component 'TokenProcessing' ` -ThrowError -ErrorOperation 'TokenDecoding' -ErrorMessage 'Invalid token format' } try { $tokenPayload = $Token.Split(".")[1].Replace('-', '+').Replace('_', '/') while ($tokenPayload.Length % 4) { $tokenPayload += "=" } $tokenBytes = [System.Convert]::FromBase64String($tokenPayload) $tokenJson = [System.Text.Encoding]::ASCII.GetString($tokenBytes) $tokenData = $tokenJson | ConvertFrom-Json -AsHashtable $tokenData["expirationDateTime"] = ([DateTime]('1970,1,1')).AddSeconds($tokenData["exp"]).ToLocalTime() $tokenData["access_token"] = $Token return [PSCustomObject]$tokenData } catch { Write-ModuleLog -Message "Unexpected error during token conversion" -Level Error -Component 'TokenProcessing' ` -ErrorRecord $_ -ThrowError -ErrorOperation 'TokenDecoding' -ErrorMessage 'Failed to decode JWT token' } } ================ File: Private/Authentication/Curanet/Get-CuranetAccessToken.ps1 ================ function Get-CuranetAccessToken { [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateSet("3370", "3850")] [string]$Account, [Parameter()] [switch]$Force ) begin { try { if (!$script:CuranetCredentials -or $script:CuranetCredentials.Account -ne $Account) { $script:CuranetCredentials = Get-CuranetCredentials -Account $Account } # Define account-specific scopes $accountScopes = @{ '3850' = 'customers orders subscriptions invoicing microsoft365 microsoft365backup' '3370' = 'dns' } # Define OAuth endpoint $tokenEndpoint = 'https://apiauth.dk.team.blue/realms/Curanet/protocol/openid-connect/token' } catch { Write-ModuleLog -Message "Failed to get Curanet credentials" -Level Error -Component 'CuranetAccessToken' -ErrorRecord $_ throw [CuranetTokenOperationException]::new( 'Initialization', 'Failed to get Curanet credentials', $_ ) } } process { try { $cacheKey = "Curanet_$Account" $now = Get-Date # Set refresh threshold to 1 minute before expiration (tokens valid for 5 minutes) $refreshThreshold = $now.AddMinutes(4) # Check cache for valid token if (!$Force -and $script:TokenCache.ContainsKey($cacheKey)) { $cachedToken = $script:TokenCache[$cacheKey] # Token is still valid if ($cachedToken.ExpirationDateTime -gt $refreshThreshold) { Write-ModuleLog -Message "Using cached token for $cacheKey" -Level Verbose -Component 'CuranetAccessToken' return $cachedToken } } Write-ModuleLog -Message "Getting new token for $cacheKey" -Level Verbose -Component 'CuranetAccessToken' # Prepare token request try { $body = @{ grant_type = "client_credentials" client_id = $script:CuranetCredentials.ClientId client_secret = $script:CuranetCredentials.ClientSecret scope = $accountScopes[$Account] } $response = Invoke-RestMethod -Uri $tokenEndpoint ` -Method POST ` -Body $body ` -ContentType 'application/x-www-form-urlencoded' # Create token object with formatted Authorization header value $token = [PSCustomObject]@{ AccessToken = $response.access_token AuthorizationHeader = "bearer $($response.access_token)" TokenType = $response.token_type ExpirationDateTime = (Get-Date).AddSeconds(300) # 5 minutes Scope = $response.scope Account = $Account } } catch { $detailedError = switch -Regex ($_.Exception.Message) { 'invalid_client' { 'Invalid client credentials' } 'invalid_scope' { 'Invalid scope requested' } 'invalid_request' { 'Malformed request' } default { $_.Exception.Message } } Write-ModuleLog -Message "Failed to acquire Curanet token: $detailedError" -Level Error -Component 'CuranetAccessToken' -ErrorRecord $_ throw [CuranetTokenOperationException]::new( 'TokenAcquisition', "Failed to acquire Curanet token: $detailedError", $_ ) } # Manage cache try { if ($script:TokenCache.Count -ge $script:TokenCacheConfig.MaxSize) { $oldestTokens = $script:TokenCache.GetEnumerator() | Sort-Object { $_.Value.ExpirationDateTime } | Select-Object -First ($script:TokenCache.Count - $script:TokenCacheConfig.MaxSize + 1) foreach ($oldToken in $oldestTokens) { $script:TokenCache.Remove($oldToken.Key) } } $script:TokenCache[$cacheKey] = $token Save-TokenCache } catch { Write-ModuleLog -Message "Failed to manage token cache" -Level Error -Component 'CuranetAccessToken' -ErrorRecord $_ } return $token } catch [CuranetTokenOperationException] { throw } catch { Write-ModuleLog -Message "An unexpected error occurred during token operation" -Level Error -Component 'CuranetAccessToken' -ErrorRecord $_ throw [CuranetTokenOperationException]::new( 'Unknown', 'An unexpected error occurred during token operation', $_ ) } } } ================ File: Private/Authentication/Curanet/Get-CuranetCredentials.ps1 ================ function Get-CuranetCredentials { [CmdletBinding()] param ( [Parameter(Mandatory)] [ValidateSet("3370", "3850")] [string]$Account ) # Start by connecting to Azure Key Vault $AzContext = Get-AzContext if (!$AzContext -or $AzContext.Tenant.Id -ne $script:Config.PartnerTenantId) { try { # Try certificate auth first $Certificate = Get-ChildItem -Path Cert:\LocalMachine\My | Where-Object { $_.Thumbprint -eq $script:Config.CertificateThumbprint } if ($Certificate) { Write-Verbose "Using certificate authentication for Azure Key Vault access" Connect-AzAccount -ApplicationId $script:Config.ApplicationId ` -CertificateThumbprint $Certificate.Thumbprint ` -Tenant $script:Config.PartnerTenantId ` -SubscriptionName $script:Config.SubscriptionName | Out-Null } else { Write-Verbose "Using interactive authentication for Azure Key Vault access" Connect-AzAccount -Tenant $script:Config.PartnerTenantId ` -SubscriptionName $script:Config.SubscriptionName | Out-Null } } catch { Write-ModuleLog -Message "Failed to connect to Azure" -Level Error -Component 'AzureConnection' -ErrorRecord $_ } } try { # Retrieve secrets from Key Vault $CuranetClientId = Get-AzKeyVaultSecret -VaultName $script:Config.KeyVaultName -Name "curanet$($Account)apiclient" -AsPlainText $CuranetClientSecret = Get-AzKeyVaultSecret -VaultName $script:Config.KeyVaultName -Name "curanet$($Account)apisecret" -AsPlainText return [PSCustomObject]@{ Account = $Account ClientId = $CuranetClientId ClientSecret = $CuranetClientSecret } } catch { Write-ModuleLog -Message "Failed to retrieve credentials from Key Vault" -Level Error -Component 'CredentialRetrieval' -ErrorRecord $_ } } ================ File: Private/Authentication/Microsoft/Get-PartnerAccessToken.ps1 ================ function Get-PartnerAccessToken { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$TenantId, [Parameter(Mandatory)] [string]$Scopes, [Parameter()] [ValidateSet('Application', 'Delegated')] [string]$FlowType = 'Delegated', [Parameter()] [switch]$Force ) begin { try { if (!$script:PartnerCredentials) { $script:PartnerCredentials = Get-PartnerCredentials } } catch { Write-ModuleLog -Message "Failed to get partner credentials" -Level Error -Component 'PartnerAccessToken' -ErrorRecord $_ throw [TokenOperationException]::new( 'Initialization', 'Failed to get partner credentials', $_ ) } } process { try { $cacheKey = "$TenantId|$Scopes|$FlowType" $now = Get-Date $refreshThreshold = $now.AddMinutes($script:TokenCacheConfig.RefreshBuffer) # Validate inputs if (-not [Guid]::TryParse($TenantId, [ref][Guid]::Empty)) { Write-ModuleLog -Message "Invalid token format detected" -Level Error -Component 'PartnerAccessToken' ` -ThrowError -ErrorOperation 'ValidateTenantId' -ErrorMessage 'Invalid TenantId format' } # Check cache for valid token if (!$Force -and $script:TokenCache.ContainsKey($cacheKey)) { $cachedToken = $script:TokenCache[$cacheKey] # Token is still valid if ($cachedToken.ExpirationDateTime -gt $refreshThreshold) { Write-ModuleLog -Message "Using cached token for $cacheKey" -Level Verbose -Component 'PartnerAccessToken' return $cachedToken } # Token needs refresh elseif ($cachedToken.ExpirationDateTime -gt $now -and $FlowType -eq 'Delegated') { Write-ModuleLog -Message "Token for $cacheKey is expired, attempting refresh" -Level Verbose -Component 'PartnerAccessToken' try { $newToken = Get-RefreshedToken -ExistingToken $cachedToken -TenantId $TenantId -Scopes $Scopes $script:TokenCache[$cacheKey] = $newToken Save-TokenCache return $newToken } catch { Write-ModuleLog -Message "Token refresh failed for $cacheKey" -Level Warning -Component 'PartnerAccessToken' -ErrorRecord $_ # Fall through to get new token } } } Write-ModuleLog -Message "Getting new token for $cacheKey" -Level Verbose -Component 'PartnerAccessToken' # Prepare token request $body = @{ client_id = $script:PartnerCredentials.ApplicationId client_secret = $script:PartnerCredentials.ApplicationSecret scope = $Scopes } if ($FlowType -eq 'Application') { $body.grant_type = "client_credentials" } else { if ([string]::IsNullOrEmpty($script:PartnerCredentials.RefreshToken)) { Write-ModuleLog -Message "No refresh token available for delegated flow" -Level Error -Component 'PartnerAccessToken' ` -ThrowError -ErrorOperation 'TokenAcquisition' -ErrorMessage 'No refresh token available for delegated flow' } $body.grant_type = "refresh_token" $body.refresh_token = $script:PartnerCredentials.RefreshToken } # Get new token try { $response = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" ` -Method POST ` -Body $body ` -ContentType 'application/x-www-form-urlencoded' } catch { $detailedError = switch -Regex ($_.Exception.Message) { 'AADSTS700016' { 'Application not found or not authorized for tenant' } 'AADSTS7000215' { 'Invalid client secret provided' } 'AADSTS9000410' { 'Invalid scope requested' } 'AADSTS50034' { 'Invalid tenant ID' } default { $_.Exception.Message } } Write-ModuleLog -Message "Failed to acquire token: $detailedError" -Level Error -Component 'PartnerAccessToken' -ErrorRecord $_ } try { $token = ConvertFrom-JwtToken -Token $response.access_token # Store refresh token if available (for delegated flow) if ($FlowType -eq 'Delegated' -and $response.refresh_token) { $token | Add-Member -NotePropertyName 'refresh_token' -NotePropertyValue $response.refresh_token } } catch { Write-ModuleLog -Message "Failed to process acquired token" -Level Error -Component 'PartnerAccessToken' -ErrorRecord $_ } # Manage cache try { if ($script:TokenCache.Count -ge $script:TokenCacheConfig.MaxSize) { $oldestTokens = $script:TokenCache.GetEnumerator() | Sort-Object { $_.Value.ExpirationDateTime } | Select-Object -First ($script:TokenCache.Count - $script:TokenCacheConfig.MaxSize + 1) foreach ($oldToken in $oldestTokens) { $script:TokenCache.Remove($oldToken.Key) } } $script:TokenCache[$cacheKey] = $token Save-TokenCache } catch { Write-ModuleLog -Message "Failed to manage token cache" -Level Error -Component 'PartnerAccessToken' -ErrorRecord $_ } return $token } catch [TokenOperationException] { throw } catch { Write-ModuleLog -Message "An unexpected error occurred during token operation" -Level Error -Component 'PartnerAccessToken' -ErrorRecord $_ } } } ================ File: Private/Authentication/Microsoft/Get-PartnerCredentials.ps1 ================ function Get-PartnerCredentials { [CmdletBinding()] param() # Start by connecting to Azure Key Vault $AzContext = Get-AzContext if (!$AzContext -or $AzContext.Tenant.Id -ne $script:Config.PartnerTenantId) { try { # Try certificate auth first $Certificate = Get-ChildItem -Path Cert:\LocalMachine\My | Where-Object { $_.Thumbprint -eq $script:Config.CertificateThumbprint } if ($Certificate) { Write-Verbose "Using certificate authentication for Azure Key Vault access" Connect-AzAccount -ApplicationId $script:Config.ApplicationId ` -CertificateThumbprint $Certificate.Thumbprint ` -Tenant $script:Config.PartnerTenantId ` -SubscriptionName $script:Config.SubscriptionName | Out-Null } else { Write-Verbose "Using interactive authentication for Azure Key Vault access" Connect-AzAccount -Tenant $script:Config.PartnerTenantId ` -SubscriptionName $script:Config.SubscriptionName | Out-Null } } catch { Write-ModuleLog -Message "Failed to connect to Azure" -Level Error -Component 'AzureConnection' -ErrorRecord $_ } } try { # Retrieve secrets from Key Vault $ApplicationId = Get-AzKeyVaultSecret -VaultName $script:Config.KeyVaultName -Name "ApplicationId" -AsPlainText $ApplicationSecret = Get-AzKeyVaultSecret -VaultName $script:Config.KeyVaultName -Name "ApplicationSecret" -AsPlainText $RefreshToken = Get-AzKeyVaultSecret -VaultName $script:Config.KeyVaultName -Name "RefreshToken" -AsPlainText $ExchangeRefreshToken = Get-AzKeyVaultSecret -VaultName $script:Config.KeyVaultName -Name "ExchangeRefreshToken" -AsPlainText return [PSCustomObject]@{ ApplicationId = $ApplicationId ApplicationSecret = $ApplicationSecret RefreshToken = $RefreshToken ExchangeRefreshToken = $ExchangeRefreshToken } } catch { Write-ModuleLog -Message "Failed to retrieve credentials from Key Vault" -Level Error -Component 'CredentialRetrieval' -ErrorRecord $_ } } ================ File: Private/Authentication/Microsoft/Get-RefreshedToken.ps1 ================ function Get-RefreshedToken { [CmdletBinding()] param( [Parameter(Mandatory)] [object]$ExistingToken, [Parameter(Mandatory)] [string]$TenantId, [Parameter(Mandatory)] [string]$Scopes ) try { if ($null -eq $ExistingToken) { Write-ModuleLog -Message "Existing token is null" -Level Error -Component 'TokenRefresh' ` -ThrowError -ErrorOperation 'TokenRefresh' -ErrorMessage 'ExistingToken is null' } if (!$ExistingToken.refresh_token) { Write-ModuleLog -Message "No refresh token available in existing token" -Level Error -Component 'TokenRefresh' ` -ThrowError -ErrorOperation 'TokenRefresh' -ErrorMessage 'No refresh token available in existing token' } $body = @{ client_id = $script:PartnerCredentials.ApplicationId client_secret = $script:PartnerCredentials.ApplicationSecret grant_type = "refresh_token" refresh_token = $ExistingToken.refresh_token scope = $Scopes } try { $response = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" ` -Method POST ` -Body $body ` -ContentType 'application/x-www-form-urlencoded' } catch { $detailedError = switch -Regex ($_.Exception.Message) { 'AADSTS70000' { 'Invalid refresh token' } 'AADSTS700082' { 'The refresh token has expired' } default { $_.Exception.Message } } Write-ModuleLog -Message "Failed to refresh token: $detailedError" -Level Error -Component 'TokenRefresh' -ErrorRecord $_ } $newToken = ConvertFrom-JwtToken -Token $response.access_token if ($response.refresh_token) { $newToken | Add-Member -NotePropertyName 'refresh_token' -NotePropertyValue $response.refresh_token } return $newToken } catch [TokenOperationException] { Write-ModuleLog -Message "An error occurred during token refresh" -Level Error -Component 'TokenRefresh' -ErrorRecord $_ } catch { Write-ModuleLog -Message "An unexpected error occurred during token refresh" -Level Error -Component 'TokenRefresh' -ErrorRecord $_ throw [TokenOperationException]::new( 'TokenRefresh', 'An unexpected error occurred during token refresh', $_ ) } } ================ File: Private/Authentication/Microsoft/Initialize-TokenCache.ps1 ================ function Initialize-TokenCache { [CmdletBinding()] param() try { # Create cache directory if it doesn't exist $cacheDir = Split-Path $script:TokenCacheConfig.PersistencePath -Parent if (-not (Test-Path $cacheDir)) { New-Item -Path $cacheDir -ItemType Directory -Force | Out-Null } # Initialize empty hashtable $script:TokenCache = @{} # Load cached tokens if they exist if (Test-Path $script:TokenCacheConfig.PersistencePath) { try { $importedCache = Import-Clixml -Path $script:TokenCacheConfig.PersistencePath # Only import non-expired tokens $importedCache.GetEnumerator() | Where-Object { $_.Value.ExpirationDateTime -gt (Get-Date) } | ForEach-Object { $script:TokenCache[$_.Key] = $_.Value } } catch { Write-ModuleLog -Message "Failed to load token cache from disk" -Level Warning -Component 'TokenCache' } } } catch { Write-ModuleLog -Message "Failed to initialize token cache" -Level Warning -Component 'TokenCache' $script:TokenCache = @{} } } function Save-TokenCache { [CmdletBinding()] param() try { if((Test-Path $script:TokenCacheConfig.PersistencePath) -and ($Script:TokenCache.Count -ne 0)) { $script:TokenCache | Export-Clixml -Path $script:TokenCacheConfig.PersistencePath -Force } } catch { Write-ModuleLog -Message "Failed to save token cache to disk" -Level Error -Component 'Tokencache' ` -ErrorRecord $_ -ThrowError -ErrorOperation 'SaveToDisk' -ErrorMessage 'Failed to save token cache to disk' } } ================ File: Private/Authentication/Microsoft/TokenExceptions.ps1 ================ class TokenOperationException : Exception { [string]$Operation [string]$DetailedMessage TokenOperationException([string]$operation, [string]$message, [Exception]$innerException) : base($message, $innerException) { $this.Operation = $operation $this.DetailedMessage = $message } } ================ File: Private/Consent/Set-ApplicationConsent.ps1 ================ function Set-ApplicationConsent { [CmdletBinding()] param ( [Parameter(Mandatory)] [string]$CustomerTenantId, [Parameter()] [switch]$Force ) try { # Step 1: Create initial consent through Partner Center $customer = Get-PartnerCustomer -CustomerTenantId $CustomerTenantId if (!$customer) { Write-ModuleLog -Message "Customer with tenant ID $CustomerTenantId not found" -Level Error -Component 'ApplicationConsent' -ThrowError } $consentBody = @{ ApplicationId = $script:PartnerCredentials.ApplicationId ApplicationGrants = @( @{ EnterpriseApplicationId = '00000003-0000-0000-c000-000000000000' Scope = @( 'DelegatedPermissionGrant.ReadWrite.All', 'Directory.ReadWrite.All', 'AppRoleAssignment.ReadWrite.All' ) -Join ',' } ) } $response = Invoke-PartnerCenterRequest ` -Uri "https://api.partnercenter.microsoft.com/v1/customers/$CustomerTenantId/applicationconsents" ` -Method POST ` -Body $consentBody ` # Handle initial consent response switch ($response.StatusCode) { { $_ -in @(200, 201) } { Write-ModuleLog -Message "Successfully created consent for $($customer.displayName)" -Level Info -Component 'ApplicationConsent' Write-ModuleLog -Message "Waiting for consent to propagate..." -Level Info -Component 'ApplicationConsent' Start-Sleep -Seconds 5 } 409 { Write-ModuleLog -Message "Consent already exists for $($customer.displayName)" -Level Warning -Component 'ApplicationConsent' if ($Force) { Write-ModuleLog -Message "Force specified - recreating consent..." -Level Warning -Component 'ApplicationConsent' $consentUri = "https://api.partnercenter.microsoft.com/v1/customers/$CustomerTenantId/applicationconsents/$($script:PartnerCredentials.ApplicationId)" Invoke-PartnerCenterRequest -Uri $consentUri -Method DELETE -ErrorAction SilentlyContinue | Out-Null return Set-ApplicationConsent -CustomerTenantId $CustomerTenantId } } 400 { if ($response.message -like "*doesnt exist in customer tenant*") { Write-ModuleLog -Message "Successfully created partial consent for $($customer.displayName)" -Level Info -Component 'ApplicationConsent' Write-ModuleLog -Message "Some APIs are not available in the tenant - this is normal" -Level Warning -Component 'ApplicationConsent' Start-Sleep -Seconds 5 } else { Write-ModuleLog -Message "Bad request: $($response.message)" -Level Error -Component 'ApplicationConsent' -ThrowError } } default { Write-ModuleLog -Message "Unexpected response: $($response.StatusCode) - $($response.message)" -Level Error -Component 'ApplicationConsent' -ThrowError } } # Step 2: Connect to customer tenant to configure app permissions Write-ModuleLog -Message "Connecting to customer tenant to configure permissions" -Level Info -Component 'ApplicationConsent' Connect-CustomerGraph -CustomerTenantId $CustomerTenantId -FlowType "Delegated" -Force # Step 3: Get our service principal $ServicePrincipalList = Get-MgServicePrincipal -All $ServicePrincipal = $ServicePrincipalList | Where-Object { $_.AppId -eq $script:PartnerCredentials.ApplicationId } $retryCount = 0 $maxRetries = 20 while (!$ServicePrincipal -and $retryCount -lt $maxRetries) { Write-ModuleLog -Message "Service principal not found, waiting for propagation... Attempt $($retryCount + 1)/$maxRetries" -Level Info -Component 'ApplicationConsent' Start-Sleep -Seconds 10 $ServicePrincipalList = Get-MgServicePrincipal -All $ServicePrincipal = $ServicePrincipalList | Where-Object { $_.AppId -eq $script:PartnerCredentials.ApplicationId } $retryCount++ } if (!$ServicePrincipal) { Write-ModuleLog -Message "Service principal not found after waiting" -Level Error -Component 'ApplicationConsent' -ThrowError } # Step 4: Load required permissions from manifest and translator $ManifestPath = Join-Path $script:ModuleRoot "Config\Consent\SAMManifest.json" $TranslatorPath = Join-Path $script:ModuleRoot "Config\Consent\PermissionsTranslator.json" if (!(Test-Path $ManifestPath)) { Write-ModuleLog -Message "SAM Manifest not found at $ManifestPath" -Level Error -Component 'ApplicationConsent' -ThrowError } $RequiredResourceAccess = Get-Content $ManifestPath | ConvertFrom-Json | Select-Object -ExpandProperty requiredResourceAccess $PermissionTranslator = if (Test-Path $TranslatorPath) { Get-Content $TranslatorPath | ConvertFrom-Json } # Step 5: Configure app roles (application permissions) foreach ($App in $RequiredResourceAccess) { $ResourceServicePrincipal = $ServicePrincipalList | Where-Object { $_.AppId -eq $App.resourceAppId } if (!$ResourceServicePrincipal) { Write-ModuleLog -Message "Creating service principal for $($App.resourceAppId)" -Level Info -Component 'ApplicationConsent' $ResourceServicePrincipal = New-MgServicePrincipal -AppId $App.resourceAppId -ErrorAction SilentlyContinue if (!$ResourceServicePrincipal) { Write-ModuleLog -Message "Failed to create service principal for $($App.resourceAppId) - API might not be available in tenant" -Level Warning -Component 'ApplicationConsent' continue } } # Assign app roles # Get current role assignments $currentRoles = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $ServicePrincipal.Id foreach ($Role in ($App.ResourceAccess | Where-Object { $_.type -eq 'Role' })) { try { # Check if role is already assigned $existingAssignment = $currentRoles | Where-Object { $_.AppRoleId -eq $Role.Id -and $_.ResourceId -eq $ResourceServicePrincipal.Id } if ($existingAssignment) { Write-ModuleLog -Message "App role $($Role.Id) is already assigned for $($ResourceServicePrincipal.DisplayName)" ` -Level Verbose ` -Component 'ApplicationConsent' continue } $params = @{ ServicePrincipalId = $ServicePrincipal.Id PrincipalId = $ServicePrincipal.Id ResourceId = $ResourceServicePrincipal.Id AppRoleId = $Role.Id } Write-ModuleLog -Message "Assigning app role $($Role.Id) for $($ResourceServicePrincipal.DisplayName)" ` -Level Info ` -Component 'ApplicationConsent' New-MgServicePrincipalAppRoleAssignment @params -ErrorAction Stop | Out-Null } catch { Write-ModuleLog -Message "Failed to assign role $($Role.Id): $_" ` -Level Warning ` -Component 'ApplicationConsent' } } # Step 6: Configure delegated permissions $DelegatedScopes = $App.ResourceAccess | Where-Object { $_.type -eq 'Scope' } if ($DelegatedScopes) { # Get current delegated permissions $currentGrants = Get-MgOauth2PermissionGrant -Filter "clientId eq '$($ServicePrincipal.Id)'" $existingGrant = $currentGrants | Where-Object { $_.resourceId -eq $ResourceServicePrincipal.Id } # Calculate new scope string $NewScope = if ($PermissionTranslator) { @(($PermissionTranslator | Where-Object { $_.id -in $DelegatedScopes.id }).value | Sort-Object -Unique) -join ' ' } else { @($DelegatedScopes | ForEach-Object { $_.id } | Sort-Object -Unique) -join ' ' } try { if ($existingGrant) { # Compare existing and new scopes $existingScopes = $existingGrant.Scope -split ' ' | Sort-Object $newScopes = $NewScope -split ' ' | Sort-Object $scopeComparison = Compare-Object -ReferenceObject $existingScopes -DifferenceObject $newScopes if ($null -eq $scopeComparison) { Write-ModuleLog -Message "All delegated permissions already exist for $($ResourceServicePrincipal.DisplayName)" ` -Level Info ` -Component 'ApplicationConsent' } else { # Update existing grant with new scopes Write-ModuleLog -Message "Updating delegated permissions for $($ResourceServicePrincipal.DisplayName)" ` -Level Info ` -Component 'ApplicationConsent' # Log changes if any $added = ($scopeComparison | Where-Object { $_.SideIndicator -eq '=>' }).InputObject $removed = ($scopeComparison | Where-Object { $_.SideIndicator -eq '<=' }).InputObject if ($added) { Write-ModuleLog -Message "Adding scopes: $($added -join ', ')" ` -Level Info ` -Component 'ApplicationConsent' } if ($removed) { Write-ModuleLog -Message "Removing scopes: $($removed -join ', ')" ` -Level Info ` -Component 'ApplicationConsent' } Update-MgOauth2PermissionGrant -OAuth2PermissionGrantId $existingGrant.Id -Scope $NewScope } } else { # Create new permission grant $params = @{ ClientId = $ServicePrincipal.Id ConsentType = 'AllPrincipals' ResourceId = $ResourceServicePrincipal.Id Scope = $NewScope } if ($ResourceServicePrincipal.DisplayName -eq "Microsoft Partner Center" -or $ResourceServicePrincipal.DisplayName -eq "M365 License Manager") { # Skip consent for Partner Center Write-ModuleLog -Message "Skipping consent for $($ResourceServicePrincipal.DisplayName), since it's a customer tenant" -Level Verbose -Component 'ApplicationConsent' continue } Write-ModuleLog -Message "Granting new delegated permissions for $($ResourceServicePrincipal.DisplayName): $NewScope" ` -Level Info ` -Component 'ApplicationConsent' New-MgOAuth2PermissionGrant @params -ErrorAction Stop | Out-Null } } catch { Write-ModuleLog -Message "Failed to manage delegated permissions for $($ResourceServicePrincipal.DisplayName): $_" ` -Level Warning ` -Component 'ApplicationConsent' } } } # Step 7 # Disconnect and reconnect to get the updated token # Keep disconnecting graph until there are no open connections $GraphContext = Get-MgContext if ($GraphContext) { $dc = Disconnect-Graph -ErrorAction SilentlyContinue | Out-Null $GraphContext = Get-MgContext Write-ModuleLog -Message "Disconnected from Graph to clear connections" -Level Info -Component 'ApplicationConsent' } Write-ModuleLog -Message "Waiting for 10 seconds before reconnecting to Graph" -Level Info -Component 'ApplicationConsent' Start-Sleep -Seconds 10 Connect-CustomerGraph -CustomerTenantId $CustomerTenantId -FlowType "Delegated" -Force $GraphContext = Get-MgContext while (!$GraphContext.Scopes.Contains("RoleManagement.ReadWrite.Directory")) { Write-ModuleLog -Message "RoleManagement.ReadWrite.Directory permission is missing in the token, re-connecting until we have it." -Level Info -Component 'ApplicationConsent' $dc = Disconnect-Graph -ErrorAction SilentlyContinue | Out-Null Start-Sleep -Seconds 10 Connect-CustomerGraph -CustomerTenantId $CustomerTenantId -FlowType "Delegated" -Force $GraphContext = Get-MgContext } # Step 8 # Assign "Compliance Administrator" and "Exchange Administrator" roles to the service principal if they exist $ComplianceAdministratorRole = Get-MgDirectoryRole | Where-Object { $_.DisplayName -eq "Compliance Administrator" } $ComplianceAdministrators = Get-MgDirectoryRoleMemberAsServicePrincipal -DirectoryRoleId $ComplianceAdministratorRole.Id -ErrorAction Stop if ($ComplianceAdministrators.Id -notcontains $ServicePrincipal.Id) { New-MgDirectoryRoleMemberByRef -DirectoryRoleId $ComplianceAdministratorRole.Id -BodyParameter @{"@odata.id" = "https://graph.microsoft.com/v1.0/directoryObjects/$($ServicePrincipal.Id)" } -ErrorAction Stop Write-ModuleLog -Message "Assigning Compliance Administrator role to service principal" -Level Info -Component 'ApplicationConsent' } $ExchangeAdministratorRole = Get-MgDirectoryRole | Where-Object { $_.DisplayName -eq "Exchange Administrator" } $ExchangeAdministrators = Get-MgDirectoryRoleMemberAsServicePrincipal -DirectoryRoleId $ExchangeAdministratorRole.Id -ErrorAction Stop if ($ExchangeAdministrators.Id -notcontains $ServicePrincipal.Id) { New-MgDirectoryRoleMemberByRef -DirectoryRoleId $ExchangeAdministratorRole.Id -BodyParameter @{"@odata.id" = "https://graph.microsoft.com/v1.0/directoryObjects/$($ServicePrincipal.Id)" } -ErrorAction Stop Write-ModuleLog -Message "Assigning Exchange Administrator role to service principal" -Level Info -Component 'ApplicationConsent' } Write-ModuleLog -Message "Successfully configured application permissions for $($customer.displayName)" -Level Info -Component 'ApplicationConsent' # Keep disconnecting graph until there are no open connections $GraphContext = Get-MgContext if ($GraphContext) { $dc = Disconnect-Graph -ErrorAction SilentlyContinue | Out-Null $GraphContext = Get-MgContext } } catch { Write-ModuleLog -Message "Failed to set up application consent: $($_.Exception.Message)" -Level Error -Component 'ApplicationConsent' -ErrorRecord $_ -ThrowError } } ================ File: Private/GDAP/New-GDAPRelationship.ps1 ================ function New-GDAPRelationship { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$CustomerTenantId ) try { $customer = Get-PartnerCustomer -CustomerTenantId $CustomerTenantId if (!$customer) { Write-ModuleLog "Customer with tenant ID $CustomerTenantId not found" -Level Error -Component 'GDAP' -ThrowError } # Create relationship request parameters $relationshipParams = @{ displayName = "Jysk IT - $(New-Guid)" duration = "P730D" autoExtendDuration = "P180D" customer = @{ tenantId = $CustomerTenantId displayName = $customer.companyProfile.companyName } accessDetails = $script:Config.GDAP.AccessDetails } # Connect to partner tenant Connect-CustomerGraph -CustomerTenantId $script:Config.PartnerTenantId -FlowType 'Delegated' -Force # Create the relationship Write-Host $relationshipParams $relationship = New-MgTenantRelationshipDelegatedAdminRelationship -BodyParameter $relationshipParams Write-ModuleLog -Message "Created new GDAP relationship: $($relationship.DisplayName)" -Level Info -Component 'GDAP' # Lock for approval New-MgTenantRelationshipDelegatedAdminRelationshipRequest ` -DelegatedAdminRelationshipId $relationship.Id ` -Action "LockForApproval" | Out-Null Write-ModuleLog -Message "Successfully locked relationship $($relationship.DisplayName) for approval" -Level Info -Component 'GDAP' return $relationship } catch { Write-ModuleLog -Message "Failed to create GDAP relationship: $($_.Exception.Message)" -Level Error -Component 'GDAP' -ErrorRecord $_ -ThrowError } } function Wait-GDAPApproval { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$RelationshipId, [Parameter()] [int]$TimeoutMinutes = 30 ) try { $timeout = (Get-Date).AddMinutes($TimeoutMinutes) while ((Get-Date) -lt $timeout) { $relationship = Get-MgTenantRelationshipDelegatedAdminRelationship -DelegatedAdminRelationshipId $RelationshipId if ($relationship.Status -eq "active") { Write-ModuleLog -Message "GDAP relationship is now active" -Level Info -Component 'GDAP' return $true } Write-ModuleLog -Message "Waiting for GDAP approval..." -Level Info -Component 'GDAP' Start-Sleep -Seconds 10 } Write-ModuleLog -Message "Timeout waiting for GDAP approval" -Level Error -Component 'GDAP' -ThrowError } catch { Write-ModuleLog -Message "Failed while waiting for GDAP approval: $($_.Exception.Message)" -Level Error -Component 'GDAP' -ErrorRecord $_ -ThrowError } } ================ File: Private/GDAP/Set-GDAPPermissions.ps1 ================ function Set-GDAPPermissions { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$RelationshipId ) try { # Apply each access assignment set $accessSets = @($script:Config.GDAP.AccessAssignments) $index = 1 foreach ($accessSet in $accessSets) { Write-ModuleLog -Message "Creating access assignment for group $($accessSet.accessContainer.accessContainerId) [$($index)/$($accessSets.Count)]" -Level Info -Component 'GDAPPermissions' $assignment = New-MgTenantRelationshipDelegatedAdminRelationshipAccessAssignment ` -DelegatedAdminRelationshipId $RelationshipId ` -BodyParameter $accessSet # Wait for assignment to become active $retryCount = 0 $maxRetries = 50 while ($retryCount -lt $maxRetries) { $status = Get-MgTenantRelationshipDelegatedAdminRelationshipAccessAssignment ` -DelegatedAdminRelationshipId $RelationshipId ` -DelegatedAdminAccessAssignmentId $assignment.Id if ($status.Status -eq "active") { Write-ModuleLog -Message "Access assignment activated successfully" -Level Info -Component 'GDAPPermissions' break } Write-ModuleLog -Message "Waiting for access assignment activation..." -Level Info -Component 'GDAPPermissions' Start-Sleep -Seconds 10 $retryCount++ } if ($retryCount -eq $maxRetries) { Write-ModuleLog -Message "Timeout waiting for access assignment activation" -Level Error -Component 'GDAPPermissions' -ThrowError } $index++ } } catch { Write-ModuleLog -Message "Failed to set GDAP permissions: $($_.Exception.Message)" -Level Error -Component 'GDAPPermissions' -ErrorRecord $_ -ThrowError } } ================ File: Private/PartnerCenter/Invoke-PartnerCenterRequest.ps1 ================ function Invoke-PartnerCenterRequest { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Uri, [Parameter()] [string]$Method = 'GET', [Parameter()] [object]$Body, [Parameter()] [string]$ContentType = 'application/json' ) try { # Get Partner Center token $token = Get-PartnerAccessToken -TenantId $script:Config.PartnerTenantId ` -Scopes "https://api.partnercenter.microsoft.com/user_impersonation" $headers = @{ 'Authorization' = "Bearer $($token.access_token)" 'Accept' = 'application/json' } $params = @{ Uri = $Uri Method = $Method Headers = $headers } if ($Body) { $params.Body = if ($Body -is [string]) { $Body } else { $Body | ConvertTo-Json -Depth 100 } $params.ContentType = $ContentType } $response = Invoke-RestMethod @params -SkipHttpErrorCheck -StatusCodeVariable StatusCode # Add status code $response | Add-Member -NotePropertyName 'StatusCode' -NotePropertyValue $StatusCode return $response } catch { Write-ModuleLog -Message "Failed to execute Partner Center request" -Level Error -Component 'PartnerCenter' ` -ErrorRecord $_ -ThrowError -ErrorOperation 'PartnerCenterRequest' -ErrorMessage 'Failed to execute Partner Center request' } } ================ File: Private/PartnerMenu/Get-PartnerMenuHeader.ps1 ================ function Get-PartnerMenuHeader() { Param( [Parameter()] [string]$SectionString ) Clear-Host Write-host "#######################################" Write-Host "# Welcome to the Jysk IT Partner Menu #" -ForegroundColor Green Write-host "#######################################" if($SectionString) { Write-Host $SectionString -ForegroundColor Cyan Write-Host "" } } ================ File: Private/PartnerMenu/Start-PartnerMenuTenantCreation.ps1 ================ function Start-PartnerMenuTenantInitilization() { $TenantDomain = Read-Host "Please enter the tenant default domain (e.g. customerdomain.onmicrosoft.com)" Connect-CustomerGraph -CustomerTenantId $Script:config.PartnerTenantId -FlowType Delegated $Tenants = Get-MgContract -All $Tenants = $Tenants | Select-Object -Property DisplayName, DefaultDomainName, CustomerId, Id $Tenant = $Tenants | Where-Object { $_.DefaultDomainName -eq $TenantDomain } | Select-Object -First 1 if(!$Tenant) { Write-Error "Failed to find tenant with default domain: $($TenantDomain)" Read-Host "Press any key to continue.." Start-PartnerMenu } Clear-Host Get-PartnerMenuHeader -SectionString "($($Tenant.displayName)) - Tenant Initilization" Initialize-CustomerTenant -CustomerTenantId $Tenant.CustomerId Read-Host "Press any key to continue.." } ================ File: Private/Utilities/Get-AutomationUpdates.ps1 ================ function Get-AutomationUpdates { Set-Variable -Name AutomationCurrentVersion -Value $($MyInvocation.MyCommand.ScriptBlock.Module.Version) -Scope Global if( -not [bool](Get-Variable AutomationUpdateNotice -Erroraction SilentlyContinue) ) { Set-Variable -Name AutomationUpdateNotice -Value $true -Scope Global } if( -not $Global:AutomationNewestVersion) { $Response = ((Invoke-WebRequest -Uri "https://www.powershellgallery.com/packages/JyskIT.Automation" -MaximumRedirection 0 -ErrorAction SilentlyContinue -SkipHttpErrorCheck).Headers.Location -split '/')[-1] Set-variable -Name AutomationNewestVersion -Value $($Response) -Scope Global } if($Global:AutomationNewestVersion -gt $Global:AutomationCurrentVersion) { if( $Global:AutomationUpdateNotice ) { Write-Host "New version available: $($Global:AutomationNewestVersion)" -BackgroundColor Green -ForegroundColor Black Write-Host "Please update the module..." -BackgroundColor Green -ForegroundColor Black Set-Variable -Name AutomationUpdateNotice -Value $false -Scope Global Pause Clear-Host } } } ================ File: Private/Utilities/Get-PSGalleryKey.ps1 ================ function Get-PSGalleryKey() { # Start by connecting to our Azure Key Vault. $AzContext = Get-AzContext if (!$AzContext -or $AzContext.Tenant.Id -ne $script:Config.PartnerTenantId) { try { Write-Host "Please log in to Azure with your @jlhosting.dk account. A browser window has been opened." -ForegroundColor Yellow Connect-AzAccount -Tenant $script:Config.PartnerTenantId -SubscriptionName $script:Config.SubscriptionName | Out-Null } catch { Write-Error "Failed to connect to Azure. Please make sure you have the Az module installed." } } try { # Retreive all required values from Azure Key Vault $PSGalleryKey = Get-AzKeyVaultSecret -VaultName $script:Config.KeyVaultName -Name "psgallerykey" -AsPlainText } catch { Write-Error "Failed to connect to Azure Key Vault and retreive secrets." } return $PSGalleryKey } ================ File: Private/Utilities/Get-RandomPassword.ps1 ================ Function Get-RandomPassword { #define parameters param([int]$PasswordLength = 10) #ASCII Character set for Password $CharacterSet = @{ Uppercase = (97..122) | Get-Random -Count 10 | ForEach-Object {[char]$_} Lowercase = (65..90) | Get-Random -Count 10 | ForEach-Object {[char]$_} Numeric = (48..57) | Get-Random -Count 10 | ForEach-Object {[char]$_} SpecialChar = (33..47)+(58..64)+(91..96)+(123..126) | Get-Random -Count 10 | ForEach-Object {[char]$_} } #Frame Random Password from given character set $StringSet = $CharacterSet.Uppercase + $CharacterSet.Lowercase + $CharacterSet.Numeric + $CharacterSet.SpecialChar -join(Get-Random -Count $PasswordLength -InputObject $StringSet) } ================ File: Public/Configuration/Add-BaselineConfiguration.ps1 ================ function Add-BaselineConfiguration { param( [Parameter(Mandatory)] [string]$TenantId ) try { Connect-CustomerGraph -CustomerTenantId $TenantId $SelectedItems = $script:DefaultConfig.PolicyTypeSettings | Select-Object Type, Name, Description| Out-ConsoleGridView -Title "Select the desired baseline configuration items you want to add" -OutputMode Multiple foreach($SelectedItem in $SelectedItems) { Write-ModuleLog -Message "Adding baseline configuration item '$($SelectedItem.Name)'.." -Level Info -Component 'BaselineConfiguration' Add-BaselinePolicy -TenantId $TenantId -PolicyType $SelectedItem.Type Write-ModuleLog -Message "Baseline configuration item '$($SelectedItem.Name)' added." -Level Info -Component 'BaselineConfiguration' } } catch { Write-ModuleLog -Message "Failed to create baseline configuration" -ErrorRecord $_ -Level Error -Component 'BaselineConfiguration' } } ================ File: Public/Configuration/Add-CompanyBranding.ps1 ================ function Add-CompanyBranding { param( [Parameter(Mandatory)] [string]$TenantId, [Parameter()] [string]$SignInPageText = '**Har du udfordringer med login?** Kontakt [Jysk IT](https://jyskit.dk) på 76 60 22 00' ) try { Connect-CustomerGraph -CustomerTenantId $TenantId $Branding = Get-MgOrganizationBranding -OrganizationId $TenantId -ErrorAction SilentlyContinue if( !$Branding ) { $Branding = New-MgOrganizationBrandingLocalization -OrganizationId $TenantId -SignInPageText $SignInPageText Write-ModuleLog -Message "Created company branding." -Level Info -Component 'CompanyBranding' } elseif ( $Branding.SignInPageText -ne $SignInPageText ) { $Branding = Update-MgOrganizationBrandingLocalization -OrganizationId $TenantId -SignInPageText $SignInPageText -OrganizationalBrandingLocalizationId $Branding.Id Write-ModuleLog -Message "Updated company branding." -Level Info -Component 'CompanyBranding' } else { Write-ModuleLog -Message "Company branding already set..." -Level Info -Component 'CompanyBranding' } } catch { Write-ModuleLog -Message "Failed to create company branding: $_" -Level Error -Component 'CompanyBranding' } } ================ File: Public/Configuration/Add-CustomerDomain.ps1 ================ function Add-CustomerDomain() { param( [Parameter(Mandatory)] [string]$TenantId, [Parameter(Mandatory)] [string]$DomainName ) try { $IsDefault = $true Connect-CustomerGraph -CustomerTenantId $TenantId $Domain = Get-MgDomain -DomainId $DomainName -ErrorAction SilentlyContinue $NumberOfDomains = (Get-MgDomain).Count if($NumberOfDomains -ne 1) { $IsDefault = $false } if($null -eq $Domain) { Write-ModuleLog -Message "Adding domain $($DomainName).." -Level Info -Component 'AddCustomerDomain' $Params = @{ Id = $DomainName } New-MgDomain -BodyParameter $Params | Out-Null $VerificationCode = (Get-MgDomainVerificationDnsRecord -DomainId $DomainName | Where-Object { $_.RecordType -eq "TXT" }).AdditionalProperties.text $NameServer = Resolve-DnsName -Name $DomainName -Type NS -ErrorAction SilentlyContinue | Select-Object -First 1 if ($NameServer.NameHost) { $NameServer = $NameServer.NameHost } else { $NameServer = 'Not Found'} $DNSRecords = Get-CuranetDNSRecords -DomainName $DomainName if($NameServer -like '*.curanet.dk' -and $DNSRecords.status -notlike "4*") { $TTL = $DNSRecords | Where-Object { $_.type -eq "TXT" } | Select-Object -First 1 | Select-Object -ExpandProperty ttl if ( !$TTL ) { $TTL = 3600 } $NewRecord = New-CuranetDNSRecord -DomainName $DomainName -Type "TXT" -TTL $TTL -Value $VerificationCode if(!$NewRecord.status) { Write-ModuleLog "Successfully created DNS record on Curanet" -Level Info -Component 'AddCustomerDomain' } else { Write-ModuleLog "Failed to create DNS record on Curanet - $($NewRecord.status)" -Level Error -Component 'AddCustomerDomain' Write-ModuleLog "Please add the following DNS record to your DNS provider:" -Level Warning -Component 'AddCustomerDomain' Write-ModuleLog "Type: TXT" -Level Warning -Component 'AddCustomerDomain' Write-ModuleLog "Name: @ or leave blank" -Level Warning -Component 'AddCustomerDomain' Write-ModuleLog "Value: $($VerificationCode)" -Level Warning -Component 'AddCustomerDomain' Write-ModuleLog "TTL: $($TTL)" -Level Warning -Component 'AddCustomerDomain' Read-Host "Press any key to continue.." } } else { Write-ModuleLog "Please add the following DNS record to your DNS provider:" -Level Warning -Component 'AddCustomerDomain' Write-ModuleLog "Type: TXT" -Level Warning -Component 'AddCustomerDomain' Write-ModuleLog "Name: @ or leave blank" -Level Warning -Component 'AddCustomerDomain' Write-ModuleLog "Value: $($VerificationCode)" -Level Warning -Component 'AddCustomerDomain' Write-ModuleLog "TTL: 3600" -Level Warning -Component 'AddCustomerDomain' Read-Host "Press any key to continue.." } Write-ModuleLog "Verifying domain.." -Level Info -Component 'AddCustomerDomain' Start-Sleep -Seconds 10 $ConfirmDomain = Confirm-MgDomain -DomainId $DomainName -ErrorAction SilentlyContinue while (!$ConfirmDomain) { Write-ModuleLog -Message "Domain not verified yet, waiting 10 seconds.." -Level Warning -Component 'AddCustomerDomain' Start-Sleep -Seconds 10 $ConfirmDomain = Confirm-MgDomain -DomainId $DomainName -ErrorAction SilentlyContinue } if( $IsDefault ) { Write-ModuleLog -Message "Setting domain as default.." -Level Info -Component 'AddCustomerDomain' Start-Sleep -Seconds 5 Update-MgDomain -DomainId $DomainName -IsDefault | Out-Null } Write-ModuleLog -Message "Domain $($DomainName) has been successfully added!" -Level Info -Component 'AddCustomerDomain' } else { Write-ModuleLog -Message "Domain $($DomainName) already exists, skipping.." -Level Warning -Component 'AddCustomerDomain' } } catch { Write-ModuleLog -Message "Failed to add domain" -Level Error -Component 'AddCustomerDomain' -ErrorRecord $_ -ThrowError } } ================ File: Public/Configuration/Baseline/Add-BaselinePolicy.ps1 ================ function Add-BaselinePolicy { param( [Parameter(Mandatory)] [string]$TenantId, [Parameter(Mandatory)] [ValidateSet("ConditionalAccessPolicies", "NamedLocations", "AuthenticationStrengths", "iOSAppProtectionPolicies", "AndroidAppProtectionPolicies", "CompliancePolicies", "ConfigurationPolicies", "AuthenticationMethodPolicy", "AuthorizationPolicy", "DeviceRegistrationPolicy", "MobileDeviceManagementPolicy", "MobileDeviceManagementPolicy", "DeviceEnrollmentConfigurations", "WindowsUpdateForBusinessConfigurations", "MobileThreatDefenseConnector", "WindowsAutopilotDeploymentProfiles","MobileApps", "Groups", "ExchangeOnlineProtectionPolicies")] [string]$PolicyType ) Connect-CustomerGraph -CustomerTenantId $TenantId $ExistingPolicies = . ($script:DefaultConfig.PolicyTypeSettings.Where({ $_.Type -eq $PolicyType }).GetCommand) $PolicyFiles = Get-ChildItem -Path $script:DefaultConfig.PolicyTypeSettings.Where({ $_.Type -eq $PolicyType }).PolicyFilesPath -Filter *.json $PolicyTypeName = $script:DefaultConfig.PolicyTypeSettings.Where({ $_.Type -eq $PolicyType }).Name if($PolicyTypeName -eq "Exchange Online Protection Policies") { Add-EOPPolicies -TenantId $TenantId return } $PolicyFilesFormatted = $PolicyFiles | ForEach-Object { $Policy = Get-Content -Path $_.FullName | ConvertFrom-Json -Depth 100 if ($Policy.Description) { [PSCustomObject]@{ Name = [System.IO.Path]::GetFileNameWithoutExtension($_.Name) Description = $Policy.Description } } else { [PSCustomObject]@{ Name = [System.IO.Path]::GetFileNameWithoutExtension($_.Name) } } } $Selectable = ($script:DefaultConfig.PolicyTypeSettings.Where({ $_.Type -eq $PolicyType }).Selectable) if ($Selectable) { $Selected = $PolicyFilesFormatted | Out-ConsoleGridView -OutputMode Multiple -Title "Select the $($PolicyTypeName) you want to add" } else { $Selected = $PolicyFilesFormatted } $PolicyFiles = $PolicyFiles | Where-Object { $Selected.Name -contains [System.IO.Path]::GetFileNameWithoutExtension($_.Name) } foreach ($PolicyFile in $PolicyFiles) { $Policy = Read-PolicyFile -Path $PolicyFile.FullName -TenantId $TenantId $NameProperty = $script:DefaultConfig.PolicyTypeSettings.Where({ $_.Type -eq $PolicyType }).NameProperty if($Policy.$NameProperty -eq $null) { $Policy.$NameProperty = [System.IO.Path]::GetFileNameWithoutExtension($PolicyFile.Name) } $CheckExists = $script:DefaultConfig.PolicyTypeSettings.Where({ $_.Type -eq $PolicyType }).CheckExists $CheckExistsOverride = @("#microsoft.graph.deviceEnrollmentWindowsHelloForBusinessConfiguration") if ($Policy."@odata.type" -in $CheckExistsOverride) { $CheckExists = $false } if ($ExistingPolicies.$NameProperty -contains $Policy.$NameProperty -and $CheckExists) { Write-Host "$($PolicyTypeName) '$($Policy.$NameProperty)' already exists, not creating.." -ForegroundColor Yellow } else { $AddCommand = $script:DefaultConfig.PolicyTypeSettings.Where({ $_.Type -eq $PolicyType }).AddCommand $AddedPolicy = . $AddCommand -Body $Policy Write-ModuleLog -Message "Created '$($Policy.$NameProperty)'" -Level Info -Component 'BaselineConfiguration' # If the policy type has an assign command, assign the policy $AssignCommand = $script:DefaultConfig.PolicyTypeSettings.Where({ $_.Type -eq $PolicyType }).AssignCommand if ($AssignCommand) { . $AssignCommand -OriginalPolicy $Policy -NewPolicy $AddedPolicy Write-ModuleLog -Message "Assigned '$($Policy.$NameProperty)'" -Level Info -Component 'BaselineConfiguration' } } } } ================ File: Public/Configuration/Baseline/Add-EOPPolicies.ps1 ================ function Add-EOPPolicies { param( [Parameter(Mandatory)] [string]$TenantId ) try { Connect-CustomerExchange -CustomerTenantId $TenantId # Quarantine Policies and notifications $QuarantinePolicies = Get-QuarantinePolicy $QuarantinePolicyFiles = Get-ChildItem -Path "$script:ModuleRoot\Public\Configuration\Baseline\EOPPolicies\QuarantinePolicies" -Filter *.json foreach($QuarantinePolicyFile in $QuarantinePolicyFiles) { $QuarantinePolicy = Get-Content -Path $QuarantinePolicyFile.FullName | ConvertFrom-Json -Depth 100 -AsHashtable if($QuarantinePolicies.Identity -contains $QuarantinePolicy.Name) { Write-ModuleLog -Message "Quarantine policy '$($QuarantinePolicy.Name)' already exists, not creating.." -Level Info -Component 'EOPPolicies' } else { # If "DefaultGlobalTag" (used for global settings) if($QuarantinePolicyFile.BaseName -eq "GlobalQuarantinePolicy") { $DefaultDomainName = Get-AcceptedDomain | Where-Object { $_.Default -eq $true } | Select-Object -ExpandProperty DomainName $QuarantinePolicy.EndUserSpamNotificationCustomFromAddress = "quarantine@$($DefaultDomainName)" # Force create global policy if hasn't been updated manually $GlobalQuarantinePolicy = New-QuarantinePolicy -QuarantinePolicyType GlobalQuarantineTag -Name "DefaultGlobalTag" -ErrorAction SilentlyContinue $GlobalQuarantinePolicy = Get-QuarantinePolicy -QuarantinePolicyType GlobalQuarantinePolicy $GlobalQuarantinePolicy | Set-QuarantinePolicy @QuarantinePolicy Write-ModuleLog -Message "Updated global quarantine policy." -Level Info -Component 'EOPPolicies' } else { # Parse the quarantine permissions $RegexMatches = [regex]::Matches($QuarantinePolicy.EndUserQuarantinePermissions, '\b(\w+)\s*:\s*(\w+)\b') $QuarantinePermissions = @{} foreach ($match in $RegexMatches) { $key = $match.Groups[1].Value.TrimStart('n') $value = if ($match.Groups[2].Value -eq "True") { $true } else { $false } $QuarantinePermissions[$key] = $value } $QuarantinePolicy.EndUserQuarantinePermissions = New-QuarantinePermissions @QuarantinePermissions $QuarantinePolicy = New-QuarantinePolicy @QuarantinePolicy Write-ModuleLog -Message "Created quarantine policy '$($QuarantinePolicy.Name)'" -Level Info -Component 'EOPPolicies' } } } # Anti-phishing policies $AntiPhishingPolicies = Get-AntiPhishPolicy $AntiPhishingPolicyFiles = Get-ChildItem -Path "$script:ModuleRoot\Public\Configuration\Baseline\EOPPolicies\AntiPhishingPolicies" -Filter *.json foreach($AntiPhishingPolicyFile in $AntiPhishingPolicyFiles) { $AntiPhishingPolicy = Get-Content -Path $AntiPhishingPolicyFile.FullName | ConvertFrom-Json -Depth 100 -AsHashtable if($AntiPhishingPolicies.Identity -contains $AntiPhishingPolicy.Name) { Write-Host "Anti-phishing policy '$($AntiPhishingPolicy.Name)' already exists, not creating.." -ForegroundColor Yellow } else { $ValidParameters = (Get-Command New-AntiPhishPolicy).Parameters.Keys # Filter out parameters that are not valid for New-AntiPhishPolicy using a foreach loop returning an orderedhashtable with only valid parameters $ValidAntiPhishingPolicy = [ordered]@{} foreach ($key in $AntiPhishingPolicy.Keys) { if ($ValidParameters -contains $key) { $ValidAntiPhishingPolicy[$key] = $AntiPhishingPolicy[$key] } } $AntiPhishingPolicy = New-AntiPhishPolicy @ValidAntiPhishingPolicy Write-ModuleLog -Message "Created anti-phishing policy '$($AntiPhishingPolicy.Name)'" -Level Info -Component 'EOPPolicies' } } # Anti-spam (inbound) policies $AntiSpamInboundPolicies = Get-HostedContentFilterPolicy $AntiSpamInboundPolicyFiles = Get-ChildItem -Path "$script:ModuleRoot\Public\Configuration\Baseline\EOPPolicies\AntiSpamInboundPolicies" -Filter *.json foreach($AntiSpamInboundPolicyFile in $AntiSpamInboundPolicyFiles) { $AntiSpamInboundPolicy = Get-Content -Path $AntiSpamInboundPolicyFile.FullName | ConvertFrom-Json -Depth 100 -AsHashtable if($AntiSpamInboundPolicies.Identity -contains $AntiSpamInboundPolicy.Name) { Write-Host "Anti-spam (inbound) policy '$($AntiSpamInboundPolicy.Name)' already exists, not creating.." -ForegroundColor Yellow } else { $AntiSpamInboundPolicy = New-HostedContentFilterPolicy @AntiSpamInboundPolicy $AntiSpamInboundPolicy | Set-HostedContentFilterPolicy -MakeDefault:$true Write-Host "Created anti-spam (inbound) policy '$($AntiSpamInboundPolicy.Name)'." -ForegroundColor Green } } # Anti-spam (outbound) policies $AntiSpamOutboundPolicies = Get-HostedOutboundSpamFilterPolicy $AntiSpamOutboundPolicyFiles = Get-ChildItem -Path "$script:ModuleRoot\Public\Configuration\Baseline\EOPPolicies\AntiSpamOutboundPolicies" -Filter *.json foreach($AntiSpamOutboundPolicyFile in $AntiSpamOutboundPolicyFiles) { $AntiSpamOutboundPolicy = Get-Content -Path $AntiSpamOutboundPolicyFile.FullName | ConvertFrom-Json -Depth 100 -AsHashtable if($AntiSpamOutboundPolicies.Identity -contains $AntiSpamOutboundPolicy.Name) { Write-ModuleLog -Message "Anti-spam (outbound) policy '$($AntiSpamOutboundPolicy.Name)' already exists, not creating.." -Level Info -Component 'EOPPolicies' } else { # New can't be created, does not work correctly. Have to edit the default one. $DefaultAntiSpamOutboundPolicy = Get-HostedOutboundSpamFilterPolicy -Identity "Default" $DefaultAntiSpamOutboundPolicy | Set-HostedOutboundSpamFilterPolicy @AntiSpamOutboundPolicy Write-ModuleLog -Message "Modified default anti-spam (outbound) policy '$($DefaultAntiSpamOutboundPolicy.Name)'" -Level Info -Component 'EOPPolicies' } } # Safe links policies if(Get-Command "Get-SafeLinksPolicy" -ErrorAction SilentlyContinue) { $SafeLinksPolicies = Get-SafeLinksPolicy $SafeLinksPolicyFiles = Get-ChildItem -Path "$script:ModuleRoot\Public\Configuration\Baseline\EOPPolicies\SafeLinksPolicies" -Filter *.json foreach($SafeLinksPolicyFile in $SafeLinksPolicyFiles) { $SafeLinksPolicy = Get-Content -Path $SafeLinksPolicyFile.FullName | ConvertFrom-Json -Depth 100 -AsHashtable if($SafeLinksPolicies.Identity -contains $SafeLinksPolicy.Name) { Write-ModuleLog -Message "Safe links policy '$($SafeLinksPolicy.Name)' already exists, not creating.." -Level Info -Component 'EOPPolicies' } else { $SafeLinksPolicy = New-SafeLinksPolicy @SafeLinksPolicy Write-Host "Created safe links policy '$($SafeLinksPolicy.Name)'." -ForegroundColor Green $AcceptedDomains = Get-AcceptedDomain $SafeLinksRule = New-SafeLinksRule -Name $SafeLinksPolicy.Name -SafeLinksPolicy $SafeLinksPolicy.Name -RecipientDomainIs $AcceptedDomains.DomainName Write-ModuleLog -Message "Added all accepted domains to Safe Links Rule." -Level Info -Component 'EOPPolicies' } } } # Safe attachments policies if(Get-Command "Get-SafeAttachmentPolicy" -ErrorAction SilentlyContinue) { $SafeAttachmentsPolicies = Get-SafeAttachmentPolicy $SafeAttachmentsPolicyFiles = Get-ChildItem -Path "$script:ModuleRoot\Public\Configuration\Baseline\EOPPolicies\SafeAttachmentsPolicies" -Filter *.json foreach($SafeAttachmentsPolicyFile in $SafeAttachmentsPolicyFiles) { $SafeAttachmentsPolicy = Get-Content -Path $SafeAttachmentsPolicyFile.FullName | ConvertFrom-Json -Depth 100 -AsHashtable if($SafeAttachmentsPolicies.Identity -contains $SafeAttachmentsPolicy.Name) { Write-ModuleLog -Message "Safe attachments policy '$($SafeAttachmentsPolicy.Name)' already exists, not creating.." -Level Info -Component 'EOPPolicies' } else { $SafeAttachmentsPolicy = New-SafeAttachmentPolicy @SafeAttachmentsPolicy Write-ModuleLog "Created safe attachments policy '$($SafeAttachmentsPolicy.Name)'" -Level Info -Component 'EOPPolicies' $AcceptedDomains = Get-AcceptedDomain $SafeAttachmentsRule = New-SafeAttachmentRule -Name $SafeAttachmentsPolicy.Name -SafeAttachmentPolicy $SafeAttachmentsPolicy.Name -RecipientDomainIs $AcceptedDomains.DomainName } } } } catch { Write-ModuleLog -Message "Failed to create Exchange Online Protection (EOP) policies" -Level Error -ErrorRecord $_ -Component 'EOPPolicies' } } ================ File: Public/Configuration/Baseline/EOPPolicies/AntiPhishingPolicies/JyskIT-Baseline-EOP-AntiPhishing-Unlicensed.jx ================ { "Enabled": true, "EnableFirstContactSafetyTips": true, "AuthenticationFailAction": "MoveToJmf", "SpoofQuarantineTag": "JyskIT-Baseline-EOP-QuarantineNotifications", "EnableSpoofIntelligence": true, "EnableViaTag": true, "EnableUnauthenticatedSender": true, "HonorDmarcPolicy": true, "DmarcRejectAction": "Reject", "DmarcQuarantineAction": "Quarantine", "MakeDefault": true, "AdminDisplayName": "The default Anti-Phishing policy for Jysk IT baseline.", "Name": "JyskIT-Baseline-EOP-AntiPhishing" } ================ File: Public/Configuration/Baseline/Read-PolicyFile.ps1 ================ function Read-PolicyFile { param( [Parameter(Mandatory)] [string]$Path, [Parameter(Mandatory)] [string]$TenantId ) Connect-CustomerGraph -CustomerTenantId $TenantId $RawContent = Get-Content -Path $Path -Raw $ReplacementTypes = @( "ReplaceGroup", "ReplaceUser", "ReplaceTenantId", "ReplaceNamedLocation", "ReplaceAuthenticationStrength", "ReplaceGroupSID" ) foreach($ReplacementType in $ReplacementTypes) { # Match patterns like #ReplaceGroup#_Jysk-IT-Baseline-Group $ReplacementPattern = "#$($ReplacementType)#_([\w\s-]+)" $ReplacementsFound = [regex]::Matches($RawContent, $ReplacementPattern) | ForEach-Object { $_.Groups[1].Value } switch ($ReplacementType) { "ReplaceGroup" { foreach($ReplacementFound in $ReplacementsFound) { #Write-Host "Found replacement type '$ReplacementType' with value '$ReplacementFound' in file '$Path'" if($ReplacementFound -eq "AllUsers") { $Group = Get-MgGroup -Filter "displayName eq 'Alle brugere' or displayName eq 'All Users'" } else { $Group = Get-MgGroup -Filter "displayName eq '$ReplacementFound'" } if($Group) { $RawContent = $RawContent -replace "#$ReplacementType#_$ReplacementFound", $Group.Id #Write-Host "Replaced '$ReplacementFound' with '$($Group.Id)' in file '$Path'" } else { Write-Warning "Group '$ReplacementFound' not found in tenant." } } } "ReplaceUser" { foreach($ReplacementFound in $ReplacementsFound) { #Write-Host "Found replacement type '$ReplacementType' with value '$ReplacementFound' in file '$Path'" $User = Get-MgUser -Filter "displayName eq '$ReplacementFound' or mailNickname eq '$ReplacementFound'" if($User) { $RawContent = $RawContent -replace "#$ReplacementType#_$ReplacementFound", $User.Id #Write-Host "Replaced '$ReplacementFound' with '$($User.Id)' in file '$Path'" } else { Write-Warning "User '$ReplacementFound' not found in tenant." } } } "ReplaceTenantId" { foreach($ReplacementFound in $ReplacementsFound) { Write-Host "Found replacement type '$ReplacementType' with value '$ReplacementFound' in file '$Path'" $RawContent = $RawContent -replace "#$ReplacementType#_$ReplacementFound", $TenantId Write-Host "Replaced '$ReplacementFound' with '$TenantId' in file '$Path'" } } "ReplaceNamedLocation" { foreach($ReplacementFound in $ReplacementsFound) { #Write-Host "Found replacement type '$ReplacementType' with value '$ReplacementFound' in file '$Path'" $NamedLocation = Get-MgIdentityConditionalAccessNamedLocation -Filter "displayName eq '$ReplacementFound'" if($NamedLocation) { $RawContent = $RawContent -replace "#$ReplacementType#_$ReplacementFound", $NamedLocation.Id #Write-Host "Replaced '$ReplacementFound' with '$($NamedLocation.Id)' in file '$Path'" } else { Write-Warning "Named location '$ReplacementFound' not found in tenant." } } } "ReplaceAuthenticationStrength" { foreach($ReplacementFound in $ReplacementsFound) { #Write-Host "Found replacement type '$ReplacementType' with value '$ReplacementFound' in file '$Path'" $AuthenticationStrength = Get-MgPolicyAuthenticationStrengthPolicy -Filter "displayName eq '$ReplacementFound'" if($AuthenticationStrength) { $RawContent = $RawContent -replace "#$ReplacementType#_$ReplacementFound", $AuthenticationStrength.Id #Write-Host "Replaced '$ReplacementFound' with '$($AuthenticationStrength.Id)' in file '$Path'" } else { Write-Warning "Authentication strength '$ReplacementFound' not found in tenant." } } } "ReplaceGroupSID" { foreach($ReplacementFound in $ReplacementsFound) { #Write-Host "Found replacement type '$ReplacementType' with value '$ReplacementFound' in file '$Path'" $Group = Get-MgGroup -Filter "displayName eq '$ReplacementFound'" if($Group) { $SID = Convert-EntraIDObjectIDToSid -ObjectId $Group.id $RawContent = $RawContent -replace "#$ReplacementType#_$ReplacementFound", $SID #Write-Host "Replaced '$ReplacementFound' with '$($SID)' in file '$Path'" } else { Write-Warning "Group '$ReplacementFound' not found in tenant." } } } Default {} } } return $RawContent | ConvertFrom-Json -AsHashtable -Depth 100 } ================ File: Public/Configuration/ExchangeOnline/BitTitan/New-BitTitanAppRegistration.ps1 ================ function New-BitTitanAppRegistration() { param( [Parameter(Mandatory)] [string]$TenantId ) Connect-CustomerGraph -CustomerTenantId $TenantId Connect-CustomerExchange -CustomerTenantId $TenantId try { $Resource = Get-MgServicePrincipal -Filter "appId eq '00000002-0000-0ff1-ce00-000000000000'" -ErrorAction Stop if(!$Resource) { Write-ModuleLog -Message "Failed to find Exchange Online service principal. The customer does not have Exchange Online - and therefore app registration is impossible. Assign a license to the customer, and wait 10 minutes before trying again." -Level Error -Component 'BitTitanAppRegistration' } } catch { Write-ModuleLog -Message "Failed to find Exchange Online service principal. The customer does not have Exchange Online - and therefore app registration is impossible. Assign a license to the customer, and wait 10 minutes before trying again." -Level Error -Component 'BitTitanAppRegistration' -ThrowError } try { $AppRegistrationParams = @{ displayName = "BitTitan MigrationWiz" description = "App registration for BitTitan MigrationWiz usage." isFallbackPublicClient = "True" signInAudience = "AzureADMultipleOrgs" publicClient = @{ redirectUris = @( "urn:ietf:wg:oauth:2.0:oob" ) } requiredResourceAccess = @( @{ resourceAppId = "00000002-0000-0ff1-ce00-000000000000" resourceAccess = @( @{ id = "3b5f3d61-589b-4a3c-a359-5dd4b5ee5bd5" type = "Scope" } @{ id = "dc50a0fb-09a3-484d-be87-e023b12c6440" type = "Role" } @{ id = "dc890d15-9560-4a4c-9b7f-a736ec74ec40" type = "Role" } ) } ) } $Application = New-MgApplication -BodyParameter $AppRegistrationParams -ErrorAction Stop Write-ModuleLog -Message "Created BitTitan app registration: $($Application.DisplayName)" -Level Info -Component 'BitTitanAppRegistration' } catch { Write-ModuleLog -Message "Failed to create BitTitan app registration" -Level Error -Component 'BitTitanAppRegistration' -ErrorRecord $_ -ThrowError } try { $ServicePrincipal = Get-MgServicePrincipal -Filter "appId eq '$($Application.AppId)'" -ErrorAction Stop if (!$ServicePrincipal) { $ServicePrincipal = New-MgServicePrincipal -AppId $Application.AppId Write-ModuleLog -Message "Created BitTitan app registration service principal" -Level Info -Component 'BitTitanAppRegistration' } } catch { Write-ModuleLog -Message "Failed to find/create app registration service principal" -Level Error -Component 'BitTitanAppRegistration' -ErrorRecord $_ -ThrowError } try { Write-ModuleLog -Message "Waiting 30 seconds to ensure Entra ID Service Principal is synced to Exchange Online" -Level Info -Component 'BitTitanAppRegistration' Start-Sleep 30 $EXOSP = New-ServicePrincipal -AppId $ServicePrincipal.AppId -ObjectId $ServicePrincipal.Id -DisplayName "BitTitan MigrationWiz" -ErrorAction Stop Write-ModuleLog -Message "Created Exchange Online service principal" -Level Info -Component 'BitTitanAppRegistration' } catch { Write-ModuleLog -Message "Failed to create Exchange Online service principal" -Level Error -Component 'BitTitanAppRegistration' -ErrorRecord $_ -ThrowError } try { $OrgConfig = Get-OrganizationConfig -ErrorAction Stop if($OrgConfig.isDehydrated) { Enable-OrganizationCustomization Write-ModuleLog -Message "Succesfully organization customization" -Level Info -Component 'BitTitanAppRegistration' } else { Write-ModuleLog -Message "Organization customization is already enabled." -Level Info -Component 'BitTitanAppRegistration' } } catch { Write-ModuleLog -Message "Failed to enable organization customization" -Level Error -Component 'BitTitanAppRegistration' -ErrorRecord $_ -ThrowError } try { $ManagementRoleAssignment = New-ManagementRoleAssignment -App $EXOSP.Id -Role "Application EWS.AccessAsApp" -ErrorAction Stop Write-ModuleLog -Message "Succesfully assigned Application EWS.AccessAsApp role" -Level Info -Component 'BitTitanAppRegistration' } catch { Write-ModuleLog -Message "Failed to assign Application EWS.AccessAsApp role" -Level Error -Component 'BitTitanAppRegistration' -ErrorRecord $_ -ThrowError } # Create a client secret try { $PasswordCredential = @{ displayName = "MigrationWiz" endDateTime = (Get-Date).AddMonths(6) } $ClientSecret = (Add-MgApplicationPassword -ApplicationId $Application.Id -PasswordCredential $PasswordCredential).SecretText Write-ModuleLog -Message "Successfully created client secret." -Level Info -Component 'BitTitanAppRegistration' } catch { Write-ModuleLog -Message "Failed to create client secret" -Level Error -Component 'BitTitanAppRegistration' -ErrorRecord $_ -ThrowError } try { $AppRoleAssignment1 = New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $ServicePrincipal.Id -PrincipalId $ServicePrincipal.Id -PrincipalDisplayName "BitTitan MigrationWiz" -ResourceDisplayName "Office 365 Exchange Online" -ResourceId $Resource.Id -AppRoleId "dc50a0fb-09a3-484d-be87-e023b12c6440" -ErrorAction Stop $AppRoleAssignment2 = New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $ServicePrincipal.Id -PrincipalId $ServicePrincipal.Id -PrincipalDisplayName "BitTitan MigrationWiz" -ResourceDisplayName "Office 365 Exchange Online" -ResourceId $Resource.Id -AppRoleId "dc890d15-9560-4a4c-9b7f-a736ec74ec40" -ErrorAction Stop } catch { Write-ModuleLog -Message "Failed to create app role assignments" -Level Error -Component 'BitTitanAppRegistration' -ErrorRecord $_ -ThrowError } try { $Grant = New-MgOauth2PermissionGrant -ClientId $ServicePrincipal.Id -ConsentType "AllPrincipals" -Scope "EWS.AccessAsUser.All Exchange.ManageAsApp full_access_as_app" -ResourceId $Resource.Id -ErrorAction Stop Write-ModuleLog -Message "Successfully granted admin consent." -Level Info -Component 'BitTitanAppRegistration' } catch { Write-ModuleLog -Message "Failed to grant admin consent for EWS.AccessAsUser.All" -Level Error -Component 'BitTitanAppRegistration' -ErrorRecord $_ -ThrowError } Write-ModuleLog -Message "Client ID:" -Level Info -Component 'BitTitanAppRegistration' Write-ModuleLog -Message "$($Application.AppId)" -Level Info -Component 'BitTitanAppRegistration' Write-ModuleLog -Message "Tenant ID:" -Level Info -Component 'BitTitanAppRegistration' Write-ModuleLog -Message "$($TenantId)" -Level Info -Component 'BitTitanAppRegistration' Write-ModuleLog -Message "Client Secret:" -Level Info -Component 'BitTitanAppRegistration' Write-ModuleLog -Message "$($ClientSecret)" -Level Info -Component 'BitTitanAppRegistration' Write-ModuleLog -Message "For troubleshooting, view this:" -Level Info -Component 'BitTitanAppRegistration' Write-ModuleLog -Message "https://help.bittitan.com/hc/en-us/articles/25111263275035-Replacement-to-the-Retirement-of-Role-Based-Access-Control-for-Applications-in-Exchange-Online" -Level Info -Component 'BitTitanAppRegistration' } ================ File: Public/Configuration/ExchangeOnline/BitTitan/Start-BitTitanPreperation.ps1 ================ function Start-BitTitanPreperation { param ( [Parameter(Mandatory)] [string]$TenantId ) Write-ModuleLog "Running BitTitan Preperation for $($TenantId)" -Level Info -Component 'BitTitanPreperation' try { Connect-CustomerExchange -CustomerTenantId $TenantId Connect-CustomerGraph -CustomerTenantId $TenantId $AdminAccount = Get-MgUser -Filter "startswith(UserPrincipalName,'jyskit-adm@')" -ErrorAction SilentlyContinue | Select-Object -ExpandProperty UserPrincipalName if (!$AdminAccount) { $AdminAccount = Read-Host "Please enter the admin account to use:" } Write-ModuleLog "Adding mailbox permissions.." -Level Info -Component 'BitTitanPreperation' Get-Mailbox -ResultSize Unlimited | Add-MailboxPermission -AccessRights FullAccess -AutoMapping $false -User $AdminAccount -WarningAction SilentlyContinue | Out-Null Write-ModuleLog "Setting max send/recieve size.." -Level Info -Component 'BitTitanPreperation' Get-MailboxPlan | Set-MailboxPlan -MaxSendSize 150MB -MaxReceiveSize 150MB -WarningAction SilentlyContinue | Out-Null Get-Mailbox | Set-Mailbox -MaxReceiveSize 150MB -MaxSendSize 150MB -WarningAction SilentlyContinue | Out-Null Write-ModuleLog "Enabling Organization customization.." -Level Info -Component 'BitTitanPreperation' Enable-OrganizationCustomization -ErrorAction SilentlyContinue Write-ModuleLog "Disabling Focused Inbox.." -Level Info -Component 'BitTitanPreperation' Set-OrganizationConfig -FocusedInboxOn $false Get-Mailbox -ResultSize Unlimited | Set-FocusedInbox -FocusedInboxOn $false | Out-Null Write-ModuleLog "Disabling TNEF.." -Level Info -Component 'BitTitanPreperation' Get-RemoteDomain | Set-RemoteDomain -TNEFEnabled:$false -WarningAction SilentlyContinue| Out-Null Write-ModuleLog "Successfully ran BitTitan Preperation for $($TenantId)" -Level Info -Component 'BitTitanPreperation' } catch { Write-ModuleLog "Failed to run BitTitan Preperation for $($TenantId): $_" -Level Error -Component 'BitTitanPreperation' -ErrorRecord $_ -ThrowError } } ================ File: Public/Configuration/ExchangeOnline/Enable-Auditlogging.ps1 ================ function Enable-AuditLogging { param ( [Parameter(Mandatory)] [string]$TenantId ) Write-ModuleLog -Message "Starting configuration..." -Level Info -Component 'AuditLogging' try { Connect-CustomerExchange -CustomerTenantId $TenantId } catch { Write-ModuleLog -Message "Could not connect to Exchange" -Level Error -Component 'AuditLogging' -ErrorRecord $_ -ThrowError } $Customization = Get-OrganizationConfig | Select-Object -ExpandProperty IsDehydrated if ( $Customization -ne $false) { Write-ModuleLog -Message "Organization Customization is disabled, enabling." -Level Info -Component 'AuditLogging' Enable-OrganizationCustomization } else { Write-ModuleLog -Message "Organization Customization is already enabled." -Level Info -Component 'AuditLogging' } $AuditloggingEnabled = Get-AdminAuditLogConfig | Select-Object UnifiedAuditLogIngestionEnabled -ExpandProperty UnifiedAuditLogIngestionEnabled if ( $AuditloggingEnabled -eq $True) { Write-ModuleLog -Message "Audit logging is already enabled." -Level Info -Component 'AuditLogging' } else { try { Set-AdminAuditLogConfig -UnifiedAuditLogIngestionEnabled $true $AuditloggingEnabled = Get-AdminAuditLogConfig | Select-Object UnifiedAuditLogIngestionEnabled -ExpandProperty UnifiedAuditLogIngestionEnabled if( $AuditloggingEnabled -ne $true ) { Write-ModuleLog -Message "Could not enable auditlogging." -Level Error -Component 'AuditLogging' -ThrowError } else { Write-moduleLog -Message "Auditlogging enabled." -Level Info -Component 'AuditLogging' } } catch { Write-ModuleLog "Could not enable auditlogging : $_" -Level Error -Component 'AuditLogging' -ErrorRecord $_ -ThrowError } } } ================ File: Public/Configuration/ExchangeOnline/Enable-CustomerDKIM.ps1 ================ function Get-MainDomain { param ( [string]$domain ) # Split the domain into its components $domainParts = $domain -split '\.' # If the domain has more than two parts, return the last two parts if ($domainParts.Length -gt 2) { return ($domainParts[-2..-1] -join '.') } else { return $domain } } # Function to get the subdomain part of the domain function Get-Subdomain { param ( [string]$domain ) # Split the domain into its components $domainParts = $domain -split '\.' # If the domain has more than two parts, return all but the last two parts if ($domainParts.Length -gt 2) { return ($domainParts[0..($domainParts.Length - 3)] -join '.') } else { return "" } } function Is-Subdomain { param ( [string]$domain ) # Split the domain into its components $domainParts = $domain -split '\.' # If the domain has more than two parts, return all but the last two parts if ($domainParts.Length -gt 2) { return $true } else { return $false } } function Enable-CustomerDKIM() { param( [Parameter(Mandatory)] [string]$TenantId ) Write-ModuleLog -Message "Starting DKIM configuration..." -Level Info -Component 'EnableCustomerDKIM' try { Write-ModuleLog -Message "Connecting to Customer environment.." -Level Info -Component 'EnableCustomerDKIM' Connect-CustomerExchange -CustomerTenantId $TenantId Connect-CustomerGraph -CustomerTenantId $TenantId $DomainsWithoutDKIM = @() Get-MgDomain | Where-Object { $_.Id -NotLike '*.onmicrosoft.com' } | ForEach-Object { Write-ModuleLog -Message "Checking domain $($_.Id).." -Level Info -Component 'EnableCustomerDKIM' $DKIMStatus = Get-DkimSigningConfig -Identity $_.Id -ErrorAction SilentlyContinue if( -not $DKIMStatus -or $DKIMStatus.Enabled -eq $false) { $DomainsWithoutDKIM += [pscustomobject]@{ Domain = $_.Id } } } if ( $DomainsWithoutDKIM.Count -eq 0 ) { Write-ModuleLog -Message "No domains without DKIM found." -Level Info -Component 'EnableCustomerDKIM' return } $SelectedDomains = $DomainsWithoutDKIM | Out-ConsoleGridView -Title "Select domains to enable DKIM for.." -OutputMode Multiple $SelectedDomains | ForEach-Object { $Domain = $_ $DomainName = $Domain.Domain Write-ModuleLog -Message "Getting DKIM configuration for $DomainName" -Level Info -Component 'EnableCustomerDKIM' $DKIMConfig = Get-DkimSigningConfig -Identity $DomainName -ErrorAction SilentlyContinue if ( -not $DKIMConfig ) { Write-ModuleLog -Message "[$($DomainName)] DKIM signing config not found. Trying to enable." -Level Info -Component 'EnableCustomerDKIM' New-DkimSigningConfig -DomainName $DomainName -Enabled $false -ErrorAction SilentlyContinue | Out-Null $DKIMConfig = Get-DkimSigningConfig -Identity $DomainName -ErrorAction SilentlyContinue } if ( $DKIMConfig ) { if ( $DKIMConfig.Enabled ) { Write-ModuleLog -Message "[$($DomainName)] DKIM signing config is enabled. Skipping..." -Level Info -Component 'EnableCustomerDKIM' } else { $cname1 = "selector1._domainkey.$($DomainName)" $cname2 = "selector2._domainkey.$($DomainName)" $CreateManual = $false # Used if the domain is a subdomain $MainDomain = Get-MainDomain -Domain $DomainName $cname1Dns = Resolve-DnsName -Name $cname1 -Type CNAME -ErrorAction SilentlyContinue $cname2Dns = Resolve-DnsName -Name $cname2 -Type CNAME -ErrorAction SilentlyContinue $NameServer = Resolve-DnsName -Name $MainDomain -Type NS -ErrorAction SilentlyContinue Write-ModuleLog -Message "[$($DomainName)] Checking DNS records..." -Level Info -Component 'EnableCustomerDKIM' if ( !$cname1Dns.NameHost -and !$cname2Dns.NameHost ) { Write-ModuleLog -Message "[$($DomainName)] CNAME records not found." -Level Warning -Component 'EnableCustomerDKIM' Write-ModuleLog -Message "[$($DomainName)] Checking nameservers..." -Level Info -Component 'EnableCustomerDKIM' if( $NameServer.NameHost -notlike '*.curanet.dk' ) { Write-ModuleLog -Message "[$($DomainName)] Nameservers are not curanet.dk." -Level Warning -Component 'EnableCustomerDKIM' $CreateManual = $true } else { $DNSRecords = Get-CuranetDNSRecords -DomainName $MainDomain if ( $DNSRecords.status -ne 404 ) { Write-ModuleLog -Message "[$($DomainName)] Domain found on curanet." -Level Info -Component 'EnableCustomerDKIM' Write-ModuleLog -Message "[$($DomainName)] Trying to create first cname record..." -Level Info -Component 'EnableCustomerDKIM' if(Is-Subdomain -domain $DomainName) { $Subdomain = Get-Subdomain -domain $DomainName $Selector1 = "selector1._domainkey.$($Subdomain)" $Selector2 = "selector2._domainkey.$($Subdomain)" } else { $Selector1 = "selector1._domainkey" $Selector2 = "selector2._domainkey" } $Cname1Result = New-CuranetDNSRecord -DomainName $MainDomain -Hostname $Selector1 -Type 'CNAME' -Value $($DKIMConfig.Selector1CNAME.Trim()) if ( -not $Cname1Result.status ) { Write-ModuleLog -Message "Selector1 - SUCCESS!" -Level Info -Component 'EnableCustomerDKIM' } else { Write-ModuleLog -Message "Selector1 - FAILED!" -Level Error -Component 'EnableCustomerDKIM' $CreateManual = $true } Write-ModuleLog -Message "[$($DomainName)] Trying to create second cname record..." -Level Info -Component 'EnableCustomerDKIM' $Cname2Result = New-CuranetDNSRecord -DomainName $MainDomain -Hostname $Selector2 -Type 'CNAME' -Value $($DKIMConfig.Selector2CNAME.Trim()) if( -not $Cname2Result.status ) { Write-ModuleLog -Message "Selector2 - SUCCESS!" -Level Info -Component 'EnableCustomerDKIM' } else { Write-ModuleLog -Message "Selector2 - FAILED!" -Level Error -Component 'EnableCustomerDKIM' $CreateManual = $true } } else { Write-ModuleLog -Message "[$($DomainName)] Domain not found on curanet." -Level Warning -Component 'EnableCustomerDKIM' $CreateManual = $true } } if ( $CreateManual ) { Write-ModuleLog -Message "[$($DomainName)] Please create the following CNAME records manually:" -Level Warning -Component 'EnableCustomerDKIM' Write-ModuleLog -Message "[$($DomainName)] selector1._domainkey.$($DomainName) -> $($DKIMConfig.Selector1CNAME.Trim())" -Level Warning -Component 'EnableCustomerDKIM' Write-ModuleLog -Message "[$($DomainName)] selector2._domainkey.$($DomainName) -> $($DKIMConfig.Selector2CNAME.Trim())" -Level Warning -Component 'EnableCustomerDKIM' Write-ModuleLog -Message "[$($DomainName)] Press any key to continue..." -Level Warning -Component 'EnableCustomerDKIM' Read-Host } } else { Write-ModuleLog -Message "[$($DomainName)] CNAME records found." -Level Info -Component 'EnableCustomerDKIM' } Write-ModuleLog -Message "[$($DomainName)] Enabling DKIM" -Level Info -Component 'EnableCustomerDKIM' Set-DkimSigningConfig -Identity $DomainName -Enabled $true -ErrorAction SilentlyContinue | Out-Null While(1) { Write-ModuleLog -Message "[$($DomainName)] Waiting for DKIM to be enabled..." -Level Info -Component 'EnableCustomerDKIM' $result = Get-DkimSigningConfig -Identity $DomainName -ErrorAction SilentlyContinue if($result.Enabled -eq $true) { Write-ModuleLog -Message "[$($DomainName)] DKIM enabled!" -Level Info -Component 'EnableCustomerDKIM' break } Set-DkimSigningConfig -Identity $DomainName -Enabled $true -ErrorAction SilentlyContinue | Out-Null Start-Sleep -Seconds 5 } } } else { Write-ModuleLog -Message "[$($DomainName)] Could not enable DKIM Signing config. -Maybe exchange has not propagated yet on the tenant!" -Level Error -Component 'EnableCustomerDKIM' } } } catch { Write-ModuleLog -Message "Failed to enable DKIM: $_" -Level Error -Component 'EnableCustomerDKIM' } } ================ File: Public/Configuration/ExchangeOnline/Enable-CustomerDMARC.ps1 ================ function Enable-CustomerDMARC { param( [Parameter(Mandatory)] [string]$TenantId, [Parameter()] [array]$Domains = @() ) Write-ModuleLog -Message "Starting DMARC configuration.." -Level Info -Component 'EnableCustomerDMARC' try { Connect-CustomerGraph -CustomerTenantId $TenantId if ( $Domains.Count -eq 0 ) { $TenantDomains = Get-MgDomain | Where-Object { $_.Id -NotLike '*.onmicrosoft.com' } $Domains = @() $TenantDomains | ForEach-Object { Write-ModuleLog -Message "Checking domain $($_.Id).." -Level Info -Component 'EnableCustomerDMARC' $DMARCRecord = Resolve-DnsName -Name "_dmarc.$($_.Id)" -Type TXT -ErrorAction SilentlyContinue if( $null -eq $DMARCRecord.Strings) { $Domains += $_.Id } } if ( $Domains.Count -eq 0 ) { Write-ModuleLog -Message "No domains without DMARC found." -Level Info -Component 'EnableCustomerDMARC' return } $Domains = $Domains | Out-ConsoleGridView -Title "Select domains to enable DMARC for" -OutputMode Multiple } if(!$Domains) { Write-ModuleLog -Message "No domains selected. Exiting.." -Level Warning -Component 'EnableCustomerDMARC' return } $Domains | ForEach-Object { $CreateManual = $false $DomainName = $_ $NameServer = Resolve-DnsName -Name $DomainName -Type NS -ErrorAction SilentlyContinue | Select-Object -First 1 if ( $NameServer -and $NameServer.NameHost -like '*.curanet.dk' ) { $DNSRecords = Get-CuranetDNSRecords -Domain $DomainName if ( $DNSRecords.status -ne 404 ) { $Result = New-CuranetDNSRecord -DomainName $DomainName -Hostname '_dmarc' -Type 'TXT' -Value "v=DMARC1; p=reject;" if ( !$Result.status ) { Write-ModuleLog -Message "DMARC record created successfully for $DomainName" -Level Info -Component 'EnableCustomerDMARC' } else { $CreateManual = $true } } else { $CreateManual = $true } } else { $CreateManual = $true } if ( $CreateManual ) { Write-ModuleLog -Message "[$($DomainName)] DMARC record could not be created automatically. Please create the following record manually:" -Level Warning -Component 'EnableCustomerDMARC' Write-ModuleLog -Message "[$($DomainName)] Hostname: _dmarc" -Level Warning -Component 'EnableCustomerDMARC' Write-ModuleLog -Message "[$($DomainName)] Type: TXT" -Level Warning -Component 'EnableCustomerDMARC' Write-ModuleLog -Message "[$($DomainName)] Value: v=DMARC1; p=reject;" -Level Warning -Component 'EnableCustomerDMARC' Read-Host "Press enter to continue.." } } Write-ModuleLog -Message "DMARC configuration completed." -Level Info -Component 'EnableCustomerDMARC' } catch { Write-ModuleLog -Message "Failed to enable DMARC" -Level Error -Component 'EnableCustomerDMARC' -ErrorRecord $_ -ThrowError } } ================ File: Public/Configuration/New-AdminUser.ps1 ================ function New-AdminUser() { param( [Parameter(Mandatory)] [string]$TenantId ) Connect-CustomerGraph -CustomerTenantId $TenantId -FlowType "Delegated" -Force $CustomerOrganization = Get-MgOrganization $CustomerInitialDomain = $CustomerOrganization.VerifiedDomains | Where-Object { $_.IsInitial -eq $true } # Create Jysk IT Administrator try { $Users = Get-MgUser -All if ($Users.UserPrincipalName -notcontains "jyskit-adm@$($CustomerInitialDomain.Name)") { Write-Host "Jysk IT Administrator does not exist, creating.." $PasswordProfile = @{ Password = (Get-RandomPassword -PasswordLength 16) ForceChangePasswordNextSignIn = $false ForceChangePasswordNextSignInWithMfa = $false } $AdminUser = New-MgUser -DisplayName "Jysk IT Administrator" -PasswordProfile $PasswordProfile -AccountEnabled -MailNickname "jyskit-adm" -UserPrincipalName "jyskit-adm@$($CustomerInitialDomain.Name)" -ErrorAction Stop Write-ModuleLog -Message "Created Jysk IT Administrator: $($AdminUser.UserPrincipalName) with password $($PasswordProfile.Password)" -Level Info -Component 'AdminUser' } else { Write-ModuleLog -Message "Jysk IT Administrator already exists" -Level Info -Component 'AdminUser' $AdminUser = $Users | Where-Object { $_.UserPrincipalName -eq "jyskit-adm@$($CustomerInitialDomain.Name)" } } } catch { Write-ModuleLog -Message "Failed to create Jysk IT Administrator: $_" -Level Error -Component 'AdminUser' } # Assign to Global Administrator group try { $Role = Get-MgDirectoryRole | Where-Object { $_.DisplayName -eq "Global Administrator" } $GlobalAdmins = Get-MgDirectoryRoleMemberAsUser -DirectoryRoleId $Role.Id -ErrorAction Stop if ($GlobalAdmins.Id -notcontains $AdminUser.Id) { New-MgDirectoryRoleMemberByRef -DirectoryRoleId $Role.Id -BodyParameter @{"@odata.id" = "https://graph.microsoft.com/v1.0/directoryObjects/$($AdminUser.Id)" } -ErrorAction Stop Write-ModuleLog -Message "Assigned Jysk IT Administrator to Global Administrator group" -Level Info -Component 'AdminUser' } else { Write-ModuleLog -Message "Jysk IT Administrator is already a member of Global Administrator group" -Level Info -Component 'AdminUser' } } catch { Write-ModuleLog -Message "Failed to assign Jysk IT Administrator to Global Administrator group: $_" -Level Error -Component 'AdminUser' } } ================ File: Public/Connect/Connect-CustomerExchange.ps1 ================ function Connect-CustomerExchange { [CmdletBinding()] param ( [Parameter(Mandatory)] [string]$CustomerTenantId, [Parameter()] [ValidateSet('Application', 'Delegated')] [string]$FlowType = 'Application', [Parameter()] [string[]]$Scopes = @('https://outlook.office365.com/.default'), [Parameter()] [switch]$Force ) try { # Get token with optional cache bypass Write-ModuleLog -Message "Getting Exchange Online token for tenant $CustomerTenantId using $($FlowType) flow" -Level Verbose -Component 'ExchangeConnection' if ($Force) { Write-ModuleLog -Message "Force parameter specified - bypassing token cache" -Level Verbose -Component 'ExchangeConnection' } $token = Get-PartnerAccessToken ` -TenantId $CustomerTenantId ` -Scopes ($Scopes -join ' ') ` -FlowType $FlowType ` -Force:$Force # Connect to Graph Write-ModuleLog -Message "Connecting to Exchange Online" -Level Verbose -Component 'ExchangeConnection' Connect-ExchangeOnline -AccessToken $token.access_token -ShowBanner:$false -DelegatedOrganization $CustomerTenantId } catch { if ($_.Exception.Message -like '*The role assigned to application*') { Write-ModuleLog -Message "Failed to connect to Exchange Online for tenant $CustomerTenantId. The application does not have the required roles. Re-creating consent!" -Level Warning -Component 'ExchangeConnection' Set-ApplicationConsent -CustomerTenantId $CustomerTenantId -Force Write-ModuleLog -Message "Waiting for 30 seconds before retrying connection after consent has been granted.." -Level Info -Component 'ExchangeConnection' Start-Sleep -Seconds 30 Connect-CustomerExchange -CustomerTenantId $CustomerTenantId -Force Write-ModuleLog -Message "Successfully connected to Exchange Online for tenant $CustomerTenantId" -Level Info -Component 'ExchangeConnection' } else { Write-ModuleLog -Message "Failed to connect to Exchange Online for tenant $CustomerTenantId" -Level Error -Component 'ExchangeConnection' -ErrorRecord $_ } } } ================ File: Public/Connect/Connect-CustomerGraph.ps1 ================ function Connect-CustomerGraph { [CmdletBinding()] param ( [Parameter(Mandatory)] [string]$CustomerTenantId, [Parameter()] [ValidateSet('Application', 'Delegated')] [string]$FlowType = 'Application', [Parameter()] [string[]]$Scopes = @('https://graph.microsoft.com/.default'), [Parameter()] [switch]$Force ) try { # Get token with optional cache bypass Write-ModuleLog -Message "Getting Graph token for tenant $CustomerTenantId using $FlowType flow" -Level Verbose -Component 'GraphConnection' if ($Force) { Write-ModuleLog -Message "Force parameter specified - bypassing token cache" -Level Verbose -Component 'GraphConnection' } $token = Get-PartnerAccessToken ` -TenantId $CustomerTenantId ` -Scopes ($Scopes -join ' ') ` -FlowType $FlowType ` -Force:$Force # Connect to Graph Write-ModuleLog -Message "Connecting to Graph API" -Level Verbose -Component 'GraphConnection' $secureToken = ConvertTo-SecureString -String $token.access_token -AsPlainText -Force Connect-MgGraph -AccessToken $secureToken -NoWelcome } catch { Write-ModuleLog -Message "Failed to connect to Microsoft Graph for tenant $CustomerTenantId" -Level Error -Component 'GraphConnection' -ErrorRecord $_ } } ================ File: Public/Curanet/Customers/Get-CuranetCustomer.ps1 ================ function Get-CuranetCustomer() { Param( [Parameter()] [string]$CustomerName, [Parameter()] [string]$CustomerId, [Parameter(Mandatory)] [ValidateSet("3370", "3850")] [string]$Account ) try { $customers = Invoke-CuranetAPI -Account $Account -Uri "https://api.curanet.dk/customers/v1/Customers?itemsPerPage=9999" -Method GET } catch { throw "Failed to retreive customers from Curanet $($Account) API: $_" } if($CustomerName) { $Customers = $Customers | Where-Object { $_.companyName -eq $CustomerName } } elseif ($CustomerId) { $Customers = $Customers | Where-Object { $_.ID -eq $CustomerId } } return $Customers } ================ File: Public/Curanet/DNS/Get-CuranetDNSRecords.ps1 ================ function Get-CuranetDNSRecords { <# .SYNOPSIS Command to fetch all DNS records for a given domain. .PARAMETER DomainName String containing domainname .OUTPUTS Object containing DNS records OR error .EXAMPLE $domain = "example.com" Get-CuranetDNSRecords -Domain $domain This example fetches the DNS records for "example.com". .EXAMPLE Get-CuranetDNSRecords -Domain "test.com" This example fetches the DNS records for "test.com". #> param ( [Parameter(Mandatory=$true)] [string] $DomainName ) return Invoke-CuranetAPI -Uri "https://api.curanet.dk/dns/v2/Domains/$($DomainName)/Records" -Method "GET" -Account "3370" } ================ File: Public/Curanet/DNS/New-CuranetDNSRecord.ps1 ================ function New-CuranetDNSRecord { <# .SYNOPSIS Command to create a new DNS record .PARAMETER DomainName String containing domainname .PARAMETER Hostname (Optional, default empty) String containing hostname of the DNS record - Should not include the domainname .PARAMETER Type String containing the type of the DNS record. .PARAMETER TTL (Optional, default 3600) Integer containing the Time To Live value. .PARAMETER Priority (Optional, default 0) Integer containing the Priority value. .PARAMETER Value String containing the value of the record. .OUTPUTS Object containing the DNS record OR error. .EXAMPLE $domain = "example.com" $hostname = "www" $type = "A" $value = "11.22.33.44" New-CuranetDNSRecord -DomainName $domain -Hostname $hostname -Type $type -Value $value This example creates a new A record for "www.example.com". .EXAMPLE New-CuranetDNSRecord -DomainName "test.com" -Type "MX" -Value "mail.test.com" This example creates a new MX record for "test.com". #> [CmdletBinding()] param ( [Parameter(Mandatory=$true)] [string] $DomainName, [Parameter()] [string] $Hostname, [Parameter(Mandatory=$true)] [string] $Type, [Parameter()] [int] $TTL = 3600, [Parameter()] [int] $Priority = 0, [Parameter(Mandatory=$true)] [string] $Value ) if( $Hostname -ne '') { $Name = "$($Hostname).$($DomainName)" } else { $Name = $DomainName } $Body = @{ name = $Name type = $Type.ToUpper() ttl = $TTL priority = $Priority data = $Value } return Invoke-CuranetAPI "3370" -Uri "https://api.curanet.dk/dns/v2/Domains/$($DomainName)/Records" -Method "POST" -Body $Body } ================ File: Public/Curanet/DNS/Remove-CuranetDNSRecord.ps1 ================ function Remove-CuranetDNSRecord { <# .SYNOPSIS Command to remove a DNS record with a given ID .PARAMETER DomainName String containing domainname .PARAMETER ID Integer containing the ID of the record needed to be removed. .OUTPUTS $true on success, $false on error #> [CmdletBinding()] param ( [Parameter(Mandatory)] [string] $DomainName, [Parameter(Mandatory)] [int] $ID ) $Existing = Get-CuranetDNSRecords -DomainName $DomainName | Where-Object id -eq $ID if ( $Existing ) { $result = Invoke-CuranetAPI 3370 -Uri "https://api.curanet.dk/dns/v2/Domains/$($DomainName)/Records/$($ID)" -Method "DELETE" if($result) { return $true } else { return $false } } return $false } ================ File: Public/Curanet/DNS/Update-CuranetDNSRecord.ps1 ================ function Update-CuranetDNSRecord { <# .SYNOPSIS Command to update an existing record. .PARAMETER DomainName String containing domainname .PARAMETER ID Integer containing the ID of the record needed to be changed. .PARAMETER Value String containing the new value .OUTPUTS $true on success, $false on error #> [CmdletBinding()] param ( [Parameter(Mandatory=$true)] [string] $DomainName, [Parameter(Mandatory=$true)] [int] $ID, [Parameter(Mandatory=$true)] [string] $Value ) $Existing = Get-CuranetDNSRecords -DomainName $DomainName | Where-Object id -eq $ID if ( $Existing ) { $Body = @{ name = $Existing.Name type = $Existing.Type ttl = $Existing.TTL priority = $Existing.Priority data = $Value } $result = Invoke-CuranetAPI "3370" -Uri "https://api.curanet.dk/dns/v2/Domains/$($DomainName)/Records/$($ID)" -Method "PUT" -Body $Body if($result) { return $true } else { return $false } } return $false } ================ File: Public/Curanet/Invoicing/Get-CuranetInvoice.ps1 ================ function Get-CuranetInvoice() { Param( [Parameter(Mandatory)] [ValidateSet("3370", "3850")] [string]$Account, [string]$InvoiceID ) try { if($InvoiceID) { $Invoices = Invoke-CuranetAPi -Account $Account -Uri "https://api.curanet.dk/invoicing/v1/Invoices/$($InvoiceID)" -Method GET } else { $Invoices = Invoke-CuranetAPI -Account $Account -Uri "https://api.curanet.dk/invoicing/v1/Invoices" -Method GET } } catch { throw "Failed to retreive invoices from Curanet $($Account) API: $_" } return $Invoices } ================ File: Public/Curanet/Invoke-CuranetAPI.ps1 ================ function Invoke-CuranetAPI { [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateSet("3370", "3850")] [string]$Account, [Parameter(Mandatory)] [string]$Uri, [Parameter()] [Microsoft.PowerShell.Commands.WebRequestMethod] $Method = 'Get', [Parameter()] $Body, [Parameter()] [switch]$Force ) try { # Get access token $token = Get-CuranetAccessToken -Account $Account -Force:$Force $params = @{ Uri = $Uri Method = $Method Headers = @{ Authorization = $token.AuthorizationHeader } ContentType = 'application/json' } if ($Body) { $params.Body = ($Body | ConvertTo-Json -Depth 10) } $response = Invoke-RestMethod @params return $response } catch { if ($_.Exception.Response.StatusCode -eq 401) { # Token expired - retry once with force if (!$Force) { return Invoke-CuranetApi @PSBoundParameters -Force } } throw } } ================ File: Public/Curanet/M365/Get-CuranetM365AzureBilling.ps1 ================ function Get-CuranetM365AzureBilling() { Param( [Parameter(Mandatory)] [string]$SubscriptionId, [Parameter(Mandatory)] [ValidateSet("3370", "3850")] [string]$Account, [Parameter(Mandatory)] [DateTime]$StartDate, [Parameter(Mandatory)] [DateTime]$EndDate ) $Body = @{ startDate = $StartDate endDate = $EndDate costType = "Usage" timeFrame = "Custom" } try { $AzureBilling = Invoke-CuranetAPI -Account $Account -Uri "https://api.curanet.dk/microsoft365/v1/Subscriptions/$($SubscriptionId)/Azurebilling/Query" -Method POST -Body $Body } catch { throw "Failed to retreive Azure Billing from Curanet $($Account) API: $_" } return $AzureBilling } ================ File: Public/Curanet/M365/Get-CuranetM365Licenses.ps1 ================ function Get-CuranetM365Licenses() { Param( [Parameter(Mandatory)] [string]$SubscriptionId, [Parameter(Mandatory)] [ValidateSet("3370", "3850")] [string]$Account ) try { $Licenses = Invoke-CuranetAPI -Account $Account -Uri "https://api.curanet.dk/microsoft365/v1/Subscriptions/$($SubscriptionId)/Licenses" -Method GET } catch { throw "Failed to retreive subscription details from Curanet $($Account) API: $_" } return $Licenses } ================ File: Public/Curanet/M365/Get-CuranetM365OnboardingCredentials.ps1 ================ function Get-CuranetM365OnboardingCredentials() { Param( [Parameter(Mandatory)] [string]$SubscriptionId, [Parameter(Mandatory)] [ValidateSet("3370", "3850")] [string]$Account ) try { $OnboardingCredentials = Invoke-CuranetAPI -Account $Account -Uri "https://api.curanet.dk/microsoft365/v1/Subscriptions/$($SubscriptionId)/OnboardingCredentials" -Method GET } catch { throw "Failed to retreive Onboarding Credentials from Curanet $($Account) API: $_" } return $OnboardingCredentials } ================ File: Public/Curanet/Subscriptions/Get-CuranetCustomerSubscriptions.ps1 ================ function Get-CuranetCustomerSubscriptions() { Param( [Parameter()] [string]$CustomerId, [Parameter(Mandatory)] [ValidateSet("3370", "3850")] [string]$Account ) if($CustomerId) { try { $Subscriptions = Invoke-CuranetAPI -Account $Account -Uri "https://api.curanet.dk/customers/v1/Customers/$($CustomerId)/Subscriptions" -Method GET } catch { throw "Failed to retreive subscriptions from Curanet $($Account) API: $_" } } else { try { $Subscriptions = Invoke-CuranetAPI -Account $Account -Uri "https://api.curanet.dk/customers/v1/Customers/Subscriptions" -Method GET } catch { throw "Failed to retreive subscriptions from Curanet $($Account) API: $_" } } return $Subscriptions } ================ File: Public/Curanet/Subscriptions/Get-CuranetSubscriptionDetails.ps1 ================ function Get-CuranetSubscriptionDetails() { Param( [Parameter(Mandatory)] [string]$SubscriptionId, [Parameter(Mandatory)] [ValidateSet("3370", "3850")] [string]$Account ) try { $Subscription = Invoke-CuranetAPI -Account $Account -Uri "https://api.curanet.dk/subscriptions/v1/Subscriptions/$($SubscriptionId)" -Method GET } catch { throw "Failed to retreive subscription details from Curanet $($Account) API: $_" } return $Subscription } ================ File: Public/Initialize-CustomerTenant.ps1 ================ function Initialize-CustomerTenant { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$CustomerTenantId ) try { # Step 1: Connect to partner tenant Write-ModuleLog -Message "Connecting to partner tenant..." -Level Info -Component 'CustomerInitialization' Connect-CustomerGraph -CustomerTenantId $script:Config.PartnerTenantId # Step 2: Get customer info and show instructions $customer = Get-PartnerCustomer -CustomerTenantId $CustomerTenantId if (!$customer) { Write-ModuleLog "Customer with tenant ID $CustomerTenantId not found" -Level Error -Component 'CustomerInitialization' -ThrowError return } Write-ModuleLog -Message "Setting up application consent for $($customer.companyProfile.companyName)" -Level Info -Component 'CustomerInitialization' Write-Host "`nInstructions:" -ForegroundColor Yellow Write-Host "1. Log in using the Curanet-provided admin credentials in a Microsoft Edge inPrivate window (admin@$($customer.DefaultDomainName))" -ForegroundColor Cyan Write-Host "2. You will be asked to change the password - change it to something random, it does not matter" -ForegroundColor Cyan Write-Host "3. Consent to their GDAP invitation from the Curanet control panel" -ForegroundColor Cyan Write-Host "4. When logged in and ready, continue here" -ForegroundColor Cyan Read-Host "`nPress Enter to continue" # Step 3: Create GDAP relationship Write-ModuleLog -Message "Creating GDAP relationship for $($customer.companyProfile.companyName)" -Level Info -Component 'CustomerInitialization' $relationship = New-GDAPRelationship -CustomerTenantId $CustomerTenantId # Step 4: Show invitation link $InvitationLink = "https://admin.microsoft.com/AdminPortal/Home#/partners/invitation/granularAdminRelationships/$($relationship.Id)" Write-ModuleLog -Message "GDAP relationship created." -Level Info -Component 'CustomerInitialization' Write-ModuleLog -Message "Invitation link: $($InvitationLink)" -Level Info -Component 'CustomerInitialization' Write-ModuleLog -Message "Please accept the invitation link to grant access to the customer" -Level Info -Component 'CustomerInitialization' # Step 5: Wait for approval if (Wait-GDAPApproval -RelationshipId $relationship.Id) { # Step 6: Set GDAP permissions Write-ModuleLog -Message "Setting up GDAP permissions for $($customer.companyProfile.companyName)" -Level Info -Component 'CustomerInitialization' Set-GDAPPermissions -RelationshipId $relationship.Id # Step 7: Set up application consent Write-ModuleLog -Message "Setting up application consent for $($customer.companyProfile.companyName)" -Level Info -Component 'CustomerInitialization' Set-ApplicationConsent -CustomerTenantId $CustomerTenantId -Force # Step 8: Connect to customer tenant and set up admin user Write-ModuleLog -Message "Setting up administrator account for $($customer.companyProfile.companyName)" -Level Info -Component 'CustomerInitialization' New-AdminUser -TenantId $CustomerTenantId # Step 9: Set up company branding Write-ModuleLog -Message "Setting up company branding for $($customer.companyProfile.companyName)" -Level Info -Component 'CustomerInitialization' Add-CompanyBranding -TenantId $CustomerTenantId Write-ModuleLog -Message "Tenant initialization completed successfully!" -Level Info -Component 'CustomerInitialization' } } catch { Write-ModuleLog -Message "Failed to initialize tenant: $_" -Level Error -Component 'CustomerInitialization' } finally { Write-ModuleLog -Message "Disconnecting from partner tenant..." -Level Info -Component 'CustomerInitialization' Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } } ================ File: Public/PartnerCenter/Get-PartnerCustomer.ps1 ================ function Get-PartnerCustomer { [CmdletBinding()] param( [Parameter()] [string]$CustomerTenantId ) try { Connect-CustomerGraph -CustomerTenantId $Script:config.PartnerTenantId -FlowType Delegated $customers = Get-MgContract -All if($CustomerTenantId) { return $customers | Where-Object { $_.CustomerId -eq $CustomerTenantId } } else { return $customers } } catch { throw [TokenOperationException]::new( 'PartnerCenter', "Failed to get customer information: $($_.Exception.Message)", $_ ) } } ================ File: Public/PartnerMenu/Start-PartnerMenu.ps1 ================ function Start-PartnerMenu() { $Version = $MyInvocation.MyCommand.ScriptBlock.Module.Version Get-AutomationUpdates $MenuItems = [ordered]@{ 1 = 'Select an existing tenant.' 2 = 'Initialize a new tenant.' 4 = 'Exit.' } $SelectedOption = ($MenuItems | Out-ConsoleGridView -Title "Welcome to the Jysk IT Partner Menu v. $($Version)" -OutputMode Single).Name switch ($SelectedOption) { "1" { Start-PartnerMenuTenantSelection } "2" { Start-PartnerMenuTenantInitilization } "4" { exit } Default { exit } } } ================ File: Public/PartnerMenu/Start-PartnerMenuTenantSelection.ps1 ================ function Start-PartnerMenuTenantSelection() { [Alias("back")] param( [Parameter()] $Tenant ) if (!$Tenant -and !$Global:Tenant) { Get-PartnerMenuHeader -SectionString "Preparing tenant selection..." # Connect to Partner graph Connect-CustomerGraph -CustomerTenantId $Script:config.PartnerTenantId -FlowType Delegated $Tenants = Get-MgContract -All $Tenants = $Tenants | Select-Object -Property DisplayName, DefaultDomainName, CustomerId, Id $Global:Tenant = $Tenants | Out-ConsoleGridView -Title "Select a tenant" -OutputMode Single } if(!$Global:Tenant) { Start-PartnerMenu } Clear-Host $MenuItems = [ordered]@{ 1 = 'Connect to Exchange Online environment.' 2 = 'Connect to Microsoft Graph environment.' 3 = 'Set mailbox language/region.' 4 = 'Set calendar permissions.' 5 = 'Disable Security Defaults.' 6 = 'Create BitTitan app registration.' 7 = 'Add a domain to the tenant.' 8 = 'Add DKIM configuration to the tenant.' 9 = 'Add DMARC configuration to the tenant.' 10 = 'BitTitan Preperation.' 11 = 'Add company branding' 12 = 'Re-create SAM consent for tenant.' 13 = 'Add baseline configuration' 14 = 'Enable Audit Logging' q = 'Back' } $SelectedOption = ($MenuItems | Out-ConsoleGridView -Title "($($Global:Tenant.displayName)) - Tenant options" -OutputMode Single).Name switch ($SelectedOption) { "1" { try { Connect-CustomerExchange -CustomerTenantId $Global:Tenant.CustomerId Write-Host "Connected to Exchange Online environment." -ForegroundColor Green Write-Host "You can always type 'back' to return to the previous menu." -ForegroundColor Yellow } catch { Write-Error "Failed to connect to Exchange Online: $_" Read-Host "Press any key to continue.." Start-PartnerMenuTenantSelection -Tenant $Tenant } } "2" { try { Connect-CustomerGraph -CustomerTenantId $Global:Tenant.CustomerId Write-Host "Connected to Microsoft Graph environment." -ForegroundColor Green Write-Host "You can always type 'back' to return to the previous menu." -ForegroundColor Yellow } catch { Write-Error "Failed to connect to Microsoft Graph: $_" Read-Host "Press any key to continue.." Start-PartnerMenuTenantSelection -Tenant $Tenant } } "3" { try { Connect-CustomerExchange -CustomerTenantId $Global:Tenant.CustomerId $Mailboxes = Get-EXOMailbox -ResultSize Unlimited | Where-Object { $_.Name -notlike 'Discovery*' } | Out-ConsoleGridView -Title "Select the mailboxes to set language/region for" -OutputMode Multiple if (($LanguageRegion = Read-Host "Which language/region do you want to set? [da-dk]") -eq '') { $LanguageRegion = "da-dk" } foreach ($Mailbox in $Mailboxes) { Write-Host "Setting language/region to $($LanguageRegion) for $($Mailbox.UserPrincipalName)..." -ForegroundColor Cyan Set-MailboxRegionalConfiguration -Identity $Mailbox -Language $LanguageRegion -LocalizeDefaultFolderName } Write-Host "Completed setting language/region to $($LanguageRegion) for $($Mailboxes.Count) mailboxes." -ForegroundColor Green } catch { Write-Error "Failed to set language/region: $_" } Read-Host "Press any key to continue.." Start-PartnerMenuTenantSelection -Tenant $Tenant } "4" { try { Connect-CustomerExchange -CustomerTenantId $Global:Tenant.CustomerId $Mailboxes = Get-EXOMailbox -ResultSize Unlimited | Where-Object { $_.Name -notlike 'Discovery*' } | Out-ConsoleGridView -Title "Select the mailboxes to set calendar permissions for" -OutputMode Multiple if (($CalendarPermission = Read-Host "Which calendar permission do you want to set? [Reviewer]") -eq '') { $LanguageRegion = "Reviewer" } foreach ($Mailbox in $Mailboxes) { $CalendarFolder = Get-EXOMailboxFolderStatistics -Identity $Mailbox -Folderscope Calendar | Select-Object -First 1 Write-Host "Setting calendar permission to $($CalendarPermission) for $($Mailbox.UserPrincipalName)..." -ForegroundColor Cyan Set-MailboxFolderPermission -Identity "$($Mailbox.UserPrincipalName):\$($CalendarFolder.Name)" -User Default -AccessRights $CalendarPermission } Write-Host "Completed setting calendar permission to $($CalendarPermission) for $($Mailboxes.Count) mailboxes." -ForegroundColor Green } catch { Write-Error "Failed to set calendar permissions: $_" } Read-Host "Press any key to continue.." Start-PartnerMenuTenantSelection -Tenant $Global:Tenant } "5" { try { Disable-SecurityDefaults -TenantId $Global:Tenant.CustomerId } catch { Write-Error "Failed to disable Security Defaults: $_" } Read-Host "Press any key to continue.." Start-PartnerMenuTenantSelection -Tenant $Global:Tenant } "6" { try { New-BitTitanAppRegistration -TenantId $Global:Tenant.CustomerId } catch { Write-Error "Failed to create BitTitan app registration: $_" } Read-Host "Press any key to continue.." Start-PartnerMenuTenantSelection -Tenant $Global:Tenant } "7" { try { Read-Host "Enter domain name to add to the tenant" -OutVariable DomainName Add-CustomerDomain -TenantId $Global:Tenant.CustomerId -DomainName $DomainName } catch { Write-Error "Failed to add domain to tenant: $_" } Read-Host "Press any key to continue.." Start-PartnerMenuTenantSelection -Tenant $Global:Tenant } "8" { try { Enable-CustomerDKIM -TenantId $Global:Tenant.CustomerId } catch { Write-Error "Failed to add DKIM configuration: $_" } Read-Host "Press any key to continue.." Start-PartnerMenuTenantSelection -Tenant $Global:Tenant } "9" { try { Enable-CustomerDMARC -TenantId $Global:Tenant.CustomerId } catch { Write-Error "Failed to add DMARC configuration: $_" } Read-Host "Press any key to continue.." Start-PartnerMenuTenantSelection -Tenant $Global:Tenant } "10" { try { Start-BitTitanPreperation -TenantId $Global:Tenant.CustomerId } catch { Write-Error "Failed to run BitTitan Preperation: $_" } Read-Host "Press any key to continue.." Start-PartnerMenuTenantSelection -Tenant $Global:Tenant } "11" { $SignInPageText = Read-Host "Enter the text to display on the sign-in page (Leave empty for defalut)" if ($SignInPageText -eq '') { Add-CompanyBranding -TenantId $Global:Tenant.CustomerId } else { Add-CompanyBranding -TenantId $Global:Tenant.CustomerId -SignInPageText $SignInPageText } Read-Host "Press any key to continue.." Start-PartnerMenuTenantSelection -Tenant $Global:Tenant } "12" { Set-ApplicationConsent -CustomerTenantId $Global:Tenant.CustomerId -Force Read-Host "Press any key to continue.." Start-PartnerMenuTenantSelection -Tenant $Global:Tenant } "13" { try { Add-BaselineConfiguration -TenantId $Global:Tenant.CustomerId } catch { Write-Error "Failed to add baseline configuration: $_" } Read-Host "Press any key to continue.." Start-PartnerMenuTenantSelection -Tenant $Global:Tenant } "14" { try { Enable-AuditLogging -TenantId $Global:Tenant.CustomerId } catch { Write-Error "Failed to enable Audit Logging: $_" } Read-Host "Press any key to continue.." Start-PartnerMenuTenantSelection -Tenant $Global:Tenant } "q" { $Global:Tenant = $null Start-PartnerMenu } Default { $Global:Tenant = $null Start-PartnerMenu } } } ================ File: Public/Utilities/Publish-JyskITAutomation.ps1 ================ function Publish-JyskITAutomation() { try { $PSGalleryKey = Get-PSGalleryKey } catch { Write-Error "Failed to retreive PSGallery key from Azure Key Vault: $_" } # Remove logs and cache folder Write-Host "Removing logs and cache folder" -ForegroundColor Yellow if(Test-Path (Join-Path $script:ModuleRoot "cache")) { Join-Path $script:ModuleRoot "cache" | Remove-Item -Recurse -Force } if(Test-Path (Join-Path $script:ModuleRoot "logs")) { Join-Path $script:ModuleRoot "logs" | Remove-Item -Recurse -Force } Write-Host "Please make sure you have updated the version number in the module manifest. (JyskIT.Automation.psd1)" -ForegroundColor Yellow $Response = Read-Host "Are you sure you want to publish the module to PSGallery? [Y/N]" if ($Response.ToLower() -ne 'y') { return } Publish-Module -Path ".\" -NuGetApiKey $PSGalleryKey } |