Skip to content

Commit 5bbf74a

Browse files
authored
Merge pull request #1053 from KelvinTegelaar/dev
[pull] dev from KelvinTegelaar:dev
2 parents 1b1ab74 + 20eb3d6 commit 5bbf74a

6 files changed

Lines changed: 232 additions & 8 deletions

File tree

Modules/CIPPCore/Public/New-CIPPBackup.ps1

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,10 @@ function New-CIPPBackup {
161161
# If building full URL fails, fall back to resource path
162162
$blobUrl = $resourcePath
163163
}
164+
165+
# Best-effort off-site replication to an external storage account.
166+
$ReplType = if ($backupType -eq 'CIPP') { 'Core' } else { 'Tenant' }
167+
Push-CIPPBackupReplication -BackupType $ReplType -BlobName $blobName -Content $BackupData -Headers $Headers
164168
} catch {
165169
$ErrorMessage = Get-CippException -Exception $_
166170
Write-LogMessage -headers $Headers -API $APINAME -message "Blob upload failed: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
function Push-CIPPBackupReplication {
2+
<#
3+
.SYNOPSIS
4+
Replicates a CIPP backup blob to an external storage account using a container SAS URL.
5+
6+
.DESCRIPTION
7+
After a backup blob is written to the CIPP-bound storage account, this helper uploads an
8+
identical copy to an external Azure Storage container. The destination is described by a
9+
container-level SAS URL (with write+create permission) stored in Key Vault, so the secret
10+
never lands in table storage or the browser.
11+
12+
There are two independent replication targets:
13+
Core -> KV secret 'BackupReplicationCore' (Config RowKey 'Core') for CIPP backups
14+
Tenant -> KV secret 'BackupReplicationTenant' (Config RowKey 'Tenant') for scheduled tenant backups
15+
16+
Replication is best-effort: any failure is logged but never thrown, so a replication problem
17+
can never abort the underlying backup.
18+
19+
.PARAMETER BackupType
20+
'Core' for CIPP backups, 'Tenant' for scheduled tenant backups.
21+
22+
.PARAMETER BlobName
23+
The blob file name to write (e.g. 'CIPPBackup_2024-01-15-1430.json').
24+
25+
.PARAMETER Content
26+
The backup payload (JSON string) to upload.
27+
28+
.PARAMETER Headers
29+
Request headers passed through to Write-LogMessage for attribution.
30+
#>
31+
[CmdletBinding()]
32+
param(
33+
[Parameter(Mandatory = $true)]
34+
[ValidateSet('Core', 'Tenant')]
35+
[string]$BackupType,
36+
37+
[Parameter(Mandatory = $true)]
38+
[string]$BlobName,
39+
40+
[Parameter(Mandatory = $true)]
41+
[string]$Content,
42+
43+
$Headers
44+
)
45+
46+
$SecretName = "BackupReplication$BackupType"
47+
48+
try {
49+
# Only replicate when explicitly enabled for this scope.
50+
$Table = Get-CIPPTable -TableName 'Config'
51+
$Config = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'BackupReplication' and RowKey eq '$BackupType'"
52+
if (-not $Config -or $Config.Enabled -ne $true) {
53+
return
54+
}
55+
56+
if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') {
57+
$DevSecretsTable = Get-CIPPTable -tablename 'DevSecrets'
58+
$SasUrl = (Get-CIPPAzDataTableEntity @DevSecretsTable -Filter "PartitionKey eq 'BackupReplication$BackupType' and RowKey eq 'BackupReplication$BackupType'").SASUrl
59+
}
60+
else {
61+
$SasUrl = Get-CippKeyVaultSecret -Name $SecretName -AsPlainText
62+
}
63+
64+
if ([string]::IsNullOrWhiteSpace($SasUrl)) {
65+
Write-LogMessage -headers $Headers -API 'BackupReplication' -message "$BackupType backup replication is enabled but no SAS URL is stored" -Sev 'Warning'
66+
return
67+
}
68+
69+
# Insert the blob name into the container SAS URL, before the query string.
70+
$UrlParts = $SasUrl -split '\?', 2
71+
$BaseUrl = $UrlParts[0].TrimEnd('/')
72+
$Target = "$BaseUrl/$BlobName"
73+
if ($UrlParts.Count -gt 1 -and -not [string]::IsNullOrWhiteSpace($UrlParts[1])) {
74+
$Target = "$Target`?$($UrlParts[1])"
75+
}
76+
77+
$null = Invoke-CIPPRestMethod -Uri $Target -Method 'PUT' -Body $Content -ContentType 'application/json; charset=utf-8' -Headers @{ 'x-ms-blob-type' = 'BlockBlob' }
78+
Write-LogMessage -headers $Headers -API 'BackupReplication' -message "Replicated $BackupType backup '$BlobName' to external storage" -Sev 'Debug'
79+
} catch {
80+
$ErrorMessage = Get-CippException -Exception $_
81+
Write-LogMessage -headers $Headers -API 'BackupReplication' -message "Failed to replicate $BackupType backup '$BlobName' to external storage: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage
82+
}
83+
}

Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecEditTemplate.ps1

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ function Invoke-ExecEditTemplate {
1212
try {
1313
$Table = Get-CippTable -tablename 'templates'
1414
$guid = $request.Body.id ? $request.Body.id : $request.Body.GUID
15-
$JSON = ConvertTo-Json -Compress -Depth 100 -InputObject ($request.Body | Select-Object * -ExcludeProperty GUID)
15+
$JSON = ConvertTo-Json -Compress -Depth 100 -InputObject ($request.Body | Select-Object * -ExcludeProperty GUID, source, isSynced, package)
1616
$Type = $request.Query.Type ?? $Request.Body.Type
1717

1818
if ($Type -eq 'IntuneTemplate') {
@@ -63,13 +63,14 @@ function Invoke-ExecEditTemplate {
6363
}
6464
Set-CIPPIntuneTemplate @IntuneTemplate
6565
} else {
66-
$Table.Force = $true
67-
Add-CIPPAzDataTableEntity @Table -Entity @{
66+
$Entity = @{
6867
JSON = "$JSON"
6968
RowKey = "$GUID"
7069
PartitionKey = "$Type"
7170
GUID = "$GUID"
71+
SHA = ''
7272
}
73+
Add-CIPPAzDataTableEntity @Table -Entity $Entity -OperationType 'UpsertMerge'
7374
Write-LogMessage -headers $Request.Headers -API $APINAME -message "Edited template $($Request.Body.name) with GUID $GUID" -Sev 'Debug'
7475
}
7576
$body = [pscustomobject]@{ 'Results' = 'Successfully saved the template' }
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
function Invoke-ExecBackupReplicationConfig {
2+
<#
3+
.FUNCTIONALITY
4+
Entrypoint
5+
.ROLE
6+
CIPP.AppSettings.ReadWrite
7+
#>
8+
[CmdletBinding()]
9+
param($Request, $TriggerMetadata)
10+
11+
$Table = Get-CIPPTable -TableName Config
12+
$Scopes = @('Core', 'Tenant')
13+
14+
# Returns whether a SAS URL secret currently exists for the given scope, without ever exposing it.
15+
function Get-ReplicationSecretIsSet {
16+
param([string]$Scope)
17+
try {
18+
if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') {
19+
$DevSecretsTable = Get-CIPPTable -tablename 'DevSecrets'
20+
$Secret = (Get-CIPPAzDataTableEntity @DevSecretsTable -Filter "PartitionKey eq 'BackupReplication$Scope' and RowKey eq 'BackupReplication$Scope'").SASUrl
21+
if ([string]::IsNullOrWhiteSpace($Secret)) {
22+
return $null
23+
}
24+
return "SentToKeyVault"
25+
}
26+
else {
27+
$Secret = Get-CippKeyVaultSecret -Name "BackupReplication$Scope" -AsPlainText
28+
if ([string]::IsNullOrWhiteSpace($Secret)) {
29+
return $null
30+
}
31+
return "SentToKeyVault"
32+
}
33+
} catch {
34+
return $null
35+
}
36+
}
37+
38+
$results = try {
39+
if ($Request.Query.List) {
40+
$Output = @{}
41+
foreach ($Scope in $Scopes) {
42+
$Config = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'BackupReplication' and RowKey eq '$Scope'"
43+
$Output[$Scope] = @{
44+
Enabled = [bool]($Config.Enabled)
45+
IsSet = Get-ReplicationSecretIsSet -Scope $Scope
46+
}
47+
}
48+
[pscustomobject]$Output
49+
} else {
50+
$BackupType = $Request.Body.BackupType
51+
if ($BackupType -notin $Scopes) {
52+
throw "BackupType must be one of: $($Scopes -join ', ')"
53+
}
54+
55+
$SASUrl = $Request.Body.SASUrl
56+
$Enabled = if ($null -ne $Request.Body.Enabled) { [bool]$Request.Body.Enabled } else { $true }
57+
58+
# Only update the stored secret when a real new value is supplied (the UI sends the
59+
# 'SentToKeyVault' sentinel when the existing, masked secret is left untouched).
60+
if (-not [string]::IsNullOrWhiteSpace($SASUrl) -and $SASUrl -ne 'SentToKeyVault') {
61+
$ParsedUri = $SASUrl -as [uri]
62+
if (-not $ParsedUri -or $ParsedUri.Query -notmatch 'sig=') {
63+
throw 'SAS URL must contain a SAS token (sig=...)'
64+
}
65+
66+
# Confirm the SAS actually grants write+create by writing and removing a tiny probe blob.
67+
$guid = [guid]::NewGuid().ToString()
68+
$UrlParts = $SASUrl -split '\?', 2
69+
$BaseUrl = $UrlParts[0].TrimEnd('/')
70+
$ProbeUrl = "$BaseUrl/.cipp-replication-test-$guid`?$($UrlParts[1])"
71+
try {
72+
$null = Invoke-CIPPRestMethod -Uri $ProbeUrl -Method 'PUT' -Body "cipp-replication-test-$guid" -ContentType 'text/plain' -Headers @{ 'x-ms-blob-type' = 'BlockBlob' }
73+
try { $null = Invoke-CIPPRestMethod -Uri $ProbeUrl -Method 'DELETE' -Headers @{} } catch { }
74+
} catch {
75+
$ProbeError = Get-CippException -Exception $_
76+
throw "SAS URL validation failed (could not write to the container): $($ProbeError.NormalizedError)"
77+
}
78+
79+
if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') {
80+
$DevSecretsTable = Get-CIPPTable -tablename 'DevSecrets'
81+
$Secret = [PSCustomObject]@{
82+
'PartitionKey' = "BackupReplication$BackupType"
83+
'RowKey' = "BackupReplication$BackupType"
84+
'SASUrl' = $SASUrl
85+
}
86+
Add-CIPPAzDataTableEntity @DevSecretsTable -Entity $Secret -Force | Out-Null
87+
}
88+
else {
89+
Set-CippKeyVaultSecret -Name "BackupReplication$BackupType" -SecretValue (ConvertTo-SecureString -String $SASUrl -AsPlainText -Force) | Out-Null
90+
}
91+
}
92+
93+
$Config = @{
94+
'PartitionKey' = 'BackupReplication'
95+
'RowKey' = $BackupType
96+
'Enabled' = $Enabled
97+
}
98+
Add-CIPPAzDataTableEntity @Table -Entity $Config -Force | Out-Null
99+
100+
Write-LogMessage -headers $Request.Headers -API $Request.Params.CIPPEndpoint -message "Updated $BackupType backup replication settings (Enabled: $Enabled)" -Sev 'Info'
101+
"Successfully updated $BackupType backup replication settings"
102+
}
103+
} catch {
104+
$ErrorMessage = Get-CippException -Exception $_
105+
Write-LogMessage -headers $Request.Headers -API $Request.Params.CIPPEndpoint -message "Failed to update backup replication configuration: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage
106+
"Failed to update configuration: $($ErrorMessage.NormalizedError)"
107+
}
108+
109+
$body = [pscustomobject]@{'Results' = $Results }
110+
111+
return ([HttpResponseContext]@{
112+
StatusCode = [HttpStatusCode]::OK
113+
Body = $body
114+
})
115+
}

Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ListCAtemplates.ps1

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ function Invoke-ListCAtemplates {
99
#>
1010
[CmdletBinding()]
1111
param($Request, $TriggerMetadata)
12-
Write-Host $Request.query.id
12+
$GUID = $Request.query.id ?? $Request.query.ID ?? $Request.query.guid ?? $Request.query.GUID
1313
#Migrating old policies whenever you do a list
1414
$Table = Get-CippTable -tablename 'templates'
1515
$Imported = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'settings'"
@@ -31,10 +31,10 @@ function Invoke-ListCAtemplates {
3131
}
3232
#List new policies
3333
$Table = Get-CippTable -tablename 'templates'
34-
$Filter = "PartitionKey eq 'CATemplate'"
35-
$RawTemplates = (Get-CIPPAzDataTableEntity @Table -Filter $Filter)
3634

3735
if ($Request.query.mode -eq 'Tag') {
36+
$Filter = "PartitionKey eq 'CATemplate'"
37+
$RawTemplates = (Get-CIPPAzDataTableEntity @Table -Filter $Filter)
3838
#when the mode is tag, show all the potential tags, return the object with: label: tag, value: tag, count: number of templates with that tag, unique only
3939
$Templates = @($RawTemplates | Where-Object { $_.Package } | Group-Object -Property Package | ForEach-Object {
4040
$package = $_.Name
@@ -59,6 +59,14 @@ function Invoke-ListCAtemplates {
5959
}
6060
} | Sort-Object -Property label)
6161
} else {
62+
if ($GUID) {
63+
$SafeGUID = ConvertTo-CIPPODataFilterValue -Value $GUID -Type Guid
64+
$Filter = "PartitionKey eq 'CATemplate' and GUID eq '$SafeGUID'"
65+
$RawTemplates = (Get-CIPPAzDataTableEntity @Table -Filter $Filter)
66+
}
67+
else {
68+
$RawTemplates = (Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'CATemplate'")
69+
}
6270
$Templates = $RawTemplates | ForEach-Object {
6371
try {
6472
$row = $_
@@ -74,8 +82,6 @@ function Invoke-ListCAtemplates {
7482
} | Sort-Object -Property displayName
7583
}
7684

77-
if ($Request.query.ID) { $Templates = $Templates | Where-Object -Property GUID -EQ $Request.query.id }
78-
7985
$Templates = ConvertTo-Json -InputObject @($Templates) -Depth 100
8086
return ([HttpResponseContext]@{
8187
StatusCode = [HttpStatusCode]::OK

Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-RemoveStandardTemplate.ps1

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,26 @@ function Invoke-RemoveStandardTemplate {
3535
Remove-AzDataTableEntity -Force @ScheduledTasksTable -Entity $DriftTask
3636
Write-LogMessage -Headers $Headers -API $APIName -message "Removed drift remediation scheduled task: $($DriftTask.Name)" -Sev Info
3737
}
38+
$StandardsReportsTable = Get-CIPPTable -TableName 'CippStandardsReports'
39+
$RemovedTemplateIds = @(@($Entities.RowKey) + $ID | Where-Object { $_ } | Select-Object -Unique)
40+
$OrphanedReports = [System.Collections.Generic.List[object]]::new()
41+
foreach ($RemovedTemplateId in $RemovedTemplateIds) {
42+
$SafeTemplateId = ConvertTo-CIPPODataFilterValue -Value $RemovedTemplateId -Type Guid
43+
$Rows = Get-CIPPAzDataTableEntity @StandardsReportsTable -Filter "TemplateId eq '$SafeTemplateId'"
44+
foreach ($Row in $Rows) { $OrphanedReports.Add($Row) }
45+
}
46+
if ($OrphanedReports.Count -gt 0) {
47+
Remove-AzDataTableEntity -Force @StandardsReportsTable -Entity @($OrphanedReports)
48+
Write-LogMessage -Headers $Headers -API $APIName -message "Removed $($OrphanedReports.Count) orphaned standards comparison row(s) for template id: $($ID)" -Sev Info
49+
}
3850

3951
$Result = "Removed Standards Template named: '$($TemplateName)' with id: $($ID)"
4052
if ($DriftTasks) {
4153
$Result += ". Also removed $(@($DriftTasks).Count) associated drift remediation scheduled task(s)."
4254
}
55+
if ($OrphanedReports.Count -gt 0) {
56+
$Result += " Cleaned up $($OrphanedReports.Count) orphaned standards comparison row(s)."
57+
}
4358
Write-LogMessage -Headers $Headers -API $APIName -message $Result -Sev Info
4459
$StatusCode = [HttpStatusCode]::OK
4560
} catch {

0 commit comments

Comments
 (0)