diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Reports/Invoke-ListMailboxForwarding.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Reports/Invoke-ListMailboxForwarding.ps1 new file mode 100644 index 000000000000..2eae2f67dd97 --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Reports/Invoke-ListMailboxForwarding.ps1 @@ -0,0 +1,93 @@ +function Invoke-ListMailboxForwarding { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Exchange.Mailbox.Read + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $TenantFilter = $Request.Query.tenantFilter + $UseReportDB = $Request.Query.UseReportDB + + try { + # If UseReportDB is specified, retrieve from report database + if ($UseReportDB -eq 'true') { + try { + $GraphRequest = Get-CIPPMailboxForwardingReport -TenantFilter $TenantFilter + $StatusCode = [HttpStatusCode]::OK + } catch { + $StatusCode = [HttpStatusCode]::InternalServerError + $GraphRequest = $_.Exception.Message + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @($GraphRequest) + }) + } + + # Live query from Exchange Online + $Select = 'UserPrincipalName,DisplayName,PrimarySMTPAddress,RecipientTypeDetails,ForwardingSmtpAddress,DeliverToMailboxAndForward,ForwardingAddress' + $ExoRequest = @{ + tenantid = $TenantFilter + cmdlet = 'Get-Mailbox' + cmdParams = @{} + Select = $Select + } + + $Mailboxes = New-ExoRequest @ExoRequest + + $GraphRequest = foreach ($Mailbox in $Mailboxes) { + $HasExternalForwarding = -not [string]::IsNullOrWhiteSpace($Mailbox.ForwardingSmtpAddress) + $HasInternalForwarding = -not [string]::IsNullOrWhiteSpace($Mailbox.ForwardingAddress) + $HasAnyForwarding = $HasExternalForwarding -or $HasInternalForwarding + + # Only include mailboxes with forwarding configured + if (-not $HasAnyForwarding) { + continue + } + + # External takes precedence when both are configured + $ForwardingType = if ($HasExternalForwarding) { + 'External' + } else { + 'Internal' + } + + # External takes precedence when both are configured + $ForwardTo = if ($HasExternalForwarding) { + $Mailbox.ForwardingSmtpAddress -replace 'smtp:', '' + } else { + $Mailbox.ForwardingAddress + } + + [PSCustomObject]@{ + UPN = $Mailbox.UserPrincipalName + DisplayName = $Mailbox.DisplayName + PrimarySmtpAddress = $Mailbox.PrimarySMTPAddress + RecipientTypeDetails = $Mailbox.RecipientTypeDetails + ForwardingType = $ForwardingType + ForwardTo = $ForwardTo + ForwardingSmtpAddress = $Mailbox.ForwardingSmtpAddress -replace 'smtp:', '' + InternalForwardingAddress = $Mailbox.ForwardingAddress + DeliverToMailboxAndForward = $Mailbox.DeliverToMailboxAndForward + } + } + + Write-LogMessage -API $APIName -tenant $TenantFilter -message "Mailbox forwarding listed for $($TenantFilter)" -sev Debug + $StatusCode = [HttpStatusCode]::OK + + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + $StatusCode = [HttpStatusCode]::Forbidden + $GraphRequest = $ErrorMessage + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @($GraphRequest) + }) +} diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 index b3e91b0b4bc1..2c631f69154f 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 @@ -32,6 +32,17 @@ function Invoke-EditUser { try { Write-Host "$([boolean]$UserObj.MustChangePass)" $UserPrincipalName = "$($UserObj.username)@$($UserObj.Domain ? $UserObj.Domain : $UserObj.primDomain.value)" + $normalizedOtherMails = @( + @($UserObj.otherMails) | ForEach-Object { + if ($null -ne $_) { + [string]$_ -split ',' + } + } | ForEach-Object { + $_.Trim() + } | Where-Object { + -not [string]::IsNullOrWhiteSpace($_) + } + ) $BodyToship = [pscustomobject] @{ 'givenName' = $UserObj.givenName 'surname' = $UserObj.surname @@ -49,7 +60,7 @@ function Invoke-EditUser { 'country' = $UserObj.country 'companyName' = $UserObj.companyName 'businessPhones' = $UserObj.businessPhones ? @($UserObj.businessPhones) : @() - 'otherMails' = $UserObj.otherMails ? @($UserObj.otherMails) : @() + 'otherMails' = $normalizedOtherMails 'passwordProfile' = @{ 'forceChangePasswordNextSignIn' = [bool]$UserObj.MustChangePass } diff --git a/Modules/CIPPCore/Public/Get-CIPPMailboxForwardingReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPMailboxForwardingReport.ps1 new file mode 100644 index 000000000000..ccc3dad5ac59 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPMailboxForwardingReport.ps1 @@ -0,0 +1,113 @@ +function Get-CIPPMailboxForwardingReport { + <# + .SYNOPSIS + Generates a mailbox forwarding report from the CIPP Reporting database + + .DESCRIPTION + Retrieves mailboxes that have forwarding configured (external, internal, or both) + from the cached mailbox data. + + .PARAMETER TenantFilter + The tenant to generate the report for + + .EXAMPLE + Get-CIPPMailboxForwardingReport -TenantFilter 'contoso.onmicrosoft.com' + Gets all mailboxes with forwarding configured + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter + ) + + try { + Write-LogMessage -API 'MailboxForwardingReport' -tenant $TenantFilter -message 'Generating mailbox forwarding report' -sev Debug + + # Handle AllTenants + if ($TenantFilter -eq 'AllTenants') { + # Get all tenants that have mailbox data + $AllMailboxItems = Get-CIPPDbItem -TenantFilter 'allTenants' -Type 'Mailboxes' + $Tenants = @($AllMailboxItems | Where-Object { $_.RowKey -ne 'Mailboxes-Count' } | Select-Object -ExpandProperty PartitionKey -Unique) + + $TenantList = Get-Tenants -IncludeErrors + $Tenants = $Tenants | Where-Object { $TenantList.defaultDomainName -contains $_ } + + $AllResults = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($Tenant in $Tenants) { + try { + $TenantResults = Get-CIPPMailboxForwardingReport -TenantFilter $Tenant + foreach ($Result in $TenantResults) { + $Result | Add-Member -NotePropertyName 'Tenant' -NotePropertyValue $Tenant -Force + $AllResults.Add($Result) + } + } catch { + Write-LogMessage -API 'MailboxForwardingReport' -tenant $Tenant -message "Failed to get report for tenant: $($_.Exception.Message)" -sev Warning + } + } + return $AllResults + } + + # Get mailboxes from reporting DB + $MailboxItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'Mailboxes' | Where-Object { $_.RowKey -ne 'Mailboxes-Count' } + if (-not $MailboxItems) { + throw 'No mailbox data found in reporting database. Sync the mailbox data first.' + } + + # Get the most recent cache timestamp + $CacheTimestamp = ($MailboxItems | Where-Object { $_.Timestamp } | Sort-Object Timestamp -Descending | Select-Object -First 1).Timestamp + + # Parse mailbox data and build report + $Report = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($Item in $MailboxItems) { + $Mailbox = $Item.Data | ConvertFrom-Json + + # Determine forwarding status + $HasExternalForwarding = -not [string]::IsNullOrWhiteSpace($Mailbox.ForwardingSmtpAddress) + $HasInternalForwarding = -not [string]::IsNullOrWhiteSpace($Mailbox.InternalForwardingAddress) + $HasAnyForwarding = $HasExternalForwarding -or $HasInternalForwarding + + # Only include mailboxes with forwarding configured + if (-not $HasAnyForwarding) { + continue + } + + # Determine forwarding type for display (external takes precedence) + $ForwardingType = if ($HasExternalForwarding) { + 'External' + } else { + 'Internal' + } + + # Build the forward-to address display (external takes precedence) + $ForwardTo = if ($HasExternalForwarding) { + $Mailbox.ForwardingSmtpAddress + } else { + $Mailbox.InternalForwardingAddress + } + + $Report.Add([PSCustomObject]@{ + UPN = $Mailbox.UPN + DisplayName = $Mailbox.displayName + PrimarySmtpAddress = $Mailbox.primarySmtpAddress + RecipientTypeDetails = $Mailbox.recipientTypeDetails + ForwardingType = $ForwardingType + ForwardTo = $ForwardTo + ForwardingSmtpAddress = $Mailbox.ForwardingSmtpAddress + InternalForwardingAddress = $Mailbox.InternalForwardingAddress + DeliverToMailboxAndForward = $Mailbox.DeliverToMailboxAndForward + Tenant = $TenantFilter + CacheTimestamp = $CacheTimestamp + }) + } + + # Sort by display name + $Report = $Report | Sort-Object -Property DisplayName + + Write-LogMessage -API 'MailboxForwardingReport' -tenant $TenantFilter -message "Generated forwarding report with $($Report.Count) entries" -sev Debug + return $Report + + } catch { + Write-LogMessage -API 'MailboxForwardingReport' -tenant $TenantFilter -message "Failed to generate mailbox forwarding report: $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) + throw "Failed to generate mailbox forwarding report: $($_.Exception.Message)" + } +} diff --git a/Modules/CIPPCore/Public/GraphHelper/Set-CIPPEnvVarBackup.ps1 b/Modules/CIPPCore/Public/GraphHelper/Set-CIPPEnvVarBackup.ps1 new file mode 100644 index 000000000000..fca4657d58f6 --- /dev/null +++ b/Modules/CIPPCore/Public/GraphHelper/Set-CIPPEnvVarBackup.ps1 @@ -0,0 +1,68 @@ +function Set-CIPPEnvVarBackup { + param() + + $FunctionAppName = $env:WEBSITE_SITE_NAME + $PropertiesToBackup = @( + 'AzureWebJobsStorage' + 'WEBSITE_RUN_FROM_PACKAGE' + 'FUNCTIONS_EXTENSION_VERSION' + 'FUNCTIONS_WORKER_RUNTIME' + 'CIPP_HOSTED' + 'CIPP_HOSTED_KV_SUB' + 'WEBSITE_ENABLE_SYNC_UPDATE_SITE' + 'WEBSITE_AUTH_AAD_ALLOWED_TENANTS' + ) + + $RequiredProperties = @('AzureWebJobsStorage', 'FUNCTIONS_EXTENSION_VERSION', 'FUNCTIONS_WORKER_RUNTIME', 'WEBSITE_RUN_FROM_PACKAGE') + + if ($env:WEBSITE_SKU -eq 'FlexConsumption') { + $RequiredProperties = $RequiredProperties | Where-Object { $_ -ne 'WEBSITE_RUN_FROM_PACKAGE' } + } + + $Backup = @{} + foreach ($Property in $PropertiesToBackup) { + $Backup[$Property] = [environment]::GetEnvironmentVariable($Property) + } + + $EnvBackupTable = Get-CIPPTable -tablename 'EnvVarBackups' + $CurrentBackup = Get-CIPPAzDataTableEntity @EnvBackupTable -Filter "PartitionKey eq 'EnvVarBackup' and RowKey eq '$FunctionAppName'" + + # ConvertFrom-Json returns PSCustomObject - convert to hashtable for consistent key/value access + $CurrentValues = @{} + if ($CurrentBackup -and $CurrentBackup.Values) { + ($CurrentBackup.Values | ConvertFrom-Json).PSObject.Properties | ForEach-Object { + $CurrentValues[$_.Name] = $_.Value + } + } + + $IsNew = $CurrentValues.Count -eq 0 + + if ($IsNew) { + # First capture - write everything from the live environment + $SavedValues = $Backup + Write-Information "Creating new environment variable backup for $FunctionAppName" + } else { + # Backup already exists - keep existing values fixed, only backfill any properties not yet captured + $SavedValues = $CurrentValues + foreach ($Property in $PropertiesToBackup) { + if (-not $SavedValues[$Property] -and $Backup[$Property]) { + Write-Information "Backfilling missing backup property '$Property' from current environment." + $SavedValues[$Property] = $Backup[$Property] + } + } + Write-Information "Environment variable backup already exists for $FunctionAppName - preserving fixed values" + } + + # Validate all required properties are present in the final backup + $MissingRequired = $RequiredProperties | Where-Object { -not $SavedValues[$_] } + if ($MissingRequired) { + Write-Warning "Environment variable backup for $FunctionAppName is missing required properties: $($MissingRequired -join ', ')" + } + + $Entity = @{ + PartitionKey = 'EnvVarBackup' + RowKey = $FunctionAppName + Values = [string]($SavedValues | ConvertTo-Json -Compress) + } + Add-CIPPAzDataTableEntity @EnvBackupTable -Entity $Entity -Force | Out-Null +} diff --git a/Modules/CIPPCore/Public/GraphHelper/Set-CIPPOffloadFunctionTriggers.ps1 b/Modules/CIPPCore/Public/GraphHelper/Set-CIPPOffloadFunctionTriggers.ps1 index e3f5ed469340..48d155353998 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Set-CIPPOffloadFunctionTriggers.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Set-CIPPOffloadFunctionTriggers.ps1 @@ -25,7 +25,17 @@ function Set-CIPPOffloadFunctionTriggers { # Get offloading state from Config table $Table = Get-CippTable -tablename 'Config' $OffloadConfig = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'OffloadFunctions' and RowKey eq 'OffloadFunctions'" - $OffloadEnabled = [bool]$OffloadConfig.state + $OffloadEnabled = $false + [bool]::TryParse($OffloadConfig.state, [ref]$OffloadEnabled) | Out-Null + + # Trigger Last change table + $TriggerChangeTable = Get-CippTable -tablename 'OffloadTriggerChange' + $LastChange = Get-CIPPAzDataTableEntity @TriggerChangeTable + + if ($LastChange -and $LastChange.Timestamp -gt (Get-Date).AddMinutes(-30).ToUniversalTime() -and $LastChange.Offloading -eq $OffloadEnabled) { + Write-Information "Last trigger change was at $LastChange, skipping update to avoid rapid changes." + return $true + } # Determine resource group if ($env:WEBSITE_RESOURCE_GROUP) { @@ -69,6 +79,12 @@ function Set-CIPPOffloadFunctionTriggers { # Update app settings only if there are changes to make if ($AppSettings.Count -gt 0) { if ($PSCmdlet.ShouldProcess($FunctionAppName, 'Disable non-HTTP triggers')) { + $LastChange = @{ + PartitionKey = 'TriggerChange' + RowKey = 'LastChange' + Offloading = $OffloadEnabled + } + Add-CIPPAzDataTableEntity @TriggerChangeTable -Entity $LastChange -Force | Out-Null Update-CIPPAzFunctionAppSetting -Name $FunctionAppName -ResourceGroupName $ResourceGroupName -AppSetting $AppSettings | Out-Null Write-Information "Successfully disabled $($AppSettings.Count) non-HTTP trigger(s) on $FunctionAppName" } @@ -94,6 +110,12 @@ function Set-CIPPOffloadFunctionTriggers { # Update app settings with removal of keys only if there are changes to make if ($RemoveKeys.Count -gt 0) { if ($PSCmdlet.ShouldProcess($FunctionAppName, 'Re-enable non-HTTP triggers')) { + $LastChange = @{ + PartitionKey = 'TriggerChange' + RowKey = 'LastChange' + Offloading = $OffloadEnabled + } + Add-CIPPAzDataTableEntity @TriggerChangeTable -Entity $LastChange -Force | Out-Null Update-CIPPAzFunctionAppSetting -Name $FunctionAppName -ResourceGroupName $ResourceGroupName -AppSetting @{} -RemoveKeys $RemoveKeys | Out-Null Write-Information "Successfully re-enabled $($RemoveKeys.Count) non-HTTP trigger(s) on $FunctionAppName" } diff --git a/Modules/CIPPCore/Public/GraphHelper/Update-CIPPAzFunctionAppSetting.ps1 b/Modules/CIPPCore/Public/GraphHelper/Update-CIPPAzFunctionAppSetting.ps1 index e39c07e9e6cc..878fdfb5fab0 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Update-CIPPAzFunctionAppSetting.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Update-CIPPAzFunctionAppSetting.ps1 @@ -62,6 +62,19 @@ function Update-CIPPAzFunctionAppSetting { $currentProps[$prop.Name] = [string]$prop.Value } } + } else { + # Could not retrieve current settings - backfill from EnvVarBackup to avoid overwriting required properties with empty values + Write-Warning "Could not retrieve current Function App settings for $Name - attempting to backfill from environment variable backup." + $EnvBackupTable = Get-CIPPTable -tablename 'EnvVarBackups' + $BackupEntity = Get-CIPPAzDataTableEntity @EnvBackupTable -Filter "PartitionKey eq 'EnvVarBackup' and RowKey eq '$Name'" + if ($BackupEntity -and $BackupEntity.Values) { + ($BackupEntity.Values | ConvertFrom-Json).PSObject.Properties | ForEach-Object { + if ($_.Value) { $currentProps[$_.Name] = [string]$_.Value } + } + Write-Information "Backfilled $($currentProps.Count) properties from environment variable backup for $Name" + } else { + throw "Failed to retrieve current settings for Function App $Name and no backup found - aborting update to avoid potential misconfiguration." + } } # Merge requested settings diff --git a/Modules/CIPPCore/Public/New-CippUser.ps1 b/Modules/CIPPCore/Public/New-CippUser.ps1 index 7381e1426548..37b8ff9d70c0 100644 --- a/Modules/CIPPCore/Public/New-CippUser.ps1 +++ b/Modules/CIPPCore/Public/New-CippUser.ps1 @@ -16,6 +16,17 @@ function New-CIPPUser { $UserPrincipalName = "$($UserObj.username)@$($UserObj.Domain ? $UserObj.Domain : $UserObj.PrimDomain.value)" Write-Host "Creating user $UserPrincipalName" Write-Host "tenant filter is $($UserObj.tenantFilter)" + $normalizedOtherMails = @( + @($UserObj.otherMails) | ForEach-Object { + if ($null -ne $_) { + [string]$_ -split ',' + } + } | ForEach-Object { + $_.Trim() + } | Where-Object { + -not [string]::IsNullOrWhiteSpace($_) + } + ) $BodyToship = [pscustomobject] @{ 'givenName' = $UserObj.givenName 'surname' = $UserObj.surname @@ -25,7 +36,7 @@ function New-CIPPUser { 'mailNickname' = $UserObj.username ? $UserObj.username : $UserObj.mailNickname 'userPrincipalName' = $UserPrincipalName 'usageLocation' = $UserObj.usageLocation.value ? $UserObj.usageLocation.value : $UserObj.usageLocation - 'otherMails' = $UserObj.otherMails ? @($UserObj.otherMails) : @() + 'otherMails' = $normalizedOtherMails 'jobTitle' = $UserObj.jobTitle 'mobilePhone' = $UserObj.mobilePhone 'streetAddress' = $UserObj.streetAddress diff --git a/host.json b/host.json index 1246bb822fed..32862eb0e52d 100644 --- a/host.json +++ b/host.json @@ -16,9 +16,9 @@ "distributedTracingEnabled": false, "version": "None" }, - "defaultVersion": "10.2.0", + "defaultVersion": "10.2.1", "versionMatchStrategy": "Strict", "versionFailureStrategy": "Fail" } } -} +} \ No newline at end of file diff --git a/profile.ps1 b/profile.ps1 index 9d52861225d7..cf882819db24 100644 --- a/profile.ps1 +++ b/profile.ps1 @@ -127,6 +127,7 @@ if (!$LastStartup -or $CurrentVersion -ne $LastStartup.Version) { $SwVersion.Stop() $Timings['VersionCheck'] = $SwVersion.Elapsed.TotalMilliseconds +Set-CIPPEnvVarBackup if ($env:AzureWebJobsStorage -ne 'UseDevelopmentStorage=true' -and $env:NonLocalHostAzurite -ne 'true') { Set-CIPPOffloadFunctionTriggers } diff --git a/version_latest.txt b/version_latest.txt index 2bd6f7e39277..85651cb5eaa6 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.2.0 +10.2.1 \ No newline at end of file