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 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