Skip to content

Commit 62491f1

Browse files
authored
Merge pull request #1028 from KelvinTegelaar/dev
[pull] dev from KelvinTegelaar:dev
2 parents a039a76 + 444a746 commit 62491f1

6 files changed

Lines changed: 176 additions & 51 deletions

File tree

Modules/CIPPCore/Public/Authentication/Add-CIPPSSOAppSecret.ps1

Lines changed: 71 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,49 +3,103 @@ function Add-CIPPSSOAppSecret {
33
.SYNOPSIS
44
Creates a client secret on the CIPP-SSO app registration with retry.
55
.DESCRIPTION
6-
Adds a new password credential to the given app object via Graph. Retries up to
7-
MaxRetries times with backoff because Entra propagation can take a few seconds
8-
after the app is freshly created or its app-management-policy exemption is set.
9-
Throws on final failure so callers can persist Status=error + LastError.
6+
Adds a new password credential to the given app object via Graph. Before adding the
7+
secret it ensures the app is exempt from the tenant default app-management policy (so a
8+
'passwordAddition' restriction can't block the secret) via Update-AppManagementPolicy,
9+
and honours any 'passwordLifetime' restriction when building the credential body.
10+
Retries up to MaxRetries times with backoff because Entra propagation can take a few
11+
seconds after the app is freshly created or its app-management-policy exemption is set:
12+
replication misses back off 3s, and credential-policy blocks back off min(30, 5*attempt)s
13+
while the exemption propagates. Throws on final failure so callers can persist
14+
Status=error + LastError.
1015
.PARAMETER ObjectId
1116
Graph object ID of the application (NOT the appId/clientId).
17+
.PARAMETER AppId
18+
AppId/clientId of the application, used to target the app-management-policy exemption.
19+
Resolved from ObjectId when not supplied.
1220
.PARAMETER DisplayName
1321
Display name to set on the password credential. Defaults to 'CIPP-SSO-Secret'.
1422
.PARAMETER MaxRetries
15-
Number of secret-creation attempts before giving up. Defaults to 5.
23+
Number of secret-creation attempts before giving up. Defaults to 6.
1624
#>
1725
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '')]
1826
[CmdletBinding()]
1927
param(
2028
[Parameter(Mandatory = $true)]
2129
[string]$ObjectId,
2230

31+
[Parameter(Mandatory = $false)]
32+
[string]$AppId,
33+
2334
[Parameter(Mandatory = $false)]
2435
[string]$DisplayName = 'CIPP-SSO-Secret',
2536

2637
[Parameter(Mandatory = $false)]
27-
[int]$MaxRetries = 5
38+
[int]$MaxRetries = 6
2839
)
2940

