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
4,490 changes: 3,140 additions & 1,350 deletions Config/standards.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,30 @@ function Push-ExecOnboardTenantQueue {
$OnboardingSteps.Step2.Status = 'succeeded'
$OnboardingSteps.Step2.Message = 'Your GDAP relationship has the required roles'
}

# Validate (and correct) that the mapped security groups still exist in the partner tenant before
# Step 3 tries to POST the access assignments - a missing group surfaces as a raw Graph
# "access container does not exist" error otherwise.
if ($OnboardingSteps.Step2.Status -ne 'failed' -and ($Item.Roles | Measure-Object).Count -gt 0) {
$Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = 'Validating GDAP security group mappings against the partner tenant' })
$GroupCheck = Test-CIPPGDAPGroupMappings -RoleMappings $Item.Roles -CreateMissing:([bool]$Item.AddMissingGroups) -WriteBack
foreach ($GroupResult in $GroupCheck.Results) {
if ($GroupResult.Status -in @('Stale', 'Created', 'Missing')) {
$Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = $GroupResult.Message })
}
}
# Use the corrected mappings for the remainder of the onboarding (group mapping, SAM membership, retries)
$Item.Roles = @($GroupCheck.RoleMappings)

if (-not $GroupCheck.Valid) {
$MissingGroupNames = ($GroupCheck.MissingGroups.Name | Sort-Object -Unique) -join ', '
$Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = "Missing GDAP security groups in the partner tenant: $MissingGroupNames" })
$TenantOnboarding.Status = 'failed'
$OnboardingSteps.Step2.Status = 'failed'
$OnboardingSteps.Step2.Message = "The following GDAP security groups are missing in the partner tenant, recreate the GDAP roles and retry: $MissingGroupNames"
}
}

$TenantOnboarding.OnboardingSteps = [string](ConvertTo-Json -InputObject $OnboardingSteps -Compress)
$TenantOnboarding.Logs = [string](ConvertTo-Json -InputObject @($Logs) -Compress)
Add-CIPPAzDataTableEntity @OnboardTable -Entity $TenantOnboarding -Force -ErrorAction Stop
Expand Down
88 changes: 71 additions & 17 deletions Modules/CIPPCore/Public/Authentication/Add-CIPPSSOAppSecret.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -3,49 +3,103 @@ function Add-CIPPSSOAppSecret {
.SYNOPSIS
Creates a client secret on the CIPP-SSO app registration with retry.
.DESCRIPTION
Adds a new password credential to the given app object via Graph. Retries up to
MaxRetries times with backoff because Entra propagation can take a few seconds
after the app is freshly created or its app-management-policy exemption is set.
Throws on final failure so callers can persist Status=error + LastError.
Adds a new password credential to the given app object via Graph. Before adding the
secret it ensures the app is exempt from the tenant default app-management policy (so a
'passwordAddition' restriction can't block the secret) via Update-AppManagementPolicy,
and honours any 'passwordLifetime' restriction when building the credential body.
Retries up to MaxRetries times with backoff because Entra propagation can take a few
seconds after the app is freshly created or its app-management-policy exemption is set:
replication misses back off 3s, and credential-policy blocks back off min(30, 5*attempt)s
while the exemption propagates. Throws on final failure so callers can persist
Status=error + LastError.
.PARAMETER ObjectId
Graph object ID of the application (NOT the appId/clientId).
.PARAMETER AppId
AppId/clientId of the application, used to target the app-management-policy exemption.
Resolved from ObjectId when not supplied.
.PARAMETER DisplayName
Display name to set on the password credential. Defaults to 'CIPP-SSO-Secret'.
.PARAMETER MaxRetries
Number of secret-creation attempts before giving up. Defaults to 5.
Number of secret-creation attempts before giving up. Defaults to 6.
#>
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '')]
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$ObjectId,

[Parameter(Mandatory = $false)]
[string]$AppId,

[Parameter(Mandatory = $false)]
[string]$DisplayName = 'CIPP-SSO-Secret',

