From 37e81e5bcbca375fabb796085100f019bd98b4ac Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 16 Mar 2026 09:29:03 -0400 Subject: [PATCH 01/12] update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 93317828cfe5..f9f512565e51 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ yarn.lock # Ignore all root PowerShell files except profile.ps1 /*.ps1 !/profile.ps1 +.DS_Store From e75aacbd8a7ba0548aa6817cabd363e7fc0d9605 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 17 Mar 2026 00:20:43 +0800 Subject: [PATCH 02/12] Fallback to auditLogs for guest sign-ins Add fallback lookup to auditLogs/signIns when a guest's signInActivity is null. Guests are now enriched with EnrichedLastSignInDateTime (from signInActivity or auditLogs) and only those with a last sign-in older than the cutoff are kept. Update disable flow to use the enriched timestamp in logs and mark accountEnabled = $false on success. Adjust reporting to include EnrichedLastSignInDateTime and only list currently enabled guests. --- .../Invoke-CIPPStandardDisableGuests.ps1 | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDisableGuests.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDisableGuests.ps1 index dd7d018f5413..de94b63430dd 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDisableGuests.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDisableGuests.ps1 @@ -49,8 +49,27 @@ function Invoke-CIPPStandardDisableGuests { $AuditLookup = (Get-Date).AddDays(-7).ToUniversalTime().ToString('o') try { - $GraphRequest = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users?`$filter=createdDateTime le $Lookup and userType eq 'Guest' and accountEnabled eq true &`$select=id,UserPrincipalName,signInActivity,mail,userType,accountEnabled,createdDateTime,externalUserState" -scope 'https://graph.microsoft.com/.default' -tenantid $Tenant | - Where-Object { $_.signInActivity.lastSuccessfulSignInDateTime -le $Days } + $GraphRequest = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users?`$filter=createdDateTime le $Lookup and userType eq 'Guest' and accountEnabled eq true &`$select=id,UserPrincipalName,signInActivity,mail,userType,accountEnabled,createdDateTime,externalUserState" -scope 'https://graph.microsoft.com/.default' -tenantid $Tenant + + $EnrichedGuests = [System.Collections.Generic.List[object]]::new() + foreach ($guest in $GraphRequest) { + $lastSignIn = $null + if ($guest.signInActivity -and $guest.signInActivity.lastSuccessfulSignInDateTime) { + $lastSignIn = [datetime]$guest.signInActivity.lastSuccessfulSignInDateTime + } else { + # signInActivity is null, try auditLogs/signIns + $SignInLogs = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/auditLogs/signIns?`$filter=userId eq '$($guest.id)' and status/errorCode eq 0&`$orderby=createdDateTime desc&`$top=1" -scope 'https://graph.microsoft.com/.default' -tenantid $Tenant -noPagination $true + if ($SignInLogs -and $SignInLogs.Count -gt 0) { + $lastSignIn = [datetime]$SignInLogs[0].authenticationDetails.authenticationStepDateTime + } + } + # Only add guests whose last sign-in is older than cutoff + if ($lastSignIn -and $lastSignIn.ToUniversalTime() -le $Days) { + $guest | Add-Member -MemberType NoteProperty -Name 'EnrichedLastSignInDateTime' -Value $lastSignIn -Force + $EnrichedGuests.Add($guest) + } + } + $GraphRequest = $EnrichedGuests } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the DisableGuests state for $Tenant. Error: $ErrorMessage" -Sev Error @@ -84,8 +103,14 @@ function Invoke-CIPPStandardDisableGuests { $result = $BulkResults[$i] $guest = $GraphRequest[$i] + $lastSignIn = $guest.signInActivity?.lastSuccessfulSignInDateTime + if (-not $lastSignIn -and $guest.EnrichedLastSignInDateTime) { + $lastSignIn = $guest.EnrichedLastSignInDateTime + } + if ($result.status -eq 200 -or $result.status -eq 204) { - Write-LogMessage -API 'Standards' -tenant $tenant -message "Disabled guest $($guest.UserPrincipalName) ($($guest.id)). Last sign-in: $($guest.signInActivity.lastSuccessfulSignInDateTime)" -sev Info + $guest.accountEnabled = $false + Write-LogMessage -API 'Standards' -tenant $tenant -message "Disabled guest $($guest.UserPrincipalName) ($($guest.id)). Last sign-in: $lastSignIn" -sev Info } else { $errorMsg = if ($result.body.error.message) { $result.body.error.message } else { "Unknown error (Status: $($result.status))" } Write-LogMessage -API 'Standards' -tenant $tenant -message "Failed to disable guest $($guest.UserPrincipalName) ($($guest.id)): $errorMsg" -sev Error @@ -110,7 +135,7 @@ function Invoke-CIPPStandardDisableGuests { } } if ($Settings.report -eq $true) { - $Filtered = $GraphRequest | Select-Object -Property UserPrincipalName, id, signInActivity, mail, userType, accountEnabled + $Filtered = $GraphRequest | Where-Object { $_.accountEnabled } | Select-Object -Property UserPrincipalName, id, signInActivity, EnrichedLastSignInDateTime, mail, userType, accountEnabled $CurrentValue = [PSCustomObject]@{ GuestsDisabledAfterDays = $checkDays From 7f97550bc99cceac5eed102da04f1805216a5bd8 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 16 Mar 2026 14:49:26 -0400 Subject: [PATCH 03/12] fix: cache name for role assignment schedule instances --- .../Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 index a72746ef000c..f3f1d954fccc 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 @@ -141,7 +141,7 @@ function Push-CIPPDBCacheData { 'ServicePrincipalRiskDetections' 'RiskDetections' 'RoleEligibilitySchedules' - 'RoleAssignmentSchedules' + 'RoleAssignmentScheduleInstances' 'RoleManagementPolicies' ) foreach ($CacheFunction in $P2CacheFunctions) { From 220ddef6d4e082402d3a1d0967d326c64747e99e Mon Sep 17 00:00:00 2001 From: Luis Mengel Date: Mon, 16 Mar 2026 23:12:23 +0100 Subject: [PATCH 04/12] Add includeUsage parameter to ListTenantGroups endpoint --- .../CIPP/Settings/Invoke-ListTenantGroups.ps1 | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ListTenantGroups.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ListTenantGroups.ps1 index 4aae7a0858df..d546f23ba30c 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ListTenantGroups.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ListTenantGroups.ps1 @@ -11,7 +11,157 @@ function Invoke-ListTenantGroups { param($Request, $TriggerMetadata) $groupFilter = $Request.Query.groupId ?? $Request.Body.groupId + $includeUsage = $Request.Query.includeUsage ?? $Request.Body.includeUsage $TenantGroups = (Get-TenantGroups -GroupId $groupFilter -SkipCache) ?? @() + + if ($includeUsage -eq 'true') { + $UsageByGroup = @{} + foreach ($Group in $TenantGroups) { + $UsageByGroup[$Group.Id] = [System.Collections.Generic.List[PSCustomObject]]::new() + } + + $AddGroupUsage = { + param($FilterArray, $UsedIn, $Name, $Type) + foreach ($Filter in $FilterArray) { + if ($Filter.type -eq 'Group' -and $Filter.value -and $UsageByGroup.ContainsKey($Filter.value)) { + $UsageByGroup[$Filter.value].Add([PSCustomObject]@{ + UsedIn = $UsedIn + Name = $Name + Type = $Type + }) + } + } + } + + # Standards Templates + $TemplateTable = Get-CippTable -tablename 'templates' + $TemplateFilter = "PartitionKey eq 'StandardsTemplateV2'" + $Templates = Get-CIPPAzDataTableEntity @TemplateTable -Filter $TemplateFilter + + foreach ($Template in $Templates) { + try { + $TemplateData = $Template.JSON | ConvertFrom-Json + $TemplateName = $TemplateData.templateName ?? $Template.RowKey + if ($TemplateData.tenantFilter) { + & $AddGroupUsage $TemplateData.tenantFilter 'Standards Template' $TemplateName 'Tenant Filter' + } + if ($TemplateData.excludedTenants) { + & $AddGroupUsage $TemplateData.excludedTenants 'Standards Template' $TemplateName 'Excluded Tenants' + } + } catch { + Write-Warning "Failed to parse standards template $($Template.RowKey): $($_.Exception.Message)" + } + } + + # Scheduled Tasks + $TaskTable = Get-CippTable -tablename 'ScheduledTasks' + $TaskFilter = "PartitionKey eq 'ScheduledTask'" + $Tasks = Get-CIPPAzDataTableEntity @TaskTable -Filter $TaskFilter + + foreach ($Task in $Tasks) { + if ($Task.TenantGroup) { + try { + $TenantGroupObject = $Task.TenantGroup | ConvertFrom-Json -ErrorAction SilentlyContinue + if ($TenantGroupObject.value -and $UsageByGroup.ContainsKey($TenantGroupObject.value)) { + $UsageByGroup[$TenantGroupObject.value].Add([PSCustomObject]@{ + UsedIn = 'Scheduled Task' + Name = $Task.Name ?? $Task.RowKey + Type = 'Tenant Filter' + }) + } + } catch { + Write-Warning "Failed to parse tenant group for task $($Task.RowKey): $($_.Exception.Message)" + } + } + } + + # Dynamic Group Rules referencing other groups + foreach ($Group in $TenantGroups) { + if ($Group.GroupType -eq 'dynamic' -and $Group.DynamicRules) { + foreach ($Rule in $Group.DynamicRules) { + if ($Rule.property -eq 'TenantGroup' -and $Rule.value -and $UsageByGroup.ContainsKey($Rule.value)) { + $UsageByGroup[$Rule.value].Add([PSCustomObject]@{ + UsedIn = 'Dynamic Group Rule' + Name = $Group.Name + Type = 'Rule Reference' + }) + } + } + } + } + + # Webhook Rules + $WebhookTable = Get-CippTable -tablename 'WebhookRules' + $WebhookRules = Get-CIPPAzDataTableEntity @WebhookTable + + foreach ($Rule in $WebhookRules) { + try { + $RuleName = $Rule.Name ?? $Rule.RowKey + if ($Rule.Tenants) { + $Tenants = $Rule.Tenants | ConvertFrom-Json -ErrorAction SilentlyContinue + if ($Tenants) { + & $AddGroupUsage $Tenants 'Alert Rule' $RuleName 'Tenant Filter' + } + } + if ($Rule.excludedTenants) { + $ExclTenants = $Rule.excludedTenants | ConvertFrom-Json -ErrorAction SilentlyContinue + if ($ExclTenants) { + & $AddGroupUsage $ExclTenants 'Alert Rule' $RuleName 'Excluded Tenants' + } + } + } catch { + Write-Warning "Failed to parse webhook rule $($Rule.RowKey): $($_.Exception.Message)" + } + } + + # Custom Roles + $RolesTable = Get-CippTable -tablename 'CustomRoles' + $CustomRoles = Get-CIPPAzDataTableEntity @RolesTable + + foreach ($Role in $CustomRoles) { + try { + $RoleName = $Role.Name ?? $Role.RowKey + if ($Role.AllowedTenants) { + $AllowedTenants = $Role.AllowedTenants | ConvertFrom-Json -ErrorAction SilentlyContinue + if ($AllowedTenants) { + & $AddGroupUsage $AllowedTenants 'Custom Role' $RoleName 'Allowed Tenants' + } + } + if ($Role.BlockedTenants) { + $BlockedTenants = $Role.BlockedTenants | ConvertFrom-Json -ErrorAction SilentlyContinue + if ($BlockedTenants) { + & $AddGroupUsage $BlockedTenants 'Custom Role' $RoleName 'Blocked Tenants' + } + } + } catch { + Write-Warning "Failed to parse custom role $($Role.RowKey): $($_.Exception.Message)" + } + } + + # Custom Data Mappings + $MappingsTable = Get-CippTable -tablename 'CustomDataMappings' + $Mappings = Get-CIPPAzDataTableEntity @MappingsTable + + foreach ($Mapping in $Mappings) { + try { + $MappingName = $Mapping.Name ?? $Mapping.RowKey + if ($Mapping.tenantFilter) { + $TenantFilters = $Mapping.tenantFilter | ConvertFrom-Json -ErrorAction SilentlyContinue + if ($TenantFilters) { + if ($TenantFilters -isnot [System.Array]) { $TenantFilters = @($TenantFilters) } + & $AddGroupUsage $TenantFilters 'Data Mapping' $MappingName 'Tenant Filter' + } + } + } catch { + Write-Warning "Failed to parse custom data mapping $($Mapping.RowKey): $($_.Exception.Message)" + } + } + + foreach ($Group in $TenantGroups) { + $Group | Add-Member -MemberType NoteProperty -Name 'Usage' -Value @($UsageByGroup[$Group.Id]) -Force + } + } + $Body = @{ Results = @($TenantGroups) } return ([HttpResponseContext]@{ From 1170d57c878cadbd54884e9fbff2abd6c436a396 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Mon, 16 Mar 2026 23:39:17 +0100 Subject: [PATCH 05/12] fix(mfa-report): resolve role-targeted CA policies showing as Not Enforced Add includeRoles/excludeRoles handling to Get-CIPPMFAState, mirroring the existing group resolution pattern. This ensures that CA policies targeting specific roles are correctly evaluated --- Modules/CIPPCore/Public/Get-CIPPMFAState.ps1 | 168 ++++++++++++++++++- 1 file changed, 164 insertions(+), 4 deletions(-) diff --git a/Modules/CIPPCore/Public/Get-CIPPMFAState.ps1 b/Modules/CIPPCore/Public/Get-CIPPMFAState.ps1 index ee6ee2ea96a1..69cd2dda7beb 100644 --- a/Modules/CIPPCore/Public/Get-CIPPMFAState.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPMFAState.ps1 @@ -28,6 +28,9 @@ function Get-CIPPMFAState { $UserGroupMembership = @{} $UserExcludeGroupMembership = @{} $GroupNameLookup = @{} + $UserRoleMembership = @{} + $UserExcludeRoleMembership = @{} + $RoleNameLookup = @{} $MFAIndex = @{} try { @@ -64,6 +67,22 @@ function Get-CIPPMFAState { $AllUserPolicies = [System.Collections.Generic.List[object]]::new() $GroupsToResolve = [System.Collections.Generic.HashSet[string]]::new() $ExcludeGroupsToResolve = [System.Collections.Generic.HashSet[string]]::new() + $RolesToResolve = [System.Collections.Generic.HashSet[string]]::new() + $ExcludeRolesToResolve = [System.Collections.Generic.HashSet[string]]::new() + + # Fetch role assignments and definitions early for role-targeted CA policies + $assignments = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments?`$expand=principal" -tenantid $TenantFilter -ErrorAction SilentlyContinue + $roleDefinitions = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/roleManagement/directory/roleDefinitions?`$select=id,templateId,displayName" -tenantid $TenantFilter -ErrorAction SilentlyContinue + + # Build lookup tables: CA policies use templateId, assignments use definitionId + $TemplateToDefinitionId = @{} + $RoleNameLookup = @{} + foreach ($rd in $roleDefinitions) { + if ($null -ne $rd.templateId) { + $TemplateToDefinitionId[$rd.templateId] = $rd.id + $RoleNameLookup[$rd.templateId] = $rd.displayName + } + } foreach ($Policy in $CAPolicies) { # Only include policies that require MFA @@ -110,6 +129,20 @@ function Get-CIPPMFAState { [void]$ExcludeGroupsToResolve.Add($GroupId) } } + + # Collect roles to resolve + if ($null -ne $Policy.conditions.users.includeRoles -and $Policy.conditions.users.includeRoles.Count -gt 0) { + foreach ($RoleId in $Policy.conditions.users.includeRoles) { + [void]$RolesToResolve.Add($RoleId) + } + } + + # Collect exclude roles to resolve + if ($null -ne $Policy.conditions.users.excludeRoles -and $Policy.conditions.users.excludeRoles.Count -gt 0) { + foreach ($RoleId in $Policy.conditions.users.excludeRoles) { + [void]$ExcludeRolesToResolve.Add($RoleId) + } + } } } @@ -219,6 +252,67 @@ function Get-CIPPMFAState { } } } + + # Resolve role memberships from already-fetched $assignments + if ($RolesToResolve.Count -gt 0 -or $ExcludeRolesToResolve.Count -gt 0) { + $UserRoleMembership = @{} + $UserExcludeRoleMembership = @{} + $AllRoleTemplateIds = [System.Collections.Generic.HashSet[string]]::new() + foreach ($r in $RolesToResolve) { [void]$AllRoleTemplateIds.Add($r) } + foreach ($r in $ExcludeRolesToResolve) { [void]$AllRoleTemplateIds.Add($r) } + + # Map each assignment's definitionId back to templateId + foreach ($assignment in $assignments) { + if ($assignment.principal.'@odata.type' -ne '#microsoft.graph.user') { continue } + $principalId = $assignment.principalId + $defId = $assignment.roleDefinitionId + + foreach ($templateId in $AllRoleTemplateIds) { + # Match: either the definition ID maps from this template, or for built-in roles they're equal + $matchedDefId = $TemplateToDefinitionId[$templateId] + if ($defId -eq $matchedDefId -or $defId -eq $templateId) { + if ($RolesToResolve.Contains($templateId)) { + if (-not $UserRoleMembership.ContainsKey($principalId)) { + $UserRoleMembership[$principalId] = [System.Collections.Generic.HashSet[string]]::new() + } + [void]$UserRoleMembership[$principalId].Add($templateId) + } + if ($ExcludeRolesToResolve.Contains($templateId)) { + if (-not $UserExcludeRoleMembership.ContainsKey($principalId)) { + $UserExcludeRoleMembership[$principalId] = [System.Collections.Generic.HashSet[string]]::new() + } + [void]$UserExcludeRoleMembership[$principalId].Add($templateId) + } + } + } + } + + # Add policies to users based on role membership (mirrors group pattern) + foreach ($Policy in $CAPolicies | Where-Object { $null -ne $_.conditions.users.includeRoles -and $_.conditions.users.includeRoles.Count -gt 0 }) { + $RequiresMFA = $false + if ($Policy.grantControls.builtInControls -contains 'mfa') { $RequiresMFA = $true } + if ($Policy.grantControls.authenticationStrength.requirementsSatisfied -eq 'mfa') { $RequiresMFA = $true } + + if ($RequiresMFA) { + foreach ($UserId in $UserRoleMembership.Keys) { + $IsMember = $false + foreach ($RoleId in $Policy.conditions.users.includeRoles) { + if ($UserRoleMembership[$UserId].Contains($RoleId)) { + $IsMember = $true + break + } + } + + if ($IsMember) { + if (-not $PolicyTable.ContainsKey($UserId)) { + $PolicyTable[$UserId] = [System.Collections.Generic.List[object]]::new() + } + $PolicyTable[$UserId].Add($Policy) + } + } + } + } + } } catch { $CASuccess = $false $CAError = "CA policies not available: $($_.Exception.Message)" @@ -228,7 +322,10 @@ function Get-CIPPMFAState { if ($CAState.count -eq 0) { $CAState.Add('None') | Out-Null } - $assignments = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments?`$expand=principal" -tenantid $TenantFilter -ErrorAction SilentlyContinue + # Fetch role assignments if not already fetched (e.g., when MFA registration was unavailable) + if ($null -eq $assignments) { + $assignments = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments?`$expand=principal" -tenantid $TenantFilter -ErrorAction SilentlyContinue + } $adminObjectIds = $assignments | Where-Object { @@ -266,9 +363,10 @@ function Get-CIPPMFAState { } if ($IsGuestIncluded) { - # Check if user is excluded directly or via group + # Check if user is excluded directly or via group/role $IsExcluded = $Policy.conditions.users.excludeUsers -contains $_.ObjectId $ExcludedViaGroup = $null + $ExcludedViaRole = $null # Check exclude groups if (-not $IsExcluded -and $null -ne $Policy.conditions.users.excludeGroups -and $Policy.conditions.users.excludeGroups.Count -gt 0) { @@ -287,6 +385,23 @@ function Get-CIPPMFAState { } } + # Check exclude roles + if (-not $IsExcluded -and $null -ne $Policy.conditions.users.excludeRoles -and $Policy.conditions.users.excludeRoles.Count -gt 0) { + if ($UserExcludeRoleMembership.ContainsKey($_.ObjectId)) { + foreach ($ExcludeRoleId in $Policy.conditions.users.excludeRoles) { + if ($UserExcludeRoleMembership[$_.ObjectId].Contains($ExcludeRoleId)) { + $IsExcluded = $true + $ExcludedViaRole = if ($RoleNameLookup.ContainsKey($ExcludeRoleId)) { + $RoleNameLookup[$ExcludeRoleId] + } else { + $ExcludeRoleId + } + break + } + } + } + } + $PolicyObj = [PSCustomObject]@{ DisplayName = $Policy.displayName UserIncluded = -not $IsExcluded @@ -296,6 +411,9 @@ function Get-CIPPMFAState { if ($ExcludedViaGroup) { $PolicyObj | Add-Member -NotePropertyName 'ExcludedViaGroup' -NotePropertyValue $ExcludedViaGroup } + if ($ExcludedViaRole) { + $PolicyObj | Add-Member -NotePropertyName 'ExcludedViaRole' -NotePropertyValue $ExcludedViaRole + } $UserCAState.Add($PolicyObj) } } @@ -304,9 +422,10 @@ function Get-CIPPMFAState { # Add policies that apply to this specific user if ($PolicyTable.ContainsKey($_.ObjectId)) { foreach ($Policy in $PolicyTable[$_.ObjectId]) { - # Check if user is excluded directly or via group + # Check if user is excluded directly or via group/role $IsExcluded = $Policy.conditions.users.excludeUsers -contains $_.ObjectId $ExcludedViaGroup = $null + $ExcludedViaRole = $null # Check exclude groups if (-not $IsExcluded -and $null -ne $Policy.conditions.users.excludeGroups -and $Policy.conditions.users.excludeGroups.Count -gt 0) { @@ -325,6 +444,23 @@ function Get-CIPPMFAState { } } + # Check exclude roles + if (-not $IsExcluded -and $null -ne $Policy.conditions.users.excludeRoles -and $Policy.conditions.users.excludeRoles.Count -gt 0) { + if ($UserExcludeRoleMembership.ContainsKey($_.ObjectId)) { + foreach ($ExcludeRoleId in $Policy.conditions.users.excludeRoles) { + if ($UserExcludeRoleMembership[$_.ObjectId].Contains($ExcludeRoleId)) { + $IsExcluded = $true + $ExcludedViaRole = if ($RoleNameLookup.ContainsKey($ExcludeRoleId)) { + $RoleNameLookup[$ExcludeRoleId] + } else { + $ExcludeRoleId + } + break + } + } + } + } + $PolicyObj = [PSCustomObject]@{ DisplayName = $Policy.displayName UserIncluded = -not $IsExcluded @@ -334,15 +470,19 @@ function Get-CIPPMFAState { if ($ExcludedViaGroup) { $PolicyObj | Add-Member -NotePropertyName 'ExcludedViaGroup' -NotePropertyValue $ExcludedViaGroup } + if ($ExcludedViaRole) { + $PolicyObj | Add-Member -NotePropertyName 'ExcludedViaRole' -NotePropertyValue $ExcludedViaRole + } $UserCAState.Add($PolicyObj) } } # Add policies that apply to all users foreach ($Policy in $AllUserPolicies) { - # Check if user is excluded directly or via group + # Check if user is excluded directly or via group/role $IsExcluded = $Policy.conditions.users.excludeUsers -contains $_.ObjectId $ExcludedViaGroup = $null + $ExcludedViaRole = $null # Check if guests are excluded from this "All users" policy if (-not $IsExcluded -and $_.UserType -eq 'Guest') { @@ -372,6 +512,23 @@ function Get-CIPPMFAState { } } + # Check exclude roles + if (-not $IsExcluded -and $null -ne $Policy.conditions.users.excludeRoles -and $Policy.conditions.users.excludeRoles.Count -gt 0) { + if ($UserExcludeRoleMembership.ContainsKey($_.ObjectId)) { + foreach ($ExcludeRoleId in $Policy.conditions.users.excludeRoles) { + if ($UserExcludeRoleMembership[$_.ObjectId].Contains($ExcludeRoleId)) { + $IsExcluded = $true + $ExcludedViaRole = if ($RoleNameLookup.ContainsKey($ExcludeRoleId)) { + $RoleNameLookup[$ExcludeRoleId] + } else { + $ExcludeRoleId + } + break + } + } + } + } + # Always add the policy to show it applies (even if excluded) $PolicyObj = [PSCustomObject]@{ DisplayName = $Policy.displayName @@ -382,6 +539,9 @@ function Get-CIPPMFAState { if ($ExcludedViaGroup) { $PolicyObj | Add-Member -NotePropertyName 'ExcludedViaGroup' -NotePropertyValue $ExcludedViaGroup } + if ($ExcludedViaRole) { + $PolicyObj | Add-Member -NotePropertyName 'ExcludedViaRole' -NotePropertyValue $ExcludedViaRole + } $UserCAState.Add($PolicyObj) } From f935028249b47f909ac90cf358f22e3fa279b233 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 17 Mar 2026 10:48:54 +0800 Subject: [PATCH 06/12] Fix for incorrect exchange option --- .../Standards/Invoke-CIPPStandardSafeSendersDisable.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardSafeSendersDisable.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardSafeSendersDisable.ps1 index cd172bd96070..cd39f478b008 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardSafeSendersDisable.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardSafeSendersDisable.ps1 @@ -47,8 +47,8 @@ function Invoke-CIPPStandardSafeSendersDisable { CmdletInput = @{ CmdletName = 'Set-MailboxJunkEmailConfiguration' Parameters = @{ - Identity = $Mailbox.UserPrincipalName - TrustedRecipientsAndDomains = $null + Identity = $Mailbox.UserPrincipalName + TrustedSendersAndDomains = $null } } } From 8af3285a215008f13871d0bcd1dff73247566eb7 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Tue, 17 Mar 2026 13:09:36 +0100 Subject: [PATCH 07/12] remove requieing as its no longer needed in next release --- Modules/CippEntrypoints/CippEntrypoints.psm1 | 47 -------------------- 1 file changed, 47 deletions(-) diff --git a/Modules/CippEntrypoints/CippEntrypoints.psm1 b/Modules/CippEntrypoints/CippEntrypoints.psm1 index e66e5befcded..38c8567172c5 100644 --- a/Modules/CippEntrypoints/CippEntrypoints.psm1 +++ b/Modules/CippEntrypoints/CippEntrypoints.psm1 @@ -487,53 +487,6 @@ function Receive-CIPPTimerTrigger { $UtcNow = (Get-Date).ToUniversalTime() - try { - #temporary orphan check - Remove at next release. - $OrphanConfigTable = Get-CIPPTable -tablename 'Config' - $OrphanFlag = Get-CIPPAzDataTableEntity @OrphanConfigTable -Filter "PartitionKey eq 'OrphanRequeue' and RowKey eq 'OrphanRequeue'" -ErrorAction SilentlyContinue - if (-not $OrphanFlag -or $OrphanFlag.state -ne $true) { - $OrchestratorTable = Get-CIPPTable -TableName 'CippOrchestratorInput' - $OrphanedInputs = Get-CIPPAzDataTableEntity @OrchestratorTable -Filter "PartitionKey eq 'Input'" - $CutoffTime = $UtcNow.AddMinutes(-5) - $MaxAge = $UtcNow.AddHours(-24) - $StaleOrphans = @($OrphanedInputs | Where-Object { $_.Timestamp.DateTime -lt $CutoffTime -and $_.Timestamp.DateTime -gt $MaxAge }) - if ($StaleOrphans.Count -gt 0) { - Write-Information "Found $($StaleOrphans.Count) orphaned orchestration inputs, re-queuing..." - foreach ($Orphan in $StaleOrphans) { - try { - Add-CippQueueMessage -Cmdlet 'Start-CIPPOrchestrator' -Parameters @{ InputObjectGuid = $Orphan.RowKey } - Write-Information "Re-queued orphaned orchestration: $($Orphan.RowKey)" - } catch { - Write-Warning "Failed to re-queue orphan $($Orphan.RowKey): $($_.Exception.Message)" - } - } - Write-LogMessage -API 'TimerFunction' -message "Re-queued $($StaleOrphans.Count) orphaned orchestration inputs" -sev Info - } - # Clean up orphans older than 24h - too stale to run - $ExpiredOrphans = @($OrphanedInputs | Where-Object { $_.Timestamp.DateTime -le $MaxAge }) - if ($ExpiredOrphans.Count -gt 0) { - Write-Information "Removing $($ExpiredOrphans.Count) expired orphaned inputs (older than 24h)..." - foreach ($Expired in $ExpiredOrphans) { - try { - Remove-AzDataTableEntity @OrchestratorTable -Entity $Expired -Force - } catch { - Write-Warning "Failed to remove expired orphan $($Expired.RowKey): $($_.Exception.Message)" - } - } - } - # Mark as completed so we don't scan again - $null = Add-CIPPAzDataTableEntity @OrphanConfigTable -Entity @{ - PartitionKey = 'OrphanRequeue' - RowKey = 'OrphanRequeue' - state = $true - Timestamp = $UtcNow - Count = $StaleOrphans.Count - } -Force - } - } catch { - Write-Warning "Orphan re-queue check failed: $($_.Exception.Message)" - } - $Functions = Get-CIPPTimerFunctions $Table = Get-CIPPTable -tablename CIPPTimers $Statuses = Get-CIPPAzDataTableEntity @Table From 891986396b709c7bff354330496849d3ddc3b5c8 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 17 Mar 2026 11:42:14 -0400 Subject: [PATCH 08/12] fix: offboarding not running for offloading --- .../Identity/Administration/Users/Invoke-CIPPOffboardingJob.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-CIPPOffboardingJob.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-CIPPOffboardingJob.ps1 index a8b0a78c87fe..10f00534abae 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-CIPPOffboardingJob.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-CIPPOffboardingJob.ps1 @@ -320,7 +320,7 @@ function Invoke-CIPPOffboardingJob { } } - $InstanceId = Start-CIPPOrchestrator -InputObject $InputObject -CallerIsQueueTrigger + $InstanceId = Start-CIPPOrchestrator -InputObject $InputObject Write-Information "Started offboarding job for $Username with ID = '$InstanceId'" Write-LogMessage -API $APIName -tenant $TenantFilter -message "Started offboarding job for $Username with $($Batch.Count) tasks. Instance ID: $InstanceId" -sev Info From 0d1f744756d348e846f37274e90e098fd1827bcc Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 17 Mar 2026 16:59:04 -0400 Subject: [PATCH 09/12] refactor: streamline user offboarding and improve task scheduling logic run now makes tasks actually run immediately all user offboardings are now routed through scheduler for proper task tracking and alerting --- .../CIPPCore/Public/Add-CIPPScheduledTask.ps1 | 31 ++++++---- .../Scheduler/Invoke-AddScheduledItem.ps1 | 38 ++++++------- .../Scheduler/Invoke-ListScheduledItems.ps1 | 6 ++ .../Users/Invoke-ExecOffboardUser.ps1 | 56 ++++++++++--------- .../Start-CIPPOrchestrator.ps1 | 5 +- .../Start-UserTasksOrchestrator.ps1 | 30 +++++++--- 6 files changed, 98 insertions(+), 68 deletions(-) diff --git a/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 b/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 index 0981e4b361d3..7db4d3fff943 100644 --- a/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 @@ -1,29 +1,25 @@ function Add-CIPPScheduledTask { - [CmdletBinding(DefaultParameterSetName = 'Default')] + [CmdletBinding()] param( - [Parameter(Mandatory = $true, ParameterSetName = 'Default')] + [Parameter(Mandatory = $true)] [pscustomobject]$Task, - [Parameter(Mandatory = $false, ParameterSetName = 'Default')] + [Parameter(Mandatory = $false)] [bool]$Hidden, - [Parameter(Mandatory = $false, ParameterSetName = 'Default')] + [Parameter(Mandatory = $false)] $DisallowDuplicateName = $false, - [Parameter(Mandatory = $false, ParameterSetName = 'Default')] + [Parameter(Mandatory = $false)] [string]$SyncType = $null, - [Parameter(Mandatory = $false, ParameterSetName = 'RunNow')] + [Parameter(Mandatory = $false)] [switch]$RunNow, - [Parameter(Mandatory = $true, ParameterSetName = 'RunNow')] - [string]$RowKey, - - [Parameter(Mandatory = $false, ParameterSetName = 'Default')] + [Parameter(Mandatory = $false)] [string]$DesiredStartTime = $null, - [Parameter(Mandatory = $false, ParameterSetName = 'Default')] - [Parameter(Mandatory = $false, ParameterSetName = 'RunNow')] + [Parameter(Mandatory = $false)] $Headers ) @@ -39,6 +35,9 @@ function Add-CIPPScheduledTask { $ExistingTask.TaskState = 'Planned' Add-CIPPAzDataTableEntity @Table -Entity $ExistingTask -Force Write-LogMessage -headers $Headers -API 'RunNow' -message "Task $($ExistingTask.Name) scheduled to run now" -Sev 'Info' -Tenant $ExistingTask.Tenant + Add-CippQueueMessage -Cmdlet 'Start-UserTasksOrchestrator' -Parameters @{ + TaskId = $RowKey + } return "Task $($ExistingTask.Name) scheduled to run now" } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message @@ -192,6 +191,7 @@ function Add-CIPPScheduledTask { Hidden = [bool]$Hidden Results = 'Planned' AlertComment = [string]$task.AlertComment + CustomSubject = [string]$task.CustomSubject } @@ -299,6 +299,13 @@ function Add-CIPPScheduledTask { default { 'less than a minute' } } + if ($RunNow.IsPresent) { + Add-CippQueueMessage -Cmdlet 'Start-UserTasksOrchestrator' -Parameters @{ + TaskId = $RowKey + } + return "Task $($entity.Name) scheduled to run now" + } + return "Successfully added task: $($entity.Name). It will run in $relativeTime." } } catch { diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-AddScheduledItem.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-AddScheduledItem.ps1 index 94bf679ca9e6..48bccf1b6582 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-AddScheduledItem.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-AddScheduledItem.ps1 @@ -18,29 +18,22 @@ function Invoke-AddScheduledItem { $HeaderProperties = @('x-ms-client-principal', 'x-ms-client-principal-id', 'x-ms-client-principal-name', 'x-forwarded-for') $Headers = $Request.Headers | Select-Object -Property $HeaderProperties -ErrorAction SilentlyContinue - if ($Request.Body.RunNow -eq $true) { - try { - $Table = Get-CIPPTable -TableName 'ScheduledTasks' - $Filter = "PartitionKey eq 'ScheduledTask' and RowKey eq '$($Request.Body.RowKey)'" - $ExistingTask = (Get-CIPPAzDataTableEntity @Table -Filter $Filter) + $Table = Get-CIPPTable -TableName 'ScheduledTasks' - if ($ExistingTask) { - $RerunParams = @{ - TenantFilter = $ExistingTask.Tenant - Type = 'ScheduledTask' - API = $Request.Body.RowKey - Clear = $true - } - $null = Test-CIPPRerun @RerunParams - $Result = Add-CIPPScheduledTask -RowKey $Request.Body.RowKey -RunNow -Headers $Headers - } else { - $Result = "Task with id $($Request.Body.RowKey) does not exist" - } - } catch { - Write-Warning "Error scheduling task: $($_.Exception.Message)" - Write-Information $_.InvocationInfo.PositionMessage - $Result = "Error scheduling task: $($_.Exception.Message)" + if ($Request.Body.RowKey) { + $Filter = "PartitionKey eq 'ScheduledTask' and RowKey eq '$($Request.Body.RowKey)'" + $ExistingTask = (Get-CIPPAzDataTableEntity @Table -Filter $Filter) + } + + if ($ExistingTask -and $Request.Body.RunNow -eq $true) { + $RerunParams = @{ + TenantFilter = $ExistingTask.Tenant + Type = 'ScheduledTask' + API = $Request.Body.RowKey + Clear = $true } + $null = Test-CIPPRerun @RerunParams + $Result = Add-CIPPScheduledTask -RowKey $Request.Body.RowKey -RunNow -Headers $Headers } else { $ScheduledTask = @{ Task = $Request.Body @@ -49,6 +42,9 @@ function Invoke-AddScheduledItem { DisallowDuplicateName = $DisallowDuplicateName DesiredStartTime = $Request.Body.DesiredStartTime } + if ($Request.Body.RunNow -eq $true) { + $ScheduledTask.RunNow = $true + } $Result = Add-CIPPScheduledTask @ScheduledTask } return ([HttpResponseContext]@{ diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ListScheduledItems.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ListScheduledItems.ps1 index 2062a5e8f64b..0bfc9afc6019 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ListScheduledItems.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ListScheduledItems.ps1 @@ -11,6 +11,8 @@ function Invoke-ListScheduledItems { $ScheduledItemFilter.Add("PartitionKey eq 'ScheduledTask'") $Id = $Request.Query.Id ?? $Request.Body.Id + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + if ($Id) { # Interact with query parameters. $ScheduledItemFilter.Add("RowKey eq '$($Id)'") @@ -48,6 +50,10 @@ function Invoke-ListScheduledItems { $Tasks = $Tasks | Where-Object { $_.command -eq $Type } } + if ($TenantFilter) { + $Tasks = $Tasks | Where-Object { $_.tenant -eq $TenantFilter -or $TenantFilter -eq 'AllTenants' } + } + if ($SearchTitle) { $Tasks = $Tasks | Where-Object { $_.Name -like $SearchTitle } } diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecOffboardUser.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecOffboardUser.ps1 index 0ca013970a1f..85342af27473 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecOffboardUser.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecOffboardUser.ps1 @@ -10,39 +10,41 @@ function Invoke-ExecOffboardUser { $AllUsers = $Request.Body.user.value $TenantFilter = $request.Body.tenantFilter.value ? $request.Body.tenantFilter.value : $request.Body.tenantFilter $OffboardingOptions = $Request.Body | Select-Object * -ExcludeProperty user, tenantFilter, Scheduled + + $StatusCode = [HttpStatusCode]::OK $Results = foreach ($username in $AllUsers) { try { - $APIName = 'ExecOffboardUser' $Headers = $Request.Headers - - - if ($Request.Body.Scheduled.enabled) { - $taskObject = [PSCustomObject]@{ - TenantFilter = $TenantFilter - Name = "Offboarding: $Username" - Command = @{ - value = 'Invoke-CIPPOffboardingJob' - } - Parameters = [pscustomobject]@{ - Username = $Username - APIName = 'Scheduled Offboarding' - options = $OffboardingOptions - RunScheduled = $true - } - ScheduledTime = $Request.Body.Scheduled.date - PostExecution = @{ - Webhook = [bool]$Request.Body.PostExecution.webhook - Email = [bool]$Request.Body.PostExecution.email - PSA = [bool]$Request.Body.PostExecution.psa - } - Reference = $Request.Body.reference + $taskObject = [PSCustomObject]@{ + TenantFilter = $TenantFilter + Name = "Offboarding: $Username" + Command = @{ + value = 'Invoke-CIPPOffboardingJob' + } + Parameters = [pscustomobject]@{ + Username = $Username + APIName = 'Scheduled Offboarding' + options = $OffboardingOptions + RunScheduled = $true } - Add-CIPPScheduledTask -Task $taskObject -hidden $false -Headers $Headers + PostExecution = @{ + Webhook = [bool]$Request.Body.PostExecution.webhook + Email = [bool]$Request.Body.PostExecution.email + PSA = [bool]$Request.Body.PostExecution.psa + } + Reference = $Request.Body.reference + } + $Params = @{ + Task = $taskObject + hidden = $false + Headers = $Headers + } + if ($Request.Body.Scheduled.enabled) { + $taskObject.ScheduledTime = $Request.Body.Scheduled.date } else { - Invoke-CIPPOffboardingJob -Username $Username -TenantFilter $TenantFilter -Options $OffboardingOptions -APIName $APIName -Headers $Headers + $Params.RunNow = $true } - $StatusCode = [HttpStatusCode]::OK - + Add-CIPPScheduledTask @Params } catch { $StatusCode = [HttpStatusCode]::Forbidden $_.Exception.message diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 index 8004fd0a6aed..bbc88f160553 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 @@ -37,7 +37,10 @@ function Start-CIPPOrchestrator { # If already running in processor context (e.g., timer trigger) and we have an InputObject, # start orchestration directly without queuing - if ($InputObject -and ($env:CIPP_PROCESSOR -eq 'true' -or $CallerIsQueueTrigger.IsPresent)) { + + $OrchestratorTriggerDisabled = $env:AzureWebJobs_CIPPOrchestrator_Disabled -eq 'true' -or $env:AzureWebJobs_CIPPOrchestrator_Disabled -eq '1' + + if ($InputObject -and -not $OrchestratorTriggerDisabled) { Write-Information 'Running in processor context - starting orchestration directly' try { $InstanceId = Start-NewOrchestration -FunctionName 'CIPPOrchestrator' -InputObject ($InputObject | ConvertTo-Json -Depth 10 -Compress) diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 index ffd3152d6915..7601c1d7222c 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 @@ -7,15 +7,31 @@ function Start-UserTasksOrchestrator { Entrypoint #> [CmdletBinding(SupportsShouldProcess = $true)] - param() + param( + $TaskId = $null + ) $Table = Get-CippTable -tablename 'ScheduledTasks' - $4HoursAgo = (Get-Date).AddHours(-4).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') - $24HoursAgo = (Get-Date).AddHours(-24).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') - # Pending = orchestrator queued, Running = actively executing - # Pick up: Planned, Failed-Planned, stuck Pending (>24hr), or stuck Running (>4hr for large AllTenants tasks) - $Filter = "PartitionKey eq 'ScheduledTask' and (TaskState eq 'Planned' or TaskState eq 'Failed - Planned' or (TaskState eq 'Pending' and Timestamp lt datetime'$24HoursAgo') or (TaskState eq 'Running' and Timestamp lt datetime'$4HoursAgo') or (TaskState eq 'Processing' and Timestamp lt datetime'$4HoursAgo'))" - $tasks = Get-CIPPAzDataTableEntity @Table -Filter $Filter + + if ($TaskId) { + $Filter = "PartitionKey eq 'ScheduledTask' and RowKey eq '$TaskId'" + $task = Get-CIPPAzDataTableEntity @Table -Filter $Filter + + if (-not $task.RowKey) { + Write-Warning "No scheduled task found with ID: $TaskId" + return + } else { + Write-Information "Starting orchestrator for scheduled task: $($task.Name) with ID: $TaskId" + $tasks = @($task) + } + } else { + $4HoursAgo = (Get-Date).AddHours(-4).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + $24HoursAgo = (Get-Date).AddHours(-24).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + # Pending = orchestrator queued, Running = actively executing + # Pick up: Planned, Failed-Planned, stuck Pending (>24hr), or stuck Running (>4hr for large AllTenants tasks) + $Filter = "PartitionKey eq 'ScheduledTask' and (TaskState eq 'Planned' or TaskState eq 'Failed - Planned' or (TaskState eq 'Pending' and Timestamp lt datetime'$24HoursAgo') or (TaskState eq 'Running' and Timestamp lt datetime'$4HoursAgo') or (TaskState eq 'Processing' and Timestamp lt datetime'$4HoursAgo'))" + $tasks = Get-CIPPAzDataTableEntity @Table -Filter $Filter + } $Batch = [System.Collections.Generic.List[object]]::new() $TenantList = Get-Tenants -IncludeErrors From 7c73b31424bece33771e2036dbc35cbab8f3c656 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 17 Mar 2026 18:07:09 -0400 Subject: [PATCH 10/12] fix: missing param --- Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 b/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 index 7db4d3fff943..59d699ecfd34 100644 --- a/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 @@ -16,6 +16,9 @@ function Add-CIPPScheduledTask { [Parameter(Mandatory = $false)] [switch]$RunNow, + [Parameter(Mandatory = $false)] + [string]$RowKey = $null, + [Parameter(Mandatory = $false)] [string]$DesiredStartTime = $null, From c30aa8bf7df60bdcb134892bcf6ff7ecca344d4c Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 17 Mar 2026 18:09:33 -0400 Subject: [PATCH 11/12] fix: mandatory param --- Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 b/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 index 59d699ecfd34..a4dfd9d24da6 100644 --- a/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 @@ -1,7 +1,7 @@ function Add-CIPPScheduledTask { [CmdletBinding()] param( - [Parameter(Mandatory = $true)] + [Parameter(Mandatory = $false)] [pscustomobject]$Task, [Parameter(Mandatory = $false)] From a30d28126c6176232aecb3e137fd23e048605d19 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 17 Mar 2026 18:13:33 -0400 Subject: [PATCH 12/12] chore: update default version to 10.2.4 in host.json and version_latest.txt --- host.json | 4 ++-- version_latest.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/host.json b/host.json index 95659a07ee21..75920f8e7db8 100644 --- a/host.json +++ b/host.json @@ -16,9 +16,9 @@ "distributedTracingEnabled": false, "version": "None" }, - "defaultVersion": "10.2.2", + "defaultVersion": "10.2.4", "versionMatchStrategy": "Strict", "versionFailureStrategy": "Fail" } } -} +} \ No newline at end of file diff --git a/version_latest.txt b/version_latest.txt index ea657e002bee..06bcad3c615e 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.2.3 +10.2.4