Skip to content
21 changes: 0 additions & 21 deletions Config/AdditionalPermissions.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,26 +60,5 @@
"type": "Scope"
}
]
},
{
"resourceAppId": "00000003-0000-0000-c000-000000000000",
"resourceAccess": [
{
"id": "CopilotPolicySettings.ReadWrite",
"type": "Scope"
},
{
"id": "CopilotSettings-LimitedMode.ReadWrite",
"type": "Scope"
},
{
"id": "CopilotPackages.Read.All",
"type": "Scope"
},
{
"id": "CopilotPackages.ReadWrite.All",
"type": "Scope"
}
]
}
]
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