Skip to content

Commit e11f91c

Browse files
authored
Merge pull request #1042 from KelvinTegelaar/dev
[pull] dev from KelvinTegelaar:dev
2 parents 1cbc617 + 05f5f1f commit e11f91c

9 files changed

Lines changed: 257 additions & 30 deletions

File tree

Config/ConversionTable.csv

Lines changed: 123 additions & 0 deletions
Large diffs are not rendered by default.
File renamed without changes.

Modules/CIPPCore/Public/Invoke-CIPPOffboardingJob.ps1

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,8 +306,9 @@ function Invoke-CIPPOffboardingJob {
306306
}
307307

308308
if ($Batch.Count -eq 0) {
309-
Write-LogMessage -API $APIName -tenant $TenantFilter -message "No offboarding tasks selected for user $Username" -sev Warning
310-
return "No offboarding tasks were selected for $Username"
309+
$NoTasksMessage = "No offboarding tasks were selected for $Username. The offboarding job was not executed - check that at least one action was enabled."
310+
Write-LogMessage -API $APIName -tenant $TenantFilter -message $NoTasksMessage -sev Error
311+
throw $NoTasksMessage
311312
}
312313

313314
Write-Information "Built batch of $($Batch.Count) offboarding tasks for $Username"

Modules/CIPPCore/Public/MCP/Get-CippMcpSpec.ps1

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,9 @@ function Get-CippMcpSpec {
1616
return $script:CippMcpSpec
1717
}
1818

19-
$Root = $env:CIPPRootPath
20-
if (-not $Root -or -not (Test-Path (Join-Path $Root 'openapi.json'))) {
21-
# Fallback: walk up from this module until openapi.json is found.
22-
$Root = $PSScriptRoot
23-
while ($Root -and -not (Test-Path (Join-Path $Root 'openapi.json'))) {
24-
$Parent = Split-Path $Root -Parent
25-
if (-not $Parent -or $Parent -eq $Root) { $Root = $null; break }
26-
$Root = $Parent
27-
}
28-
}
19+
$SpecPath = Join-Path -Path $env:CIPPRootPath -ChildPath 'Config\openapi.json'
2920

