Skip to content

Commit f236956

Browse files
authored
Merge pull request #1063 from KelvinTegelaar/dev
[pull] dev from KelvinTegelaar:dev
2 parents 656f6a2 + 0cb0ddc commit f236956

38 files changed

Lines changed: 2099 additions & 438 deletions

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,7 @@ Shared/CIPPSharp/obj/
2424
!/profile.ps1
2525
.DS_Store
2626
proxyman.pem
27+
28+
# Pester test runner artifacts (Invoke-CippTests.ps1 -CI / -Coverage)
29+
Tests/TestResults.xml
30+
Tests/coverage.xml

Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ function Add-CIPPScheduledTask {
234234
Results = 'Planned'
235235
AlertComment = [string]$task.AlertComment
236236
CustomSubject = [string]$task.CustomSubject
237+
PsaTicketStrategy = [string]($task.PsaTicketStrategy.value ?? $task.PsaTicketStrategy)
237238
}
238239

239240

Modules/CIPPCore/Public/Compare-CIPPIntuneAssignments.ps1

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ function Compare-CIPPIntuneAssignments {
8080
$ExpectedGroupIds = @(
8181
$ExpectedCustomGroup.Split(',').Trim() | ForEach-Object {
8282
$name = $_
83-
$AllGroupsCache | Where-Object { $_.displayName -like $name } | Select-Object -ExpandProperty id
83+
$AllGroupsCache | Where-Object { $_.displayName -like ($name -replace '\[', '`[' -replace '\]', '`]') } | Select-Object -ExpandProperty id
8484
} | Where-Object { $_ }
8585
)
8686
$MissingIds = @($ExpectedGroupIds | Where-Object { $_ -notin $ExistingIncludeGroupIds })
@@ -97,7 +97,7 @@ function Compare-CIPPIntuneAssignments {
9797
$ExpectedExcludeIds = @(
9898
$ExpectedExcludeGroup.Split(',').Trim() | ForEach-Object {
9999
$name = $_
100-
$AllGroupsCache | Where-Object { $_.displayName -like $name } | Select-Object -ExpandProperty id
100+
$AllGroupsCache | Where-Object { $_.displayName -like ($name -replace '\[', '`[' -replace '\]', '`]') } | Select-Object -ExpandProperty id
101101
} | Where-Object { $_ }
102102
)
103103
$MissingExcludeIds = @($ExpectedExcludeIds | Where-Object { $_ -notin $ExistingExcludeGroupIds })

Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ function Compare-CIPPIntuneObject {
3535
'isSynced'
3636
'locationInfo',
3737
'templateId',
38-
'source'
38+
'source',
39+
'package'
3940
)
4041

4142
$excludeProps = $defaultExcludeProperties + $ExcludeProperties
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
function Get-CIPPMSPAppInstallCommand {
2+
<#
3+
.SYNOPSIS
4+
Builds the install/uninstall command lines for an MSP RMM app for a single tenant.
5+
.DESCRIPTION
6+
Shared by Invoke-AddMSPApp (manual/queue deploy) and New-CIPPIntuneAppDeployment
7+
(the 'Deploy Intune Application Template' standard). Each parameter value may be:
8+
- a flat string, optionally containing %CIPP variables% that resolve per-tenant
9+
(Application Template shape), or
10+
- an object keyed by tenant customerId (legacy per-tenant deploy shape).
11+
Values are resolved for the tenant, run through Get-CIPPTextReplacement so any
12+
%variables% are substituted with the tenant's value, then escaped with
13+
ConvertTo-CIPPSafePwshArg before being placed on the command line.
14+
.PARAMETER RmmName
15+
The MSP tool identifier (datto, syncro, Huntress, automate, cwcommand, ninja, NCentral).
16+
.PARAMETER Params
17+
The params object from the app config / request body.
18+
.PARAMETER Tenant
19+
The tenant object, requires customerId and defaultDomainName.
20+
.PARAMETER PackageName
21+
Package name for ninja/NCentral installs (not stored under params).
22+
#>
23+
[CmdletBinding()]
24+
param(
25+
[Parameter(Mandatory = $true)]
26+
[string]$RmmName,
27+
28+
$Params,
29+
30+
[Parameter(Mandatory = $true)]
31+
$Tenant,
32+
33+
[string]$PackageName
34+
)
35+
36+
$InstallParams = [PSCustomObject]$Params
37+
38+
# Resolve a raw parameter value for this tenant: pick the per-tenant keyed value when the
39+
# value is an object (legacy shape), otherwise use it as-is (template shape), then replace
40+
# any %CIPP variables% using the tenant context. Returns the raw (unescaped) string.
41+
function Resolve-MSPValue {
42+
param($Value)
43+
if ($null -eq $Value) { return '' }
44+
if ($Value -is [string]) {
45+
$Resolved = $Value
46+
} elseif ($Value -is [System.Collections.IDictionary]) {
47+
$Resolved = [string]$Value[$Tenant.customerId]
48+
} elseif ($Value -is [pscustomobject]) {
49+
$Resolved = [string]$Value.$($Tenant.customerId)
50+
} else {
51+
$Resolved = [string]$Value
52+
}
53+
if ($Resolved -match '%') {
54+
$Resolved = Get-CIPPTextReplacement -TenantFilter $Tenant.defaultDomainName -Text $Resolved
55+
}
56+
return $Resolved
57+
}
58+
59+
$DetectionScriptContent = $null
60+
61+
switch ($RmmName) {
62+
'datto' {
63+
$DattoUrl = ConvertTo-CIPPSafePwshArg -Value (Resolve-MSPValue $InstallParams.DattoURL)
64+
$DattoGuid = ConvertTo-CIPPSafePwshArg -Value (Resolve-MSPValue $InstallParams.DattoGUID)
65+
$installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -URL $DattoUrl -GUID $DattoGuid"
66+
$uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1'
67+
}
68+
'ninja' {
69+
$NinjaPackage = ConvertTo-CIPPSafePwshArg -Value ([string]$PackageName)
70+
$installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -InstallParam $NinjaPackage"
71+
$uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1'
72+
}
73+
'Huntress' {
74+
$HuntressOrgKey = ConvertTo-CIPPSafePwshArg -Value (Resolve-MSPValue $InstallParams.Orgkey)
75+
$HuntressAccountKey = ConvertTo-CIPPSafePwshArg -Value (Resolve-MSPValue $InstallParams.AccountKey)
76+
$installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -OrgKey $HuntressOrgKey -acctkey $HuntressAccountKey"
77+
$uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\install.ps1 -Uninstall'
78+
}
79+
'syncro' {
80+
$SyncroUrl = ConvertTo-CIPPSafePwshArg -Value (Resolve-MSPValue $InstallParams.ClientURL)
81+
$installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -URL $SyncroUrl"
82+
$uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1'
83+
}
84+
'NCentral' {
85+
$NCentralPackage = ConvertTo-CIPPSafePwshArg -Value ([string]$PackageName)
86+
$installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -InstallParam $NCentralPackage"
87+
$uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1'
88+
}
89+
'automate' {
90+
$ServerRaw = Resolve-MSPValue $InstallParams.Server
91+
$AutomateServer = ConvertTo-CIPPSafePwshArg -Value $ServerRaw
92+
$AutomateInstallerToken = ConvertTo-CIPPSafePwshArg -Value (Resolve-MSPValue $InstallParams.InstallerToken)
93+
$AutomateLocationId = ConvertTo-CIPPSafePwshArg -Value (Resolve-MSPValue $InstallParams.LocationID)
94+
$installCommandLine = "c:\windows\sysnative\windowspowershell\v1.0\powershell.exe -ExecutionPolicy Bypass .\install.ps1 -Server $AutomateServer -InstallerToken $AutomateInstallerToken -LocationID $AutomateLocationId"
95+
$uninstallCommandLine = "c:\windows\sysnative\windowspowershell\v1.0\powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1 -Server $AutomateServer"
96+
$DetectionScriptContent = (Get-Content 'AddMSPApp\automate.detection.ps1' -Raw) -replace '##SERVER##', $ServerRaw
97+
}
98+
'cwcommand' {
99+
$CwClientUrl = ConvertTo-CIPPSafePwshArg -Value (Resolve-MSPValue $InstallParams.ClientURL)
100+
$installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -Url $CwClientUrl"
101+
$uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1'
102+
}
103+
default {
104+
throw "Unknown MSP app type '$RmmName'"
105+
}
106+
}
107+
108+
return [PSCustomObject]@{
109+
InstallCommandLine = $installCommandLine
110+
UninstallCommandLine = $uninstallCommandLine
111+
DetectionScriptContent = $DetectionScriptContent
112+
}
113+
}

Modules/CIPPCore/Public/New-CIPPIntuneAppDeployment.ps1

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,29 @@ function New-CIPPIntuneAppDeployment {
7878
}
7979
}
8080

81+
# Build IntuneBody from raw config if not pre-built (template/standard path). MSP apps store
82+
# only the vendor + params in the template, so build the install command here using the shared
83+
# helper, which resolves %CIPP variables% in the params per-tenant.
84+
if (-not $IntuneBody -and $AppType -eq 'MSPApp') {
85+
$MSPAppName = $AppConfig.MSPAppName ?? $AppConfig.rmmname.value ?? $AppConfig.rmmname
86+
if ([string]::IsNullOrWhiteSpace($MSPAppName)) {
87+
throw 'MSP app vendor (rmmname) is required for MSP app deployments but was not found in the template config.'
88+
}
89+
# Ensure the file-loading block below can locate the packaged app files.
90+
$AppConfig | Add-Member -NotePropertyName 'MSPAppName' -NotePropertyValue $MSPAppName -Force
91+
92+
$IntuneBody = Get-Content (Join-Path $env:CIPPRootPath "AddMSPApp\$MSPAppName.app.json") | ConvertFrom-Json
93+
$IntuneBody.displayName = $AppConfig.Applicationname ?? $AppConfig.displayName
94+
95+
$TenantObj = Get-Tenants -TenantFilter $TenantFilter
96+
$CommandResult = Get-CIPPMSPAppInstallCommand -RmmName $MSPAppName -Params $AppConfig.params -Tenant $TenantObj -PackageName $AppConfig.PackageName
97+
$IntuneBody.installCommandLine = $CommandResult.InstallCommandLine
98+
$IntuneBody.UninstallCommandLine = $CommandResult.UninstallCommandLine
99+
if ($CommandResult.DetectionScriptContent) {
100+
$IntuneBody.detectionRules[0].scriptContent = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($CommandResult.DetectionScriptContent))
101+
}
102+
}
103+
81104
# Load files based on app type (only for types that need them)
82105
$Intunexml = $null
83106
$Infile = $null

