From 9445eed32cc4ad5c868a7dcac95f749cec449ae6 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 30 Jun 2026 00:34:01 +0800 Subject: [PATCH 01/12] Permission update fixes --- .../GraphHelper/Get-CippSamPermissions.ps1 | 76 +++++++--- .../SAMManifest/Update-CippSamPermissions.ps1 | 131 ++++++++---------- .../Settings/Invoke-ExecPermissionRepair.ps1 | 23 ++- 3 files changed, 132 insertions(+), 98 deletions(-) diff --git a/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 b/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 index 39b3e22721501..6408240f54042 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 @@ -12,9 +12,11 @@ function Get-CippSamPermissions { The effective set returned in .Permissions is therefore always manifest ∪ extras. Each permission is annotated with a 'required' boolean so the UI can lock the manifest-defined defaults. - Unless -NoDiff is used, the function also pulls the live CIPP-SAM application registration from the - partner tenant and diffs its requiredResourceAccess against the effective set, surfacing - permissions that need to be added to (MissingPermissions) and removed from (PartnerAppDiff) the app. + Unless -NoDiff is used, the function also reads what is actually granted on the CIPP-SAM enterprise + application (service principal) in the partner tenant - appRoleAssignments (application/Role) and + oauth2PermissionGrants (delegated/Scope) - and diffs those grants against the effective set, + surfacing permissions that need to be granted (MissingPermissions) and grants that are present but + not in the effective set (PartnerAppDiff). The app registration's requiredResourceAccess is not used. .EXAMPLE Get-CippSamPermissions @@ -195,38 +197,68 @@ function Get-CippSamPermissions { } } - # Diff the effective set against the live CIPP-SAM application registration in the partner tenant. - # MissingPermissions = effective perms not yet on the app (need to be added). - # PartnerAppDiff also surfaces extra perms on the app that are not in the effective set (need to be removed). + # Diff the effective set against what is actually GRANTED on the partner CIPP-SAM enterprise + # application (service principal): appRoleAssignments for application (Role) permissions and + # oauth2PermissionGrants for delegated (Scope) permissions. The app registration's + # requiredResourceAccess is intentionally NOT used - permissions are applied as SP grants, so the + # grants are the real source of truth for what the app can do. + # MissingPermissions = effective perms not yet granted on the SP (need to be added). + # PartnerAppDiff also surfaces extra grants on the SP that are not in the effective set. $MissingPermissions = @{} $PartnerAppDiff = @{} if (!$NoDiff.IsPresent) { try { - $PartnerApp = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/applications(appId='$($env:ApplicationID)')?`$select=requiredResourceAccess" -tenantid $env:TenantID -NoAuthCheck $true + $PartnerSP = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals(appId='$($env:ApplicationID)')?`$select=id" -tenantid $env:TenantID -NoAuthCheck $true + $AppRoleAssignments = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals/$($PartnerSP.id)/appRoleAssignments?`$top=999" -tenantid $env:TenantID -NoAuthCheck $true + $OAuthGrants = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals/$($PartnerSP.id)/oauth2PermissionGrants?`$top=999" -tenantid $env:TenantID -NoAuthCheck $true + + # Grants reference the resource SP's object id; map it back to the resource appId the + # effective set is keyed on. Use $UsedServicePrincipals - it carries both id and appId + # ($ServicePrincipals is selected without id, so its .id is null). + $ResourceIdToAppId = @{} + foreach ($SP in $UsedServicePrincipals) { if ($SP.id) { $ResourceIdToAppId[$SP.id] = $SP.appId } } + + # Granted application roles (GUIDs) per resource appId. + $GrantedRoleIdsByApp = @{} + foreach ($Assignment in $AppRoleAssignments) { + $ResAppId = $ResourceIdToAppId[$Assignment.resourceId] + if (!$ResAppId -or !$Assignment.appRoleId) { continue } + if (-not $GrantedRoleIdsByApp.ContainsKey($ResAppId)) { $GrantedRoleIdsByApp[$ResAppId] = [System.Collections.Generic.List[string]]::new() } + $GrantedRoleIdsByApp[$ResAppId].Add([string]$Assignment.appRoleId) + } + + # Granted delegated scope NAMES per resource appId (oauth2 grants store space-delimited names). + $GrantedScopesByApp = @{} + foreach ($Grant in $OAuthGrants) { + $ResAppId = $ResourceIdToAppId[$Grant.resourceId] + if (!$ResAppId) { continue } + if (-not $GrantedScopesByApp.ContainsKey($ResAppId)) { $GrantedScopesByApp[$ResAppId] = [System.Collections.Generic.List[string]]::new() } + foreach ($ScopeName in @(($Grant.scope -split ' ') | Where-Object { $_ })) { $GrantedScopesByApp[$ResAppId].Add($ScopeName) } + } + foreach ($AppId in $AllAppIds) { $ServicePrincipal = $ServicePrincipals | Where-Object -Property appId -EQ $AppId - $AppRegResource = $PartnerApp.requiredResourceAccess | Where-Object -Property resourceAppId -EQ $AppId - $AppRegRoleIds = @(($AppRegResource.resourceAccess | Where-Object { $_.type -eq 'Role' }).id) - $AppRegScopeIds = @(($AppRegResource.resourceAccess | Where-Object { $_.type -eq 'Scope' }).id) + $GrantedRoleIds = @($GrantedRoleIdsByApp[$AppId] | Where-Object { $_ }) + $GrantedScopeNames = @($GrantedScopesByApp[$AppId] | Where-Object { $_ }) - # Only GUID-based permissions live in the app registration's requiredResourceAccess. - # String-named scopes (e.g. the .Sdp AdditionalPermissions) are applied as direct grants, - # so excluding them here avoids permanent false-positive "missing" entries. + # Application (Role) permissions compare by GUID against appRoleAssignments. $EffApp = @($EffectivePermissions.$AppId.applicationPermissions | Where-Object { $_.id -match $GuidRegex }) - $EffDel = @($EffectivePermissions.$AppId.delegatedPermissions | Where-Object { $_.id -match $GuidRegex }) + # Delegated (Scope) permissions compare by NAME (value) against oauth2 grant scopes - + # this covers both GUID-resolved scopes and the string-named AdditionalPermissions. + $EffDel = @($EffectivePermissions.$AppId.delegatedPermissions) $EffAppIds = @($EffApp.id) - $EffDelIds = @($EffDel.id) + $EffDelNames = @($EffDel.value) - $MissingApp = @(foreach ($Permission in $EffApp) { if ($AppRegRoleIds -notcontains $Permission.id) { $Permission } }) - $MissingDel = @(foreach ($Permission in $EffDel) { if ($AppRegScopeIds -notcontains $Permission.id) { $Permission } }) - $ExtraApp = @(foreach ($Id in $AppRegRoleIds) { + $MissingApp = @(foreach ($Permission in $EffApp) { if ($GrantedRoleIds -notcontains $Permission.id) { $Permission } }) + $MissingDel = @(foreach ($Permission in $EffDel) { if ($Permission.value -and $GrantedScopeNames -notcontains $Permission.value) { $Permission } }) + $ExtraApp = @(foreach ($Id in ($GrantedRoleIds | Sort-Object -Unique)) { if ($EffAppIds -notcontains $Id) { [PSCustomObject]@{ id = $Id; value = (($ServicePrincipal.appRoles | Where-Object -Property id -EQ $Id).value) ?? $Id } } }) - $ExtraDel = @(foreach ($Id in $AppRegScopeIds) { - if ($EffDelIds -notcontains $Id) { - [PSCustomObject]@{ id = $Id; value = (($ServicePrincipal.publishedPermissionScopes | Where-Object -Property id -EQ $Id).value) ?? $Id } + $ExtraDel = @(foreach ($Name in ($GrantedScopeNames | Sort-Object -Unique)) { + if ($EffDelNames -notcontains $Name) { + [PSCustomObject]@{ id = $Name; value = $Name } } }) @@ -246,7 +278,7 @@ function Get-CippSamPermissions { } } } catch { - Write-Information "Failed to retrieve partner app registration for permission diff: $($_.Exception.Message)" + Write-Information "Failed to retrieve partner enterprise app grants for permission diff: $($_.Exception.Message)" } } diff --git a/Modules/CIPPCore/Public/SAMManifest/Update-CippSamPermissions.ps1 b/Modules/CIPPCore/Public/SAMManifest/Update-CippSamPermissions.ps1 index 226e9b9945422..7ed67f5be3111 100644 --- a/Modules/CIPPCore/Public/SAMManifest/Update-CippSamPermissions.ps1 +++ b/Modules/CIPPCore/Public/SAMManifest/Update-CippSamPermissions.ps1 @@ -1,15 +1,19 @@ function Update-CippSamPermissions { <# .SYNOPSIS - Repairs the CIPP-SAM app registration permissions in the partner tenant. + Reconciles the saved CIPP-SAM additional-permission set in the AppPermissions table. .DESCRIPTION - Diffs the effective CIPP-SAM permission set (manifest defaults + saved extras) against the live - CIPP-SAM application registration in the partner tenant and ADDS any missing permissions to the - app registration's requiredResourceAccess. This is additive only: it never removes permissions, - so it cannot strip a legitimately-configured entry. Extra permissions found on the app that are - not part of the effective set are reported back so an admin can review/remove them manually. + The SAM manifest is the immutable permission base and is always layered in at read time by + Get-CippSamPermissions, so the AppPermissions table only ever needs to hold the EXTRA + permissions an admin layered on top. This function keeps that row clean: it drops any saved + entries the manifest now covers (e.g. legacy rows that stored the full manifest+extras set) + so the table stays "extras only". - Pushing these permissions out to customer tenants is handled separately by the CPV refresh. + It deliberately does NOT write the partner CIPP-SAM app registration's requiredResourceAccess. + Permissions reach the CIPP-SAM service principal(s) - partner and clients - through the grant + flow (Add-CIPPApplicationPermission / Add-CIPPDelegatedPermission, which read this table), not + through the app registration. Refreshing those grants is handled by the caller + (Invoke-ExecPermissionRepair for the partner, the per-tenant permission refresh for clients). .PARAMETER UpdatedBy The user or system that is performing the update. Defaults to 'CIPP-API'. .OUTPUTS @@ -22,87 +26,70 @@ function Update-CippSamPermissions { ) try { - $CurrentPermissions = Get-CippSamPermissions - $PartnerAppDiff = $CurrentPermissions.PartnerAppDiff - $MissingPermissions = $CurrentPermissions.MissingPermissions + # Manifest base - always-required permissions that are layered in at read time, so they never + # need to live in the saved extras row. + $ManifestPermissions = (Get-CippSamPermissions -ManifestOnly).Permissions - $MissingAppIds = @($MissingPermissions.PSObject.Properties.Name) - $ExtraAppIds = @($PartnerAppDiff.PSObject.Properties.Name | Where-Object { - ($PartnerAppDiff.$_.extraApplicationPermissions | Measure-Object).Count -gt 0 -or - ($PartnerAppDiff.$_.extraDelegatedPermissions | Measure-Object).Count -gt 0 - }) - - if ($MissingAppIds.Count -eq 0) { - if ($ExtraAppIds.Count -gt 0) { - $ExtraSummary = foreach ($AppId in $ExtraAppIds) { - $Names = @($PartnerAppDiff.$AppId.extraApplicationPermissions.value) + @($PartnerAppDiff.$AppId.extraDelegatedPermissions.value) - "$AppId ($($Names -join ', '))" - } - return "No missing permissions to add. The following extra permissions are present on the app and should be reviewed/removed manually: $($ExtraSummary -join '; ')" - } - return 'No permissions to update' + $Table = Get-CIPPTable -TableName 'AppPermissions' + $SavedRow = Get-CippAzDataTableEntity @Table -Filter "PartitionKey eq 'CIPP-SAM' and RowKey eq 'CIPP-SAM'" + if (-not $SavedRow.Permissions) { + return 'No additional permissions saved. CIPP default (manifest) permissions are always applied.' } - # Retrieve the live CIPP-SAM application registration in the partner tenant. - $PartnerApp = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/applications(appId='$($env:ApplicationID)')?`$select=id,requiredResourceAccess" -tenantid $env:TenantID -NoAuthCheck $true - - $RequiredResourceAccess = [System.Collections.Generic.List[object]]::new() - foreach ($Resource in $PartnerApp.requiredResourceAccess) { - $ResourceAccess = [System.Collections.Generic.List[object]]::new() - foreach ($Access in $Resource.resourceAccess) { - $ResourceAccess.Add(@{ id = $Access.id; type = $Access.type }) - } - $RequiredResourceAccess.Add([PSCustomObject]@{ - resourceAppId = $Resource.resourceAppId - resourceAccess = $ResourceAccess - }) + try { + $Saved = $SavedRow.Permissions | ConvertFrom-Json -ErrorAction Stop + } catch { + return 'Saved additional permissions could not be parsed; nothing to reconcile.' } - $AddedPermissions = [System.Collections.Generic.List[string]]::new() - foreach ($AppId in $MissingAppIds) { - $Resource = $RequiredResourceAccess | Where-Object -Property resourceAppId -EQ $AppId | Select-Object -First 1 - if (!$Resource) { - $Resource = [PSCustomObject]@{ - resourceAppId = $AppId - resourceAccess = [System.Collections.Generic.List[object]]::new() + # Keep only the entries the manifest does NOT already cover. + $Extras = @{} + $RemovedCount = 0 + foreach ($AppId in $Saved.PSObject.Properties.Name) { + $ManifestApp = $ManifestPermissions.$AppId + $ManifestAppIds = @($ManifestApp.applicationPermissions.id) + $ManifestDelIds = @($ManifestApp.delegatedPermissions.id) + + $ExtraApp = [System.Collections.Generic.List[object]]::new() + foreach ($Permission in $Saved.$AppId.applicationPermissions) { + if ($Permission.id -and $ManifestAppIds -notcontains $Permission.id) { + $ExtraApp.Add([PSCustomObject]@{ id = $Permission.id; value = $Permission.value }) + } else { + $RemovedCount++ } - $RequiredResourceAccess.Add($Resource) } - $ExistingIds = @($Resource.resourceAccess.id) - - foreach ($Permission in $MissingPermissions.$AppId.applicationPermissions) { - if ($Permission.id -and $ExistingIds -notcontains $Permission.id) { - $Resource.resourceAccess.Add(@{ id = $Permission.id; type = 'Role' }) - $AddedPermissions.Add("$($Permission.value) (Application)") + $ExtraDel = [System.Collections.Generic.List[object]]::new() + foreach ($Permission in $Saved.$AppId.delegatedPermissions) { + if ($Permission.id -and $ManifestDelIds -notcontains $Permission.id) { + $ExtraDel.Add([PSCustomObject]@{ id = $Permission.id; value = $Permission.value }) + } else { + $RemovedCount++ } } - foreach ($Permission in $MissingPermissions.$AppId.delegatedPermissions) { - if ($Permission.id -and $ExistingIds -notcontains $Permission.id) { - $Resource.resourceAccess.Add(@{ id = $Permission.id; type = 'Scope' }) - $AddedPermissions.Add("$($Permission.value) (Delegated)") + + if ($ExtraApp.Count -gt 0 -or $ExtraDel.Count -gt 0) { + $Extras.$AppId = @{ + applicationPermissions = @($ExtraApp) + delegatedPermissions = @($ExtraDel) } } } - if ($AddedPermissions.Count -eq 0) { - return 'No permissions to update' + if ($RemovedCount -eq 0) { + return 'Saved additional permissions already reconciled; no manifest-covered entries to remove.' } - $PatchBody = @{ requiredResourceAccess = @($RequiredResourceAccess) } | ConvertTo-Json -Depth 10 -Compress - $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/applications/$($PartnerApp.id)" -tenantid $env:TenantID -body $PatchBody -type PATCH -NoAuthCheck $true - - Write-LogMessage -API 'UpdateCippSamPermissions' -message "CIPP-SAM app registration permissions repaired by $UpdatedBy" -Sev 'Info' -LogData @{ Added = $AddedPermissions } - - $Result = "Added $($AddedPermissions.Count) missing permission(s) to the CIPP-SAM app registration: $($AddedPermissions -join ', '). Run a CPV refresh to apply these to customer tenants." - if ($ExtraAppIds.Count -gt 0) { - $ExtraSummary = foreach ($AppId in $ExtraAppIds) { - $Names = @($PartnerAppDiff.$AppId.extraApplicationPermissions.value) + @($PartnerAppDiff.$AppId.extraDelegatedPermissions.value) - "$AppId ($($Names -join ', '))" - } - $Result += " Extra permissions present on the app that should be reviewed/removed manually: $($ExtraSummary -join '; ')." + $Entity = @{ + 'PartitionKey' = 'CIPP-SAM' + 'RowKey' = 'CIPP-SAM' + 'Permissions' = [string]([PSCustomObject]$Extras | ConvertTo-Json -Depth 10 -Compress) + 'UpdatedBy' = $UpdatedBy } - return $Result + $null = Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force + + $Plural = if ($RemovedCount -eq 1) { 'entry' } else { 'entries' } + return "Reconciled saved additional permissions: removed $RemovedCount $Plural now covered by the CIPP manifest." } catch { - throw "Failed to update permissions: $($_.Exception.Message)" + throw "Failed to reconcile permissions: $($_.Exception.Message)" } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecPermissionRepair.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecPermissionRepair.ps1 index 513e0bd5aca07..8cb5eb1cda8ef 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecPermissionRepair.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecPermissionRepair.ps1 @@ -1,9 +1,13 @@ function Invoke-ExecPermissionRepair { <# .SYNOPSIS - This endpoint will update the CIPP-SAM app permissions. + Reconciles the CIPP-SAM permissions and re-applies them to the partner service principal. .DESCRIPTION - Merges new permissions from the SAM manifest into the AppPermissions entry for CIPP-SAM. + Reconciles the saved additional-permission set (Update-CippSamPermissions), then refreshes the + grants on the CIPP-SAM service principal in the PARTNER tenant so the current effective set + (manifest + extras) is consented. This never writes the app registration's requiredResourceAccess; + permissions are applied as service-principal grants, the same way the routine refresh does. + Client tenants pick up the same effective set through their own permission refresh. .FUNCTIONALITY Entrypoint .ROLE @@ -14,8 +18,19 @@ function Invoke-ExecPermissionRepair { try { $User = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Request.Headers.'x-ms-client-principal')) | ConvertFrom-Json - $Result = Update-CippSamPermissions -UpdatedBy ($User.UserDetails ?? 'CIPP-API') - $Body = @{'Results' = $Result } + $UpdatedBy = $User.UserDetails ?? 'CIPP-API' + + # 1) Reconcile the saved extras table (no app-registration write). + $TableResult = Update-CippSamPermissions -UpdatedBy $UpdatedBy + + # 2) Refresh the grants on the partner CIPP-SAM service principal so the effective set + # (manifest + extras, read from the table) is actually consented on the SP. + $AppResults = Add-CIPPApplicationPermission -RequiredResourceAccess 'CIPPDefaults' -ApplicationId $env:ApplicationID -TenantFilter $env:TenantID + $DelegatedResults = Add-CIPPDelegatedPermission -RequiredResourceAccess 'CIPPDefaults' -ApplicationId $env:ApplicationID -TenantFilter $env:TenantID + + $Results = @($TableResult) + @($AppResults) + @($DelegatedResults) | Where-Object { $_ } + Write-LogMessage -Headers $Request.Headers -API 'ExecPermissionRepair' -message "CIPP-SAM permissions repaired by $UpdatedBy" -Sev 'Info' -LogData @{ Results = @($Results) } + $Body = @{'Results' = ($Results -join [Environment]::NewLine) } } catch { $Body = @{ 'Results' = "$($_.Exception.Message) - at line $($_.InvocationInfo.ScriptLineNumber)" From a9d5c4e0b9db3f9654cd4d6c920c54eeeea4ac1a Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 30 Jun 2026 17:56:44 -0400 Subject: [PATCH 02/12] chore: update version to 10.5.6 --- host.json | 4 ++-- version_latest.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/host.json b/host.json index 67e853b0f3cf9..cfa1e128d096c 100644 --- a/host.json +++ b/host.json @@ -16,9 +16,9 @@ "distributedTracingEnabled": false, "version": "None" }, - "defaultVersion": "10.5.5", + "defaultVersion": "10.5.6", "versionMatchStrategy": "Strict", "versionFailureStrategy": "Fail" } } -} +} \ No newline at end of file diff --git a/version_latest.txt b/version_latest.txt index 23b7528bc2089..3b24057083036 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.5.5 +10.5.6 From 5c2dc98a9d3dd71beb343662f8133d91e7e55047 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:56:59 +0800 Subject: [PATCH 03/12] Revert permission repair changes (AsApp grant application + Jun 30 rework) --- .../Settings/Invoke-ExecPermissionRepair.ps1 | 23 ++++--------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecPermissionRepair.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecPermissionRepair.ps1 index 8cb5eb1cda8ef..513e0bd5aca07 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecPermissionRepair.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecPermissionRepair.ps1 @@ -1,13 +1,9 @@ function Invoke-ExecPermissionRepair { <# .SYNOPSIS - Reconciles the CIPP-SAM permissions and re-applies them to the partner service principal. + This endpoint will update the CIPP-SAM app permissions. .DESCRIPTION - Reconciles the saved additional-permission set (Update-CippSamPermissions), then refreshes the - grants on the CIPP-SAM service principal in the PARTNER tenant so the current effective set - (manifest + extras) is consented. This never writes the app registration's requiredResourceAccess; - permissions are applied as service-principal grants, the same way the routine refresh does. - Client tenants pick up the same effective set through their own permission refresh. + Merges new permissions from the SAM manifest into the AppPermissions entry for CIPP-SAM. .FUNCTIONALITY Entrypoint .ROLE @@ -18,19 +14,8 @@ function Invoke-ExecPermissionRepair { try { $User = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Request.Headers.'x-ms-client-principal')) | ConvertFrom-Json - $UpdatedBy = $User.UserDetails ?? 'CIPP-API' - - # 1) Reconcile the saved extras table (no app-registration write). - $TableResult = Update-CippSamPermissions -UpdatedBy $UpdatedBy - - # 2) Refresh the grants on the partner CIPP-SAM service principal so the effective set - # (manifest + extras, read from the table) is actually consented on the SP. - $AppResults = Add-CIPPApplicationPermission -RequiredResourceAccess 'CIPPDefaults' -ApplicationId $env:ApplicationID -TenantFilter $env:TenantID - $DelegatedResults = Add-CIPPDelegatedPermission -RequiredResourceAccess 'CIPPDefaults' -ApplicationId $env:ApplicationID -TenantFilter $env:TenantID - - $Results = @($TableResult) + @($AppResults) + @($DelegatedResults) | Where-Object { $_ } - Write-LogMessage -Headers $Request.Headers -API 'ExecPermissionRepair' -message "CIPP-SAM permissions repaired by $UpdatedBy" -Sev 'Info' -LogData @{ Results = @($Results) } - $Body = @{'Results' = ($Results -join [Environment]::NewLine) } + $Result = Update-CippSamPermissions -UpdatedBy ($User.UserDetails ?? 'CIPP-API') + $Body = @{'Results' = $Result } } catch { $Body = @{ 'Results' = "$($_.Exception.Message) - at line $($_.InvocationInfo.ScriptLineNumber)" From 3d835b5dab4c1749da50cdbd15e37d0750d29634 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 2 Jul 2026 15:17:55 -0400 Subject: [PATCH 04/12] chore: bump version to 10.5.7 --- host.json | 4 ++-- version_latest.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/host.json b/host.json index cfa1e128d096c..2d527d8c41083 100644 --- a/host.json +++ b/host.json @@ -16,9 +16,9 @@ "distributedTracingEnabled": false, "version": "None" }, - "defaultVersion": "10.5.6", + "defaultVersion": "10.5.7", "versionMatchStrategy": "Strict", "versionFailureStrategy": "Fail" } } -} \ No newline at end of file +} diff --git a/version_latest.txt b/version_latest.txt index 3b24057083036..e9d57a4235a04 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.5.6 +10.5.7 From c4915b4f7498f6acbb0c14d18e2c95344588629a Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:19:26 +0800 Subject: [PATCH 05/12] Revert partner SP permission lookup and revert to table permissions list for setting CPV permissions --- .../GraphHelper/Get-CippSamPermissions.ps1 | 102 +++++------------- .../SAMManifest/Update-CippSamPermissions.ps1 | 84 +++++++-------- .../Settings/Invoke-ExecSAMAppPermissions.ps1 | 52 +++++---- 3 files changed, 103 insertions(+), 135 deletions(-) diff --git a/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 b/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 index 6408240f54042..4b01cda92a402 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 @@ -197,88 +197,44 @@ function Get-CippSamPermissions { } } - # Diff the effective set against what is actually GRANTED on the partner CIPP-SAM enterprise - # application (service principal): appRoleAssignments for application (Role) permissions and - # oauth2PermissionGrants for delegated (Scope) permissions. The app registration's - # requiredResourceAccess is intentionally NOT used - permissions are applied as SP grants, so the - # grants are the real source of truth for what the app can do. - # MissingPermissions = effective perms not yet granted on the SP (need to be added). - # PartnerAppDiff also surfaces extra grants on the SP that are not in the effective set. + # Diff the manifest-required base against the saved AppPermissions table. The table records what has + # been applied to the CIPP-SAM app - the repair/update flow persists it as manifest ∪ extras - so it + # stands in for the "current" permission set and no partner-tenant Graph call is needed here. + # MissingPermissions = manifest-required perms not yet present in the table (a Permissions repair is needed). + # PartnerAppDiff mirrors MissingPermissions in the shape the SAM permissions page expects. $MissingPermissions = @{} $PartnerAppDiff = @{} if (!$NoDiff.IsPresent) { - try { - $PartnerSP = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals(appId='$($env:ApplicationID)')?`$select=id" -tenantid $env:TenantID -NoAuthCheck $true - $AppRoleAssignments = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals/$($PartnerSP.id)/appRoleAssignments?`$top=999" -tenantid $env:TenantID -NoAuthCheck $true - $OAuthGrants = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals/$($PartnerSP.id)/oauth2PermissionGrants?`$top=999" -tenantid $env:TenantID -NoAuthCheck $true - - # Grants reference the resource SP's object id; map it back to the resource appId the - # effective set is keyed on. Use $UsedServicePrincipals - it carries both id and appId - # ($ServicePrincipals is selected without id, so its .id is null). - $ResourceIdToAppId = @{} - foreach ($SP in $UsedServicePrincipals) { if ($SP.id) { $ResourceIdToAppId[$SP.id] = $SP.appId } } - - # Granted application roles (GUIDs) per resource appId. - $GrantedRoleIdsByApp = @{} - foreach ($Assignment in $AppRoleAssignments) { - $ResAppId = $ResourceIdToAppId[$Assignment.resourceId] - if (!$ResAppId -or !$Assignment.appRoleId) { continue } - if (-not $GrantedRoleIdsByApp.ContainsKey($ResAppId)) { $GrantedRoleIdsByApp[$ResAppId] = [System.Collections.Generic.List[string]]::new() } - $GrantedRoleIdsByApp[$ResAppId].Add([string]$Assignment.appRoleId) - } + foreach ($AppId in $AllAppIds) { + $ManifestApp = $ManifestPermissions.$AppId + $SavedApp = $SavedPermissions.$AppId - # Granted delegated scope NAMES per resource appId (oauth2 grants store space-delimited names). - $GrantedScopesByApp = @{} - foreach ($Grant in $OAuthGrants) { - $ResAppId = $ResourceIdToAppId[$Grant.resourceId] - if (!$ResAppId) { continue } - if (-not $GrantedScopesByApp.ContainsKey($ResAppId)) { $GrantedScopesByApp[$ResAppId] = [System.Collections.Generic.List[string]]::new() } - foreach ($ScopeName in @(($Grant.scope -split ' ') | Where-Object { $_ })) { $GrantedScopesByApp[$ResAppId].Add($ScopeName) } - } + $SavedAppIds = @($SavedApp.applicationPermissions.id) + $SavedDelIds = @($SavedApp.delegatedPermissions.id) - foreach ($AppId in $AllAppIds) { - $ServicePrincipal = $ServicePrincipals | Where-Object -Property appId -EQ $AppId - $GrantedRoleIds = @($GrantedRoleIdsByApp[$AppId] | Where-Object { $_ }) - $GrantedScopeNames = @($GrantedScopesByApp[$AppId] | Where-Object { $_ }) - - # Application (Role) permissions compare by GUID against appRoleAssignments. - $EffApp = @($EffectivePermissions.$AppId.applicationPermissions | Where-Object { $_.id -match $GuidRegex }) - # Delegated (Scope) permissions compare by NAME (value) against oauth2 grant scopes - - # this covers both GUID-resolved scopes and the string-named AdditionalPermissions. - $EffDel = @($EffectivePermissions.$AppId.delegatedPermissions) - $EffAppIds = @($EffApp.id) - $EffDelNames = @($EffDel.value) - - $MissingApp = @(foreach ($Permission in $EffApp) { if ($GrantedRoleIds -notcontains $Permission.id) { $Permission } }) - $MissingDel = @(foreach ($Permission in $EffDel) { if ($Permission.value -and $GrantedScopeNames -notcontains $Permission.value) { $Permission } }) - $ExtraApp = @(foreach ($Id in ($GrantedRoleIds | Sort-Object -Unique)) { - if ($EffAppIds -notcontains $Id) { - [PSCustomObject]@{ id = $Id; value = (($ServicePrincipal.appRoles | Where-Object -Property id -EQ $Id).value) ?? $Id } - } - }) - $ExtraDel = @(foreach ($Name in ($GrantedScopeNames | Sort-Object -Unique)) { - if ($EffDelNames -notcontains $Name) { - [PSCustomObject]@{ id = $Name; value = $Name } - } - }) - - if ($MissingApp.Count -gt 0 -or $MissingDel.Count -gt 0) { - $MissingPermissions.$AppId = @{ - applicationPermissions = $MissingApp - delegatedPermissions = $MissingDel + $MissingApp = @(foreach ($Permission in $ManifestApp.applicationPermissions) { + if ($Permission.id -and $SavedAppIds -notcontains $Permission.id) { + [PSCustomObject]@{ id = $Permission.id; value = $Permission.value } } - } - if ($MissingApp.Count -gt 0 -or $MissingDel.Count -gt 0 -or $ExtraApp.Count -gt 0 -or $ExtraDel.Count -gt 0) { - $PartnerAppDiff.$AppId = @{ - missingApplicationPermissions = $MissingApp - missingDelegatedPermissions = $MissingDel - extraApplicationPermissions = $ExtraApp - extraDelegatedPermissions = $ExtraDel + }) + $MissingDel = @(foreach ($Permission in $ManifestApp.delegatedPermissions) { + if ($Permission.id -and $SavedDelIds -notcontains $Permission.id) { + [PSCustomObject]@{ id = $Permission.id; value = $Permission.value } } + }) + + if ($MissingApp.Count -gt 0 -or $MissingDel.Count -gt 0) { + $MissingPermissions.$AppId = @{ + applicationPermissions = $MissingApp + delegatedPermissions = $MissingDel + } + $PartnerAppDiff.$AppId = @{ + missingApplicationPermissions = $MissingApp + missingDelegatedPermissions = $MissingDel + extraApplicationPermissions = @() + extraDelegatedPermissions = @() } } - } catch { - Write-Information "Failed to retrieve partner enterprise app grants for permission diff: $($_.Exception.Message)" } } diff --git a/Modules/CIPPCore/Public/SAMManifest/Update-CippSamPermissions.ps1 b/Modules/CIPPCore/Public/SAMManifest/Update-CippSamPermissions.ps1 index 7ed67f5be3111..bad4157e0f263 100644 --- a/Modules/CIPPCore/Public/SAMManifest/Update-CippSamPermissions.ps1 +++ b/Modules/CIPPCore/Public/SAMManifest/Update-CippSamPermissions.ps1 @@ -1,19 +1,18 @@ function Update-CippSamPermissions { <# .SYNOPSIS - Reconciles the saved CIPP-SAM additional-permission set in the AppPermissions table. + Reconciles the applied CIPP-SAM permission set in the AppPermissions table. .DESCRIPTION - The SAM manifest is the immutable permission base and is always layered in at read time by - Get-CippSamPermissions, so the AppPermissions table only ever needs to hold the EXTRA - permissions an admin layered on top. This function keeps that row clean: it drops any saved - entries the manifest now covers (e.g. legacy rows that stored the full manifest+extras set) - so the table stays "extras only". + Writes the full applied permission set - the SAM manifest base PLUS any admin-configured extra + permissions - into the AppPermissions table, so the table always reflects everything the + CIPP-SAM app is expected to have. Get-CippSamPermissions diffs the manifest against this table + to decide when a Permissions repair is needed, so persisting the manifest here is what lets that + check clear after a repair. It deliberately does NOT write the partner CIPP-SAM app registration's requiredResourceAccess. Permissions reach the CIPP-SAM service principal(s) - partner and clients - through the grant flow (Add-CIPPApplicationPermission / Add-CIPPDelegatedPermission, which read this table), not - through the app registration. Refreshing those grants is handled by the caller - (Invoke-ExecPermissionRepair for the partner, the per-tenant permission refresh for clients). + through the app registration. .PARAMETER UpdatedBy The user or system that is performing the update. Defaults to 'CIPP-API'. .OUTPUTS @@ -26,69 +25,68 @@ function Update-CippSamPermissions { ) try { - # Manifest base - always-required permissions that are layered in at read time, so they never - # need to live in the saved extras row. + # Manifest base - the always-required permissions. $ManifestPermissions = (Get-CippSamPermissions -ManifestOnly).Permissions $Table = Get-CIPPTable -TableName 'AppPermissions' $SavedRow = Get-CippAzDataTableEntity @Table -Filter "PartitionKey eq 'CIPP-SAM' and RowKey eq 'CIPP-SAM'" - if (-not $SavedRow.Permissions) { - return 'No additional permissions saved. CIPP default (manifest) permissions are always applied.' - } - - try { - $Saved = $SavedRow.Permissions | ConvertFrom-Json -ErrorAction Stop - } catch { - return 'Saved additional permissions could not be parsed; nothing to reconcile.' + $Saved = $null + if ($SavedRow.Permissions) { + try { + $Saved = $SavedRow.Permissions | ConvertFrom-Json -ErrorAction Stop + } catch { + $Saved = $null + } } - # Keep only the entries the manifest does NOT already cover. - $Extras = @{} - $RemovedCount = 0 - foreach ($AppId in $Saved.PSObject.Properties.Name) { + # Build the full applied set = manifest base ∪ admin extras, keyed by resource appId. + $Applied = @{} + $AppIds = @(@($ManifestPermissions.PSObject.Properties.Name) + @($Saved.PSObject.Properties.Name)) | Where-Object { $_ } | Sort-Object -Unique + foreach ($AppId in $AppIds) { $ManifestApp = $ManifestPermissions.$AppId + $SavedApp = $Saved.$AppId $ManifestAppIds = @($ManifestApp.applicationPermissions.id) $ManifestDelIds = @($ManifestApp.delegatedPermissions.id) - $ExtraApp = [System.Collections.Generic.List[object]]::new() - foreach ($Permission in $Saved.$AppId.applicationPermissions) { + $AppPerms = [System.Collections.Generic.List[object]]::new() + $DelPerms = [System.Collections.Generic.List[object]]::new() + + # Manifest base (always applied). + foreach ($Permission in $ManifestApp.applicationPermissions) { + $AppPerms.Add([PSCustomObject]@{ id = $Permission.id; value = $Permission.value }) + } + foreach ($Permission in $ManifestApp.delegatedPermissions) { + $DelPerms.Add([PSCustomObject]@{ id = $Permission.id; value = $Permission.value }) + } + # Admin extras (anything the manifest does not already cover). + foreach ($Permission in $SavedApp.applicationPermissions) { if ($Permission.id -and $ManifestAppIds -notcontains $Permission.id) { - $ExtraApp.Add([PSCustomObject]@{ id = $Permission.id; value = $Permission.value }) - } else { - $RemovedCount++ + $AppPerms.Add([PSCustomObject]@{ id = $Permission.id; value = $Permission.value }) } } - $ExtraDel = [System.Collections.Generic.List[object]]::new() - foreach ($Permission in $Saved.$AppId.delegatedPermissions) { + foreach ($Permission in $SavedApp.delegatedPermissions) { if ($Permission.id -and $ManifestDelIds -notcontains $Permission.id) { - $ExtraDel.Add([PSCustomObject]@{ id = $Permission.id; value = $Permission.value }) - } else { - $RemovedCount++ + $DelPerms.Add([PSCustomObject]@{ id = $Permission.id; value = $Permission.value }) } } - if ($ExtraApp.Count -gt 0 -or $ExtraDel.Count -gt 0) { - $Extras.$AppId = @{ - applicationPermissions = @($ExtraApp) - delegatedPermissions = @($ExtraDel) + if ($AppPerms.Count -gt 0 -or $DelPerms.Count -gt 0) { + $Applied.$AppId = @{ + applicationPermissions = @($AppPerms) + delegatedPermissions = @($DelPerms) } } } - if ($RemovedCount -eq 0) { - return 'Saved additional permissions already reconciled; no manifest-covered entries to remove.' - } - $Entity = @{ 'PartitionKey' = 'CIPP-SAM' 'RowKey' = 'CIPP-SAM' - 'Permissions' = [string]([PSCustomObject]$Extras | ConvertTo-Json -Depth 10 -Compress) + 'Permissions' = [string]([PSCustomObject]$Applied | ConvertTo-Json -Depth 10 -Compress) 'UpdatedBy' = $UpdatedBy } $null = Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force - $Plural = if ($RemovedCount -eq 1) { 'entry' } else { 'entries' } - return "Reconciled saved additional permissions: removed $RemovedCount $Plural now covered by the CIPP manifest." + return 'CIPP-SAM permissions reconciled: the applied permission table now contains the CIPP manifest permissions plus any additional permissions.' } catch { throw "Failed to reconcile permissions: $($_.Exception.Message)" } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecSAMAppPermissions.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecSAMAppPermissions.ps1 index 9c7885ab908a1..0a19eeb588041 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecSAMAppPermissions.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecSAMAppPermissions.ps1 @@ -16,27 +16,41 @@ function Invoke-ExecSAMAppPermissions { $Submitted = $Request.Body.Permissions $ManifestPermissions = (Get-CippSamPermissions -ManifestOnly).Permissions - $Extras = @{} - foreach ($AppId in $Submitted.PSObject.Properties.Name) { + # Persist the full applied set = manifest base ∪ submitted extras, so the AppPermissions + # table always reflects everything the CIPP-SAM app should have (the manifest is always + # applied and cannot be removed). Get-CippSamPermissions diffs the manifest against this + # table to decide when a Permissions repair is needed. + $Applied = @{} + $AppIds = @(@($ManifestPermissions.PSObject.Properties.Name) + @($Submitted.PSObject.Properties.Name)) | Where-Object { $_ } | Sort-Object -Unique + foreach ($AppId in $AppIds) { $ManifestApp = $ManifestPermissions.$AppId $ManifestAppIds = @($ManifestApp.applicationPermissions.id) $ManifestDelIds = @($ManifestApp.delegatedPermissions.id) - $ExtraApp = @(foreach ($Permission in $Submitted.$AppId.applicationPermissions) { - if ($Permission.id -and $ManifestAppIds -notcontains $Permission.id) { - [PSCustomObject]@{ id = $Permission.id; value = $Permission.value } - } - }) - $ExtraDel = @(foreach ($Permission in $Submitted.$AppId.delegatedPermissions) { - if ($Permission.id -and $ManifestDelIds -notcontains $Permission.id) { - [PSCustomObject]@{ id = $Permission.id; value = $Permission.value } - } - }) + $AppPerms = [System.Collections.Generic.List[object]]::new() + $DelPerms = [System.Collections.Generic.List[object]]::new() - if ($ExtraApp.Count -gt 0 -or $ExtraDel.Count -gt 0) { - $Extras.$AppId = @{ - applicationPermissions = $ExtraApp - delegatedPermissions = $ExtraDel + foreach ($Permission in $ManifestApp.applicationPermissions) { + $AppPerms.Add([PSCustomObject]@{ id = $Permission.id; value = $Permission.value }) + } + foreach ($Permission in $ManifestApp.delegatedPermissions) { + $DelPerms.Add([PSCustomObject]@{ id = $Permission.id; value = $Permission.value }) + } + foreach ($Permission in $Submitted.$AppId.applicationPermissions) { + if ($Permission.id -and $ManifestAppIds -notcontains $Permission.id) { + $AppPerms.Add([PSCustomObject]@{ id = $Permission.id; value = $Permission.value }) + } + } + foreach ($Permission in $Submitted.$AppId.delegatedPermissions) { + if ($Permission.id -and $ManifestDelIds -notcontains $Permission.id) { + $DelPerms.Add([PSCustomObject]@{ id = $Permission.id; value = $Permission.value }) + } + } + + if ($AppPerms.Count -gt 0 -or $DelPerms.Count -gt 0) { + $Applied.$AppId = @{ + applicationPermissions = @($AppPerms) + delegatedPermissions = @($DelPerms) } } } @@ -44,15 +58,15 @@ function Invoke-ExecSAMAppPermissions { $Entity = @{ 'PartitionKey' = 'CIPP-SAM' 'RowKey' = 'CIPP-SAM' - 'Permissions' = [string]([PSCustomObject]$Extras | ConvertTo-Json -Depth 10 -Compress) + 'Permissions' = [string]([PSCustomObject]$Applied | ConvertTo-Json -Depth 10 -Compress) 'UpdatedBy' = $User.UserDetails ?? 'CIPP-API' } $Table = Get-CIPPTable -TableName 'AppPermissions' $null = Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force $Body = @{ - 'Results' = 'Additional permissions updated. Default CIPP permissions are always applied and cannot be removed. Please run a Permissions check and CPV refresh to finalise the changes.' + 'Results' = 'Permissions updated. Default CIPP permissions are always applied and cannot be removed. Please run a Permissions check and CPV refresh to finalise the changes.' } - Write-LogMessage -headers $Request.Headers -API 'ExecSAMAppPermissions' -message 'CIPP-SAM additional permissions updated' -Sev 'Info' -LogData $Extras + Write-LogMessage -headers $Request.Headers -API 'ExecSAMAppPermissions' -message 'CIPP-SAM permissions updated' -Sev 'Info' -LogData $Applied } catch { $Body = @{ 'Results' = $_.Exception.Message From 0ec258d90ca33ae06cb2b0183bb55e0009dd1ace Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 3 Jul 2026 08:44:23 -0400 Subject: [PATCH 06/12] chore: update version to 10.5.8 --- host.json | 2 +- version_latest.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/host.json b/host.json index 2d527d8c41083..9de15356dcb97 100644 --- a/host.json +++ b/host.json @@ -16,7 +16,7 @@ "distributedTracingEnabled": false, "version": "None" }, - "defaultVersion": "10.5.7", + "defaultVersion": "10.5.8", "versionMatchStrategy": "Strict", "versionFailureStrategy": "Fail" } diff --git a/version_latest.txt b/version_latest.txt index e9d57a4235a04..0b09579a68d16 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.5.7 +10.5.8 From 06d0bd80f12167223b1fd2e5b12cce7e27e723bf Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 10 Jul 2026 10:03:03 -0400 Subject: [PATCH 07/12] feat: add functions to manage SharePoint external users and site user removal --- .../Public/Get-CIPPSPOExternalUsers.ps1 | 56 ++++++ .../Public/Remove-CIPPSPOSiteUser.ps1 | 74 ++++++++ .../Invoke-ExecRemoveSPOExternalUser.ps1 | 85 +++++++++ .../Invoke-ExecRemoveSiteUser.ps1 | 46 ++--- .../Invoke-ListSharePointExternalUsers.ps1 | 164 ++++++++++++++++++ 5 files changed, 397 insertions(+), 28 deletions(-) create mode 100644 Modules/CIPPCore/Public/Get-CIPPSPOExternalUsers.ps1 create mode 100644 Modules/CIPPCore/Public/Remove-CIPPSPOSiteUser.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSPOExternalUser.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharePointExternalUsers.ps1 diff --git a/Modules/CIPPCore/Public/Get-CIPPSPOExternalUsers.ps1 b/Modules/CIPPCore/Public/Get-CIPPSPOExternalUsers.ps1 new file mode 100644 index 0000000000000..522f788fed179 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPSPOExternalUsers.ps1 @@ -0,0 +1,56 @@ +function Get-CIPPSPOExternalUsers { + <# + .SYNOPSIS + List the SharePoint tenant external users store via CSOM + + .DESCRIPTION + Enumerates every external user SharePoint Online knows about (the store behind + Get-SPOExternalUser), using the CSOM Office365Tenant.GetExternalUsers method against the + admin endpoint. This includes legacy email-authenticated guests (urn:spo:guest) that have + no backing Entra object, and B2B guests whose Entra user may since have been deleted. + + .PARAMETER TenantFilter + Tenant to query + + .EXAMPLE + Get-CIPPSPOExternalUsers -TenantFilter 'contoso.onmicrosoft.com' + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter + ) + + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $AdminUrl = $SharePointInfo.AdminUrl + $AdditionalHeaders = @{ 'Accept' = 'application/json;odata=verbose' } + + $AllUsers = [System.Collections.Generic.List[object]]::new() + $Position = 0 + $PageSize = 50 + + do { + # Office365Tenant (TenantManagement) constructor -> GetExternalUsers(position, pageSize, filter, sortOrder) + $XML = @" +$Position$PageSize0 +"@ + + $Results = New-GraphPostRequest -scope "$AdminUrl/.default" -tenantid $TenantFilter -Uri "$AdminUrl/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AddedHeaders $AdditionalHeaders + + $CsomError = ($Results | Where-Object { $_.ErrorInfo } | Select-Object -First 1).ErrorInfo.ErrorMessage + if ($CsomError) { throw $CsomError } + + $ResultObject = $Results | Where-Object { $null -ne $_.TotalUserCount } | Select-Object -First 1 + $Batch = @($ResultObject.ExternalUserCollection._Child_Items_) + foreach ($User in $Batch) { + [void]$AllUsers.Add($User) + } + $Position = $ResultObject.UserCollectionPosition + # Continue while SPO reports more users beyond the current position. + } while ($Batch.Count -eq $PageSize -and $Position -ge 0 -and $AllUsers.Count -lt [int]$ResultObject.TotalUserCount) + + return $AllUsers +} diff --git a/Modules/CIPPCore/Public/Remove-CIPPSPOSiteUser.ps1 b/Modules/CIPPCore/Public/Remove-CIPPSPOSiteUser.ps1 new file mode 100644 index 0000000000000..f62defdce7a55 --- /dev/null +++ b/Modules/CIPPCore/Public/Remove-CIPPSPOSiteUser.ps1 @@ -0,0 +1,74 @@ +function Remove-CIPPSPOSiteUser { + <# + .SYNOPSIS + Remove a user from one or more SharePoint sites entirely + + .DESCRIPTION + Deletes the user from each site's user list via the SharePoint REST API with certificate + authentication, which removes them from every site group and direct permission grant on + that site at once. Site collection admins are refused (remove their admin flag first). + + .PARAMETER TenantFilter + Tenant the sites belong to + + .PARAMETER SiteUrls + One or more site URLs to remove the user from + + .PARAMETER LoginName + SharePoint claims login (i:0#.f|membership|upn); a bare UPN is converted automatically + + .EXAMPLE + Remove-CIPPSPOSiteUser -TenantFilter 'contoso.onmicrosoft.com' -SiteUrls @('https://contoso.sharepoint.com/sites/HR') -LoginName 'guest_example.com#ext#@contoso.onmicrosoft.com' + + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [Parameter(Mandatory = $true)] + [string[]]$SiteUrls, + [Parameter(Mandatory = $true)] + [string]$LoginName + ) + + if ($LoginName -notmatch '\|') { $LoginName = "i:0#.f|membership|$LoginName" } + + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $Scope = "$($SharePointInfo.SharePointUrl)/.default" + $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } + + $Succeeded = [System.Collections.Generic.List[string]]::new() + $Failed = [System.Collections.Generic.List[string]]::new() + + foreach ($SiteUrl in $SiteUrls) { + if (-not $PSCmdlet.ShouldProcess($SiteUrl, "Remove $LoginName")) { continue } + $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + try { + try { + $EnsureBody = ConvertTo-Json -Compress -InputObject @{ logonName = $LoginName } + $EnsuredUser = New-GraphPostRequest -uri "$BaseUri/web/ensureuser" -tenantid $TenantFilter -scope $Scope -type POST -body $EnsureBody -contentType 'application/json;odata=nometadata' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true + } catch { + throw "could not resolve the user (ensureuser): $($_.Exception.Message)" + } + if (-not $EnsuredUser.Id) { throw 'could not resolve the user on the site.' } + if ($EnsuredUser.IsSiteAdmin) { + throw 'user is a site collection admin; remove their admin permission first (Remove Site Admin action).' + } + try { + $null = New-GraphPostRequest -uri "$BaseUri/web/siteusers/removebyid($($EnsuredUser.Id))" -tenantid $TenantFilter -scope $Scope -type POST -body '{}' -contentType 'application/json;odata=nometadata' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true + } catch { + throw "removal failed: $($_.Exception.Message)" + } + $Succeeded.Add($SiteUrl) + } catch { + $Failed.Add("$($SiteUrl): $($_.Exception.Message)") + } + } + + return [PSCustomObject]@{ + Succeeded = @($Succeeded) + Failed = @($Failed) + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSPOExternalUser.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSPOExternalUser.ps1 new file mode 100644 index 0000000000000..945cd60f15cdf --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSPOExternalUser.ps1 @@ -0,0 +1,85 @@ +function Invoke-ExecRemoveSPOExternalUser { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.ReadWrite + .DESCRIPTION + Fully removes an external user's guest access: deletes their Entra guest account (when + one exists) AND removes them from every SharePoint site they hold membership on, in one + pass - so no orphaned accounts or lingering site access are left behind. The inert + SharePoint external-store entry cannot be deleted (Microsoft deprecated + RemoveExternalUsers) and ages out on its own; sharing links the user received are + revoked separately via the Sharing Report. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Body.tenantFilter + $EntraUserId = $Request.Body.EntraUserId + $LoginName = $Request.Body.LoginName + $SiteUrls = @($Request.Body.SiteUrls) | Where-Object { $_ } + $DisplayName = $Request.Body.DisplayName ?? $EntraUserId ?? $LoginName + + try { + if (-not $EntraUserId -and $SiteUrls.Count -eq 0) { + throw 'This entry has no Entra guest account and no known site memberships. The remaining SharePoint store entry cannot be removed (Microsoft deprecated the API) and ages out on its own; revoke any sharing links they hold via the Sharing Report.' + } + + $Messages = [System.Collections.Generic.List[string]]::new() + $Errors = [System.Collections.Generic.List[string]]::new() + + # 1. Strip the SharePoint footprint first (needs the login; falls back to the Entra UPN). + if ($SiteUrls.Count -gt 0) { + $RemovalLogin = $LoginName + if (-not $RemovalLogin -and $EntraUserId) { + try { + $RemovalLogin = (New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/users/$($EntraUserId)?`$select=userPrincipalName" -tenantid $TenantFilter -AsApp $true).userPrincipalName + } catch { + $Errors.Add("Could not resolve the user's login for site removal: $($_.Exception.Message)") + } + } + if ($RemovalLogin) { + $Removal = Remove-CIPPSPOSiteUser -TenantFilter $TenantFilter -SiteUrls $SiteUrls -LoginName $RemovalLogin + if ($Removal.Succeeded.Count -gt 0) { + $Messages.Add("Removed from $($Removal.Succeeded.Count) site(s): $($Removal.Succeeded -join ', ').") + } + if ($Removal.Failed.Count -gt 0) { + $Errors.Add("Site removal failed on: $($Removal.Failed -join '; ')") + } + } + } + + # 2. Delete the Entra guest account so the user cannot sign in anywhere. + if ($EntraUserId) { + try { + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/users/$EntraUserId" -tenantid $TenantFilter -type DELETE -body '' -asapp $true + $Messages.Add('Deleted the Entra guest account, blocking their sign-in.') + } catch { + $Errors.Add("Deleting the Entra guest account failed: $($_.Exception.Message)") + } + } + + if ($Messages.Count -eq 0) { + throw ($Errors -join ' ') + } + $Results = "Removed guest access for $($DisplayName): $($Messages -join ' ')" + if ($Errors.Count -gt 0) { + $Results += " Issues: $($Errors -join '; ')" + } + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Results = "Failed to remove guest access for $($DisplayName): $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ 'Results' = $Results } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSiteUser.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSiteUser.ps1 index 693e8e993b7ab..a2315847ea6ae 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSiteUser.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSiteUser.ps1 @@ -5,10 +5,10 @@ function Invoke-ExecRemoveSiteUser { .ROLE Sharepoint.Site.ReadWrite .DESCRIPTION - Removes a user from an entire SharePoint site: deleting the site user removes them - from every site group and direct permission grant at once. Uses the SharePoint REST - API with certificate authentication. Note this does not revoke sharing links the user - received by mail; use the sharing link actions for those. + Removes a user from one or more SharePoint sites entirely: deleting the site user + removes them from every site group and direct permission grant at once. Uses the + SharePoint REST API with certificate authentication. Note this does not revoke + sharing links the user received by mail; use the sharing link actions for those. #> [CmdletBinding()] param($Request, $TriggerMetadata) @@ -16,44 +16,34 @@ function Invoke-ExecRemoveSiteUser { $APIName = $Request.Params.CIPPEndpoint $Headers = $Request.Headers $TenantFilter = $Request.Body.tenantFilter - $SiteUrl = $Request.Body.SiteUrl + # Single site (SiteUrl) or several at once (SiteUrls, e.g. from the External Users report). + $SiteUrls = @($Request.Body.SiteUrls ?? $Request.Body.SiteUrl) | Where-Object { $_ } # The picker supplies the SP claims login via addedFields; fall back to building it from the UPN. $LoginName = $Request.Body.user.addedFields.LoginName ?? $Request.Body.user.value - $Label = $Request.Body.user.value ?? $LoginName + $Label = $Request.Body.DisplayName ?? $Request.Body.user.value ?? $LoginName try { - if (-not $SiteUrl) { throw 'SiteUrl is required.' } + if ($SiteUrls.Count -eq 0) { throw 'SiteUrl is required.' } if (-not $LoginName) { throw 'No user was selected.' } - if ($LoginName -notmatch '\|') { $LoginName = "i:0#.f|membership|$LoginName" } - $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter - $Scope = "$($SharePointInfo.SharePointUrl)/.default" - $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } - $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + $Removal = Remove-CIPPSPOSiteUser -TenantFilter $TenantFilter -SiteUrls $SiteUrls -LoginName $LoginName - try { - $EnsureBody = ConvertTo-Json -Compress -InputObject @{ logonName = $LoginName } - $EnsuredUser = New-GraphPostRequest -uri "$BaseUri/web/ensureuser" -tenantid $TenantFilter -scope $Scope -type POST -body $EnsureBody -contentType 'application/json;odata=nometadata' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true - } catch { - throw "Could not resolve $Label on the site (ensureuser): $($_.Exception.Message)" + $Messages = [System.Collections.Generic.List[string]]::new() + if ($Removal.Succeeded.Count -gt 0) { + $Messages.Add("Successfully removed $Label (all site groups and direct permissions) from: $($Removal.Succeeded -join ', ').") } - if (-not $EnsuredUser.Id) { throw "Could not resolve $Label on the site." } - if ($EnsuredUser.IsSiteAdmin) { - throw "$Label is a site collection admin. Remove their admin permission first (Remove Site Admin action)." + if ($Removal.Failed.Count -gt 0) { + $Messages.Add("Failed on: $($Removal.Failed -join '; ')") } - - try { - $null = New-GraphPostRequest -uri "$BaseUri/web/siteusers/removebyid($($EnsuredUser.Id))" -tenantid $TenantFilter -scope $Scope -type POST -body '{}' -contentType 'application/json;odata=nometadata' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true - } catch { - throw "Could not remove $Label from the site: $($_.Exception.Message)" + $Results = $Messages -join ' ' + if ($Removal.Succeeded.Count -eq 0) { + throw $Results } - - $Results = "Successfully removed $Label from $SiteUrl (all site groups and direct permissions)." Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info $StatusCode = [HttpStatusCode]::OK } catch { $ErrorMessage = Get-CippException -Exception $_ - $Results = "Failed to remove $Label from $($SiteUrl): $($ErrorMessage.NormalizedError)" + $Results = "Failed to remove $Label from the selected site(s): $($ErrorMessage.NormalizedError)" Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Error -LogData $ErrorMessage $StatusCode = [HttpStatusCode]::BadRequest } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharePointExternalUsers.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharePointExternalUsers.ps1 new file mode 100644 index 0000000000000..803dfa847989b --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharePointExternalUsers.ps1 @@ -0,0 +1,164 @@ +function Invoke-ListSharePointExternalUsers { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.Read + .DESCRIPTION + Lists external/guest users known to SharePoint from two sources: the tenant external + users store (populated when a guest first redeems a share) and a sweep of every site's + user list (which also catches guests who were granted membership but never signed in). + Every entry is classified against Entra: 'Entra B2B' (live Entra guest), 'Orphaned B2B' + (the Entra guest was deleted but SharePoint still references them) or 'SharePoint-only' + (legacy email-authenticated guest that never had an Entra object). + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter + + # CSOM verbose JSON dates arrive as '/Date(year,month,day,h,m,s,ms)/' with a 0-based month. + function ConvertFrom-CsomDate($Value) { + if ("$Value" -match '/Date\((\d+),(\d+),(\d+),(\d+),(\d+),(\d+),(\d+)\)/') { + return ([datetime]::new([int]$Matches[1], ([int]$Matches[2] + 1), [int]$Matches[3], [int]$Matches[4], [int]$Matches[5], [int]$Matches[6], [System.DateTimeKind]::Utc)).ToString('yyyy-MM-ddTHH:mm:ssZ') + } + return $Value + } + + try { + # --- Source 1: tenant external users store --- + try { + $StoreUsers = @(Get-CIPPSPOExternalUsers -TenantFilter $TenantFilter) + } catch { + throw "Could not enumerate the SharePoint external users store: $($_.Exception.Message)" + } + + # --- Source 2: guest entries in every site's user list --- + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $Scope = "$($SharePointInfo.SharePointUrl)/.default" + $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } + $SiteGuests = @{} + try { + # NB: getAllSites returns an empty set when $select is combined with this $filter. + $Sites = @((New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/sites/getAllSites?`$filter=isPersonalSite eq false&`$top=999" -tenantid $TenantFilter -AsApp $true).webUrl) | Where-Object { $_ } + } catch { + $Sites = @() + Write-Information "Site enumeration failed; external users report is store-only: $($_.Exception.Message)" + } + foreach ($SiteUrl in $Sites) { + try { + $Users = New-GraphGetRequest -uri "$($SiteUrl.TrimEnd('/'))/_api/web/siteusers?`$select=Title,Email,LoginName,IsShareByEmailGuestUser,IsEmailAuthenticationGuestUser" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true + } catch { + continue # sites the app cannot reach (locked etc.) are skipped + } + foreach ($User in $Users) { + $IsGuest = [bool]$User.IsShareByEmailGuestUser -or [bool]$User.IsEmailAuthenticationGuestUser -or $User.LoginName -match '(?i)#ext#|urn%3aspo%3aguest' + if (-not $IsGuest) { continue } + $Key = ($User.LoginName -split '\|')[-1].ToLower() + if (-not $SiteGuests.ContainsKey($Key)) { + $SiteGuests[$Key] = [PSCustomObject]@{ + Title = $User.Title + Email = $User.Email + LoginName = $User.LoginName + Sites = [System.Collections.Generic.List[string]]::new() + } + } + [void]$SiteGuests[$Key].Sites.Add($SiteUrl) + } + } + + # --- Entra guest sweep for the join --- + $EntraGuests = @() + if ($StoreUsers.Count -gt 0 -or $SiteGuests.Count -gt 0) { + try { + $EntraGuests = @(New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/users?`$filter=userType eq 'Guest'&`$select=id,userPrincipalName,mail&`$top=999" -tenantid $TenantFilter -AsApp $true) + } catch { + throw "Could not list Entra guest users for cross-referencing: $($_.Exception.Message)" + } + } + $GuestsByUpn = @{} + $GuestsByMail = @{} + foreach ($Guest in $EntraGuests) { + if ($Guest.userPrincipalName) { $GuestsByUpn[$Guest.userPrincipalName.ToLower()] = $Guest } + if ($Guest.mail) { $GuestsByMail[$Guest.mail.ToLower()] = $Guest } + } + + function Resolve-GuestClassification($LoginName, $AcceptedAs, $InvitedAs) { + if ("$LoginName" -match '(?i)urn(%3a|:)spo(%3a|:)guest') { + return @('SharePoint-only (email authenticated)', $null) + } + $ClaimsUpn = ("$LoginName" -split '\|')[-1].ToLower() + $EntraUser = $GuestsByUpn[$ClaimsUpn] + if (-not $EntraUser -and $AcceptedAs) { $EntraUser = $GuestsByMail[([string]$AcceptedAs).ToLower()] } + if (-not $EntraUser -and $InvitedAs) { $EntraUser = $GuestsByMail[([string]$InvitedAs).ToLower()] } + if ($EntraUser) { return @('Entra B2B', $EntraUser) } + return @('Orphaned B2B (not in Entra)', $null) + } + + $Rows = [System.Collections.Generic.List[object]]::new() + $SeenKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + + foreach ($User in $StoreUsers) { + $GuestType, $EntraUser = Resolve-GuestClassification $User.LoginName $User.AcceptedAs $User.InvitedAs + # Match this store entry to any site membership sweep hit for a Sites column. + $MatchKey = $null + foreach ($Key in $SiteGuests.Keys) { + $SG = $SiteGuests[$Key] + if (($User.AcceptedAs -and $SG.Email -eq $User.AcceptedAs) -or ($EntraUser -and $Key -eq $EntraUser.userPrincipalName.ToLower())) { $MatchKey = $Key; break } + } + if ($MatchKey) { [void]$SeenKeys.Add($MatchKey) } + # Direct assignment keeps the List intact; an if-expression would pipeline-unwrap + # a single-element list to a scalar and the API would emit a string, not an array. + $RowSites = [System.Collections.Generic.List[string]]::new() + if ($MatchKey) { $RowSites = $SiteGuests[$MatchKey].Sites } + $Rows.Add([PSCustomObject]@{ + DisplayName = $User.DisplayName + InvitedAs = $User.InvitedAs + AcceptedAs = $User.AcceptedAs + # Store entries often carry no login; the site sweep's claims login fills the gap. + LoginName = if ($User.LoginName) { $User.LoginName } elseif ($MatchKey) { $SiteGuests[$MatchKey].LoginName } else { $null } + UniqueId = $User.UniqueId + WhenCreated = ConvertFrom-CsomDate $User.WhenCreated + InvitedBy = $User.InvitedBy + GuestType = $GuestType + InEntra = [bool]$EntraUser + EntraUserId = $EntraUser.id + Source = 'External users store' + Sites = $RowSites + }) + } + + # Guests that only exist as site members (never redeemed / store aged out) + foreach ($Key in $SiteGuests.Keys) { + if ($SeenKeys.Contains($Key)) { continue } + $SG = $SiteGuests[$Key] + $GuestType, $EntraUser = Resolve-GuestClassification $SG.LoginName $SG.Email $null + $Rows.Add([PSCustomObject]@{ + DisplayName = $SG.Title + InvitedAs = $null + AcceptedAs = $SG.Email + LoginName = $SG.LoginName + UniqueId = $null + WhenCreated = $null + InvitedBy = $null + GuestType = $GuestType + InEntra = [bool]$EntraUser + EntraUserId = $EntraUser.id + Source = 'Site membership' + Sites = $SG.Sites + }) + } + + $Body = @($Rows) + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Body = "Failed to list SharePoint external users: $($ErrorMessage.NormalizedError)" + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ 'Results' = $Body } + }) +} From e42d8d3d3eda248fd143fbeef7deafd9624fcf3b Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:49:27 +0200 Subject: [PATCH 08/12] version up --- version_latest.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version_latest.txt b/version_latest.txt index 0b09579a68d16..d1dd3f904c728 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.5.8 +10.6.0 From f921f04ecf2c12eff2ad17794000955830eba5d9 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:52:13 +0200 Subject: [PATCH 09/12] version up --- host.json | 2 +- version_latest.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/host.json b/host.json index 9de15356dcb97..a88d17f4bdc9d 100644 --- a/host.json +++ b/host.json @@ -16,7 +16,7 @@ "distributedTracingEnabled": false, "version": "None" }, - "defaultVersion": "10.5.8", + "defaultVersion": "10.6.0", "versionMatchStrategy": "Strict", "versionFailureStrategy": "Fail" } diff --git a/version_latest.txt b/version_latest.txt index 0b09579a68d16..d1dd3f904c728 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.5.8 +10.6.0 From 11beb6dde91a005dc3a36be98d9bcaa7477dfb0a Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:57:21 +0800 Subject: [PATCH 10/12] Conventional Commits Check for PR titles --- .github/workflows/Conventional_Commits.yml | 65 ++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 .github/workflows/Conventional_Commits.yml diff --git a/.github/workflows/Conventional_Commits.yml b/.github/workflows/Conventional_Commits.yml new file mode 100644 index 0000000000000..537e86d6823f9 --- /dev/null +++ b/.github/workflows/Conventional_Commits.yml @@ -0,0 +1,65 @@ +name: Conventional Commits Check + +on: + # Using pull_request_target instead of pull_request for secure handling of fork PRs + pull_request_target: + # Re-run on title edits so a corrected title clears the check + types: [opened, synchronize, reopened, edited] + # Only check PRs targeting the dev branch + branches: + - dev + +permissions: + pull-requests: write + issues: write + +jobs: + conventional-commits: + name: Validate Conventional Commits + runs-on: ubuntu-slim + steps: + - name: Validate PR title + uses: actions/github-script@v9 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const title = context.payload.pull_request.title || ''; + const pattern = /^(feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert)(\(.+\))?!?: .+/; + + if (pattern.test(title)) { + console.log(`✓ PR title follows Conventional Commits format: "${title}"`); + return; + } + + console.log(`❌ PR title does not follow Conventional Commits format: "${title}"`); + + const message = [ + '❌ **This PR title does not follow the [Conventional Commits](https://www.conventionalcommits.org/) format.**', + '', + 'Expected format: `type(scope)?: description`', + '', + 'Allowed types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`, `ci`, `build`, `revert`', + '', + 'Examples:', + '- `feat: add SharePoint external user management`', + '- `fix(auth): handle expired refresh tokens`', + '- `docs: update self-hosting guide`', + '', + `Received: \`${title}\``, + '', + '🔒 This PR has been automatically closed. Please update the title to match the format above and reopen the PR.' + ].join('\n'); + + await github.rest.issues.createComment({ + ...context.repo, + issue_number: context.issue.number, + body: message + }); + + await github.rest.pulls.update({ + ...context.repo, + pull_number: context.issue.number, + state: 'closed' + }); + + core.setFailed(`PR title does not follow Conventional Commits format: "${title}"`); From fb8f1938acbaaff9358fef2faf71a938acd3965b Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:58:55 +0800 Subject: [PATCH 11/12] feat: Copilot instructions for commit messages --- .github/copilot-instructions.md | 41 +++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b4fad38b5fd26..467dab7a3d371 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -162,3 +162,44 @@ Detailed PowerShell coding conventions are in `.github/instructions/powershell-c - Do not create new Azure Function trigger folders — use the existing five triggers - Do not call `Write-Output` in HTTP functions — return an `[HttpResponseContext]` (the outer trigger handles `Push-OutputBinding`) - Do not hardcode tenant IDs or secrets — use environment variables and `Get-GraphToken` + +## Commit messages + +Generate all commit messages in **Conventional Commits** format: + +``` +(): + +[optional body] + +[optional footer(s)] +``` + +**Rules:** + +- **Type** is required and must be one of: + - `feat` — a new feature + - `fix` — a bug fix + - `docs` — documentation only + - `style` — formatting, whitespace, no code-behavior change + - `refactor` — code change that neither fixes a bug nor adds a feature + - `perf` — a performance improvement + - `test` — adding or correcting tests + - `build` — build system or dependency changes + - `ci` — CI/CD configuration changes + - `chore` — routine maintenance, no production code change + - `revert` — reverts a previous commit +- **Scope** is optional and given in parentheses after the type (e.g. `feat(identity):`). Use a short, lowercase area name when it adds clarity. +- **Description** is a short, imperative-mood summary ("add", not "added"/"adds"), lowercase, no trailing period, ideally ≤ 72 characters. +- **Body** (optional) explains the *what* and *why*, not the *how*. Separate it from the description with one blank line. +- **Breaking changes** are indicated with a `!` before the colon (e.g. `feat!:`) and/or a `BREAKING CHANGE:` footer describing the change. +- Reference issues/PRs in the footer where relevant (e.g. `Closes #123`). + +**Examples:** + +``` +feat(identity): add bulk user offboarding endpoint +fix(graph): handle expired token on retry +docs: update authentication model overview +refactor(standards)!: rename remediation parameter +``` From 5ae417185390b24fbdd17a56a6292cef4c05ed73 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 10 Jul 2026 14:22:27 -0400 Subject: [PATCH 12/12] fix: prevent cert provision loop and skip KV 404 retries Add SAMCertProvisionAttempted guard to break recursion when Update-CIPPSAMCertificate calls Get-GraphToken, which re-enters Get-CIPPAuthentication before the cert env var is set. Also short-circuit Key Vault retry loop on 404 responses since the secret is definitively absent and retrying with backoff only adds latency. --- Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1 | 8 ++++++-- Modules/CIPPCore/Public/Get-CippKeyVaultSecret.ps1 | 4 ++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1 b/Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1 index 348d103e911a1..3f244f5e8418e 100644 --- a/Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1 @@ -49,9 +49,13 @@ function Get-CIPPAuthentication { } } - if (-not $env:SAMCertificate) { - # First run on this instance: provision the certificate now. + if (-not $env:SAMCertificate -and $env:SAMCertProvisionAttempted -ne 'true') { + # First run on this instance: provision the certificate now, at most once per + # process. The guard also breaks a recursion loop: Update-CIPPSAMCertificate + # calls Get-GraphToken, which re-enters this function when the AppCache + # ApplicationId does not match the environment. # Set-CIPPSAMCertificate refreshes $env:SAMCertificate on success. + $env:SAMCertProvisionAttempted = 'true' Write-Information 'No SAM certificate found, provisioning one now' $CertResult = Update-CIPPSAMCertificate -ErrorAction Stop Write-LogMessage -message "Provisioned SAM certificate during authentication load. Thumbprint: $($CertResult.Thumbprint), storage mode: $($CertResult.StorageMode)" -Sev 'Info' -API 'CIPP Authentication' diff --git a/Modules/CIPPCore/Public/Get-CippKeyVaultSecret.ps1 b/Modules/CIPPCore/Public/Get-CippKeyVaultSecret.ps1 index 30f0ac4b08f05..c88ba805076ef 100644 --- a/Modules/CIPPCore/Public/Get-CippKeyVaultSecret.ps1 +++ b/Modules/CIPPCore/Public/Get-CippKeyVaultSecret.ps1 @@ -60,6 +60,10 @@ function Get-CippKeyVaultSecret { break } catch { $lastError = $_ + # 404 is definitive - the secret does not exist and retrying cannot change that + if ($_.Exception.Message -match '404|SecretNotFound') { + throw "Failed to retrieve secret '$Name' from vault '$VaultName': $($_.Exception.Message)" + } if ($i -lt ($maxRetries - 1)) { Start-Sleep -Seconds $retryDelay $retryDelay *= 2 # Exponential backoff