30-
$SpecPath = if ($Root) { Join-Path $Root 'openapi.json' } else { $null }
31-
if (-not $SpecPath -or -not (Test-Path $SpecPath)) {
21+
if (-not (Test-Path $SpecPath)) {
3222
throw [pscustomobject]@{ code = -32603; message = 'OpenAPI spec (openapi.json) not found; cannot project MCP tools.' }
3323
}
3424

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
function Test-CIPPOffboardingRequest {
2+
<#
3+
.SYNOPSIS
4+
Validates the shape of an ExecOffboardUser request body before a scheduled task is queued.
5+
.DESCRIPTION
6+
Invoke-ExecOffboardUser queues an asynchronous scheduled task and returns 200 the instant the
7+
task is created - it never waits for, or reports on, the actual offboarding result. That means a
8+
malformed payload silently "succeeds": it reports OK, queues nothing useful, runs no actions, and
9+
never appears in the Offboarding view.
10+
11+
The common failure modes this catches:
12+
- 'user' sent as bare UPN strings instead of { value = '<upn>' } objects, so the backend's
13+
$Request.Body.user.value resolves to nothing and no task is created.
14+
- 'tenantFilter' missing or not resolvable to a domain.
15+
- 'Scheduled.enabled' true with a missing/invalid date.
16+
- No offboarding actions selected, which produces an empty batch that completes as a no-op.
17+
18+
Returns a structured result. The endpoint rejects the request with a 400 when IsValid is false,
19+
and reuses the normalized TenantFilter/Users so the extraction logic matches what was validated.
20+
.PARAMETER Body
21+
The $Request.Body of the ExecOffboardUser call.
22+
#>
23+
[CmdletBinding()]
24+
param(
25+
[Parameter(Mandatory = $true)]
26+
$Body
27+
)
28+
29+
$Errors = [System.Collections.Generic.List[string]]::new()
30+
31+
# tenantFilter: required, must resolve to a non-empty domain string (accepts a string or { value })
32+
$TenantFilter = $Body.tenantFilter.value ?? $Body.tenantFilter
33+
if ([string]::IsNullOrWhiteSpace([string]$TenantFilter)) {
34+
$Errors.Add("'tenantFilter' is required and must resolve to a tenant domain (a string, or an object with a non-empty 'value' property).")
35+
}
36+
37+
# user: required, >= 1 entry, each resolving to a non-empty userPrincipalName.
38+
# Accepts the UI shape ([{ value = '<upn>' }]) and bare UPN strings (['<upn>']).
39+
# Only string values are accepted - an object without a 'value' must NOT fall back to the object
40+
# itself (its string form is "@{...}", which would otherwise sneak past the UPN '@' check).
41+
$Users = @(
42+
$Body.user | ForEach-Object {
43+
$UserValue = $_.value ?? $_
44+
if ($UserValue -is [string]) { $UserValue }
45+
} | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
46+
)
47+
if (-not $Body.user -or @($Body.user).Count -eq 0) {
48+
$Errors.Add("'user' is required and must be a non-empty array of users (objects with a 'value' property, or userPrincipalName strings).")
49+
} elseif ($Users.Count -eq 0) {
50+
$Errors.Add("'user' did not resolve to any usable userPrincipalName. Each entry must be a UPN string or an object with a non-empty 'value' property.")
51+
} else {
52+
$InvalidUsers = @($Users | Where-Object { [string]$_ -notmatch '@' })
53+
if ($InvalidUsers.Count -gt 0) {
54+
$Errors.Add("These user values do not look like userPrincipalNames (missing '@'): $($InvalidUsers -join ', ').")
55+
}
56+
}
57+
58+
# Scheduled: when enabled, date must be a valid Unix timestamp
59+
if ($Body.Scheduled.enabled) {
60+
$Epoch = [int64]0
61+
if ($null -eq $Body.Scheduled.date -or -not [int64]::TryParse([string]$Body.Scheduled.date, [ref]$Epoch) -or $Epoch -le 0) {
62+
$Errors.Add("'Scheduled.enabled' is true but 'Scheduled.date' is not a valid Unix timestamp.")
63+
}
64+
}
65+
66+
# At least one offboarding action must be selected, otherwise the job builds an empty batch and no-ops.
67+
# Keep this list in sync with the conditions in Invoke-CIPPOffboardingJob.
68+
$BooleanActions = @(
69+
'ConvertToShared', 'HideFromGAL', 'removeCalendarInvites', 'removePermissions', 'removeCalendarPermissions',
70+
'RemoveRules', 'RemoveMobile', 'RemoveGroups', 'RemoveLicenses', 'RevokeSessions', 'DisableSignIn',
71+
'ClearImmutableId', 'ResetPass', 'RemoveMFADevices', 'RemoveTeamsPhoneDID', 'DeleteUser',
72+
'DisableOneDriveSharing', 'disableForwarding'
73+
)
74+
$CollectionActions = @('AccessNoAutomap', 'AccessAutomap', 'OnedriveAccess')
75+
76+
$HasAction = $false
77+
foreach ($Key in $BooleanActions) {
78+
if ($Body.$Key -eq $true) { $HasAction = $true; break }
79+
}
80+
if (-not $HasAction) {
81+
foreach ($Key in $CollectionActions) {
82+
if (@($Body.$Key | Where-Object { $null -ne $_ }).Count -gt 0) { $HasAction = $true; break }
83+
}
84+
}
85+
if (-not $HasAction -and -not [string]::IsNullOrWhiteSpace([string]($Body.forward.value ?? $Body.forward))) {
86+
$HasAction = $true
87+
}
88+
if (-not $HasAction -and -not [string]::IsNullOrWhiteSpace([string]$Body.OOO)) {
89+
$HasAction = $true
90+
}
91+
if (-not $HasAction) {
92+
$Errors.Add('No offboarding actions were selected. Enable at least one action (e.g. RemoveLicenses, DisableSignIn, RevokeSessions) before submitting.')
93+
}
94+
95+
return [PSCustomObject]@{
96+
IsValid = ($Errors.Count -eq 0)
97+
Errors = @($Errors)
98+
TenantFilter = $TenantFilter
99+
Users = $Users
100+
}
101+
}

Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecOffboardUser.ps1

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,17 @@ function Invoke-ExecOffboardUser {
77
#>
88
[CmdletBinding()]
99
param($Request, $TriggerMetadata)
10-
$AllUsers = $Request.Body.user.value
11-
$TenantFilter = $request.Body.tenantFilter.value ? $request.Body.tenantFilter.value : $request.Body.tenantFilter
10+
11+
$Validation = Test-CIPPOffboardingRequest -Body $Request.Body
12+
if (-not $Validation.IsValid) {
13+
return ([HttpResponseContext]@{
14+
StatusCode = [HttpStatusCode]::BadRequest
15+
Body = [pscustomobject]@{ Results = @($Validation.Errors) }
16+
})
17+
}
18+
19+
$AllUsers = $Validation.Users
20+
$TenantFilter = $Validation.TenantFilter
1221
$OffboardingOptions = $Request.Body | Select-Object * -ExcludeProperty user, tenantFilter, Scheduled
1322

1423
$StatusCode = [HttpStatusCode]::OK

Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/Custom-Scripts/Invoke-AddCustomScript.ps1

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,10 @@ function Invoke-AddCustomScript {
3131
$LatestVersion = $ExistingVersions | Sort-Object -Property Version -Descending | Select-Object -First 1
3232
$CurrentEnabled = if ($LatestVersion.PSObject.Properties['Enabled']) { [bool]$LatestVersion.Enabled } else { $true }
3333
$CurrentAlertOnFailure = if ($LatestVersion.PSObject.Properties['AlertOnFailure']) { [bool]$LatestVersion.AlertOnFailure } else { $false }
34-
$CurrentAlertStatuses = if ($LatestVersion.PSObject.Properties['AlertStatuses'] -and -not [string]::IsNullOrWhiteSpace($LatestVersion.AlertStatuses)) { $LatestVersion.AlertStatuses } else { '[]' }
3534
$CurrentResultMode = if ($LatestVersion.PSObject.Properties['ResultMode'] -and -not [string]::IsNullOrWhiteSpace($LatestVersion.ResultMode)) { $LatestVersion.ResultMode } else { 'Auto' }
3635

3736
$NewEnabled = $CurrentEnabled
3837
$NewAlertOnFailure = $CurrentAlertOnFailure
39-
$NewAlertStatuses = $CurrentAlertStatuses
4038
$NewResultMode = $CurrentResultMode
4139

4240
switch ($Action) {
@@ -48,13 +46,9 @@ function Invoke-AddCustomScript {
4846
}
4947
'EnableAlerts' {
5048
$NewAlertOnFailure = $true
51-
if ($NewAlertStatuses -eq '[]') {
52-
$NewAlertStatuses = @('Failed') | ConvertTo-Json -Compress
53-
}
5449
}
5550
'DisableAlerts' {
5651
$NewAlertOnFailure = $false
57-
$NewAlertStatuses = '[]'
5852
}
5953
'SetResultMode' {
6054
$RequestedMode = $Request.Body.ResultMode
@@ -71,7 +65,6 @@ function Invoke-AddCustomScript {
7165
RowKey = $LatestVersion.RowKey
7266
Enabled = $NewEnabled
7367
AlertOnFailure = $NewAlertOnFailure
74-
AlertStatuses = $NewAlertStatuses
7568
ResultMode = $NewResultMode
7669
}
7770

@@ -122,7 +115,7 @@ function Invoke-AddCustomScript {
122115
$UserImpact = $Request.Body.UserImpact
123116
$Enabled = $Request.Body.Enabled
124117
$AlertOnFailure = $Request.Body.AlertOnFailure
125-
$AlertStatuses = if ($Request.Body.AlertStatuses) { $Request.Body.AlertStatuses | ConvertTo-Json -Compress } else { '[]' }
118+
$AlertStatuses = $Request.Body.AlertStatuses
126119
$ReturnType = $Request.Body.ReturnType
127120
$MarkdownTemplate = $Request.Body.MarkdownTemplate
128121
$ResultSchema = $Request.Body.ResultSchema

Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardColleagueImpersonationAlert.ps1

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,9 @@ function Invoke-CIPPStandardColleagueImpersonationAlert {
4646

4747
param($Tenant, $Settings)
4848
$TestResult = Test-CIPPStandardLicense -StandardName 'ColleagueImpersonationAlert' -TenantFilter $Tenant -Preset Exchange
49-
49+
5050
if ($TestResult -eq $false) {
51-
return $true
51+
return $true
5252
} #we're done.
5353

5454
$ruleHtml = $Settings.disclaimerHtml
@@ -135,8 +135,8 @@ function Invoke-CIPPStandardColleagueImpersonationAlert {
135135
$range = $entry.Key
136136
$pattern = $entry.Value
137137
$ruleName = "($range) Colleague Impersonation Alert"
138-
$names = @($displayNames | Where-Object { $_ -match $pattern })
139-
if ($names.Count -eq 0) { $names = @("($range)") }
138+
$names = @($displayNames | Where-Object { $_ -match $pattern } | ForEach-Object { [regex]::Escape($_) })
139+
if ($names.Count -eq 0) { $names = @([regex]::Escape("($range)")) }
140140
$existing = $Rules | Where-Object { $_.Name -eq $ruleName } | Select-Object -First 1
141141

142142
$namesMatch = $false

Modules/CIPPTests/Public/Tests/Custom/Invoke-CippTestCustomScripts.ps1

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,19 @@ function Invoke-CippTestCustomScripts {
5656
$TestId = "CustomScript-$($Script.ScriptGuid)"
5757
$ScriptName = if ([string]::IsNullOrWhiteSpace($Script.ScriptName)) { $TestId } else { $Script.ScriptName }
5858

59+
$AllStatuses = @('Passed', 'Failed', 'Info', 'Investigate')
5960
$AlertStatuses = @('Failed')
6061
if ($AlertStatusesProp -and -not [string]::IsNullOrWhiteSpace($AlertStatusesProp.Value)) {
61-
$AlertStatuses = $AlertStatusesProp.Value | ConvertFrom-Json
62+
$RawAlertStatuses = [string]$AlertStatusesProp.Value
63+
if ($RawAlertStatuses.TrimStart().StartsWith('[')) {
64+
$AlertStatuses = @($RawAlertStatuses | ConvertFrom-Json)
65+
} else {
66+
$AlertStatuses = @($RawAlertStatuses)
67+
}
68+
}
69+
# 'All' alerts on every result status.
70+
if ($AlertStatuses -contains 'All') {
71+
$AlertStatuses = $AllStatuses
6272
}
6373

6474
try {
@@ -105,7 +115,7 @@ function Invoke-CippTestCustomScripts {
105115
Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Custom' -Status $FinalStatus -ResultDataJson $ResultDataJson -ResultMarkdown $ResultMarkdown -Risk ($Script.Risk ?? 'Medium') -Name $ScriptName -Pillar $Script.Pillar -UserImpact $Script.UserImpact -ImplementationEffort $Script.ImplementationEffort -Category 'Custom Script'
106116

107117
if ($ShouldAlert -and $FinalStatus -in $AlertStatuses) {
108-
Write-AlertMessage -tenant $Tenant -message "Custom script test failed: $ScriptName ($($Script.ScriptGuid))"
118+
Write-AlertMessage -tenant $Tenant -message "Custom script test '$ScriptName' returned status '$FinalStatus' ($($Script.ScriptGuid))"
109119
}
110120
} catch {
111121
$ErrorMessage = Get-CippException -Exception $_

0 commit comments

Comments
 (0)