Modules/CIPPCore/Public/Send-CIPPAlert.ps1

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ function Send-CIPPAlert {
1616
$TableName,
1717
$RowKey = [string][guid]::NewGuid(),
1818
$Attachments,
19+
$AffectedUser,
1920
[switch]$UseStandardizedSchema
2021
)
2122
Write-Information 'Shipping Alert'
@@ -326,21 +327,31 @@ function Send-CIPPAlert {
326327

327328
if ($Type -eq 'psa') {
328329
Write-Information 'Trying to send to PSA'
329-
if ($config.sendtoIntegration) {
330-
if ($PSCmdlet.ShouldProcess('PSA', 'Sending alert')) {
331-
try {
332-
$Alert = @{
333-
TenantId = $TenantFilter
334-
AlertText = "$HTMLContent"
335-
AlertTitle = "$($Title)"
336-
}
337-
New-CippExtAlert -Alert $Alert
338-
Write-LogMessage -API 'Webhook Alerts' -tenant $TenantFilter -message "Sent PSA alert $title" -sev info
339-
} catch {
340-
$ErrorMessage = Get-CippException -Exception $_
341-
Write-Information "Could not send alerts to ticketing system: $($ErrorMessage.NormalizedError)"
342-
Write-LogMessage -API 'Webhook Alerts' -tenant $TenantFilter -message "Could not send alerts to ticketing system: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage
330+
if (-not $config.sendtoIntegration) {
331+
Write-Information 'PSA delivery skipped: sendtoIntegration is disabled in CippNotifications config. Enable it under Settings -> Notifications to route alerts to your PSA.'
332+
return
333+
}
334+
if ($PSCmdlet.ShouldProcess('PSA', 'Sending alert')) {
335+
try {
336+
$Alert = @{
337+
TenantId = $TenantFilter
338+
AlertText = "$HTMLContent"
339+
AlertTitle = "$($Title)"
340+
}
341+
if ($AffectedUser) {
342+
$Alert.AffectedUser = $AffectedUser
343+
$UserLabel = if ($AffectedUser.UPN) { $AffectedUser.UPN } elseif ($AffectedUser.AzureOID) { "OID:$($AffectedUser.AzureOID)" } else { 'unknown' }
344+
Write-Information "PSA alert AffectedUser: $UserLabel"
345+
}
346+
$PsaResult = New-CippExtAlert -Alert $Alert
347+
if ($PsaResult) {
348+
Write-Information "PSA result: $PsaResult"
343349
}
350+
Write-LogMessage -API 'Webhook Alerts' -tenant $TenantFilter -message "Sent PSA alert $title" -sev info
351+
} catch {
352+
$ErrorMessage = Get-CippException -Exception $_
353+
Write-Information "Could not send alerts to ticketing system: $($ErrorMessage.NormalizedError)"
354+
Write-LogMessage -API 'Webhook Alerts' -tenant $TenantFilter -message "Could not send alerts to ticketing system: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage
344355
}
345356
}
346357
}

Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,65 @@ function Send-CIPPScheduledTaskAlert {
146146
# Send to configured alert targets
147147
switch -wildcard ($TaskInfo.PostExecution) {
148148
'*psa*' {
149-
Send-CIPPAlert -Type 'psa' -Title $title -HTMLContent $HTML -TenantFilter $TenantFilter
149+
$PsaSplitSent = $false
150+
if ($TaskType -eq 'Alert' -and $Results -is [array] -and $Results.Count -gt 0 -and $Results[0] -isnot [string]) {
151+
try {
152+
$ExtConfigTable = Get-CIPPTable -TableName Extensionsconfig
153+
$ExtConfig = (Get-CIPPAzDataTableEntity @ExtConfigTable).config | ConvertFrom-Json -ErrorAction SilentlyContinue
154+
$HaloConfig = $ExtConfig.HaloPSA
155+
156+
# Per-task PsaTicketStrategy (configured on the alert) overrides the global
157+
# HaloPSA.LinkTicketsToUsers toggle. Lets MSPs decide on a per-alert basis
158+
# whether a wide alert (e.g. "users without MFA") should produce one ticket
159+
# per user or one consolidated ticket per tenant.
160+
$TaskStrategy = $TaskInfo.PsaTicketStrategy
161+
$ShouldSplit = switch ($TaskStrategy) {
162+
'split' { $true }
163+
'consolidated' { $false }
164+
default { [bool]$HaloConfig.LinkTicketsToUsers }
165+
}
166+
167+
if ($HaloConfig -and $HaloConfig.Enabled -and $ShouldSplit) {
168+
$UpnFieldCandidates = @('UserPrincipalName', 'userPrincipalName', 'UPN', 'userId', 'Userkey')
169+
$RowProperties = $Results[0].PSObject.Properties.Name
170+
$UpnField = $UpnFieldCandidates | Where-Object { $_ -in $RowProperties } | Select-Object -First 1
171+
172+
if ($UpnField) {
173+
$DisplayFieldCandidates = @('DisplayName', 'displayName', 'userDisplayName')
174+
$DisplayField = $DisplayFieldCandidates | Where-Object { $_ -in $RowProperties } | Select-Object -First 1
175+
176+
$Groups = $Results | Group-Object -Property $UpnField
177+
178+
foreach ($Group in $Groups) {
179+
$GroupKey = $Group.Name
180+
$GroupHTMLFragment = $Group.Group | ConvertTo-Html -Fragment
181+
$GroupHTML = $GroupHTMLFragment -replace '<table>', "This alert is for tenant $TenantFilter. <br /><br /> $TableDesign<table class=adaptiveTable>" | Out-String
182+
183+
if ([string]::IsNullOrWhiteSpace($GroupKey)) {
184+
# Rows without a usable user identifier - send as a single tenant-scoped ticket.
185+
Send-CIPPAlert -Type 'psa' -Title $title -HTMLContent $GroupHTML -TenantFilter $TenantFilter
186+
} else {
187+
$GroupDisplayName = if ($DisplayField) { $Group.Group[0].$DisplayField } else { $null }
188+
$UserLabel = if ($GroupDisplayName) { "$GroupDisplayName ($GroupKey)" } else { $GroupKey }
189+
$UserTitle = "$title - $UserLabel"
190+
$AffectedUser = [pscustomobject]@{
191+
UPN = $GroupKey
192+
DisplayName = $GroupDisplayName
193+
}
194+
Send-CIPPAlert -Type 'psa' -Title $UserTitle -HTMLContent $GroupHTML -TenantFilter $TenantFilter -AffectedUser $AffectedUser
195+
}
196+
}
197+
$PsaSplitSent = $true
198+
}
199+
}
200+
} catch {
201+
Write-Information "Failed to split PSA alert by user, falling back to consolidated ticket: $($_.Exception.Message)"
202+
}
203+
}
204+
205+
if (-not $PsaSplitSent) {
206+
Send-CIPPAlert -Type 'psa' -Title $title -HTMLContent $HTML -TenantFilter $TenantFilter
207+
}
150208
}
151209
'*email*' {
152210
$EmailParams = @{

Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ function Set-CIPPAssignedApplication {
114114
$resolvedGroupIds = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$top=999&$select=id,displayName' -tenantid $TenantFilter | ForEach-Object {
115115
$Group = $_
116116
foreach ($SingleName in $GroupNames) {
117-
if ($Group.displayName -like $SingleName) {
117+
if ($Group.displayName -like ($SingleName -replace '\[', '`[' -replace '\]', '`]')) {
118118
$Group.id
119119
}
120120
}
@@ -161,7 +161,7 @@ function Set-CIPPAssignedApplication {
161161
$ResolvedExcludeIds = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$top=999&$select=id,displayName' -tenantid $TenantFilter | ForEach-Object {
162162
$Group = $_
163163
foreach ($SingleName in $ExcludeGroupNames) {
164-
if ($Group.displayName -like $SingleName) {
164+
if ($Group.displayName -like ($SingleName -replace '\[', '`[' -replace '\]', '`]')) {
165165
$Group.id
166166
}
167167
}

Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ function Set-CIPPAssignedPolicy {
8989
$resolvedGroupIds = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$select=id,displayName&$top=999' -tenantid $TenantFilter |
9090
ForEach-Object {
9191
foreach ($SingleName in $GroupNames) {
92-
if ($_.displayName -like $SingleName) {
92+
if ($_.displayName -like ($SingleName -replace '\[', '`[' -replace '\]', '`]')) {
9393
$_.id
9494
}
9595
}
@@ -129,7 +129,7 @@ function Set-CIPPAssignedPolicy {
129129
$ResolvedExcludeIds = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$select=id,displayName&$top=999' -tenantid $TenantFilter |
130130
ForEach-Object {
131131
foreach ($SingleName in $ExcludeGroupNames) {
132-
if ($_.displayName -like $SingleName) {
132+
if ($_.displayName -like ($SingleName -replace '\[', '`[' -replace '\]', '`]')) {
133133
$_.id
134134
}
135135
}

0 commit comments

Comments
 (0)