Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 29 additions & 73 deletions Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
}
}

Expand Down
84 changes: 41 additions & 43 deletions Modules/CIPPCore/Public/SAMManifest/Update-CippSamPermissions.ps1
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,43 +16,57 @@ 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)
}
}
}

$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
Expand Down
2 changes: 1 addition & 1 deletion host.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"distributedTracingEnabled": false,
"version": "None"
},
"defaultVersion": "10.5.7",
"defaultVersion": "10.5.8",
"versionMatchStrategy": "Strict",
"versionFailureStrategy": "Fail"
}
Expand Down
Loading