Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions backend/Config/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -5601,6 +5601,7 @@
"tags": [
"Tenant > Administration > Alerts"
],
"description": "Creates or updates a scripted CIPP alert, stored as a hidden scheduled task.\n\nA selection of two or more tenants or groups is stored verbatim and expanded on every run,\nso tenant group membership is always current.",
"requestBody": {
"required": true,
"content": {
Expand All @@ -5609,16 +5610,15 @@
"type": "object",
"properties": {
"excludedTenants": {
"type": "string"
},
"RowKey": {
"type": "string"
"type": "string",
"description": "Tenants or tenant groups to skip even when they fall within the selection above. Optional."
},
"tenantFilter": {
"type": "array",
"items": {
"type": "string"
}
},
"description": "The tenants, tenant groups or *All Tenants the alert applies to. At least one is required."
}
},
"required": [
Expand Down Expand Up @@ -45922,7 +45922,7 @@
"x-cipp-field-source": "storage"
},
"ExecutedTime": {
"x-cipp-field-source": "frontend"
"x-cipp-field-source": "storage,frontend"
},
"Hidden": {
"type": "boolean",
Expand Down Expand Up @@ -54719,6 +54719,9 @@
"type": "string",
"x-cipp-field-source": "storage"
},
"ExecutedTime": {
"x-cipp-field-source": "storage"
},
"Hidden": {
"type": "boolean",
"x-cipp-field-source": "storage"
Expand Down
7 changes: 7 additions & 0 deletions backend/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,13 @@ function Add-CIPPScheduledTask {
}
}

# Stored verbatim so the orchestrator expands groups at run time. The version marker tells
# it excludedTenants holds only the operator's picks, not a snapshot of unselected tenants.
if ($task.Tenants) {
$entity['Tenants'] = $task.Tenants -is [string] ? [string]$task.Tenants : [string]($task.Tenants | ConvertTo-Json -Compress -Depth 10)
$entity['TenantSelectionVersion'] = 2
}

if ($task.Trigger) {
$entity.Trigger = [string]($task.Trigger | ConvertTo-Json -Compress)
$TriggerType = $task.Trigger.Type.value ?? $task.Trigger.Type
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,17 +119,73 @@ function Start-UserTasksOrchestrator {
FunctionName = 'ExecScheduledCommand'
}

if ($task.Tenant -eq 'AllTenants') {
$ExcludedTenants = @($task.excludedTenants -split ',' | Where-Object { $_ })
if ($task.excludedTenantGroups) {
# Expand excluded tenant groups at runtime so membership changes are honored
$ExcludedGroups = $task.excludedTenantGroups | ConvertFrom-Json -ErrorAction SilentlyContinue
if ($ExcludedGroups) {
$ExcludedTenants = @($ExcludedTenants + (Expand-CIPPTenantGroups -TenantFilter $ExcludedGroups).value | Where-Object { $_ })
# Scope is resolved on every run so group membership stays current, as
# Test-CIPPAuditLogRules does for audit alerts. The stored selection is only trusted
# on a row the execution gates also read as multi-tenant, otherwise the fan-out here
# and Push-ExecScheduledCommand would disagree about the task's shape.
$UsesStoredSelection = $task.Tenants -and $task.Tenant -eq 'AllTenants'
if ($task.Tenants -and -not $UsesStoredSelection) {
Write-Information "Task $($task.Name): ignoring the stored selection, Tenant is '$($task.Tenant)' rather than AllTenants"
}
$Selection = if ($UsesStoredSelection) {
@($task.Tenants | ConvertFrom-Json -ErrorAction SilentlyContinue)
} elseif ($task.TenantGroup) {
@($task.TenantGroup | ConvertFrom-Json -ErrorAction SilentlyContinue)
}

$TargetTenants = $null
$ResolvedScope = $false
if ($Selection) {
try {
$Expanded = Expand-CIPPTenantGroups -TenantFilter $Selection
} catch {
# Must not fall through to the single-tenant path below: Tenant is the
# AllTenants sentinel for a multi-entry selection. Fail the task instead.
throw "Failed to expand tenant selection for task $($task.Name): $($_.Exception.Message)"
}
# Non-group entries pass through unexpanded, so the sentinel survives.
$TargetTenants = if ($Expanded.value -contains 'AllTenants') {
$TenantList
} else {
@($TenantList | Where-Object { $_.defaultDomainName -in $Expanded.value })
}
$ResolvedScope = $true
} elseif ($task.Tenant -eq 'AllTenants') {
# An explicit *All Tenants pick, with no selection stored alongside it
$TargetTenants = $TenantList
$ResolvedScope = $true
}

# Rows predating runtime expansion merged a snapshot of every unselected tenant into
# excludedTenants, indistinguishable from the operator's own picks, so it is ignored
# for those. A selection carrying the AllTenants sentinel never had a snapshot
# written, so its exclusions are the operator's and are kept. excludedTenantGroups
# was never part of the snapshot either and always applies.
$IsLegacySnapshot = $UsesStoredSelection -and -not $task.TenantSelectionVersion -and ($Selection.value -notcontains 'AllTenants')
$ExcludedTenants = [System.Collections.Generic.List[string]]::new()
if ($task.excludedTenants) {
$StoredExclusions = @($task.excludedTenants -split ',' | Where-Object { $_ })
if ($IsLegacySnapshot) {
# Only report a snapshot that would actually have dropped a tenant in scope
# now, or every run of every legacy row logs the same no-op indefinitely.
$Reinstated = @($StoredExclusions | Where-Object { $_ -in $TargetTenants.defaultDomainName })
if ($Reinstated.Count -gt 0) {
Write-LogMessage -API 'Scheduler_UserTasks' -tenant $tenant -message "Task $($task.Name): ignored $($Reinstated.Count) stale snapshot exclusions, tenant group membership is now resolved at runtime" -Sev 'Info'
}
} else {
$ExcludedTenants.AddRange([string[]]$StoredExclusions)
}
}
if ($task.excludedTenantGroups) {
$ExcludedGroups = $task.excludedTenantGroups | ConvertFrom-Json -ErrorAction SilentlyContinue
if ($ExcludedGroups) {
$ExcludedTenants.AddRange([string[]]@((Expand-CIPPTenantGroups -TenantFilter $ExcludedGroups).value | Where-Object { $_ }))
}
Write-Host "Excluded Tenants from this task: $ExcludedTenants"
$AllTenantCommands = foreach ($Tenant in $TenantList | Where-Object { $_.defaultDomainName -notin $ExcludedTenants }) {
}

if ($ResolvedScope) {
Write-Information "Task $($task.Name): $(@($TargetTenants).Count) tenants in scope, $($ExcludedTenants.Count) excluded"
$FanOutCommands = foreach ($Tenant in $TargetTenants | Where-Object { $_.defaultDomainName -notin $ExcludedTenants }) {
$NewParams = $task.Parameters.Clone()
if ($HasTenantFilter) {
# TenantFilter always carries the execution tenant context; it is stripped
Expand All @@ -146,62 +202,27 @@ function Start-UserTasksOrchestrator {
FunctionName = 'ExecScheduledCommand'
}
}
$Batch.AddRange(@($AllTenantCommands))
} elseif ($task.TenantGroup) {
# Handle tenant groups - expand group to individual tenants
try {
$TenantGroupObject = $task.TenantGroup | ConvertFrom-Json
Write-Host "Expanding tenant group: $($TenantGroupObject.label) with ID: $($TenantGroupObject.value)"

# Create a tenant filter object for expansion
$TenantFilterForExpansion = @([PSCustomObject]@{
type = 'Group'
value = $TenantGroupObject.value
label = $TenantGroupObject.label
})

# Expand the tenant group to individual tenants
$ExpandedTenants = Expand-CIPPTenantGroups -TenantFilter $TenantFilterForExpansion

$ExcludedTenants = @($task.excludedTenants -split ',' | Where-Object { $_ })
if ($task.excludedTenantGroups) {
# Expand excluded tenant groups at runtime so membership changes are honored
$ExcludedGroups = $task.excludedTenantGroups | ConvertFrom-Json -ErrorAction SilentlyContinue
if ($ExcludedGroups) {
$ExcludedTenants = @($ExcludedTenants + (Expand-CIPPTenantGroups -TenantFilter $ExcludedGroups).value | Where-Object { $_ })
}
}
Write-Host "Excluded Tenants from this task: $ExcludedTenants"

$GroupTenantCommands = foreach ($ExpandedTenant in $ExpandedTenants | Where-Object { $_.value -notin $ExcludedTenants }) {
$NewParams = $task.Parameters.Clone()
if ($HasTenantFilter) {
$NewParams.TenantFilter = $ExpandedTenant.value
$NewParams.$PrimaryTenantParam = $ExpandedTenant.value
}
# Clone TaskInfo to prevent shared object references
$TaskInfoClone = $task.PSObject.Copy()
[pscustomobject]@{
Command = $task.Command
Parameters = $NewParams
TaskInfo = $TaskInfoClone
FunctionName = 'ExecScheduledCommand'
}
}
$Batch.AddRange(@($GroupTenantCommands))
} catch {
Write-Host "Error expanding tenant group: $($_.Exception.Message)"
Write-LogMessage -API 'Scheduler_UserTasks' -tenant $tenant -message "Failed to expand tenant group for task $($task.Name): $($_.Exception.Message)" -sev Error

# Fall back to treating as single tenant
if ($HasTenantFilter) {
$ScheduledCommand.Parameters['TenantFilter'] = $task.Tenant
$ScheduledCommand.Parameters[$PrimaryTenantParam] = $task.Tenant
if (@($FanOutCommands).Count -gt 0) {
$Batch.AddRange(@($FanOutCommands))
} else {
# Every selected group resolved empty, or was deleted. Close the run out here:
# the row is already Pending, and with no batch item no orchestrator or post
# execution runs, so it would be reclaimed as stale every hour and a recurring
# task would never advance its schedule.
$NextRun = Get-CIPPScheduledTaskNextRun -Recurrence $task.Recurrence -ScheduledTime $task.ScheduledTime
$EmptyScopeEntity = @{
PartitionKey = $task.PartitionKey
RowKey = $task.RowKey
Results = 'No tenants in scope for this task.'
ExecutedTime = "$currentUnixTime"
TaskState = $NextRun -gt 0 ? 'Planned' : 'Completed'
}
$Batch.Add($ScheduledCommand)
if ($NextRun -gt 0) { $EmptyScopeEntity.ScheduledTime = "$NextRun" }
$null = Update-AzDataTableEntity -Force @Table -Entity $EmptyScopeEntity
Write-LogMessage -API 'Scheduler_UserTasks' -tenant $tenant -message "Task $($task.Name): no tenants in scope, nothing to run" -Sev 'Info'
}
} else {
# Handle single tenant
# Single tenant
if ($HasTenantFilter) {
$ScheduledCommand.Parameters['TenantFilter'] = $task.Tenant
$ScheduledCommand.Parameters[$PrimaryTenantParam] = $task.Tenant
Expand All @@ -211,13 +232,19 @@ function Start-UserTasksOrchestrator {
} catch {
$errorMessage = $_.Exception.Message

$null = Update-AzDataTableEntity -Force @Table -Entity @{
# Failed is terminal - the pickup filter only reads Planned and Failed - Planned - so
# a recurring task parked there never runs again. A transient failure here (a tenant
# or group table read, say) must not permanently stop it.
$NextRun = Get-CIPPScheduledTaskNextRun -Recurrence $task.Recurrence -ScheduledTime $task.ScheduledTime
$FailureEntity = @{
PartitionKey = $task.PartitionKey
RowKey = $task.RowKey
Results = "$errorMessage"
ExecutedTime = "$currentUnixTime"
TaskState = 'Failed'
TaskState = $NextRun -gt 0 ? 'Failed - Planned' : 'Failed'
}
if ($NextRun -gt 0) { $FailureEntity.ScheduledTime = "$NextRun" }
$null = Update-AzDataTableEntity -Force @Table -Entity $FailureEntity
Write-LogMessage -API 'Scheduler_UserTasks' -tenant $tenant -message "Failed to execute task $($task.Name): $errorMessage" -sev Error
}
}
Expand Down
32 changes: 32 additions & 0 deletions backend/Modules/CIPPCore/Public/Get-CIPPScheduledTaskNextRun.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
function Get-CIPPScheduledTaskNextRun {
<#
.SYNOPSIS
Next run time for a scheduled task, in unix seconds, or 0 when it does not repeat.
.DESCRIPTION
Recurrence is stored as 30m, 1h, 1d and so on; a bare number is a day count, the shape older
tasks carry. A run further back than one interval is treated as starting now, so a task that
was disabled or stuck does not replay a backlog of missed runs.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][AllowNull()]$Recurrence,
[Parameter(Mandatory = $true)][AllowNull()]$ScheduledTime
)

$Value = [string]$Recurrence
if ($Value -match '^\d+$') { $Value = '{0}d' -f $Value }

$SecondsToAdd = switch -Regex ($Value) {
'(\d+)m$' { [int64]$Matches[1] * 60 }
'(\d+)h$' { [int64]$Matches[1] * 3600 }
'(\d+)d$' { [int64]$Matches[1] * 86400 }
default { 0 }
}
if ($SecondsToAdd -le 0) { return 0 }

$Now = [int64](([datetime]::UtcNow) - (Get-Date '1/1/1970')).TotalSeconds
$Last = [int64]($ScheduledTime ?? 0)
if ($Last -lt ($Now - $SecondsToAdd)) { $Last = $Now }

return $Last + $SecondsToAdd
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ function Invoke-ListScheduledItemDetails {

# Retrieve the task information
$TaskTable = Get-CIPPTable -TableName 'ScheduledTasks'
$Task = Get-CIPPAzDataTableEntity @TaskTable -Filter "RowKey eq '$SafeRowKey' and PartitionKey eq 'ScheduledTask'" | Select-Object RowKey, Name, TaskState, Command, Parameters, Recurrence, ExecutedTime, ScheduledTime, PostExecution, PostExecutionResults, Tenant, TenantGroup, Hidden, Results, Timestamp, Trigger
$Task = Get-CIPPAzDataTableEntity @TaskTable -Filter "RowKey eq '$SafeRowKey' and PartitionKey eq 'ScheduledTask'" | Select-Object RowKey, Name, TaskState, Command, Parameters, Recurrence, ExecutedTime, ScheduledTime, PostExecution, PostExecutionResults, Tenant, TenantGroup, Tenants, TenantSelectionVersion, excludedTenants, excludedTenantGroups, Hidden, Results, Timestamp, Trigger


if (-not $Task) {
return ([HttpResponseContext]@{
Expand Down Expand Up @@ -72,7 +73,21 @@ function Invoke-ListScheduledItemDetails {
} catch {}

# Handle tenant group display information (similar to Invoke-ListScheduledItems)
if ($Task.TenantGroup) {
if ($Task.Tenants) {
# Tenant stays 'AllTenants' for the execution gates, so report the real scope from Tenants.
try {
$TenantsParsed = $Task.Tenants | ConvertFrom-Json -Depth 10 -ErrorAction Stop
$Task.Tenant = @($TenantsParsed | ForEach-Object {
[PSCustomObject]@{
label = $_.label ?? $_.value
value = $_.value
type = $_.type ?? 'Tenant'
}
})
} catch {
Write-Warning "Failed to parse tenant selection for task $($Task.RowKey): $($_.Exception.Message)"
}
} elseif ($Task.TenantGroup) {
try {
$TenantGroupObject = $Task.TenantGroup | ConvertFrom-Json -ErrorAction SilentlyContinue
if ($TenantGroupObject) {
Expand Down
Loading
Loading