41+
# Update-AppManagementPolicy targets the app by appId/clientId; resolve it from the object id when not supplied.
42+
if (-not $AppId) {
43+
try {
44+
$SSOApp = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/applications/$ObjectId`?`$select=id,appId" -NoAuthCheck $true -AsApp $true
45+
$AppId = $SSOApp.appId
46+
} catch {
47+
Write-Warning "[SSO-Secret] Failed to resolve appId for objectId $ObjectId : $($_.Exception.Message)"
48+
}
49+
}
50+
51+
# Ensure the app is exempt from any credential-addition restriction before adding the secret.
52+
if ($AppId) {
53+
try {
54+
$PolicyUpdate = Update-AppManagementPolicy -ApplicationId $AppId
55+
Write-Information "[SSO-Secret] App management policy: $($PolicyUpdate.PolicyAction)"
56+
} catch {
57+
Write-Information "[SSO-Secret] Failed to update app management policy: $($_.Exception.Message)"
58+
}
59+
}
60+
61+
# Honour the tenant password-lifetime restriction (if enforced) when building the credential body.
62+
$AppManagementPolicy = New-GraphGetRequest -uri 'https://graph.microsoft.com/v1.0/policies/defaultAppManagementPolicy' -AsApp $true -NoAuthCheck $true
63+
$PasswordExpirationPolicy = $AppManagementPolicy.applicationRestrictions.passwordcredentials |
64+
Where-Object { $_.restrictionType -eq 'passwordLifetime' }
65+
if (-not ($PasswordExpirationPolicy.state -eq 'disabled' -or $null -eq $PasswordExpirationPolicy.state)) {
66+
$TimeToExpiration = [System.Xml.XmlConvert]::ToTimeSpan($PasswordExpirationPolicy.maxLifetime)
67+
$ExpirationDate = (Get-Date).AddDays($TimeToExpiration.Days).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
68+
$PasswordBody = "{`"passwordCredential`":{`"displayName`":`"$DisplayName`",`"endDateTime`":`"$ExpirationDate`"}}"
69+
} else {
70+
$PasswordBody = "{`"passwordCredential`":{`"displayName`":`"$DisplayName`"}}"
71+
}
72+
3073
$SecretText = $null
31-
$SecretAttempt = 0
32-
$BackoffSchedule = @(2, 5, 10, 15, 30)
3374
$LastException = $null
34-
35-
while ($SecretAttempt -lt $MaxRetries -and -not $SecretText) {
75+
for ($Attempt = 1; $Attempt -le $MaxRetries; $Attempt++) {
3676
try {
37-
$PasswordBody = @{ passwordCredential = @{ displayName = $DisplayName } } | ConvertTo-Json -Compress
38-
$PasswordResult = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/applications/$ObjectId/addPassword" -body $PasswordBody -type POST -NoAuthCheck $true -AsApp $true
77+
$PasswordResult = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/applications/$ObjectId/addPassword" -AsApp $true -NoAuthCheck $true -type POST -body $PasswordBody -maxRetries 3
3978
$SecretText = $PasswordResult.secretText
4079
Write-Information "[SSO-Secret] Client secret created on objectId $ObjectId"
80+
break
4181
} catch {
42-
$SecretAttempt++
4382
$LastException = $_
44-
Write-Warning "[SSO-Secret] Secret creation attempt $SecretAttempt/$MaxRetries failed: $($_.Exception.Message)"
45-
if ($SecretAttempt -lt $MaxRetries) {
46-
$Delay = $BackoffSchedule[[Math]::Min($SecretAttempt - 1, $BackoffSchedule.Count - 1)]
47-
Start-Sleep -Seconds $Delay
83+
$ExceptionMessage = $_.Exception.Message
84+
$IsNotReplicatedYet = $ExceptionMessage -match "Resource '.*' does not exist or one of its queried reference-property objects are not present"
85+
$IsCredentialPolicyBlocked = $ExceptionMessage -match 'Credential type not allowed as per assigned policy'
86+
Write-Warning "[SSO-Secret] Secret creation attempt $Attempt/$MaxRetries failed: $ExceptionMessage"
87+
88+
if ($IsNotReplicatedYet -and $Attempt -lt $MaxRetries) {
89+
$DelaySeconds = 3
90+
Write-Information "[SSO-Secret] Application object not yet replicated for addPassword (attempt $Attempt of $MaxRetries). Retrying in $DelaySeconds second(s)."
91+
Start-Sleep -Seconds $DelaySeconds
92+
continue
93+
}
94+
95+
if ($IsCredentialPolicyBlocked -and $Attempt -lt $MaxRetries) {
96+
$DelaySeconds = [Math]::Min(30, 5 * $Attempt)
97+
Write-Information "[SSO-Secret] Credential policy still blocks addPassword (attempt $Attempt of $MaxRetries). Waiting for policy propagation and retrying in $DelaySeconds second(s)."
98+
Start-Sleep -Seconds $DelaySeconds
99+
continue
48100
}
101+
102+
throw
49103
}
50104
}
51105

Modules/CIPPCore/Public/Set-CIPPMailboxType.ps1

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,35 @@ function Set-CIPPMailboxType {
1414
if ([string]::IsNullOrWhiteSpace($Username)) { $Username = $UserID }
1515
$null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-Mailbox' -cmdParams @{Identity = $UserID; Type = $MailboxType } -Anchor $Username
1616
$Message = "Successfully converted $Username to a $MailboxType mailbox"
17+
18+
# When converting to a shared mailbox, surface the cached mailbox size if it exceeds the
19+
# unlicensed shared-mailbox limit (50 GiB; we warn at 49 GiB). This is best-effort: any
20+
# lookup failure or unexpected response shape falls through to the standard success message.
21+
if ($MailboxType -eq 'Shared') {
22+
try {
23+
# 49 GiB warning threshold (shared mailboxes are capped at 50 GiB without a license)
24+
$SharedMailboxWarnBytes = 49GB
25+
# Resolve the partition key (defaultDomainName) the reporting DB is keyed on
26+
$PartitionKey = (Get-Tenants -TenantFilter $TenantFilter).defaultDomainName
27+
if ($PartitionKey) {
28+
# Server-side point lookup for this specific mailbox only.
29+
# Cached mailbox rows are keyed RowKey = 'Mailboxes-<EntraObjectId>'.
30+
$Table = Get-CippTable -tablename 'CippReportingDB'
31+
$Filter = "PartitionKey eq '{0}' and RowKey eq 'Mailboxes-{1}'" -f $PartitionKey, $UserID
32+
$CachedMailbox = Get-CIPPAzDataTableEntity @Table -Filter $Filter | Select-Object -First 1
33+
if ($CachedMailbox.Data) {
34+
$StorageBytes = [int64]([string]($CachedMailbox.Data | ConvertFrom-Json).storageUsedInBytes)
35+
if ($StorageBytes -ge $SharedMailboxWarnBytes) {
36+
$StorageGB = [math]::Round($StorageBytes / 1GB, 1)
37+
$Message = "$Message. Warning: detected mailbox size is $StorageGB GB, which exceeds the 50 GB shared mailbox limit. The mailbox may stop receiving mail unless an Exchange Online Plan 2 license is retained."
38+
}
39+
}
40+
}
41+
} catch {
42+
# Best-effort size check only; ignore lookup/parse errors and return the standard message.
43+
}
44+
}
45+
1746
Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev 'Info' -tenant $TenantFilter
1847
return $Message
1948
} catch {

Modules/CIPPCore/Public/Standards/Merge-CippStandards.ps1

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,34 @@ function Merge-CippStandards {
1111

1212
# If the standard name ends with 'Template', we treat them as arrays to merge.
1313
if ($StandardName -like '*Template') {
14-
$ExistingIsArray = $Existing -is [System.Collections.IEnumerable] -and -not ($Existing -is [string])
15-
$NewIsArray = $New -is [System.Collections.IEnumerable] -and -not ($New -is [string])
14+
# Combine both tiers, then collapse duplicates that target the same template
15+
# (same TemplateList.value). Without this, the same Intune/CA template configured
16+
# in more than one tier (or in more than one standard) for a tenant gets
17+
# concatenated into a multi-element array, which downstream stringifies into a
18+
# doubled GUID ("Failed to find template <guid> <guid>") that matches no RowKey.
19+
#
20+
# The standards engine already keys each template instance by TemplateList.value,
21+
# so when this function runs the items share a template GUID and should resolve to
22+
# a single deployment. Items without a TemplateList.value can't be keyed, so they
23+
# are always kept (preserves the additive behaviour for those).
24+
$Combined = @($Existing) + @($New)
1625

17-
# Make sure both are arrays
18-
if (-not $ExistingIsArray) { $Existing = @($Existing) }
19-
if (-not $NewIsArray) { $New = @($New) }
26+
$Deduped = [System.Collections.Generic.List[object]]::new()
27+
$SeenValues = [System.Collections.Generic.HashSet[string]]::new()
28+
# Walk newest-first so the most-specific tier wins for a given template, while
29+
# Insert(0, ...) keeps the overall ordering stable.
30+
for ($i = $Combined.Count - 1; $i -ge 0; $i--) {
31+
$Item = $Combined[$i]
32+
$TemplateValue = $Item.TemplateList.value
33+
if ([string]::IsNullOrEmpty($TemplateValue)) {
34+
$Deduped.Insert(0, $Item)
35+
} elseif ($SeenValues.Add([string]$TemplateValue)) {
36+
$Deduped.Insert(0, $Item)
37+
}
38+
}
2039

21-
return $Existing + $New
40+
if ($Deduped.Count -eq 1) { return $Deduped[0] }
41+
return $Deduped.ToArray()
2242
} else {
2343
# Single‐value standard: override the old with the new
2444
return $New

Modules/CIPPCore/Public/Tools/Import-CommunityTemplate.ps1

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -84,15 +84,6 @@ function Import-CommunityTemplate {
8484

8585
switch -Wildcard ($Type) {
8686
'*Group*' {
87-
$RawJsonObj = [PSCustomObject]@{
88-
Displayname = $Template.displayName
89-
Description = $Template.Description
90-
MembershipRules = $Template.membershipRule
91-
username = $Template.mailNickname
92-
GUID = $id
93-
groupType = 'generic'
94-
} | ConvertTo-Json -Depth 100
95-
9687
# Check for duplicate template
9788
$DuplicateFilter = "PartitionKey eq 'GroupTemplate'"
9889
$ExistingTemplates = Get-CIPPAzDataTableEntity @Table -Filter $DuplicateFilter -ErrorAction SilentlyContinue
@@ -115,6 +106,19 @@ function Import-CommunityTemplate {
115106
break
116107
}
117108

109+
# On update, reuse the existing GUID so the JSON-embedded GUID stays in
110+
# sync with the table RowKey (see the Intune path below for the full rationale).
111+
$TemplateGuid = if ($Duplicate) { $Duplicate.GUID } else { $id }
112+
113+
$RawJsonObj = [PSCustomObject]@{
114+
Displayname = $Template.displayName
115+
Description = $Template.Description
116+
MembershipRules = $Template.membershipRule
117+
username = $Template.mailNickname
118+
GUID = $TemplateGuid
119+
groupType = 'generic'
120+
} | ConvertTo-Json -Depth 100
121+
118122
if ($Duplicate) {
119123
$StatusMessage = "Updating Group template '$($Template.displayName)' from source '$Source' (SHA changed)."
120124
Write-Information $StatusMessage
@@ -126,7 +130,7 @@ function Import-CommunityTemplate {
126130
JSON = "$RawJsonObj"
127131
PartitionKey = 'GroupTemplate'
128132
SHA = $SHA
129-
GUID = if ($Duplicate) { $Duplicate.GUID } else { $id }
133+
GUID = $TemplateGuid
130134
RowKey = if ($Duplicate) { $Duplicate.RowKey } else { $id }
131135
Source = $Source
132136
}
@@ -239,14 +243,6 @@ function Import-CommunityTemplate {
239243
#create a new template
240244
$DisplayName = $Template.displayName ?? $template.Name
241245

242-
$RawJsonObj = [PSCustomObject]@{
243-
Displayname = $DisplayName
244-
Description = $Template.Description
245-
RAWJson = $RawJson
246-
Type = $URLName
247-
GUID = $id
248-
} | ConvertTo-Json -Depth 100 -Compress
249-
250246
# Check for duplicate template
251247
$DuplicateFilter = "PartitionKey eq 'IntuneTemplate'"
252248
$ExistingTemplates = Get-CIPPAzDataTableEntity @Table -Filter $DuplicateFilter -ErrorAction SilentlyContinue
@@ -263,6 +259,21 @@ function Import-CommunityTemplate {
263259
}
264260
} | Select-Object -First 1
265261

262+
# On update, reuse the existing template's GUID so the GUID embedded
263+
# in the JSON blob stays in sync with the table RowKey. Minting a fresh
264+
# GUID here desyncs the two: the standards engine resolves templates by
265+
# RowKey, while the template picker surfaces the JSON GUID, so the drift
266+
# would point at a GUID that no longer matches any RowKey.
267+
$TemplateGuid = if ($Duplicate) { $Duplicate.GUID } else { $id }
268+
269+
$RawJsonObj = [PSCustomObject]@{
270+
Displayname = $DisplayName
271+
Description = $Template.Description
272+
RAWJson = $RawJson
273+
Type = $URLName
274+
GUID = $TemplateGuid
275+
} | ConvertTo-Json -Depth 100 -Compress
276+
266277
if ($Duplicate -and $Duplicate.SHA -eq $SHA -and -not $Force) {
267278
$StatusMessage = "Intune template '$DisplayName' from source '$Source' is already up to date. Skipping import."
268279
Write-Information $StatusMessage
@@ -280,7 +291,7 @@ function Import-CommunityTemplate {
280291
JSON = "$RawJsonObj"
281292
PartitionKey = 'IntuneTemplate'
282293
SHA = $SHA
283-
GUID = if ($Duplicate) { $Duplicate.GUID } else { $id }
294+
GUID = $TemplateGuid
284295
RowKey = if ($Duplicate) { $Duplicate.RowKey } else { $id }
285296
Source = $Source
286297
}

Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneTemplates.ps1

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,16 @@ function Invoke-ListIntuneTemplates {
137137
}
138138
} | Sort-Object -Property label)
139139
} else {
140-
$Templates = $RawTemplates.JSON | ForEach-Object { try { ConvertFrom-Json -InputObject $_ -Depth 100 -ErrorAction SilentlyContinue } catch {} }
140+
# Force GUID to the table RowKey (the authoritative key the standards engine
141+
# resolves against). The JSON-embedded GUID can drift out of sync with the
142+
# RowKey after a community-repo re-sync, so never surface it as the selectable value.
143+
$Templates = $RawTemplates | ForEach-Object {
144+
try {
145+
$Parsed = ConvertFrom-Json -InputObject $_.JSON -Depth 100 -ErrorAction SilentlyContinue
146+
if ($Parsed) { $Parsed | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $_.RowKey -Force }
147+
$Parsed
148+
} catch {}
149+
}
141150

142151
}
143152
}

0 commit comments

Comments
 (0)