|
| 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 | +} |
0 commit comments