[Parameter(Mandatory = $false)]
[int]$MaxRetries = 5
[int]$MaxRetries = 6
)

# Update-AppManagementPolicy targets the app by appId/clientId; resolve it from the object id when not supplied.
if (-not $AppId) {
try {
$SSOApp = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/applications/$ObjectId`?`$select=id,appId" -NoAuthCheck $true -AsApp $true
$AppId = $SSOApp.appId
} catch {
Write-Warning "[SSO-Secret] Failed to resolve appId for objectId $ObjectId : $($_.Exception.Message)"
}
}

# Ensure the app is exempt from any credential-addition restriction before adding the secret.
if ($AppId) {
try {
$PolicyUpdate = Update-AppManagementPolicy -ApplicationId $AppId
Write-Information "[SSO-Secret] App management policy: $($PolicyUpdate.PolicyAction)"
} catch {
Write-Information "[SSO-Secret] Failed to update app management policy: $($_.Exception.Message)"
}
}

# Honour the tenant password-lifetime restriction (if enforced) when building the credential body.
$AppManagementPolicy = New-GraphGetRequest -uri 'https://graph.microsoft.com/v1.0/policies/defaultAppManagementPolicy' -AsApp $true -NoAuthCheck $true
$PasswordExpirationPolicy = $AppManagementPolicy.applicationRestrictions.passwordcredentials |
Where-Object { $_.restrictionType -eq 'passwordLifetime' }
if (-not ($PasswordExpirationPolicy.state -eq 'disabled' -or $null -eq $PasswordExpirationPolicy.state)) {
$TimeToExpiration = [System.Xml.XmlConvert]::ToTimeSpan($PasswordExpirationPolicy.maxLifetime)
$ExpirationDate = (Get-Date).AddDays($TimeToExpiration.Days).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
$PasswordBody = "{`"passwordCredential`":{`"displayName`":`"$DisplayName`",`"endDateTime`":`"$ExpirationDate`"}}"
} else {
$PasswordBody = "{`"passwordCredential`":{`"displayName`":`"$DisplayName`"}}"
}

$SecretText = $null
$SecretAttempt = 0
$BackoffSchedule = @(2, 5, 10, 15, 30)
$LastException = $null

