diff --git a/Config/CIPPTimers.json b/Config/CIPPTimers.json index 745034562ce3..b03f2070fa0b 100644 --- a/Config/CIPPTimers.json +++ b/Config/CIPPTimers.json @@ -237,6 +237,16 @@ "TZOffset": true, "IsSystem": true }, + { + "Id": "5e8a9b4c-2d6f-4a3e-b7c1-9d0e5f3a8b2c", + "Command": "Start-IntuneReportExportOrchestrator", + "Description": "Submit Intune report-export jobs ahead of nightly DB cache run", + "Cron": "0 0 2 * * *", + "Priority": 22, + "RunOnProcessor": true, + "TZOffset": true, + "IsSystem": true + }, { "Id": "9a7f8e6d-5c4b-3a2d-1e0f-9b8c7d6e5f4a", "Command": "Start-CIPPDBCacheOrchestrator", diff --git a/Config/version_latest.txt b/Config/version_latest.txt deleted file mode 100644 index bb13e7c9bc64..000000000000 --- a/Config/version_latest.txt +++ /dev/null @@ -1 +0,0 @@ -10.4.2 diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 index fde3ca8e0658..505570ec1ae5 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 @@ -179,7 +179,7 @@ function Push-ExecScheduledCommand { return } - if ($Command.Module -notin @('CIPPCore', 'CIPPAlerts', 'CIPPStandards', 'CIPPTests', 'CIPPDB')) { + if ($Command.Module -notin @('CIPPCore', 'CIPPAlerts', 'CIPPStandards', 'CIPPTests', 'CIPPDB', 'CippExtensions')) { $State = 'Failed' Write-LogMessage -headers $Headers -API 'ScheduledTask' -message "Blocked attempt to schedule command from unauthorized module: $($Command.ModuleName)\$($Item.Command)" -Sev 'Warning' $Results = "Task blocked: The command '$($Item.Command)' is not permitted to run as a scheduled task." diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-IntuneReportExportSubmit.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-IntuneReportExportSubmit.ps1 new file mode 100644 index 000000000000..bee6da069645 --- /dev/null +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-IntuneReportExportSubmit.ps1 @@ -0,0 +1,66 @@ +function Push-IntuneReportExportSubmit { + <# + .SYNOPSIS + Submits an Intune report export job for a tenant and stores the job id. + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param($Item) + + $TenantFilter = $Item.TenantFilter + $ReportName = $Item.ReportName + + if (-not $TenantFilter -or -not $ReportName) { + Write-LogMessage -API 'IntuneReportExport' -message 'Missing TenantFilter or ReportName on activity item' -sev Error + return @{ Status = 'Failed'; Reason = 'MissingInput' } + } + + try { + $Select = switch ($ReportName) { + 'AppInvRawData' { + @( + 'ApplicationKey', 'ApplicationName', 'ApplicationPublisher', 'ApplicationVersion', + 'DeviceId', 'DeviceName', 'OSDescription', 'OSVersion', 'Platform', + 'UserId', 'UserName', 'EmailAddress' + ) + } + default { throw "Unknown Intune report '$ReportName'" } + } + + $Body = @{ + reportName = $ReportName + format = 'json' + localizationType = 'replaceLocalizableValues' + select = $Select + } | ConvertTo-Json -Depth 5 + + $Job = New-GraphPOSTRequest ` + -uri 'https://graph.microsoft.com/beta/deviceManagement/reports/exportJobs' ` + -tenantid $TenantFilter ` + -body $Body + + if (-not $Job.id) { throw "Intune returned no job id for $ReportName" } + + $JobsTable = Get-CIPPTable -tablename 'IntuneReportJobs' + $Existing = Get-CIPPAzDataTableEntity @JobsTable -Filter "PartitionKey eq '$TenantFilter' and RowKey eq '$ReportName'" + if ($Existing) { + Remove-AzDataTableEntity @JobsTable -Entity $Existing -Force -ErrorAction SilentlyContinue + } + + Add-CIPPAzDataTableEntity @JobsTable -Entity @{ + PartitionKey = $TenantFilter + RowKey = $ReportName + JobId = $Job.id + ReportName = $ReportName + SubmittedAt = ([DateTime]::UtcNow).ToString('o') + } -Force + + Write-LogMessage -API 'IntuneReportExport' -tenant $TenantFilter -message "Submitted $ReportName export job $($Job.id)" -sev Info + return @{ Status = 'Submitted'; JobId = $Job.id; ReportName = $ReportName; TenantFilter = $TenantFilter } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'IntuneReportExport' -tenant $TenantFilter -message "Failed to submit $ReportName export: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + return @{ Status = 'Failed'; ReportName = $ReportName; TenantFilter = $TenantFilter; Error = $ErrorMessage.NormalizedError } + } +} diff --git a/Modules/CIPPCore/Public/Add-CIPPGroupMember.ps1 b/Modules/CIPPCore/Public/Add-CIPPGroupMember.ps1 index 547634031dfc..3a5ea194fe18 100644 --- a/Modules/CIPPCore/Public/Add-CIPPGroupMember.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPGroupMember.ps1 @@ -45,54 +45,37 @@ function Add-CIPPGroupMember { } $Users = New-GraphBulkRequest -Requests @($Requests) -tenantid $TenantFilter - $SuccessfulUsers = [System.Collections.Generic.List[string]]::new() - $FailedUsers = [System.Collections.Generic.List[string]]::new() - - # Accept both human-readable labels (from Invoke-EditGroup / older callers) and - # camelCase calculatedGroupType values (from the user template / add-edit-user form) - $ExoGroupTypes = @('Distribution list', 'Distribution List', 'Mail-Enabled Security', 'distributionList', 'security') - - if ($GroupType -in $ExoGroupTypes) { + if ($GroupType -eq 'Distribution list' -or $GroupType -eq 'Mail-Enabled Security') { $ExoBulkRequests = [System.Collections.Generic.List[object]]::new() - $GuidToUpn = @{} + $ExoLogs = [System.Collections.Generic.List[object]]::new() foreach ($User in $Users) { - $UserUpn = $User.body.userPrincipalName - if (-not $UserUpn) { continue } - $OpGuid = [guid]::NewGuid().ToString() - $GuidToUpn[$OpGuid] = $UserUpn - $Params = @{ Identity = $GroupId; Member = $UserUpn; BypassSecurityGroupManagerCheck = $true } + $Params = @{ Identity = $GroupId; Member = $User.body.userPrincipalName; BypassSecurityGroupManagerCheck = $true } $ExoBulkRequests.Add(@{ - OperationGuid = $OpGuid - CmdletInput = @{ + CmdletInput = @{ CmdletName = 'Add-DistributionGroupMember' Parameters = $Params } }) + $ExoLogs.Add(@{ + message = "Added member $($User.body.userPrincipalName) to $($GroupId) group" + target = $User.body.userPrincipalName + }) } if ($ExoBulkRequests.Count -gt 0) { - $RawExoRequest = @(New-ExoBulkRequest -tenantid $TenantFilter -cmdletArray @($ExoBulkRequests)) + $RawExoRequest = New-ExoBulkRequest -tenantid $TenantFilter -cmdletArray @($ExoBulkRequests) + $LastError = $RawExoRequest | Select-Object -Last 1 - # Index responses by OperationGuid so each user is correlated by position, not by error.target - $ResponseByGuid = @{} - foreach ($Response in $RawExoRequest) { - if ($Response.OperationGuid) { - $ResponseByGuid[$Response.OperationGuid] = $Response - } + foreach ($ExoError in $LastError.error) { + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $ExoError -Sev 'Error' + throw $ExoError } - foreach ($OpGuid in $GuidToUpn.Keys) { - $UserUpn = $GuidToUpn[$OpGuid] - $Response = $ResponseByGuid[$OpGuid] - - if ($Response -and $Response.error) { - $ErrorText = if ($Response.error -is [string]) { $Response.error } else { ($Response.error | Out-String).Trim() } - Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to add member $($UserUpn) to $($GroupId): $ErrorText" -Sev 'Error' - $FailedUsers.Add($UserUpn) - } else { - Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Added member $($UserUpn) to $($GroupId) group" -Sev 'Info' - $SuccessfulUsers.Add($UserUpn) + foreach ($ExoLog in $ExoLogs) { + $ExoError = $LastError | Where-Object { $ExoLog.target -in $_.target -and $_.error } + if (!$LastError -or ($LastError.error -and $LastError.target -notcontains $ExoLog.target)) { + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $ExoLog.message -Sev 'Info' } } } @@ -108,26 +91,19 @@ function Add-CIPPGroupMember { } } $AddResults = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($AddRequests) + $SuccessfulUsers = [system.collections.generic.list[string]]::new() foreach ($Result in $AddResults) { - $UserPrincipalName = $Users | Where-Object { $_.body.id -eq $Result.id } | Select-Object -ExpandProperty body | Select-Object -ExpandProperty userPrincipalName if ($Result.status -lt 200 -or $Result.status -gt 299) { - Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to add member $($UserPrincipalName): $($Result.body.error.message)" -Sev 'Error' - $FailedUsers.Add($UserPrincipalName) + $FailedUsername = $Users | Where-Object { $_.body.id -eq $Result.id } | Select-Object -ExpandProperty body | Select-Object -ExpandProperty userPrincipalName + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to add member $($FailedUsername): $($Result.body.error.message)" -Sev 'Error' } else { + $UserPrincipalName = $Users | Where-Object { $_.body.id -eq $Result.id } | Select-Object -ExpandProperty body | Select-Object -ExpandProperty userPrincipalName $SuccessfulUsers.Add($UserPrincipalName) } } } - - if ($SuccessfulUsers.Count -eq 0 -and $FailedUsers.Count -gt 0) { - $Results = "Failed to add user $($FailedUsers -join ', ') to $($GroupId)." - throw $Results - } - - $Results = "Successfully added user $($SuccessfulUsers -join ', ') to $($GroupId)." - if ($FailedUsers.Count -gt 0) { - $Results = "$Results Failed to add: $($FailedUsers -join ', ')." - } + $UserList = ($SuccessfulUsers -join ', ') + $Results = "Successfully added user $UserList to $($GroupId)." Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Results -Sev 'Info' return $Results } catch { diff --git a/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 b/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 index 94142b32371c..576c2bcca127 100644 --- a/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 @@ -70,7 +70,7 @@ function Add-CIPPScheduledTask { $ImportedModules = [System.Collections.Generic.List[string]]::new() if (-not $Command) { try { - foreach ($SiblingModule in @('CIPPStandards', 'CIPPAlerts', 'CIPPTests', 'CIPPDB')) { + foreach ($SiblingModule in @('CIPPStandards', 'CIPPAlerts', 'CIPPTests', 'CIPPDB', 'CippExtensions')) { if (-not (Get-Module -Name $SiblingModule)) { Import-Module $SiblingModule -ErrorAction SilentlyContinue if (Get-Module -Name $SiblingModule) { @@ -91,7 +91,7 @@ function Add-CIPPScheduledTask { return "Error - The command '$RequestedCommand' does not exist and cannot be scheduled." } - if ($Command.Module -notin @('CIPPCore', 'CIPPAlerts', 'CIPPStandards', 'CIPPTests', 'CIPPDB')) { + if ($Command.Module -notin @('CIPPCore', 'CIPPAlerts', 'CIPPStandards', 'CIPPTests', 'CIPPDB', 'CippExtensions')) { Write-LogMessage -headers $Headers -API 'ScheduledTask' -message "Blocked attempt to schedule command from unauthorized module: $($Command.ModuleName)\$RequestedCommand" -Sev 'Warning' return "Error - The command '$RequestedCommand' is not permitted to run as a scheduled task." } diff --git a/Modules/CIPPCore/Public/Authentication/Add-CIPPSSOAppSecret.ps1 b/Modules/CIPPCore/Public/Authentication/Add-CIPPSSOAppSecret.ps1 new file mode 100644 index 000000000000..7d40ec88282f --- /dev/null +++ b/Modules/CIPPCore/Public/Authentication/Add-CIPPSSOAppSecret.ps1 @@ -0,0 +1,58 @@ +function Add-CIPPSSOAppSecret { + <# + .SYNOPSIS + Creates a client secret on the CIPP-SSO app registration with retry. + .DESCRIPTION + Adds a new password credential to the given app object via Graph. Retries up to + MaxRetries times with backoff because Entra propagation can take a few seconds + after the app is freshly created or its app-management-policy exemption is set. + Throws on final failure so callers can persist Status=error + LastError. + .PARAMETER ObjectId + Graph object ID of the application (NOT the appId/clientId). + .PARAMETER DisplayName + Display name to set on the password credential. Defaults to 'CIPP-SSO-Secret'. + .PARAMETER MaxRetries + Number of secret-creation attempts before giving up. Defaults to 5. + #> + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '')] + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$ObjectId, + + [Parameter(Mandatory = $false)] + [string]$DisplayName = 'CIPP-SSO-Secret', + + [Parameter(Mandatory = $false)] + [int]$MaxRetries = 5 + ) + + $SecretText = $null + $SecretAttempt = 0 + $BackoffSchedule = @(2, 5, 10, 15, 30) + $LastException = $null + + while ($SecretAttempt -lt $MaxRetries -and -not $SecretText) { + try { + $PasswordBody = @{ passwordCredential = @{ displayName = $DisplayName } } | ConvertTo-Json -Compress + $PasswordResult = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/applications/$ObjectId/addPassword" -body $PasswordBody -type POST -NoAuthCheck $true -AsApp $true + $SecretText = $PasswordResult.secretText + Write-Information "[SSO-Secret] Client secret created on objectId $ObjectId" + } catch { + $SecretAttempt++ + $LastException = $_ + Write-Warning "[SSO-Secret] Secret creation attempt $SecretAttempt/$MaxRetries failed: $($_.Exception.Message)" + if ($SecretAttempt -lt $MaxRetries) { + $Delay = $BackoffSchedule[[Math]::Min($SecretAttempt - 1, $BackoffSchedule.Count - 1)] + Start-Sleep -Seconds $Delay + } + } + } + + if (-not $SecretText) { + $InnerMessage = if ($LastException) { $LastException.Exception.Message } else { 'unknown error' } + throw "Failed to create client secret for CIPP-SSO after $MaxRetries attempts: $InnerMessage" + } + + return $SecretText +} diff --git a/Modules/CIPPCore/Public/Authentication/Get-CippAllowedPermissions.ps1 b/Modules/CIPPCore/Public/Authentication/Get-CippAllowedPermissions.ps1 index 0d0130689cd9..5a16b3c70a4e 100644 --- a/Modules/CIPPCore/Public/Authentication/Get-CippAllowedPermissions.ps1 +++ b/Modules/CIPPCore/Public/Authentication/Get-CippAllowedPermissions.ps1 @@ -23,7 +23,7 @@ function Get-CippAllowedPermissions { # Get all available permissions and base roles configuration - $Version = (Get-Content -Path (Join-Path $env:CIPPRootPath 'Config\version_latest.txt')).trim() + $Version = (Get-Content -Path (Join-Path $env:CIPPRootPath 'version_latest.txt')).trim() $BaseRoles = Get-Content -Path (Join-Path $env:CIPPRootPath 'Config\cipp-roles.json') | ConvertFrom-Json $DefaultRoles = @('superadmin', 'admin', 'editor', 'readonly', 'anonymous', 'authenticated') diff --git a/Modules/CIPPCore/Public/Authentication/New-CIPPSSOApp.ps1 b/Modules/CIPPCore/Public/Authentication/New-CIPPSSOApp.ps1 index d8291eacd06b..39a461977343 100644 --- a/Modules/CIPPCore/Public/Authentication/New-CIPPSSOApp.ps1 +++ b/Modules/CIPPCore/Public/Authentication/New-CIPPSSOApp.ps1 @@ -6,8 +6,9 @@ function New-CIPPSSOApp { Creates a new or updates an existing Entra ID app registration for CIPP-SSO with openid, profile, and email delegated permissions. If ExistingAppId is provided, looks up that specific app by clientId. If the app no longer exists in the tenant, - creates a new one. Generates a client secret and returns the details needed to - configure EasyAuth. + creates a new one. Does NOT create a client secret — call Add-CIPPSSOAppSecret + for that as a separate step so the AppId can be persisted before the (sometimes + flaky) secret creation runs. #> [CmdletBinding()] param( @@ -120,37 +121,12 @@ function New-CIPPSSOApp { Write-Warning "[SSO-App] App management policy update failed (secret creation may still work): $($_.Exception.Message)" } - # Create client secret with retry - $SecretText = $null - $SecretAttempt = 0 - $MaxSecretRetries = 5 - while ($SecretAttempt -lt $MaxSecretRetries -and -not $SecretText) { - try { - $PasswordBody = '{"passwordCredential":{"displayName":"CIPP-SSO-Secret"}}' - $PasswordResult = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/applications/$AppObjectId/addPassword" -body $PasswordBody -type POST -NoAuthCheck $true -AsApp $true - $SecretText = $PasswordResult.secretText - Write-Information "[SSO-App] Client secret created" - } catch { - $SecretAttempt++ - Write-Warning "[SSO-App] Secret creation attempt $SecretAttempt/$MaxSecretRetries failed: $($_.Exception.Message)" - if ($SecretAttempt -lt $MaxSecretRetries) { - $Delay = @(2, 5, 10, 15, 30)[$SecretAttempt - 1] - Start-Sleep -Seconds $Delay - } - } - } - - if (-not $SecretText) { - throw "Failed to create client secret for $AppDisplayName after $MaxSecretRetries attempts" - } - return [PSCustomObject]@{ - AppId = $AppClientId - ObjectId = $AppObjectId - ClientSecret = $SecretText - TenantId = $env:TenantID - DisplayName = $AppDisplayName - State = $State - MultiTenant = $MultiTenant + AppId = $AppClientId + ObjectId = $AppObjectId + TenantId = $env:TenantID + DisplayName = $AppDisplayName + State = $State + MultiTenant = $MultiTenant } } diff --git a/Modules/CIPPCore/Public/Authentication/Set-CIPPSSOStoredCredentials.ps1 b/Modules/CIPPCore/Public/Authentication/Set-CIPPSSOStoredCredentials.ps1 new file mode 100644 index 000000000000..8ee4bd1456c1 --- /dev/null +++ b/Modules/CIPPCore/Public/Authentication/Set-CIPPSSOStoredCredentials.ps1 @@ -0,0 +1,54 @@ +function Set-CIPPSSOStoredCredentials { + <# + .SYNOPSIS + Persists CIPP-SSO credentials to Key Vault (or the DevSecrets table in dev mode). + .DESCRIPTION + Writes whichever of -AppId / -AppSecret / -MultiTenant were supplied. Pass only + the values you actually want to update — e.g. Repair passes only -AppSecret, + Create passes all three. + #> + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '')] + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)][string]$AppId, + [Parameter(Mandatory = $false)][string]$AppSecret, + [Parameter(Mandatory = $false)][object]$MultiTenant + ) + + $IsDev = $env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true' + + if ($IsDev) { + $DevSecretsTable = Get-CIPPTable -tablename 'DevSecrets' + $Secret = Get-CIPPAzDataTableEntity @DevSecretsTable -Filter "PartitionKey eq 'SSO' and RowKey eq 'SSO'" -ErrorAction SilentlyContinue + if (-not $Secret) { $Secret = [PSCustomObject]@{} } + $Secret | Add-Member -MemberType NoteProperty -Name 'PartitionKey' -Value 'SSO' -Force + $Secret | Add-Member -MemberType NoteProperty -Name 'RowKey' -Value 'SSO' -Force + if ($AppId) { $Secret | Add-Member -MemberType NoteProperty -Name 'SSOAppId' -Value $AppId -Force } + if ($AppSecret) { $Secret | Add-Member -MemberType NoteProperty -Name 'SSOAppSecret' -Value $AppSecret -Force } + if ($PSBoundParameters.ContainsKey('MultiTenant')) { + $Secret | Add-Member -MemberType NoteProperty -Name 'SSOMultiTenant' -Value ([string]([bool]$MultiTenant)) -Force + } + Add-CIPPAzDataTableEntity @DevSecretsTable -Entity $Secret -Force | Out-Null + return + } + + $KV = $env:WEBSITE_DEPLOYMENT_ID + $VaultName = if ($KV) { ($KV -split '-')[0] } else { $null } + if (-not $VaultName) { throw 'Cannot determine Key Vault name from WEBSITE_DEPLOYMENT_ID' } + + if ($AppId) { + $ExistingAppIdSecret = $null + try { $ExistingAppIdSecret = Get-CippKeyVaultSecret -VaultName $VaultName -Name 'SSOAppId' -AsPlainText -ErrorAction Stop } catch { } + if (-not $ExistingAppIdSecret -or $ExistingAppIdSecret -ne $AppId) { + Set-CippKeyVaultSecret -VaultName $VaultName -Name 'SSOAppId' -SecretValue (ConvertTo-SecureString -String $AppId -AsPlainText -Force) + } + } + + if ($AppSecret) { + Set-CippKeyVaultSecret -VaultName $VaultName -Name 'SSOAppSecret' -SecretValue (ConvertTo-SecureString -String $AppSecret -AsPlainText -Force) + } + + if ($PSBoundParameters.ContainsKey('MultiTenant')) { + Set-CippKeyVaultSecret -VaultName $VaultName -Name 'SSOMultiTenant' -SecretValue (ConvertTo-SecureString -String ([string]([bool]$MultiTenant)) -AsPlainText -Force) + } +} diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-IntuneReportExportOrchestrator.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-IntuneReportExportOrchestrator.ps1 new file mode 100644 index 000000000000..c772192523cd --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-IntuneReportExportOrchestrator.ps1 @@ -0,0 +1,57 @@ +function Start-IntuneReportExportOrchestrator { + <# + .SYNOPSIS + Submits Intune report-export jobs at 02:00 UTC ahead of the 03:00 cache run. + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param() + + try { + Write-LogMessage -API 'IntuneReportExport' -message 'Starting Intune report export submission' -sev Info + + $TenantList = Get-Tenants | Where-Object { $_.defaultDomainName -ne $null } + if ($TenantList.Count -eq 0) { + return + } + + $LicensedTenants = @(foreach ($Tenant in $TenantList) { + try { + if (Test-CIPPStandardLicense -StandardName 'IntuneReportExportSubmission' -TenantFilter $Tenant.defaultDomainName -Preset Intune -SkipLog) { + $Tenant + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'IntuneReportExport' -tenant $Tenant.defaultDomainName -message "Intune license check failed: $($ErrorMessage.NormalizedError)" -sev Warning -LogData $ErrorMessage + } + }) + + if ($LicensedTenants.Count -eq 0) { + return + } + + $Queue = New-CippQueueEntry -Name 'Intune Report Export Submission' -TotalTasks $LicensedTenants.Count + + $Batch = foreach ($Tenant in $LicensedTenants) { + [PSCustomObject]@{ + FunctionName = 'IntuneReportExportSubmit' + TenantFilter = $Tenant.defaultDomainName + ReportName = 'AppInvRawData' + QueueId = $Queue.RowKey + QueueName = "Intune Export Submit - $($Tenant.defaultDomainName)" + } + } + + Start-CIPPOrchestrator -InputObject ([PSCustomObject]@{ + Batch = @($Batch) + OrchestratorName = 'IntuneReportExportOrchestrator' + SkipLog = $false + }) + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'IntuneReportExport' -message "Failed to start orchestration: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + throw + } +} diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPStatsTimer.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPStatsTimer.ps1 index 8bb757c4fbcf..7df1c6fa0b46 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPStatsTimer.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPStatsTimer.ps1 @@ -15,7 +15,7 @@ function Start-CIPPStatsTimer { $TenantCount = (Get-Tenants -IncludeAll).count - $APIVersion = Get-Content (Join-Path $env:CIPPRootPath 'Config\version_latest.txt') | Out-String + $APIVersion = Get-Content (Join-Path $env:CIPPRootPath 'version_latest.txt') | Out-String $Table = Get-CIPPTable -TableName Extensionsconfig try { $RawExt = (Get-CIPPAzDataTableEntity @Table).config | ConvertFrom-Json -Depth 10 -ErrorAction Stop diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/MCP/Get-CippMcpSpec.ps1 b/Modules/CIPPCore/Public/MCP/Get-CippMcpSpec.ps1 similarity index 100% rename from Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/MCP/Get-CippMcpSpec.ps1 rename to Modules/CIPPCore/Public/MCP/Get-CippMcpSpec.ps1 diff --git a/Modules/CIPPCore/Public/MCP/Get-CippMcpToolList.ps1 b/Modules/CIPPCore/Public/MCP/Get-CippMcpToolList.ps1 new file mode 100644 index 000000000000..5852d6d966e8 --- /dev/null +++ b/Modules/CIPPCore/Public/MCP/Get-CippMcpToolList.ps1 @@ -0,0 +1,194 @@ +function Get-CippMcpToolList { + <# + .SYNOPSIS + Projects the CIPP OpenAPI spec into the read-only MCP tool list, with optional per-connection filtering. + .DESCRIPTION + Returns every operation whose x-cipp-role ends in '.Read' (never '.ReadWrite') as an + MCP tool definition: name (the API endpoint), description, inputSchema (JSON Schema + built from the operation's query parameters / request body with $ref inlined), and + read-only annotations. The full projection is cached per worker; pass -Force to rebuild. + + The connector URL's query string filters what is advertised (the query is NOT part of the + OAuth resource, so it does not affect auth). One CIPP instance can therefore back several + connector instances, each scoped to a subset. Supported query parameters: + ?tags=Identity,Exchange only tools in those top-level CIPP categories (the OpenAPI tag) + ?tools=ListUsers,ListGroups explicit allow-list of tool names + ?first=70 / ?limit=70 cap the number of tools (e.g. for clients with a tool ceiling) + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Request, + [switch]$Force + ) + + # Build (and cache) the full read-only tool list. Each cached entry carries an internal _category + # (top-level OpenAPI tag) used only for filtering; it is stripped before the list is returned. + if (-not $script:CippMcpToolListCache -or $Force) { + $Spec = Get-CippMcpSpec + $Tools = [System.Collections.Generic.List[object]]::new() + + foreach ($PathEntry in $Spec['paths'].GetEnumerator()) { + $Endpoint = $PathEntry.Key -replace '^/api/', '' + + # Never expose the MCP transport itself as a tool. + if ($Endpoint -eq 'ExecMcp') { continue } + + foreach ($MethodEntry in $PathEntry.Value.GetEnumerator()) { + $Method = [string]$MethodEntry.Key + if ($Method -notin @('get', 'post')) { continue } + + $Op = $MethodEntry.Value + $Role = $Op['x-cipp-role'] + + # Read-only surface only. + if (-not $Role -or $Role -notmatch '\.Read$') { continue } + + # Defensive backstop: never expose an endpoint whose name implies a mutation, + # even if its x-cipp-role is mislabeled '.Read' (e.g. AddTestReport, EditIntunePolicy). + if ($Endpoint -match '^(Add|Set|Remove|Delete|Edit|New|Update|Disable|Enable|Reset|Revoke|Push|Clear|Start|Stop|Rename|Move|Copy)') { continue } + + $Properties = [ordered]@{} + $RequiredList = [System.Collections.Generic.List[string]]::new() + + # Query / path parameters. + foreach ($ParamRaw in @($Op['parameters'])) { + if (-not $ParamRaw) { continue } + $Param = Resolve-CippMcpNode -Node $ParamRaw -Spec $Spec + if ($Param['in'] -notin @('query', 'path')) { continue } + $Schema = if ($Param['schema']) { $Param['schema'] } else { @{ type = 'string' } } + $Properties[[string]$Param['name']] = $Schema + if ($Param['required']) { $RequiredList.Add([string]$Param['name']) } + } + + # Request body (uncommon for reads; included for completeness). + if ($Op['requestBody'] -and $Op['requestBody']['content'] -and $Op['requestBody']['content']['application/json']) { + $BodySchema = Resolve-CippMcpNode -Node $Op['requestBody']['content']['application/json']['schema'] -Spec $Spec + if ($BodySchema -and $BodySchema['properties']) { + foreach ($BodyProp in $BodySchema['properties'].GetEnumerator()) { + $Properties[[string]$BodyProp.Key] = $BodyProp.Value + } + foreach ($Req in @($BodySchema['required'])) { if ($Req) { $RequiredList.Add([string]$Req) } } + } + } + + $InputSchema = [ordered]@{ + type = 'object' + properties = $Properties + } + if ($RequiredList.Count -gt 0) { + $InputSchema['required'] = @($RequiredList | Select-Object -Unique) + } + + $Tag = @($Op['tags'])[0] + $Category = if ($Tag) { ([string]$Tag -split '\s*>\s*')[0].Trim() } else { 'Uncategorized' } + + $Tools.Add([ordered]@{ + name = $Endpoint + description = Get-CippMcpDescription -Operation $Op + inputSchema = $InputSchema + annotations = [ordered]@{ title = $Endpoint; readOnlyHint = $true } + _category = $Category + }) + } + } + + $script:CippMcpToolListCache = $Tools + } + + $Filtered = @($script:CippMcpToolListCache) + + # Per-connection filtering from the connector URL's query string. + $Query = $Request.Query + $TagFilter = "$($Query.tags ?? $Query.category ?? $Query.tag)".Trim() + if ($TagFilter) { + $WantedCats = @($TagFilter -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + $Filtered = @($Filtered | Where-Object { $_._category -in $WantedCats }) + } + + $ToolFilter = "$($Query.tools)".Trim() + if ($ToolFilter) { + $WantedTools = @($ToolFilter -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + $Filtered = @($Filtered | Where-Object { $_.name -in $WantedTools }) + } + + $Limit = ($Query.first ?? $Query.limit) -as [int] + if ($Limit -gt 0) { + $Filtered = @($Filtered | Select-Object -First $Limit) + } + + Write-Information "[MCP] tools/list -> $($Filtered.Count) tools (tags='$TagFilter' tools='$ToolFilter' first='$Limit')" + + # Project to the wire shape (drop the internal _category). + return @($Filtered | ForEach-Object { + [ordered]@{ + name = $_.name + description = $_.description + inputSchema = $_.inputSchema + annotations = $_.annotations + } + }) +} + +function Resolve-CippMcpNode { + # Deep-resolves a parsed OpenAPI node (hashtable/array/scalar), inlining any $ref. Internal helper. + param($Node, $Spec, [int]$Depth = 0, [string[]]$Seen = @()) + + if ($null -eq $Node) { return $null } + if ($Depth -gt 15) { return @{ type = 'object' } } + if ($Node -is [string] -or $Node -is [valuetype]) { return $Node } + + if ($Node -is [System.Collections.IDictionary]) { + if ($Node.Contains('$ref')) { + $Ref = [string]$Node['$ref'] + if ($Seen -contains $Ref) { return [ordered]@{ type = 'object'; description = 'recursive reference omitted' } } + $Target = Resolve-CippMcpRef -Ref $Ref -Spec $Spec + return Resolve-CippMcpNode -Node $Target -Spec $Spec -Depth ($Depth + 1) -Seen ($Seen + $Ref) + } + $Out = [ordered]@{} + foreach ($Entry in $Node.GetEnumerator()) { + if ($Entry.Key -eq '$ref') { continue } + $Out[[string]$Entry.Key] = Resolve-CippMcpNode -Node $Entry.Value -Spec $Spec -Depth ($Depth + 1) -Seen $Seen + } + return $Out + } + + if ($Node -is [System.Collections.IEnumerable]) { + return @($Node | ForEach-Object { Resolve-CippMcpNode -Node $_ -Spec $Spec -Depth ($Depth + 1) -Seen $Seen }) + } + + return $Node +} + +function Resolve-CippMcpRef { + # Resolves a JSON pointer like '#/components/parameters/tenantFilter' against the spec. Internal helper. + param([string]$Ref, $Spec) + + $Segments = $Ref.TrimStart('#') -split '/' | Where-Object { $_ -ne '' } + $Node = $Spec + foreach ($Seg in $Segments) { + $Key = $Seg -replace '~1', '/' -replace '~0', '~' + if ($Node -is [System.Collections.IDictionary] -and $Node.Contains($Key)) { + $Node = $Node[$Key] + } else { + return $null + } + } + return $Node +} + +function Get-CippMcpDescription { + # Cleans the operation description (strips leaked PowerShell help) and prefixes the tag. Internal helper. + param($Operation) + + $Desc = [string]$Operation['description'] + $Desc = $Desc -replace '(?s)\s*#>.*$', '' + $Desc = $Desc -replace '(?s)\[CmdletBinding.*$', '' + $Desc = $Desc.Trim() + if ([string]::IsNullOrWhiteSpace($Desc)) { $Desc = [string]$Operation['summary'] } + + $Tag = @($Operation['tags'])[0] + if ($Tag -and $Tag -ne 'Uncategorized') { $Desc = "[$Tag] $Desc" } + return $Desc +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/MCP/Get-CippMcpToolResult.ps1 b/Modules/CIPPCore/Public/MCP/Get-CippMcpToolResult.ps1 similarity index 100% rename from Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/MCP/Get-CippMcpToolResult.ps1 rename to Modules/CIPPCore/Public/MCP/Get-CippMcpToolResult.ps1 diff --git a/Modules/CIPPCore/Public/New-CIPPUserTask.ps1 b/Modules/CIPPCore/Public/New-CIPPUserTask.ps1 index a026e122bb63..f95dfb90b586 100644 --- a/Modules/CIPPCore/Public/New-CIPPUserTask.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPUserTask.ps1 @@ -70,7 +70,7 @@ function New-CIPPUserTask { # Add to groups if ($UserObj.AddToGroups) { - $ExoGroupTypes = @('Distribution list', 'Distribution List', 'Mail-Enabled Security', 'distributionList', 'security') + $ExoGroupTypes = @('Distribution list', 'Mail-Enabled Security') $UserObj.AddToGroups | ForEach-Object { $Group = $_ $GroupType = $Group.addedFields.groupType diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDetectedApps.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDetectedApps.ps1 index 8783690f4b61..09c22d47b137 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDetectedApps.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDetectedApps.ps1 @@ -1,13 +1,14 @@ function Set-CIPPDBCacheDetectedApps { <# .SYNOPSIS - Caches all detected apps for a tenant, including devices that have each app + Caches detected apps using the AppInvRawData export submitted earlier, + enriched with the live /detectedApps catalog. .PARAMETER TenantFilter - The tenant to cache detected apps for + The tenant to cache detected apps for. .PARAMETER QueueId - The queue ID to update with total tasks (optional) + Optional queue ID for progress tracking. #> [CmdletBinding()] param( @@ -16,86 +17,111 @@ function Set-CIPPDBCacheDetectedApps { [string]$QueueId ) + $ReportName = 'AppInvRawData' + try { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching detected apps' -sev Debug + $JobsTable = Get-CIPPTable -tablename 'IntuneReportJobs' + $JobRow = Get-CIPPAzDataTableEntity @JobsTable -Filter "PartitionKey eq '$TenantFilter' and RowKey eq '$ReportName'" - # Step 1: Get first page with noPaginate to avoid sequential chase, and read @odata.count - $FirstPageResult = New-GraphBulkRequest -Requests @( - [PSCustomObject]@{ - id = 'detectedApps-0' - method = 'GET' - url = 'deviceManagement/detectedApps' - } - ) -tenantid $TenantFilter -NoPaginateIds @('detectedApps-0') + if (-not $JobRow) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "No $ReportName job submitted - skipping detected apps cache" -sev Info + return + } - $FirstResponse = ($FirstPageResult | Where-Object { $_.id -eq 'detectedApps-0' }).body - $TotalCount = $FirstResponse.'@odata.count' - $DetectedApps = [System.Collections.Generic.List[PSCustomObject]]::new() - foreach ($app in $FirstResponse.value) { $DetectedApps.Add($app) } + $JobId = $JobRow.JobId + if (-not $JobId) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "IntuneReportJobs row missing JobId - removing" -sev Warning + Remove-AzDataTableEntity @JobsTable -Entity $JobRow -Force -ErrorAction SilentlyContinue + return + } - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "DetectedApps total count: $TotalCount, first page: $($DetectedApps.Count)" -sev Debug + try { + $Job = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceManagement/reports/exportJobs/$JobId" -tenantid $TenantFilter + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "$ReportName job $JobId not retrievable: $($ErrorMessage.NormalizedError)" -sev Warning -LogData $ErrorMessage + Remove-AzDataTableEntity @JobsTable -Entity $JobRow -Force -ErrorAction SilentlyContinue + return + } - # Step 2: If more pages exist, pre-calculate all skip offsets and fire as batches - if ($FirstResponse.'@odata.nextLink' -and $TotalCount -gt 50) { - $SkipRequests = [System.Collections.Generic.List[PSCustomObject]]::new() - for ($skip = 50; $skip -lt $TotalCount; $skip += 50) { - $SkipRequests.Add([PSCustomObject]@{ - id = "detectedApps-$skip" - method = 'GET' - url = "deviceManagement/detectedApps?`$skip=$skip" - }) + switch ($Job.status) { + 'completed' { } + 'failed' { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "$ReportName job $JobId failed" -sev Error + Remove-AzDataTableEntity @JobsTable -Entity $JobRow -Force -ErrorAction SilentlyContinue + return } - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Fetching $($SkipRequests.Count) remaining pages in bulk" -sev Debug - - # New-GraphBulkRequest auto-batches into groups of 20, NoPaginateIds prevents chasing empty nextLinks - $SkipResults = New-GraphBulkRequest -Requests @($SkipRequests) -tenantid $TenantFilter -NoPaginateIds @($SkipRequests.id) - - foreach ($Result in $SkipResults) { - if ($Result.status -eq 200 -and $Result.body.value) { - foreach ($app in $Result.body.value) { $DetectedApps.Add($app) } - } + default { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "$ReportName job $JobId still '$($Job.status)' - skipping" -sev Info + return } } - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Retrieved $($DetectedApps.Count) detected apps (expected $TotalCount)" -sev Debug - - if ($DetectedApps.Count -eq 0) { - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DetectedApps' -Data @() -AddCount + if (-not $Job.url) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "$ReportName job $JobId completed but no url returned" -sev Error + Remove-AzDataTableEntity @JobsTable -Entity $JobRow -Force -ErrorAction SilentlyContinue return } - # Step 3: Bulk fetch managed devices for each app (unchanged from original) - $DeviceRequests = $DetectedApps | Where-Object { $_.id } | ForEach-Object { - [PSCustomObject]@{ - id = $_.id - method = 'GET' - url = "deviceManagement/detectedApps('$($_.id)')/managedDevices" - } + $ZipBytes = (Invoke-WebRequest -Uri $Job.url -UseBasicParsing -ErrorAction Stop).Content + if ($ZipBytes -isnot [byte[]]) { throw "Expected binary content from $ReportName download" } + + $JsonText = $null + $ZipStream = [System.IO.MemoryStream]::new($ZipBytes, $false) + try { + $Archive = [System.IO.Compression.ZipArchive]::new($ZipStream, [System.IO.Compression.ZipArchiveMode]::Read) + try { + $Entry = $Archive.Entries | Where-Object { $_.Name -like '*.json' } | Select-Object -First 1 + if (-not $Entry) { throw "No JSON entry in $ReportName archive" } + $EntryStream = $Entry.Open() + try { + $Reader = [System.IO.StreamReader]::new($EntryStream) + try { $JsonText = $Reader.ReadToEnd() } finally { $Reader.Dispose() } + } finally { $EntryStream.Dispose() } + } finally { $Archive.Dispose() } + } finally { + $ZipStream.Dispose() + $ZipBytes = $null } - if ($DeviceRequests) { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Fetching devices for $($DetectedApps.Count) detected apps" -sev Debug - $DeviceResults = New-GraphBulkRequest -Requests @($DeviceRequests) -tenantid $TenantFilter - - # Add devices to each detected app object - $DetectedAppsWithDevices = foreach ($App in $DetectedApps) { - $Devices = Get-GraphBulkResultByID -Results $DeviceResults -ID $App.id -Value - $App | Add-Member -NotePropertyName 'managedDevices' -NotePropertyValue ($Devices ?? @()) -Force - $App + $ExportRows = @(($JsonText | ConvertFrom-Json).values) + $JsonText = $null + + $AppsByKey = @{} + foreach ($Row in $ExportRows) { + $AppId = $Row.ApplicationKey + if (-not $AppId) { continue } + if (-not $AppsByKey.ContainsKey($AppId)) { + $AppsByKey[$AppId] = [pscustomobject]@{ + id = $AppId + displayName = $Row.ApplicationName + version = $Row.ApplicationVersion + publisher = $Row.ApplicationPublisher + platform = $Row.Platform + deviceCount = 0 + managedDevices = [System.Collections.Generic.List[object]]::new() + } } - - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DetectedApps' -Data $DetectedAppsWithDevices -AddCount - $DetectedApps = $null - $DetectedAppsWithDevices = $null - } else { - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DetectedApps' -Data $DetectedApps -AddCount - $DetectedApps = $null + $App = $AppsByKey[$AppId] + $App.managedDevices.Add([pscustomobject]@{ + id = $Row.DeviceId + deviceName = $Row.DeviceName + osVersion = $Row.OSVersion + platform = $Row.Platform + userId = $Row.UserId + userPrincipalName = $Row.UserName + emailAddress = $Row.EmailAddress + }) + $App.deviceCount++ } - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached detected apps with devices successfully' -sev Debug + $DetectedApps = @($AppsByKey.Values) + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DetectedApps' -Data $DetectedApps -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($DetectedApps.Count) detected apps with devices from export $JobId" -sev Info + Remove-AzDataTableEntity @JobsTable -Entity $JobRow -Force -ErrorAction SilentlyContinue } catch { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter ` - -message "Failed to cache detected apps: $($_.Exception.Message)" -sev Error + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache detected apps: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/MCP/Get-CippMcpToolList.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/MCP/Get-CippMcpToolList.ps1 deleted file mode 100644 index a102a82e340b..000000000000 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/MCP/Get-CippMcpToolList.ps1 +++ /dev/null @@ -1,149 +0,0 @@ -function Get-CippMcpToolList { - <# - .SYNOPSIS - Projects the CIPP OpenAPI spec into the read-only MCP tool list. - .DESCRIPTION - Returns every operation whose x-cipp-role ends in '.Read' (never '.ReadWrite') as an - MCP tool definition: name (the API endpoint), description, inputSchema (JSON Schema - built from the operation's query parameters / request body with $ref inlined), and - read-only annotations. Cached per worker; pass -Force to rebuild. Not an entrypoint. - The spec is consumed as nested hashtables (Get-CippMcpSpec uses -AsHashtable). - .FUNCTIONALITY - Internal - #> - [CmdletBinding()] - param([switch]$Force) - - if ($script:CippMcpToolListCache -and -not $Force) { - return $script:CippMcpToolListCache - } - - $Spec = Get-CippMcpSpec - $Tools = [System.Collections.Generic.List[object]]::new() - - foreach ($PathEntry in $Spec['paths'].GetEnumerator()) { - $Endpoint = $PathEntry.Key -replace '^/api/', '' - - # Never expose the MCP transport itself as a tool. - if ($Endpoint -eq 'ExecMcp') { continue } - - foreach ($MethodEntry in $PathEntry.Value.GetEnumerator()) { - $Method = [string]$MethodEntry.Key - if ($Method -notin @('get', 'post')) { continue } - - $Op = $MethodEntry.Value - $Role = $Op['x-cipp-role'] - - # Read-only surface only. - if (-not $Role -or $Role -notmatch '\.Read$') { continue } - - # Defensive backstop: never expose an endpoint whose name implies a mutation, - # even if its x-cipp-role is mislabeled '.Read' (e.g. AddTestReport, EditIntunePolicy). - if ($Endpoint -match '^(Add|Set|Remove|Delete|Edit|New|Update|Disable|Enable|Reset|Revoke|Push|Clear|Start|Stop|Rename|Move|Copy)') { continue } - - $Properties = [ordered]@{} - $RequiredList = [System.Collections.Generic.List[string]]::new() - - # Query / path parameters. - foreach ($ParamRaw in @($Op['parameters'])) { - if (-not $ParamRaw) { continue } - $Param = Resolve-CippMcpNode -Node $ParamRaw -Spec $Spec - if ($Param['in'] -notin @('query', 'path')) { continue } - $Schema = if ($Param['schema']) { $Param['schema'] } else { @{ type = 'string' } } - $Properties[[string]$Param['name']] = $Schema - if ($Param['required']) { $RequiredList.Add([string]$Param['name']) } - } - - # Request body (uncommon for reads; included for completeness). - if ($Op['requestBody'] -and $Op['requestBody']['content'] -and $Op['requestBody']['content']['application/json']) { - $BodySchema = Resolve-CippMcpNode -Node $Op['requestBody']['content']['application/json']['schema'] -Spec $Spec - if ($BodySchema -and $BodySchema['properties']) { - foreach ($BodyProp in $BodySchema['properties'].GetEnumerator()) { - $Properties[[string]$BodyProp.Key] = $BodyProp.Value - } - foreach ($Req in @($BodySchema['required'])) { if ($Req) { $RequiredList.Add([string]$Req) } } - } - } - - $InputSchema = [ordered]@{ - type = 'object' - properties = $Properties - } - if ($RequiredList.Count -gt 0) { - $InputSchema['required'] = @($RequiredList | Select-Object -Unique) - } - - $Tools.Add([ordered]@{ - name = $Endpoint - description = Get-CippMcpDescription -Operation $Op - inputSchema = $InputSchema - annotations = [ordered]@{ title = $Endpoint; readOnlyHint = $true } - }) - } - } - - $script:CippMcpToolListCache = $Tools - return $Tools -} - -function Resolve-CippMcpNode { - # Deep-resolves a parsed OpenAPI node (hashtable/array/scalar), inlining any $ref. Internal helper. - param($Node, $Spec, [int]$Depth = 0, [string[]]$Seen = @()) - - if ($null -eq $Node) { return $null } - if ($Depth -gt 15) { return @{ type = 'object' } } - if ($Node -is [string] -or $Node -is [valuetype]) { return $Node } - - if ($Node -is [System.Collections.IDictionary]) { - if ($Node.Contains('$ref')) { - $Ref = [string]$Node['$ref'] - if ($Seen -contains $Ref) { return [ordered]@{ type = 'object'; description = 'recursive reference omitted' } } - $Target = Resolve-CippMcpRef -Ref $Ref -Spec $Spec - return Resolve-CippMcpNode -Node $Target -Spec $Spec -Depth ($Depth + 1) -Seen ($Seen + $Ref) - } - $Out = [ordered]@{} - foreach ($Entry in $Node.GetEnumerator()) { - if ($Entry.Key -eq '$ref') { continue } - $Out[[string]$Entry.Key] = Resolve-CippMcpNode -Node $Entry.Value -Spec $Spec -Depth ($Depth + 1) -Seen $Seen - } - return $Out - } - - if ($Node -is [System.Collections.IEnumerable]) { - return @($Node | ForEach-Object { Resolve-CippMcpNode -Node $_ -Spec $Spec -Depth ($Depth + 1) -Seen $Seen }) - } - - return $Node -} - -function Resolve-CippMcpRef { - # Resolves a JSON pointer like '#/components/parameters/tenantFilter' against the spec. Internal helper. - param([string]$Ref, $Spec) - - $Segments = $Ref.TrimStart('#') -split '/' | Where-Object { $_ -ne '' } - $Node = $Spec - foreach ($Seg in $Segments) { - $Key = $Seg -replace '~1', '/' -replace '~0', '~' - if ($Node -is [System.Collections.IDictionary] -and $Node.Contains($Key)) { - $Node = $Node[$Key] - } else { - return $null - } - } - return $Node -} - -function Get-CippMcpDescription { - # Cleans the operation description (strips leaked PowerShell help) and prefixes the tag. Internal helper. - param($Operation) - - $Desc = [string]$Operation['description'] - $Desc = $Desc -replace '(?s)\s*#>.*$', '' - $Desc = $Desc -replace '(?s)\[CmdletBinding.*$', '' - $Desc = $Desc.Trim() - if ([string]::IsNullOrWhiteSpace($Desc)) { $Desc = [string]$Operation['summary'] } - - $Tag = @($Operation['tags'])[0] - if ($Tag -and $Tag -ne 'Uncategorized') { $Desc = "[$Tag] $Desc" } - return $Desc -} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/MCP/Invoke-ExecMcp.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/MCP/Invoke-ExecMcp.ps1 index 60da65909eba..f2d9b427868a 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/MCP/Invoke-ExecMcp.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/MCP/Invoke-ExecMcp.ps1 @@ -63,7 +63,7 @@ function Invoke-ExecMcp { } } 'ping' { $Result = @{} } - 'tools/list' { $Result = [ordered]@{ tools = @(Get-CippMcpToolList) } } + 'tools/list' { $Result = [ordered]@{ tools = @(Get-CippMcpToolList -Request $Request) } } 'tools/call' { $Result = Get-CippMcpToolResult -Request $Request -TriggerMetadata $TriggerMetadata -ToolName $Rpc.params.name -Arguments $Rpc.params.arguments } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecSSOSetup.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecSSOSetup.ps1 index 076fa3c2610e..f693c9d6648a 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecSSOSetup.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecSSOSetup.ps1 @@ -14,6 +14,37 @@ function Invoke-ExecSSOSetup { $Action = $Request.Body.Action ?? $Request.Query.Action ?? 'Status' $MigrationTable = Get-CIPPTable -tablename 'SSOMigration' + # Resolve the redirect URI once for any action that needs it + $ResolveTargetUrl = { + param($BodyUrl) + if ($BodyUrl) { return $BodyUrl } + $FromHeader = $Request.Headers.origin ?? $Request.Headers.referer?.TrimEnd('/') + if ($FromHeader) { return $FromHeader } + return "https://$($env:WEBSITE_HOSTNAME)" + } + + # Save a row to the migration table while preserving fields that aren't being updated + $SaveMigrationRow = { + param([hashtable]$Updates) + $Existing = Get-CIPPAzDataTableEntity @MigrationTable -Filter "PartitionKey eq 'SSO' and RowKey eq 'MigrationConfig'" -ErrorAction SilentlyContinue + $Row = @{ + PartitionKey = 'SSO' + RowKey = 'MigrationConfig' + } + # Preserve existing fields + if ($Existing) { + foreach ($Prop in $Existing.PSObject.Properties) { + if ($Prop.Name -notin @('PartitionKey', 'RowKey', 'Timestamp', 'ETag', 'odata.etag')) { + $Row[$Prop.Name] = $Prop.Value + } + } + } + # Apply updates on top + foreach ($Key in $Updates.Keys) { $Row[$Key] = $Updates[$Key] } + $Row['LastChecked'] = (Get-Date).ToUniversalTime().ToString('o') + Add-CIPPAzDataTableEntity @MigrationTable -Entity $Row -Force | Out-Null + } + switch ($Action) { 'Status' { # Read live EasyAuth config from the platform-injected env var when available @@ -32,10 +63,19 @@ function Invoke-ExecSSOSetup { $AllowedApps = @($AAD.validation.defaultAuthorizationPolicy.allowedApplications) $ExcludedPaths = @($Config.globalValidation.excludedPaths) + # Surface migration-table state for the live AppId so the UI can offer Repair + # if the migration row matches the live ClientId AND is in a partial state. + # If the migration row is stale (different AppId), defer to live EasyAuth = complete. + $Migration = Get-CIPPAzDataTableEntity @MigrationTable -Filter "PartitionKey eq 'SSO' and RowKey eq 'MigrationConfig'" -ErrorAction SilentlyContinue + $MigrationMatches = $Migration -and $Migration.AppId -and $Migration.AppId -eq $ClientId + $MigrationStatus = if ($MigrationMatches) { $Migration.Status } else { 'complete' } + $MigrationError = if ($MigrationMatches) { $Migration.LastError } else { '' } + $MigrationCanRepair = $MigrationMatches -and ($Migration.Status -in @('error', 'app_created', 'appid_stored')) + $Body = @{ Results = @{ configured = $true - status = 'complete' + status = $MigrationStatus appId = $ClientId multiTenant = $IsMultiTenant tenantId = $IssuerTenantId @@ -44,15 +84,35 @@ function Invoke-ExecSSOSetup { allowedApps = $AllowedApps excludedPaths = $ExcludedPaths easyAuthActive = $true + lastError = $MigrationError + canRepair = [bool]$MigrationCanRepair } } } else { - $Body = @{ Results = @{ configured = $false; status = 'none'; easyAuthActive = $false } } + # EasyAuth not active — fall through to the migration table so partial-state appId/error still surfaces + $Migration = Get-CIPPAzDataTableEntity @MigrationTable -Filter "PartitionKey eq 'SSO' and RowKey eq 'MigrationConfig'" -ErrorAction SilentlyContinue + if ($Migration) { + $Body = @{ + Results = @{ + configured = $true + status = $Migration.Status + appId = $Migration.AppId + multiTenant = [bool]($Migration.MultiTenant -eq 'true' -or $Migration.MultiTenant -eq 'True') + createdAt = $Migration.CreatedAt + lastChecked = $Migration.LastChecked + lastError = $Migration.LastError + easyAuthActive = $false + canRepair = [bool]($Migration.AppId -and ($Migration.Status -in @('error', 'app_created', 'appid_stored'))) + } + } + } else { + $Body = @{ Results = @{ configured = $false; status = 'none'; easyAuthActive = $false } } + } } } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API $APIName -message "Failed to parse EasyAuth config: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage - $Body = @{ Results = @{ configured = $false; status = 'error'; error = $ErrorMessage.NormalizedError } } + $Body = @{ Results = @{ configured = $false; status = 'error'; lastError = $ErrorMessage.NormalizedError } } } } else { # Otherwise read from migration table @@ -64,10 +124,11 @@ function Invoke-ExecSSOSetup { configured = $true status = $Migration.Status appId = $Migration.AppId - multiTenant = [bool]($Migration.MultiTenant -eq 'true') + multiTenant = [bool]($Migration.MultiTenant -eq 'true' -or $Migration.MultiTenant -eq 'True') createdAt = $Migration.CreatedAt lastChecked = $Migration.LastChecked lastError = $Migration.LastError + canRepair = [bool]($Migration.AppId -and ($Migration.Status -in @('error', 'app_created', 'appid_stored'))) } } } else { @@ -76,30 +137,22 @@ function Invoke-ExecSSOSetup { } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API $APIName -message "Failed to get SSO status: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage - $Body = @{ Results = @{ configured = $false; status = 'error'; error = $ErrorMessage.NormalizedError } } + $Body = @{ Results = @{ configured = $false; status = 'error'; lastError = $ErrorMessage.NormalizedError } } } } } 'Create' { $MultiTenant = [bool]($Request.Body.multiTenant) - $TargetUrl = $Request.Body.targetUrl - - # Determine redirect URI — prefer explicit targetUrl, fall back to current host - if (-not $TargetUrl) { - $TargetUrl = $Request.Headers.origin ?? $Request.Headers.referer?.TrimEnd('/') - } - if (-not $TargetUrl) { - $TargetUrl = "https://$($env:WEBSITE_HOSTNAME)" - } + $TargetUrl = & $ResolveTargetUrl $Request.Body.targetUrl try { # Check if already provisioned $Existing = Get-CIPPAzDataTableEntity @MigrationTable -Filter "PartitionKey eq 'SSO' and RowKey eq 'MigrationConfig'" -ErrorAction SilentlyContinue - if ($Existing -and $Existing.Status -eq 'complete') { + if ($Existing -and $Existing.Status -in @('secrets_stored', 'complete')) { $Body = @{ Results = @{ - message = 'SSO migration already completed.' + message = 'SSO app is already provisioned. Use Repair to refresh the secret or Recreate to start over.' appId = $Existing.AppId severity = 'info' } @@ -107,115 +160,94 @@ function Invoke-ExecSSOSetup { break } - # If we have an existing record that isn't complete, pick up from where we left off - $AppId = $Existing.AppId - $AppSecret = $null + # Pick up from where we left off if we have a partial record + $ExistingAppId = $Existing.AppId - # Step 1: Create/update the app registration (idempotent) - # Pass stored AppId so we look up by clientId rather than name + # --- Step 1: Create or update the app registration (no secret yet) --- $SSOAppParams = @{ RedirectUri = $TargetUrl MultiTenant = $MultiTenant } - if ($AppId) { $SSOAppParams.ExistingAppId = $AppId } + if ($ExistingAppId) { $SSOAppParams.ExistingAppId = $ExistingAppId } $SSOApp = New-CIPPSSOApp @SSOAppParams $AppId = $SSOApp.AppId - $AppSecret = $SSOApp.ClientSecret + $ObjectId = $SSOApp.ObjectId Write-LogMessage -API $APIName -headers $Headers -message "CIPP-SSO app $($SSOApp.State): $AppId" -sev Info - # Save progress immediately - $MigrationRow = @{ - PartitionKey = 'SSO' - RowKey = 'MigrationConfig' - AppId = $AppId - MultiTenant = [string]$MultiTenant - RedirectUri = $TargetUrl - Status = 'app_created' - CreatedAt = $Existing.CreatedAt ?? (Get-Date).ToUniversalTime().ToString('o') - LastChecked = (Get-Date).ToUniversalTime().ToString('o') - LastError = '' + # --- Step 2: Persist AppId immediately so a later secret failure doesn't lose it --- + & $SaveMigrationRow @{ + AppId = $AppId + ObjectId = $ObjectId + MultiTenant = [string]$MultiTenant + RedirectUri = $TargetUrl + Status = 'app_created' + CreatedAt = $Existing.CreatedAt ?? (Get-Date).ToUniversalTime().ToString('o') + LastError = '' } - Add-CIPPAzDataTableEntity @MigrationTable -Entity $MigrationRow -Force | Out-Null - - $KV = $env:WEBSITE_DEPLOYMENT_ID - $VaultName = if ($KV) { ($KV -split '-')[0] } else { $null } - - if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { - # Dev mode — store in DevSecrets table - $DevSecretsTable = Get-CIPPTable -tablename 'DevSecrets' - $Secret = Get-CIPPAzDataTableEntity @DevSecretsTable -Filter "PartitionKey eq 'SSO' and RowKey eq 'SSO'" -ErrorAction SilentlyContinue - if (-not $Secret) { $Secret = [PSCustomObject]@{} } - $Secret | Add-Member -MemberType NoteProperty -Name 'PartitionKey' -Value 'SSO' -Force - $Secret | Add-Member -MemberType NoteProperty -Name 'RowKey' -Value 'SSO' -Force - $Secret | Add-Member -MemberType NoteProperty -Name 'SSOAppId' -Value $AppId -Force - $Secret | Add-Member -MemberType NoteProperty -Name 'SSOMultiTenant' -Value ([string]$MultiTenant) -Force - if ($AppSecret) { - $Secret | Add-Member -MemberType NoteProperty -Name 'SSOAppSecret' -Value $AppSecret -Force - } - Add-CIPPAzDataTableEntity @DevSecretsTable -Entity $Secret -Force | Out-Null - Write-Information '[SSO-Setup] Stored SSO credentials in DevSecrets table' - } else { - # Production — store in Key Vault - if (-not $VaultName) { - throw 'Cannot determine Key Vault name from WEBSITE_DEPLOYMENT_ID' - } - - # Step 2: Store AppId in KV (idempotent — Set overwrites) - $ExistingAppIdSecret = $null - try { - $ExistingAppIdSecret = Get-CippKeyVaultSecret -VaultName $VaultName -Name 'SSOAppId' -AsPlainText -ErrorAction Stop - } catch { } - - if (-not $ExistingAppIdSecret -or $ExistingAppIdSecret -ne $AppId) { - Set-CippKeyVaultSecret -VaultName $VaultName -Name 'SSOAppId' -SecretValue (ConvertTo-SecureString -String $AppId -AsPlainText -Force) - Write-Information "[SSO-Setup] Stored SSOAppId in Key Vault" - } - # Update status - $UpdateRow = @{ - PartitionKey = 'SSO' - RowKey = 'MigrationConfig' - Status = 'appid_stored' - LastChecked = (Get-Date).ToUniversalTime().ToString('o') + # --- Step 3: Store AppId + MultiTenant flag in KV (still no secret) --- + try { + Set-CIPPSSOStoredCredentials -AppId $AppId -MultiTenant $MultiTenant + Write-Information '[SSO-Setup] AppId and MultiTenant flag stored' + + # Best-effort: stash TenantID in KV if missing (was previously inline) + if (-not ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true')) { + $KV = $env:WEBSITE_DEPLOYMENT_ID + $VaultName = if ($KV) { ($KV -split '-')[0] } else { $null } + if ($VaultName -and $env:TenantID) { + $ExistingTenantId = $null + try { $ExistingTenantId = Get-CippKeyVaultSecret -VaultName $VaultName -Name 'TenantID' -AsPlainText -ErrorAction Stop } catch { } + if (-not $ExistingTenantId) { + Set-CippKeyVaultSecret -VaultName $VaultName -Name 'TenantID' -SecretValue (ConvertTo-SecureString -String $env:TenantID -AsPlainText -Force) + Write-Information '[SSO-Setup] Stored TenantID in Key Vault (was missing)' + } + } } - Add-CIPPAzDataTableEntity @MigrationTable -Entity $UpdateRow -Force | Out-Null - # Step 3: Store AppSecret in KV - if ($AppSecret) { - Set-CippKeyVaultSecret -VaultName $VaultName -Name 'SSOAppSecret' -SecretValue (ConvertTo-SecureString -String $AppSecret -AsPlainText -Force) - Write-Information "[SSO-Setup] Stored SSOAppSecret in Key Vault" - } + & $SaveMigrationRow @{ Status = 'appid_stored' } + } catch { + $StoreError = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -headers $Headers -message "Failed to store SSO AppId: $($StoreError.NormalizedError)" -sev Error -LogData $StoreError + & $SaveMigrationRow @{ Status = 'error'; LastError = "Failed to store AppId: $($StoreError.NormalizedError)" } + throw + } - # Step 4: Verify TenantID exists in KV (should already be there from SAM setup) - $ExistingTenantId = $null - try { - $ExistingTenantId = Get-CippKeyVaultSecret -VaultName $VaultName -Name 'TenantID' -AsPlainText -ErrorAction Stop - } catch { } + # --- Step 4: Create the client secret (may legitimately fail; Repair can resume) --- + $AppSecret = $null + try { + $AppSecret = Add-CIPPSSOAppSecret -ObjectId $ObjectId + } catch { + $SecretError = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -headers $Headers -message "SSO secret creation failed (AppId preserved, use Repair): $($SecretError.NormalizedError)" -sev Error -LogData $SecretError + & $SaveMigrationRow @{ Status = 'error'; LastError = $SecretError.NormalizedError } - if (-not $ExistingTenantId) { - Set-CippKeyVaultSecret -VaultName $VaultName -Name 'TenantID' -SecretValue (ConvertTo-SecureString -String $env:TenantID -AsPlainText -Force) - Write-Information "[SSO-Setup] Stored TenantID in Key Vault (was missing)" + $StatusCode = [HttpStatusCode]::OK + $Body = @{ + Results = @{ + message = "SSO app created (AppId: $AppId) but client secret creation failed. Use Repair to retry — the AppId is preserved." + appId = $AppId + severity = 'warning' + canRepair = $true + lastError = $SecretError.NormalizedError + } } - - # Step 5: Store MultiTenant flag in KV (used for initial EasyAuth setup on startup) - Set-CippKeyVaultSecret -VaultName $VaultName -Name 'SSOMultiTenant' -SecretValue (ConvertTo-SecureString -String ([string]$MultiTenant) -AsPlainText -Force) - Write-Information "[SSO-Setup] Stored SSOMultiTenant=$MultiTenant in Key Vault" + break } - # Mark migration as secrets_stored - $FinalRow = @{ - PartitionKey = 'SSO' - RowKey = 'MigrationConfig' - AppId = $AppId - MultiTenant = [string]$MultiTenant - RedirectUri = $TargetUrl - Status = 'secrets_stored' - CreatedAt = $Existing.CreatedAt ?? (Get-Date).ToUniversalTime().ToString('o') - LastChecked = (Get-Date).ToUniversalTime().ToString('o') - LastError = '' + # --- Step 5: Store the secret --- + try { + Set-CIPPSSOStoredCredentials -AppSecret $AppSecret + Write-Information '[SSO-Setup] AppSecret stored' + } catch { + $StoreError = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -headers $Headers -message "Failed to store SSO secret: $($StoreError.NormalizedError)" -sev Error -LogData $StoreError + & $SaveMigrationRow @{ Status = 'error'; LastError = "Secret created but storage failed: $($StoreError.NormalizedError)" } + throw } - Add-CIPPAzDataTableEntity @MigrationTable -Entity $FinalRow -Force | Out-Null + + # --- Step 6: Mark migration as secrets_stored --- + & $SaveMigrationRow @{ Status = 'secrets_stored'; LastError = '' } Write-LogMessage -API $APIName -headers $Headers -message "SSO migration credentials stored for app $AppId" -sev Info $Body = @{ @@ -230,21 +262,111 @@ function Invoke-ExecSSOSetup { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API $APIName -headers $Headers -message "SSO setup failed: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage - # Save error state so the scheduled task can retry - $ErrorRow = @{ - PartitionKey = 'SSO' - RowKey = 'MigrationConfig' - Status = 'error' - LastChecked = (Get-Date).ToUniversalTime().ToString('o') - LastError = $ErrorMessage.NormalizedError - } - try { Add-CIPPAzDataTableEntity @MigrationTable -Entity $ErrorRow -Force | Out-Null } catch { } + # Migration row already has the most accurate Status/LastError from the inner catches; only write if nothing was written + try { + $Existing = Get-CIPPAzDataTableEntity @MigrationTable -Filter "PartitionKey eq 'SSO' and RowKey eq 'MigrationConfig'" -ErrorAction SilentlyContinue + if (-not $Existing -or $Existing.Status -ne 'error') { + & $SaveMigrationRow @{ Status = 'error'; LastError = $ErrorMessage.NormalizedError } + } + } catch { } $StatusCode = [HttpStatusCode]::InternalServerError $Body = @{ Results = "SSO setup failed: $($ErrorMessage.NormalizedError)" } } } + 'Repair' { + # Picks up from any partial state — adds a new secret to the existing AppId and stores it. + try { + $Existing = Get-CIPPAzDataTableEntity @MigrationTable -Filter "PartitionKey eq 'SSO' and RowKey eq 'MigrationConfig'" -ErrorAction SilentlyContinue + + # Fall back to live EasyAuth config when the migration table is empty (e.g. forced-migration flow) + if ((-not $Existing -or -not $Existing.AppId) -and $env:CIPPNG -and $env:WEBSITE_AUTH_V2_CONFIG_JSON) { + $LiveConfig = $env:WEBSITE_AUTH_V2_CONFIG_JSON | ConvertFrom-Json -ErrorAction SilentlyContinue + $LiveAppId = $LiveConfig.identityProviders.azureActiveDirectory.registration.clientId + if ($LiveAppId) { + $Existing = [PSCustomObject]@{ AppId = $LiveAppId; MultiTenant = 'false' } + } + } + + if (-not $Existing -or -not $Existing.AppId) { + $StatusCode = [HttpStatusCode]::BadRequest + $Body = @{ Results = 'No SSO app to repair. Use Create to provision one.' } + break + } + + $AppId = $Existing.AppId + + # Look up the ObjectId — we may have stored it, or we need to fetch it from Graph + $ObjectId = $Existing.ObjectId + if (-not $ObjectId) { + $AppResponse = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/applications(appId='$AppId')?`$select=id" -NoAuthCheck $true -AsApp $true + $ObjectId = $AppResponse.id + } + + # Create a fresh secret on the existing app + $AppSecret = Add-CIPPSSOAppSecret -ObjectId $ObjectId + + # Persist it + $MultiTenantFlag = [bool]($Existing.MultiTenant -eq 'true' -or $Existing.MultiTenant -eq 'True') + Set-CIPPSSOStoredCredentials -AppId $AppId -AppSecret $AppSecret -MultiTenant $MultiTenantFlag + + & $SaveMigrationRow @{ + AppId = $AppId + ObjectId = $ObjectId + MultiTenant = [string]$MultiTenantFlag + Status = 'secrets_stored' + LastError = '' + } + + Write-LogMessage -API $APIName -headers $Headers -message "SSO app repaired — new secret stored for $AppId" -sev Info + $Body = @{ + Results = @{ + message = 'CIPP-SSO repaired. A new client secret was created and stored. EasyAuth will pick it up on next restart.' + appId = $AppId + severity = 'success' + } + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -headers $Headers -message "SSO repair failed: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + try { & $SaveMigrationRow @{ Status = 'error'; LastError = $ErrorMessage.NormalizedError } } catch { } + $StatusCode = [HttpStatusCode]::InternalServerError + $Body = @{ Results = "SSO repair failed: $($ErrorMessage.NormalizedError)" } + } + } + + 'Recreate' { + # Clears the migration record so the next Create provisions a brand new app + # (the previous app is left orphaned in the tenant — admin can delete manually if desired). + try { + $Existing = Get-CIPPAzDataTableEntity @MigrationTable -Filter "PartitionKey eq 'SSO' and RowKey eq 'MigrationConfig'" -ErrorAction SilentlyContinue + $PreviousAppId = $Existing.AppId + + if ($Existing) { + Remove-AzDataTableEntity @MigrationTable -Entity $Existing -Force | Out-Null + Write-LogMessage -API $APIName -headers $Headers -message "SSO migration record cleared (previous AppId: $PreviousAppId). Use Create to provision a new app." -sev Info + } + + $Body = @{ + Results = @{ + message = if ($PreviousAppId) { + "Previous SSO record cleared. The old app registration ($PreviousAppId) is still in your tenant — delete it manually from Entra if you no longer want it. Click Create SSO App to provision a fresh one." + } else { + 'No SSO record to clear. Click Create SSO App to provision a new app.' + } + previousAppId = $PreviousAppId + severity = 'success' + } + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -headers $Headers -message "SSO recreate failed: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::InternalServerError + $Body = @{ Results = "SSO recreate failed: $($ErrorMessage.NormalizedError)" } + } + } + 'Update' { # Update existing SSO app configuration (e.g. switch single ↔ multi-tenant) try { @@ -264,13 +386,7 @@ function Invoke-ExecSSOSetup { } $MultiTenant = [bool]($Request.Body.multiTenant) - $TargetUrl = $Request.Body.targetUrl - if (-not $TargetUrl) { - $TargetUrl = $Request.Headers.origin ?? $Request.Headers.referer?.TrimEnd('/') - } - if (-not $TargetUrl) { - $TargetUrl = "https://$($env:WEBSITE_HOSTNAME)" - } + $TargetUrl = & $ResolveTargetUrl $Request.Body.targetUrl $SignInAudience = if ($MultiTenant) { 'AzureADMultipleOrgs' } else { 'AzureADMyOrg' } $CallbackUri = $TargetUrl.TrimEnd('/') + '/.auth/login/aad/callback' @@ -289,34 +405,17 @@ function Invoke-ExecSSOSetup { New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/applications/$($AppResponse.id)" -body $PatchBody -type PATCH -NoAuthCheck $true -AsApp $true # Update migration table - $UpdateRow = @{ - PartitionKey = 'SSO' - RowKey = 'MigrationConfig' - AppId = $Existing.AppId - MultiTenant = [string]$MultiTenant - RedirectUri = $TargetUrl - Status = $Existing.Status - CreatedAt = $Existing.CreatedAt - LastChecked = (Get-Date).ToUniversalTime().ToString('o') - LastError = '' + & $SaveMigrationRow @{ + AppId = $Existing.AppId + MultiTenant = [string]$MultiTenant + RedirectUri = $TargetUrl + LastError = '' } - Add-CIPPAzDataTableEntity @MigrationTable -Entity $UpdateRow -Force | Out-Null Write-LogMessage -API $APIName -headers $Headers -message "SSO app updated: multiTenant=$MultiTenant, audience=$SignInAudience" -sev Info # Update SSOMultiTenant in KV so initial EasyAuth setup stays in sync - $KV = $env:WEBSITE_DEPLOYMENT_ID - $VaultName = if ($KV) { ($KV -split '-')[0] } else { $null } - if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { - $DevSecretsTable = Get-CIPPTable -tablename 'DevSecrets' - $Secret = Get-CIPPAzDataTableEntity @DevSecretsTable -Filter "PartitionKey eq 'SSO' and RowKey eq 'SSO'" -ErrorAction SilentlyContinue - if ($Secret) { - $Secret | Add-Member -MemberType NoteProperty -Name 'SSOMultiTenant' -Value ([string]$MultiTenant) -Force - Add-CIPPAzDataTableEntity @DevSecretsTable -Entity $Secret -Force | Out-Null - } - } elseif ($VaultName) { - Set-CippKeyVaultSecret -VaultName $VaultName -Name 'SSOMultiTenant' -SecretValue (ConvertTo-SecureString -String ([string]$MultiTenant) -AsPlainText -Force) - } + Set-CIPPSSOStoredCredentials -MultiTenant $MultiTenant # Update EasyAuth ARM config on the App Service (issuer URL + allowed tenants) try { @@ -360,42 +459,19 @@ function Invoke-ExecSSOSetup { } # Get the app object ID - $AppResponse = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/applications(appId='$($Existing.AppId)')?`$select=id" -NoAuthCheck $true -AsApp $true - - # Create new secret - $PasswordBody = '{"passwordCredential":{"displayName":"CIPP-SSO-Secret"}}' - $PasswordResult = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/applications/$($AppResponse.id)/addPassword" -body $PasswordBody -type POST -NoAuthCheck $true -AsApp $true - $NewSecret = $PasswordResult.secretText - - if (-not $NewSecret) { - throw 'Failed to create new client secret' + $ObjectId = $Existing.ObjectId + if (-not $ObjectId) { + $AppResponse = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/applications(appId='$($Existing.AppId)')?`$select=id" -NoAuthCheck $true -AsApp $true + $ObjectId = $AppResponse.id } + # Create new secret using the same retry helper + $NewSecret = Add-CIPPSSOAppSecret -ObjectId $ObjectId + # Store new secret - $KV = $env:WEBSITE_DEPLOYMENT_ID - $VaultName = if ($KV) { ($KV -split '-')[0] } else { $null } - - if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { - $DevSecretsTable = Get-CIPPTable -tablename 'DevSecrets' - $Secret = Get-CIPPAzDataTableEntity @DevSecretsTable -Filter "PartitionKey eq 'SSO' and RowKey eq 'SSO'" -ErrorAction SilentlyContinue - if (-not $Secret) { $Secret = [PSCustomObject]@{} } - $Secret | Add-Member -MemberType NoteProperty -Name 'PartitionKey' -Value 'SSO' -Force - $Secret | Add-Member -MemberType NoteProperty -Name 'RowKey' -Value 'SSO' -Force - $Secret | Add-Member -MemberType NoteProperty -Name 'SSOAppSecret' -Value $NewSecret -Force - Add-CIPPAzDataTableEntity @DevSecretsTable -Entity $Secret -Force | Out-Null - } else { - if (-not $VaultName) { throw 'Cannot determine Key Vault name from WEBSITE_DEPLOYMENT_ID' } - Set-CippKeyVaultSecret -VaultName $VaultName -Name 'SSOAppSecret' -SecretValue (ConvertTo-SecureString -String $NewSecret -AsPlainText -Force) - } + Set-CIPPSSOStoredCredentials -AppSecret $NewSecret - # Update last checked - $UpdateRow = @{ - PartitionKey = 'SSO' - RowKey = 'MigrationConfig' - LastChecked = (Get-Date).ToUniversalTime().ToString('o') - LastError = '' - } - Add-CIPPAzDataTableEntity @MigrationTable -Entity $UpdateRow -Force | Out-Null + & $SaveMigrationRow @{ LastError = '' } Write-LogMessage -API $APIName -headers $Headers -message "SSO app secret rotated for $($Existing.AppId)" -sev Info $Body = @{ @@ -426,20 +502,26 @@ function Invoke-ExecSSOSetup { $TargetUrl = "https://$($env:WEBSITE_HOSTNAME)" try { - # Check if we already have SSO credentials from a previous partial run - $KV = $env:WEBSITE_DEPLOYMENT_ID - $VaultName = if ($KV) { ($KV -split '-')[0] } else { $null } - $ExistingAppId = $null - - if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { - $DevSecretsTable = Get-CIPPTable -tablename 'DevSecrets' - $DevSecret = Get-CIPPAzDataTableEntity @DevSecretsTable -Filter "PartitionKey eq 'SSO' and RowKey eq 'SSO'" -ErrorAction SilentlyContinue - $ExistingAppId = $DevSecret.SSOAppId - } elseif ($VaultName) { - try { $ExistingAppId = Get-CippKeyVaultSecret -VaultName $VaultName -Name 'SSOAppId' -AsPlainText -ErrorAction Stop } catch { } + # Check if we have an in-progress migration record (so secret-only retries reuse the AppId) + $Existing = Get-CIPPAzDataTableEntity @MigrationTable -Filter "PartitionKey eq 'SSO' and RowKey eq 'MigrationConfig'" -ErrorAction SilentlyContinue + $ExistingAppId = $Existing.AppId + + # Also check KV / DevSecrets in case a previous partial run stored the AppId there + if (-not $ExistingAppId) { + if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { + $DevSecretsTable = Get-CIPPTable -tablename 'DevSecrets' + $DevSecret = Get-CIPPAzDataTableEntity @DevSecretsTable -Filter "PartitionKey eq 'SSO' and RowKey eq 'SSO'" -ErrorAction SilentlyContinue + $ExistingAppId = $DevSecret.SSOAppId + } else { + $KV = $env:WEBSITE_DEPLOYMENT_ID + $VaultName = if ($KV) { ($KV -split '-')[0] } else { $null } + if ($VaultName) { + try { $ExistingAppId = Get-CippKeyVaultSecret -VaultName $VaultName -Name 'SSOAppId' -AsPlainText -ErrorAction Stop } catch { } + } + } } - # Step 1: Create or update the customer's own CIPP-SSO app registration + # --- Step 1: Create or update the customer's own CIPP-SSO app registration (no secret yet) --- $SSOAppParams = @{ RedirectUri = $TargetUrl MultiTenant = $MultiTenant @@ -448,58 +530,50 @@ function Invoke-ExecSSOSetup { $SSOApp = New-CIPPSSOApp @SSOAppParams $AppId = $SSOApp.AppId - $AppSecret = $SSOApp.ClientSecret + $ObjectId = $SSOApp.ObjectId Write-LogMessage -API $APIName -headers $Headers -message "SSO migration: CIPP-SSO app $($SSOApp.State): $AppId" -sev Info - # Step 2: Store credentials - if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { - $DevSecretsTable = Get-CIPPTable -tablename 'DevSecrets' - $Secret = Get-CIPPAzDataTableEntity @DevSecretsTable -Filter "PartitionKey eq 'SSO' and RowKey eq 'SSO'" -ErrorAction SilentlyContinue - if (-not $Secret) { $Secret = [PSCustomObject]@{} } - $Secret | Add-Member -MemberType NoteProperty -Name 'PartitionKey' -Value 'SSO' -Force - $Secret | Add-Member -MemberType NoteProperty -Name 'RowKey' -Value 'SSO' -Force - $Secret | Add-Member -MemberType NoteProperty -Name 'SSOAppId' -Value $AppId -Force - $Secret | Add-Member -MemberType NoteProperty -Name 'SSOMultiTenant' -Value ([string]$MultiTenant) -Force - if ($AppSecret) { - $Secret | Add-Member -MemberType NoteProperty -Name 'SSOAppSecret' -Value $AppSecret -Force - } - Add-CIPPAzDataTableEntity @DevSecretsTable -Entity $Secret -Force | Out-Null - Write-Information '[SSO-Migrate] Stored SSO credentials in DevSecrets table' - } else { - if (-not $VaultName) { throw 'Cannot determine Key Vault name from WEBSITE_DEPLOYMENT_ID' } - - Set-CippKeyVaultSecret -VaultName $VaultName -Name 'SSOAppId' -SecretValue (ConvertTo-SecureString -String $AppId -AsPlainText -Force) - if ($AppSecret) { - Set-CippKeyVaultSecret -VaultName $VaultName -Name 'SSOAppSecret' -SecretValue (ConvertTo-SecureString -String $AppSecret -AsPlainText -Force) - } - Set-CippKeyVaultSecret -VaultName $VaultName -Name 'SSOMultiTenant' -SecretValue (ConvertTo-SecureString -String ([string]$MultiTenant) -AsPlainText -Force) - Write-Information "[SSO-Migrate] Stored SSO credentials in Key Vault ($VaultName)" + # --- Step 2: Persist AppId immediately --- + & $SaveMigrationRow @{ + AppId = $AppId + ObjectId = $ObjectId + MultiTenant = [string]$MultiTenant + RedirectUri = $TargetUrl + Status = 'app_created' + CreatedAt = $Existing.CreatedAt ?? (Get-Date).ToUniversalTime().ToString('o') + MigratedFrom = 'SWA' + LastError = '' + } + Set-CIPPSSOStoredCredentials -AppId $AppId -MultiTenant $MultiTenant + & $SaveMigrationRow @{ Status = 'appid_stored' } + + # --- Step 3: Create the client secret (with retry) --- + try { + $AppSecret = Add-CIPPSSOAppSecret -ObjectId $ObjectId + } catch { + $SecretError = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -headers $Headers -message "SSO migration secret creation failed (AppId preserved, use Repair): $($SecretError.NormalizedError)" -sev Error -LogData $SecretError + & $SaveMigrationRow @{ Status = 'error'; LastError = $SecretError.NormalizedError } + $StatusCode = [HttpStatusCode]::InternalServerError + $Body = @{ Results = "SSO migration failed at secret creation: $($SecretError.NormalizedError) — use Repair from the SSO settings page once you can sign in." } + break } - # Step 3: Configure EasyAuth on the App Service + # --- Step 4: Store the secret --- + Set-CIPPSSOStoredCredentials -AppSecret $AppSecret + + # --- Step 5: Configure EasyAuth on the App Service --- Set-CIPPSSOEasyAuth -AppId $AppId -MultiTenant $MultiTenant -TenantId $env:TenantID -UseKvReferences - # Step 4: Remove the migration trigger env var + # --- Step 6: Remove the migration trigger env var --- Remove-CIPPMigrationAppSetting -SettingName 'CIPP_SSO_MIGRATION_APPID' - # Step 5: Track in migration table (for audit/status) - $MigrationRow = @{ - PartitionKey = 'SSO' - RowKey = 'MigrationConfig' - AppId = $AppId - MultiTenant = [string]$MultiTenant - RedirectUri = $TargetUrl - Status = 'complete' - CreatedAt = (Get-Date).ToUniversalTime().ToString('o') - LastChecked = (Get-Date).ToUniversalTime().ToString('o') - LastError = '' - MigratedFrom = 'SWA' - } - Add-CIPPAzDataTableEntity @MigrationTable -Entity $MigrationRow -Force | Out-Null + # --- Step 7: Mark complete --- + & $SaveMigrationRow @{ Status = 'complete'; LastError = '' } Write-LogMessage -API $APIName -headers $Headers -message "SSO migration complete: appId=$AppId, multiTenant=$MultiTenant" -sev Info - # Step 6: Restart to apply EasyAuth + # --- Step 8: Restart to apply EasyAuth --- Request-CIPPRestart -Reason 'SSO migration complete — EasyAuth configured with customer CIPP-SSO app' $Body = @{ @@ -513,6 +587,7 @@ function Invoke-ExecSSOSetup { } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API $APIName -headers $Headers -message "SSO migration failed: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + try { & $SaveMigrationRow @{ Status = 'error'; LastError = $ErrorMessage.NormalizedError } } catch { } $StatusCode = [HttpStatusCode]::InternalServerError $Body = @{ Results = "SSO migration failed: $($ErrorMessage.NormalizedError)" } } @@ -520,7 +595,7 @@ function Invoke-ExecSSOSetup { default { $StatusCode = [HttpStatusCode]::BadRequest - $Body = @{ Results = "Unknown action: $Action. Use 'Status', 'Create', or 'Update'." } + $Body = @{ Results = "Unknown action: $Action. Use 'Status', 'Create', 'Repair', 'Recreate', 'Update', 'RotateSecret', or 'Migrate'." } } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Tools/Invoke-ListMessageTrace.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Tools/Invoke-ListMessageTrace.ps1 index c6f2cd495d67..17976e179d6d 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Tools/Invoke-ListMessageTrace.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Tools/Invoke-ListMessageTrace.ps1 @@ -3,7 +3,7 @@ function Invoke-ListMessageTrace { .FUNCTIONALITY Entrypoint .ROLE - Exchange.TransportRule.Read + Exchange.Mailbox.Read .DESCRIPTION Traces email message delivery in Exchange Online, searchable by message ID, sender, recipient, and date range. #> @@ -11,6 +11,8 @@ function Invoke-ListMessageTrace { param($Request, $TriggerMetadata) $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + try { $TenantFilter = $Request.Body.tenantFilter @@ -51,13 +53,13 @@ function Invoke-ListMessageTrace { } if ($Request.Body.recipient) { - $Searchparams.Add('RecipientAddress', $($Request.Body.recipient.value ?? $Request.Body.recipient)) + $SearchParams.Add('RecipientAddress', $($Request.Body.recipient.value ?? $Request.Body.recipient)) } if ($Request.Body.sender) { - $Searchparams.Add('SenderAddress', $($Request.Body.sender.value ?? $Request.Body.sender)) + $SearchParams.Add('SenderAddress', $($Request.Body.sender.value ?? $Request.Body.sender)) } - $trace = if ($Request.Body.traceDetail) { + $Trace = if ($Request.Body.traceDetail) { $CmdParams = @{ MessageTraceId = $Request.Body.ID RecipientAddress = $Request.Body.recipient @@ -67,17 +69,18 @@ function Invoke-ListMessageTrace { Write-Information ($SearchParams | ConvertTo-Json) New-ExoRequest -TenantId $TenantFilter -Cmdlet 'Get-MessageTraceV2' -CmdParams $SearchParams | Select-Object MessageTraceId, Status, Subject, RecipientAddress, SenderAddress, @{ Name = 'Received'; Expression = { $_.Received.ToString('u') } }, FromIP, ToIP - Write-LogMessage -headers $Request.Headers -API $APIName -tenant $($TenantFilter) -message 'Executed message trace' -Sev 'Info' + Write-LogMessage -headers $Headers -API $APIName -tenant $($TenantFilter) -message 'Executed message trace' -Sev 'Info' } } catch { - Write-LogMessage -headers $Request.Headers -API $APINAME -tenant $($tenantfilter) -message "Failed executing messagetrace. Error: $($_.Exception.Message)" -Sev 'Error' - $trace = @{Status = "Failed to retrieve message trace $($_.Exception.Message)" } + Write-LogMessage -headers $Headers -API $APIName -tenant $($TenantFilter) -message "Failed executing Message Trace. Error: $($_.Exception.Message)" -Sev 'Error' + $Trace = @{Status = "Failed to retrieve message trace $($_.Exception.Message)" } + $StatusCode = [HttpStatusCode]::InternalServerError } return ([HttpResponseContext]@{ - StatusCode = [HttpStatusCode]::OK - Body = @($trace) + StatusCode = ($StatusCode ?? [HttpStatusCode]::OK) + Body = @($Trace) }) } diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Invoke-ListObjectHistory.ps1 b/Modules/CIPPHTTP/Public/Invoke-ListObjectHistory.ps1 similarity index 100% rename from Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Invoke-ListObjectHistory.ps1 rename to Modules/CIPPHTTP/Public/Invoke-ListObjectHistory.ps1 diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPOVersionControl.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPOVersionControl.ps1 index cc43fe2c4b0b..54cffe780164 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPOVersionControl.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPOVersionControl.ps1 @@ -49,7 +49,7 @@ function Invoke-CIPPStandardSPOVersionControl { return $true } - $DesiredAutoTrim = [bool]$Settings.EnableAutoTrim + $DesiredAutoTrim = [System.Convert]::ToBoolean($Settings.EnableAutoTrim) $DesiredMajorVersionLimit = [int]($Settings.MajorVersionLimit ?? 50) $DesiredExpireVersionsAfterDays = [int]($Settings.ExpireVersionsAfterDays ?? 0) diff --git a/Modules/CippExtensions/Public/Extension Functions/Register-CippExtensionScheduledTasks.ps1 b/Modules/CippExtensions/Public/Extension Functions/Register-CippExtensionScheduledTasks.ps1 index 1cef75d498fe..402f8146bdca 100644 --- a/Modules/CippExtensions/Public/Extension Functions/Register-CippExtensionScheduledTasks.ps1 +++ b/Modules/CippExtensions/Public/Extension Functions/Register-CippExtensionScheduledTasks.ps1 @@ -2,7 +2,7 @@ function Register-CIPPExtensionScheduledTasks { param( [switch]$Reschedule, [int64]$NextSync = (([datetime]::UtcNow.AddMinutes(30)) - (Get-Date '1/1/1970')).TotalSeconds, - [string[]]$Extensions = @('Hudu', 'NinjaOne', 'CustomData') + [string[]]$Extensions = @('Hudu', 'NinjaOne', 'CustomData', 'Sherweb') ) # get extension configuration and mappings table @@ -29,6 +29,38 @@ function Register-CIPPExtensionScheduledTasks { foreach ($Extension in $Extensions) { $ExtensionConfig = $Config.$Extension if ($ExtensionConfig.Enabled -eq $true -or $Extension -eq 'CustomData') { + if ($Extension -eq 'Sherweb') { + # Sherweb migration tasks - schedule per mapped tenant + $SherwebMappings = Get-CIPPAzDataTableEntity @MappingsTable -Filter "PartitionKey eq 'SherwebMapping'" + $SherwebMigTasks = Get-CIPPAzDataTableEntity @ScheduledTasksTable -Filter 'Hidden eq true' | Where-Object { $_.Command -match 'Invoke-SherwebMigration' } + foreach ($Mapping in $SherwebMappings) { + $Tenant = $Tenants | Where-Object { $_.customerId -eq $Mapping.RowKey } + if (-not $Tenant) { continue } + $MappedTenants.Add($Tenant.defaultDomainName) + $ExistingMigTask = $SherwebMigTasks | Where-Object { $_.Tenant -eq $Tenant.defaultDomainName } + if (-not $ExistingMigTask -or $Reschedule.IsPresent) { + $Task = [pscustomobject]@{ + Name = 'Sherweb Migration Check' + Command = @{ + value = 'Invoke-SherwebMigration' + label = 'Invoke-SherwebMigration' + } + Parameters = [pscustomobject]@{ + TenantFilter = $Tenant.defaultDomainName + } + Recurrence = '1d' + ScheduledTime = $NextSync + TenantFilter = $Tenant.defaultDomainName + } + if ($ExistingMigTask) { + $Task | Add-Member -NotePropertyName 'RowKey' -NotePropertyValue $ExistingMigTask.RowKey -Force + } + $null = Add-CIPPScheduledTask -Task $Task -hidden $true -SyncType 'Sherweb' + Write-Information "Creating Sherweb migration task for tenant $($Tenant.defaultDomainName)" + } + } + continue + } if ($Extension -eq 'CustomData') { $CustomDataMappingTable = Get-CIPPTable -TableName CustomDataMappings $Mappings = Get-CIPPAzDataTableEntity @CustomDataMappingTable | ForEach-Object { @@ -77,7 +109,7 @@ function Register-CIPPExtensionScheduledTasks { continue } $MappedTenants.Add($Tenant.defaultDomainName) - + # Legacy Sync-CippExtensionData tasks are no longer needed - extensions now use CippReportingDB # All cache data is now collected by Push-CIPPDBCacheData scheduled tasks diff --git a/Modules/CippExtensions/Public/Sherweb/Invoke-SherwebMigration.ps1 b/Modules/CippExtensions/Public/Sherweb/Invoke-SherwebMigration.ps1 new file mode 100644 index 000000000000..be26e9d260c7 --- /dev/null +++ b/Modules/CippExtensions/Public/Sherweb/Invoke-SherwebMigration.ps1 @@ -0,0 +1,98 @@ +function Invoke-SherwebMigration { + [CmdletBinding()] + param ( + $TenantFilter + ) + + $Table = Get-CIPPTable -TableName Extensionsconfig + $ExtensionConfig = (Get-CIPPAzDataTableEntity @Table).config | ConvertFrom-Json + $Config = $ExtensionConfig.Sherweb + + # Get licenses within the transfer window (renewing within 7 days) + $Licenses = Get-CIPPLicenseOverview -TenantFilter $TenantFilter | Where-Object { + $null -ne $_.TermInfo -and ($_.TermInfo | Where-Object { $_.DaysUntilRenew -le 7 -and $_.DaysUntilRenew -ge 0 }) + } + + if (-not $Licenses) { return } + + # Check if the exact count of licenses is available at Sherweb, if not, we need to migrate them. + $SherwebLicenses = Get-SherwebCurrentSubscription -TenantFilter $TenantFilter + $LicencesToMigrate = foreach ($License in $Licenses) { + foreach ($Term in $License.TermInfo) { + if ($Term.DaysUntilRenew -gt 7 -or $Term.DaysUntilRenew -lt 0) { continue } + $matchedSherweb = $SherwebLicenses | Where-Object { $_.quantity -eq $Term.TotalLicenses -and $_.commitmentTerm.termEndDate -eq $Term.NextLifecycle } + if (-not $matchedSherweb) { + [PSCustomObject]@{ + LicenseName = $License.License + SkuId = $License.skuId + SubscriptionId = $Term.SubscriptionId + Term = $Term.Term + NextLifecycle = $Term.NextLifecycle + DaysUntilRenew = $Term.DaysUntilRenew + TotalLicensesAtUnknownCSP = $Term.TotalLicenses + TotalLicensesAvailableInM365 = $License.TotalLicenses + } + } + } + } + + if (-not $LicencesToMigrate) { return } + + switch -wildcard ($Config.migrationMethods) { + '*notify*' { + $Subject = "Sherweb Migration: $($TenantFilter) - $($LicencesToMigrate.Count) licenses to migrate" + $HTMLContent = New-CIPPAlertTemplate -Data $LicencesToMigrate -Format 'html' -InputObject 'sherwebmig' + $JSONContent = New-CIPPAlertTemplate -Data $LicencesToMigrate -Format 'json' -InputObject 'sherwebmig' + Send-CIPPAlert -Type 'email' -Title $Subject -HTMLContent $HTMLContent.htmlcontent -TenantFilter $TenantFilter -APIName 'Alerts' + Send-CIPPAlert -Type 'psa' -Title $Subject -HTMLContent $HTMLContent.htmlcontent -TenantFilter $TenantFilter -APIName 'Alerts' + Send-CIPPAlert -Type 'webhook' -JSONContent $JSONContent -TenantFilter $TenantFilter -APIName 'Alerts' + } + '*buy*' { + try { + foreach ($MigLicense in $LicencesToMigrate) { + $PotentialLicense = Get-SherwebCatalog -TenantFilter $TenantFilter | Where-Object { $_.microsoftSkuId -eq $MigLicense.SkuId -and $_.sku -like "*$($Config.migrateToLicense)" } | Select-Object -First 1 + if (-not $PotentialLicense) { + throw "Cannot buy new license: no matching license found in catalog for SKU $($MigLicense.SkuId)" + } + Set-SherwebSubscription -TenantFilter $TenantFilter -SKU $PotentialLicense.sku -Quantity $MigLicense.TotalLicensesAtUnknownCSP + } + } catch { + $Subject = "Sherweb Migration: $($TenantFilter) - Failed to buy licenses." + $HTMLContent = New-CIPPAlertTemplate -Data $LicencesToMigrate -Format 'html' -InputObject 'sherwebmigBuyFail' + $JSONContent = New-CIPPAlertTemplate -Data $LicencesToMigrate -Format 'json' -InputObject 'sherwebmigBuyFail' + Send-CIPPAlert -Type 'email' -Title $Subject -HTMLContent $HTMLContent.htmlcontent -TenantFilter $TenantFilter -APIName 'Alerts' + Send-CIPPAlert -Type 'psa' -Title $Subject -HTMLContent $HTMLContent.htmlcontent -TenantFilter $TenantFilter -APIName 'Alerts' + Send-CIPPAlert -Type 'webhook' -JSONContent $JSONContent -TenantFilter $TenantFilter -APIName 'Alerts' + } + } + '*cancel*' { + try { + $TenantId = (Get-Tenants -TenantFilter $TenantFilter).customerId + $Pax8Config = $ExtensionConfig.Pax8 + $Pax8ClientId = $Pax8Config.clientId + $Pax8ClientSecret = Get-ExtensionAPIKey -Extension 'Pax8' + $paxBody = @{ + client_id = $Pax8ClientId + client_secret = $Pax8ClientSecret + audience = 'https://api.pax8.com' + grant_type = 'client_credentials' + } + $Token = Invoke-RestMethod -Uri 'https://api.pax8.com/v1/token' -Method POST -Body $paxBody -ContentType 'application/x-www-form-urlencoded' + $Pax8Headers = @{ Authorization = "Bearer $($Token.access_token)" } + $cancelSubList = (Invoke-RestMethod -Uri "https://api.pax8.com/v1/subscriptions?page=0&size=100&status=Active&companyId=$($TenantId)" -Method GET -Headers $Pax8Headers).content | Where-Object { $_.productId -in $LicencesToMigrate.SkuId } + foreach ($Sub in $cancelSubList) { + #Cancelbody can be NULL, or a date in the format of 2000-10-31T01:30:00.000-05:00. This used to just be $null + $cancelBody = @{ cancellationDate = (Get-Date).ToString('yyyy-MM-ddTHH:mm:ss.fffzzz') } + $null = Invoke-RestMethod -Uri "https://api.pax8.com/v1/subscriptions/$($Sub.id)" -Method DELETE -Headers $Pax8Headers -ContentType 'application/json' -Body ($cancelBody | ConvertTo-Json) + } + } catch { + $Subject = "Sherweb Migration: $($TenantFilter) - Pax8 cancellation failed." + $HTMLContent = New-CIPPAlertTemplate -Data $LicencesToMigrate -Format 'html' -InputObject 'sherwebmigfailcancel' + $JSONContent = New-CIPPAlertTemplate -Data $LicencesToMigrate -Format 'json' -InputObject 'sherwebmigfailcancel' + Send-CIPPAlert -Type 'email' -Title $Subject -HTMLContent $HTMLContent.htmlcontent -TenantFilter $TenantFilter -APIName 'Alerts' + Send-CIPPAlert -Type 'psa' -Title $Subject -HTMLContent $HTMLContent.htmlcontent -TenantFilter $TenantFilter -APIName 'Alerts' + Send-CIPPAlert -Type 'webhook' -JSONContent $JSONContent -TenantFilter $TenantFilter -APIName 'Alerts' + } + } + } +} diff --git a/Modules/CippExtensions/Public/Sherweb/Test-SherwebMigrationAccounts.ps1 b/Modules/CippExtensions/Public/Sherweb/Test-SherwebMigrationAccounts.ps1 deleted file mode 100644 index 1fdc01e00176..000000000000 --- a/Modules/CippExtensions/Public/Sherweb/Test-SherwebMigrationAccounts.ps1 +++ /dev/null @@ -1,92 +0,0 @@ -function Test-SherwebMigrationAccounts { - [CmdletBinding()] - param ( - $TenantFilter - ) - - $Table = Get-CIPPTable -TableName Extensionsconfig - $ExtensionConfig = (Get-CIPPAzDataTableEntity @Table).config | ConvertFrom-Json - $Config = $ExtensionConfig.Sherweb - #First get a list of all subscribed skus for this tenant, that are in the transfer window. - $Licenses = (Get-CIPPLicenseOverview -TenantFilter $TenantFilter) | Where-Object { $null -ne $_.terminfo -and $_.terminfo.TransferWindow -le 7 } - - #now check if this exact count of licenses is available at Sherweb, if not, we need to migrate them. - $SherwebLicenses = Get-SherwebCurrentSubscription -TenantFilter $TenantFilter - $LicencesToMigrate = foreach ($License in $Licenses) { - foreach ($termInfo in $License.terminfo) { - $matchedSherweb = $SherwebLicenses | Where-Object { $_.quantity -eq $termInfo.TotalLicenses -and $_.commitmentTerm.termEndDate -eq $termInfo.NextLifecycle } - if (-not $matchedSherweb) { - [PSCustomObject]@{ - LicenseName = ($Licenses | Where-Object { $_.skuId -eq $License.skuId }).license - SkuId = $License.skuId - SubscriptionId = $termInfo.SubscriptionId - Term = $termInfo.Term - NextLifecycle = $termInfo.NextLifecycle - TotalLicensesAtUnknownCSP = $termInfo.TotalLicenses - TotalLicensesAvailableInM365 = ($Licenses | Where-Object { $_.skuId -eq $License.skuId }).TotalLicenses - } - - } - } - } - - switch -wildcard ($config.migrationMethods) { - '*notify*' { - $Subject = "Sherweb Migration: $($TenantFilter) - $($LicencesToMigrate.Count) licenses to migrate" - $HTMLContent = New-CIPPAlertTemplate -Data $LicencesToMigrate -Format 'html' -InputObject 'sherwebmig' - $JSONContent = New-CIPPAlertTemplate -Data $LicencesToMigrate -Format 'json' -InputObject 'sherwebmig' - Send-CIPPAlert -Type 'email' -Title $Subject -HTMLContent $HTMLContent.htmlcontent -TenantFilter $tenant -APIName 'Alerts' - Send-CIPPAlert -Type 'psa' -Title $Subject -HTMLContent $HTMLContent.htmlcontent -TenantFilter $standardsTenant -APIName 'Alerts' - Send-CIPPAlert -Type 'webhook' -JSONContent $JSONContent -TenantFilter $Tenant -APIName 'Alerts' - } - '*buy*' { - try { - $PotentialLicenses = Get-SherwebCatalog -TenantFilter $TenantFilter | Where-Object { $_.microsoftSkuId -in $LicencesToMigrate.SkuId -and $_.sku -like "*$($Config.migrateToLicense)" } - if (!$PotentialLicenses) { - throw 'cannot buy new license: no matching license found in catalog' - } else { - $PotentialLicenses | ForEach-Object { - Set-SherwebSubscription -TenantFilter $TenantFilter -SKU $PotentialLicenses.sku -Quantity $LicencesToMigrate.TotalLicensesAtUnknownCSP - } - } - } catch { - $Subject = "Sherweb Migration: $($TenantFilter) - Failed to buy licenses." - $HTMLContent = New-CIPPAlertTemplate -Data $LicencesToMigrate -Format 'html' -InputObject 'sherwebmigBuyFail' - $JSONContent = New-CIPPAlertTemplate -Data $LicencesToMigrate -Format 'json' -InputObject 'sherwebmigBuyFail' - Send-CIPPAlert -Type 'email' -Title $Subject -HTMLContent $HTMLContent.htmlcontent -TenantFilter $tenant -APIName 'Alerts' - Send-CIPPAlert -Type 'psa' -Title $Subject -HTMLContent $HTMLContent.htmlcontent -TenantFilter $standardsTenant -APIName 'Alerts' - Send-CIPPAlert -Type 'webhook' -JSONContent $JSONContent -TenantFilter $Tenant -APIName 'Alerts' - } - - } - '*Cancel' { - try { - $tenantid = (Get-Tenants -TenantFilter $TenantFilter).customerId - $Pax8Config = $ExtensionConfig.Pax8 - $Pax8ClientId = $Pax8Config.clientId - $Pax8ClientSecret = Get-ExtensionAPIKey -Extension 'Pax8' - $paxBody = @{ - client_id = $Pax8ClientId - client_secret = $Pax8ClientSecret - audience = 'https://api.pax8.com' - grant_type = 'client_credentials' - } - $Token = Invoke-RestMethod -Uri 'https://api.pax8.com/v1/token' -Method POST -Headers $headers -ContentType 'application/json' -Body $paxBody - $headers = @{ Authorization = "Bearer $($Token.access_token)" } - $cancelSubList = Invoke-RestMethod -Uri "https://api.pax8.com/v1/subscriptions?page=0&size=10&status=Active&companyId=$($tenantid)" -Method GET -Headers $headers | Where-Object -Property productId -In $LicencesToMigrate.SkuId - $cancelSubList | ForEach-Object { - $response = Invoke-RestMethod -Uri "https://api.pax8.com/v1/subscriptions/$($_.subscriptionId)" -Method DELETE -Headers $headers -ContentType 'application/json' -Body ($body | ConvertTo-Json) - } - - } catch { - $Subject = 'Sherweb Migration: Pax Migration failed' - $HTMLContent = New-CIPPAlertTemplate -Data $LicencesToMigrate -Format 'html' -InputObject 'sherwebmigfailpax' - $JSONContent = New-CIPPAlertTemplate -Data $LicencesToMigrate -Format 'json' -InputObject 'sherwebmigfailpax' - Send-CIPPAlert -Type 'email' -Title $Subject -HTMLContent $HTMLContent.htmlcontent -TenantFilter $tenant -APIName 'Alerts' - Send-CIPPAlert -Type 'psa' -Title $Subject -HTMLContent $HTMLContent.htmlcontent -TenantFilter $standardsTenant -APIName 'Alerts' - Send-CIPPAlert -Type 'webhook' -JSONContent $JSONContent -TenantFilter $Tenant -APIName 'Alerts' - } - } - - } -} diff --git a/host.json b/host.json index 5635adf20ae2..accd9b7c83bc 100644 --- a/host.json +++ b/host.json @@ -16,7 +16,7 @@ "distributedTracingEnabled": false, "version": "None" }, - "defaultVersion": "10.4.5", + "defaultVersion": "10.5.1", "versionMatchStrategy": "Strict", "versionFailureStrategy": "Fail" } diff --git a/profile.ps1 b/profile.ps1 index a93372c3085c..a9041f6e9cc6 100644 --- a/profile.ps1 +++ b/profile.ps1 @@ -216,7 +216,7 @@ $Timings['Timezone'] = $SwTimezone.Elapsed.TotalMilliseconds # Import Extra modules if needed $SwExtraModules = [System.Diagnostics.Stopwatch]::StartNew() $ModulesPath = Join-Path $env:CIPPRootPath 'Modules' -$NonHttpModules = @('CIPPStandards', 'CIPPAlerts', 'CIPPTests', 'CIPPDB', 'CIPPActivityTriggers', 'DNSHealth') +$NonHttpModules = @('CIPPStandards', 'CIPPAlerts', 'CIPPTests', 'CIPPDB', 'CIPPActivityTriggers', 'DNSHealth', 'CippExtensions') $HttpModule = @('CIPPHTTP') $HttpDisabled = $env:AzureWebJobs_CIPPHttpTrigger_Disabled -in @('true', '1') -or [System.Environment]::GetEnvironmentVariable('AzureWebJobs.CIPPHttpTrigger.Disabled') -in @('true', '1') diff --git a/version_latest.txt b/version_latest.txt index 2cf514e360ca..4a6e70e959e5 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.5.0 +10.5.1