while ($SecretAttempt -lt $MaxRetries -and -not $SecretText) {
for ($Attempt = 1; $Attempt -le $MaxRetries; $Attempt++) {
try {
$PasswordBody = @{ passwordCredential = @{ displayName = $DisplayName } } | ConvertTo-Json -Compress
$PasswordResult = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/applications/$ObjectId/addPassword" -body $PasswordBody -type POST -NoAuthCheck $true -AsApp $true
$PasswordResult = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/applications/$ObjectId/addPassword" -AsApp $true -NoAuthCheck $true -type POST -body $PasswordBody -maxRetries 3
$SecretText = $PasswordResult.secretText
Write-Information "[SSO-Secret] Client secret created on objectId $ObjectId"
break
} catch {
$SecretAttempt++
$LastException = $_
Write-Warning "[SSO-Secret] Secret creation attempt $SecretAttempt/$MaxRetries failed: $($_.Exception.Message)"
if ($SecretAttempt -lt $MaxRetries) {
$Delay = $BackoffSchedule[[Math]::Min($SecretAttempt - 1, $BackoffSchedule.Count - 1)]
Start-Sleep -Seconds $Delay
$ExceptionMessage = $_.Exception.Message
$IsNotReplicatedYet = $ExceptionMessage -match "Resource '.*' does not exist or one of its queried reference-property objects are not present"
$IsCredentialPolicyBlocked = $ExceptionMessage -match 'Credential type not allowed as per assigned policy'
Write-Warning "[SSO-Secret] Secret creation attempt $Attempt/$MaxRetries failed: $ExceptionMessage"

if ($IsNotReplicatedYet -and $Attempt -lt $MaxRetries) {
$DelaySeconds = 3
Write-Information "[SSO-Secret] Application object not yet replicated for addPassword (attempt $Attempt of $MaxRetries). Retrying in $DelaySeconds second(s)."
Start-Sleep -Seconds $DelaySeconds
continue
}

if ($IsCredentialPolicyBlocked -and $Attempt -lt $MaxRetries) {
$DelaySeconds = [Math]::Min(30, 5 * $Attempt)
Write-Information "[SSO-Secret] Credential policy still blocks addPassword (attempt $Attempt of $MaxRetries). Waiting for policy propagation and retrying in $DelaySeconds second(s)."
Start-Sleep -Seconds $DelaySeconds
continue
}

throw
}
}

Expand Down
29 changes: 29 additions & 0 deletions Modules/CIPPCore/Public/Set-CIPPMailboxType.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,35 @@ function Set-CIPPMailboxType {
if ([string]::IsNullOrWhiteSpace($Username)) { $Username = $UserID }
$null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-Mailbox' -cmdParams @{Identity = $UserID; Type = $MailboxType } -Anchor $Username
$Message = "Successfully converted $Username to a $MailboxType mailbox"

# When converting to a shared mailbox, surface the cached mailbox size if it exceeds the
# unlicensed shared-mailbox limit (50 GiB; we warn at 49 GiB). This is best-effort: any
# lookup failure or unexpected response shape falls through to the standard success message.
if ($MailboxType -eq 'Shared') {
try {
# 49 GiB warning threshold (shared mailboxes are capped at 50 GiB without a license)
$SharedMailboxWarnBytes = 49GB
# Resolve the partition key (defaultDomainName) the reporting DB is keyed on
$PartitionKey = (Get-Tenants -TenantFilter $TenantFilter).defaultDomainName
if ($PartitionKey) {
# Server-side point lookup for this specific mailbox only.
# Cached mailbox rows are keyed RowKey = 'Mailboxes-<EntraObjectId>'.
$Table = Get-CippTable -tablename 'CippReportingDB'
$Filter = "PartitionKey eq '{0}' and RowKey eq 'Mailboxes-{1}'" -f $PartitionKey, $UserID
$CachedMailbox = Get-CIPPAzDataTableEntity @Table -Filter $Filter | Select-Object -First 1
if ($CachedMailbox.Data) {
$StorageBytes = [int64]([string]($CachedMailbox.Data | ConvertFrom-Json).storageUsedInBytes)
if ($StorageBytes -ge $SharedMailboxWarnBytes) {
$StorageGB = [math]::Round($StorageBytes / 1GB, 1)
$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."
}
}
}
} catch {
# Best-effort size check only; ignore lookup/parse errors and return the standard message.
}
}

Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev 'Info' -tenant $TenantFilter
return $Message
} catch {
Expand Down
32 changes: 26 additions & 6 deletions Modules/CIPPCore/Public/Standards/Merge-CippStandards.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,34 @@ function Merge-CippStandards {

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

# Make sure both are arrays
if (-not $ExistingIsArray) { $Existing = @($Existing) }
if (-not $NewIsArray) { $New = @($New) }
$Deduped = [System.Collections.Generic.List[object]]::new()
$SeenValues = [System.Collections.Generic.HashSet[string]]::new()
# Walk newest-first so the most-specific tier wins for a given template, while
# Insert(0, ...) keeps the overall ordering stable.
for ($i = $Combined.Count - 1; $i -ge 0; $i--) {
$Item = $Combined[$i]
$TemplateValue = $Item.TemplateList.value
if ([string]::IsNullOrEmpty($TemplateValue)) {
$Deduped.Insert(0, $Item)
} elseif ($SeenValues.Add([string]$TemplateValue)) {
$Deduped.Insert(0, $Item)
}
}

return $Existing + $New
if ($Deduped.Count -eq 1) { return $Deduped[0] }
return $Deduped.ToArray()
} else {
# Single‐value standard: override the old with the new
return $New
Expand Down
Loading