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
18 changes: 18 additions & 0 deletions Config/PermissionsTranslator.json
Original file line number Diff line number Diff line change
Expand Up @@ -5363,6 +5363,15 @@
"userConsentDisplayName": "Allows the app to read and modify your tenant's acquired telephone number details on behalf of the signed-in admin user. Acquired telephone numbers may include attributes related to assigned object, emergency location, network site, etc.",
"value": "TeamsTelephoneNumber.ReadWrite.All"
},
{
"description": "Read Teams user configurations",
"displayName": "Read Teams user configurations",
"id": "5c469ce4-dab5-4afd-b9de-14f1ba4004a7",
"Origin": "Delegated",
"userConsentDescription": "Allows the app to read your tenant's user configurations on behalf of the signed-in admin user. User configuration may include attributes related to user, such as telephone number, assigned policies, etc.",
"userConsentDisplayName": "Read Teams user configurations",
"value": "TeamsUserConfiguration.Read.All"
},
{
"description": "Read and Modify Tenant-Acquired Telephone Number Details",
"displayName": "Read and Modify Tenant-Acquired Telephone Number Details",
Expand All @@ -5372,6 +5381,15 @@
"userConsentDisplayName": "Allows the app to read your tenant's acquired telephone number details, without a signed-in user. Acquired telephone numbers may include attributes related to assigned object, emergency location, network site, etc.",
"value": "TeamsTelephoneNumber.ReadWrite.All"
},
{
"description": "Read Teams user configurations",
"displayName": "Read Teams user configurations",
"id": "a91eadaf-2c3c-4362-908b-fb172d208fc6",
"Origin": "Application",
"userConsentDescription": "Allows the app to read your tenant's user configurations, without a signed-in user. User configuration may include attributes related to user, such as telephone number, assigned policies, etc.",
"userConsentDisplayName": "Read Teams user configurations",
"value": "TeamsUserConfiguration.Read.All"
},
{
"description": "Allows the app to read and write Copilot policy settings for the organization, on behalf of the signed-in user.",
"displayName": "Read and write Copilot policy settings",
Expand Down
8 changes: 8 additions & 0 deletions Config/SAMManifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,10 @@
"id": "0a42382f-155c-4eb1-9bdc-21548ccaa387",
"type": "Role"
},
{
"id": "a91eadaf-2c3c-4362-908b-fb172d208fc6",
"type": "Role"
},
{
"id": "a94a502d-0281-4d15-8cd2-682ac9362c4c",
"type": "Role"
Expand Down Expand Up @@ -559,6 +563,10 @@
"id": "424b07a8-1209-4d17-9fe4-9018a93a1024",
"type": "Scope"
},
{
"id": "5c469ce4-dab5-4afd-b9de-14f1ba4004a7",
"type": "Scope"
},
{
"id": "cac97e40-6730-457d-ad8d-4852fddab7ad",
"type": "Scope"
Expand Down
5 changes: 5 additions & 0 deletions Modules/CIPPCore/Public/Add-CIPPApplicationPermission.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,11 @@ function Add-CIPPApplicationPermission {
$Results.add("Failed to grant permissions in bulk: $(Get-NormalizedError -message $_.Exception.Message)")
}
}
if ($counter -gt 0) {
# App-only scopes changed; a cached client_credentials token still carries the old
# roles, so drop it rather than wait out its TTL.
$null = Clear-CippTokenCache -TenantFilter $TenantFilter
}
"Added $counter Application permissions to $($ourSVCPrincipal.displayName)"
return $Results
}
36 changes: 24 additions & 12 deletions Modules/CIPPCore/Public/Add-CIPPDbItem.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,19 @@ function Add-CIPPDbItem {

[switch]$Count,
[switch]$AddCount,
[switch]$Append
[switch]$Append,

[ValidateRange(0, 60)]
[int]$SkewMarginMinutes = 5
)

begin {
$Table = Get-CippTable -tablename 'CippReportingDB'
$Batch = [System.Collections.Generic.List[hashtable]]::new()
$NewRowKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
$BatchSize = 100
$Batch = [System.Collections.Generic.List[hashtable]]::new($BatchSize)
$SeenInBatch = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
$RunStartUtc = [DateTimeOffset]::UtcNow.AddMinutes(-$SkewMarginMinutes)

$TotalProcessed = 0
# Cache regex instances so each row pays only the match cost, not regex compilation.
# Two passes preserve the original semantics: path/wildcard chars → '_', control chars → stripped.
Expand All @@ -54,17 +60,18 @@ function Add-CIPPDbItem {
if ($null -eq $Item) { continue }
$ItemId = $Item.ExternalDirectoryObjectId ?? $Item.id ?? $Item.Identity ?? $Item.skuId ?? $Item.userPrincipalName ?? [guid]::NewGuid().ToString()
$RowKey = $RowKeyControlRegex.Replace($RowKeyPathRegex.Replace("$Type-$ItemId", '_'), '')
if ($NewRowKeys.Add($RowKey)) {
if ($SeenInBatch.Add($RowKey)) {
$Batch.Add(@{
PartitionKey = $TenantFilter
RowKey = $RowKey
Data = [string]($Item | ConvertTo-Json -Depth 10 -Compress)
Type = $Type
})
if ($Batch.Count -ge 500) {
if ($Batch.Count -ge $BatchSize) {
$null = Add-CIPPAzDataTableEntity @Table -Entity $Batch.ToArray() -Force
$TotalProcessed += $Batch.Count
$Batch.Clear()
$SeenInBatch.Clear()
}
}
}
Expand All @@ -76,17 +83,22 @@ function Add-CIPPDbItem {
$TotalProcessed += $Batch.Count
}

# Clean up orphaned rows (entities that no longer exist in the new dataset)
if (-not $Count.IsPresent -and -not $Append.IsPresent) {
# Clean up orphaned rows (entities that no longer exist in the new dataset).
if (-not $Count.IsPresent -and -not $Append.IsPresent -and $TotalProcessed -gt 0) {
$Filter = "PartitionKey eq '{0}' and RowKey ge '{1}-' and RowKey lt '{1}0'" -f $TenantFilter, $Type
$Existing = Get-CIPPAzDataTableEntity @Table -Filter $Filter -Property PartitionKey, RowKey, ETag, OriginalEntityId
$Existing = Get-CIPPAzDataTableEntity @Table -Filter $Filter -Property PartitionKey, RowKey, ETag, OriginalEntityId, Timestamp
if ($Existing) {
$Undated = 0
$Orphans = foreach ($Row in @($Existing)) {
if ($Row.RowKey -eq "$Type-Count") { continue }
$ParentKey = $Row.OriginalEntityId ?? $Row.RowKey
if (-not $NewRowKeys.Contains($ParentKey)) {
$Row
}

$Stamp = $Row.Timestamp -as [datetimeoffset]
if ($null -eq $Stamp) { $Undated++; continue }

if ($Stamp -lt $RunStartUtc) { $Row }
}
if ($Undated -gt 0) {
Write-LogMessage -API 'CIPPDbItem' -tenant $TenantFilter -sev Warning -message "Skipped $Undated $Type row(s) with no readable Timestamp during orphan cleanup — not deleting without positive evidence"
}
if ($Orphans) {
$null = Remove-AzDataTableEntity @Table -Entity @($Orphans) -Force
Expand Down
3 changes: 3 additions & 0 deletions Modules/CIPPCore/Public/Add-CIPPDelegatedPermission.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,9 @@ function Add-CIPPDelegatedPermission {
$Results.add("Failed to update permissions for $($svcPrincipalId.displayName): $(Get-NormalizedError -message $_.Exception.Message)")
continue
}
# Delegated scopes changed; a cached refresh_token-derived access token still
# carries the old scopes, so drop it rather than wait out its TTL.
$null = Clear-CippTokenCache -TenantFilter $TenantFilter
# Added permissions
$Added = ($Compare | Where-Object { $_.SideIndicator -eq '=>' }).InputObject -join ' '
$Removed = ($Compare | Where-Object { $_.SideIndicator -eq '<=' }).InputObject -join ' '
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
function Clear-CippAccessScopeCache {
<#
.SYNOPSIS
Invalidate cached access scope rules across every worker.

.DESCRIPTION
Bumps the shared version stamp that Get-CippAccessScopeRule keys on, so every worker misses
and recomputes from source, and drops this worker's caches immediately.

Propagation is not instant on the other workers. They each memo the stamp for a short
window (see Get-CippAccessScopeVersion), so a change lands there within that window rather
than on the very next request. Verified behaviour, not an assumption.

Call this from anything that changes what a role is allowed to see: custom role
definitions, access role group mappings, and tenant group membership. Missing a call site
does not cause indefinite staleness - the rule cache TTL still expires entries - but it
does mean an operator can save a role change and not see it take effect straight away.

A failure is logged rather than thrown. Failing the role save the operator just made
because a cache table hiccuped would be the worse outcome, and the TTL bounds the damage.

.EXAMPLE
Clear-CippAccessScopeCache

.FUNCTIONALITY
Internal
#>
[CmdletBinding(SupportsShouldProcess = $true)]
param()

if (-not $PSCmdlet.ShouldProcess('access scope cache', 'Invalidate')) {
return
}

$script:CippAccessScopeRuleCache = @{}
$script:CippAccessScopeVersionMemo = $null

try {
$Table = Get-CIPPTable -tablename 'CacheVersions'
$Entity = @{
PartitionKey = 'CacheVersion'
RowKey = 'AccessScope'
Version = [string][guid]::NewGuid()
}
Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force | Out-Null
} catch {
Write-LogMessage -API 'AccessScopeCache' -message "Failed to invalidate the access scope cache. Other workers will keep their current rules until the cache expires. $($_.Exception.Message)" -Sev 'Error'
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
function Expand-CippScopeTenantItem {
<#
.SYNOPSIS
Expand a role's tenant entries, resolving tenant groups to their member tenant ids.
.DESCRIPTION
Entries stored against a role are either literal tenant ids, the 'AllTenants' sentinel, or
a tenant group reference that has to be resolved to the ids currently in that group.
A group that cannot be expanded warns and contributes nothing, which is deliberate: the
alternative - treating an unresolvable group as 'everything' - would widen access on the
back of a lookup failure.
Used by Get-CippAccessScopeRule for both the allowed and blocked lists.
.PARAMETER Items
The stored AllowedTenants or BlockedTenants entries.
.PARAMETER Kind
'allowed' or 'blocked', used only to make the warning readable.
.EXAMPLE
Expand-CippScopeTenantItem -Items $Permission.BlockedTenants -Kind 'blocked'
.FUNCTIONALITY
Internal
#>
[CmdletBinding()]
param(
$Items,
[string]$Kind
)

foreach ($Item in $Items) {
if ($Item -is [PSCustomObject] -and $Item.type -eq 'Group') {
try {
$GroupMembers = Expand-CIPPTenantGroups -TenantFilter @($Item)
$GroupMembers | ForEach-Object { $_.addedFields.customerId }
} catch {
Write-Warning "Failed to expand $Kind tenant group '$($Item.label)': $($_.Exception.Message)"
}
} else {
$Item
}
}
}
99 changes: 99 additions & 0 deletions Modules/CIPPCore/Public/Authentication/Get-CippAccessScopeRule.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
function Get-CippAccessScopeRule {
<#
.SYNOPSIS
The tenant and group scope rule a custom role expresses.

.DESCRIPTION
Returns the rule itself - allow everything, or an explicit allow list minus a block list -
rather than the set of tenant ids it currently resolves to.

That distinction is the whole point of the cache. Caching the resolved id list would bake
in a snapshot of the tenant table, so a tenant onboarded afterwards would silently stay
invisible to the role until the entry expired: the same silent-omission failure that made
the request scope leak so hard to spot. A rule depends only on the role definition and
tenant group membership, both covered by the version stamp, so it stays correct however
many tenants come and go. Callers expand it against the live tenant list at the point of
use, which is an in-memory set operation over data they already hold.

Cached per worker, keyed by the shared version stamp so a role change invalidates every
worker at once, with a TTL as a backstop in case an invalidation call site is missed.

.PARAMETER Role
Name of the custom role.

.EXAMPLE
$Rule = Get-CippAccessScopeRule -Role 'helpdesk'
if (-not $Rule.Unrestricted) { $Rule.BlockedTenants }

.FUNCTIONALITY
Internal
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Role
)

$TtlMinutes = 5
$Now = (Get-Date).ToUniversalTime()
$Version = Get-CippAccessScopeVersion
$CacheKey = '{0}|{1}' -f $Version, $Role

if (-not $script:CippAccessScopeRuleCache) {
$script:CippAccessScopeRuleCache = @{}
}

$Cached = $script:CippAccessScopeRuleCache[$CacheKey]
if ($Cached -and $Cached.Expires -gt $Now) {
return $Cached.Rule
}

# Throws when the role no longer exists, which callers already handle as 'this role
# contributes no scope'
$Permission = Get-CIPPRolePermissions -Role $Role

$Unrestricted = ((($Permission.AllowedTenants | Measure-Object).Count -eq 0 -or
$Permission.AllowedTenants -contains 'AllTenants') -and
($Permission.BlockedTenants | Measure-Object).Count -eq 0)

$AllowAllTenants = $false
$AllowedTenants = @()
$BlockedTenants = @()
$AllowedGroups = @()

if (-not $Unrestricted) {
$Expanded = @(Expand-CippScopeTenantItem -Items $Permission.AllowedTenants -Kind 'allowed')

# 'AllTenants' alongside a block list means 'everything except', and the caller substitutes
# the live tenant list. Held as a flag rather than a resolved list precisely so that
# substitution happens at read time.
$AllowAllTenants = $Expanded -contains 'AllTenants'
$AllowedTenants = @($Expanded | Where-Object { $_ -ne 'AllTenants' })
$BlockedTenants = @(Expand-CippScopeTenantItem -Items $Permission.BlockedTenants -Kind 'blocked')
$AllowedGroups = @(
foreach ($Item in $Permission.AllowedTenants) {
if ($Item -is [PSCustomObject] -and $Item.type -eq 'Group') { $Item.value }
}
)
}

$Rule = [PSCustomObject]@{
Role = $Role
Unrestricted = $Unrestricted
AllowAllTenants = $AllowAllTenants
AllowedTenants = $AllowedTenants
BlockedTenants = $BlockedTenants
AllowedGroups = $AllowedGroups
}

# Entries keyed on a superseded version are dead weight; drop the lot rather than track ages
if ($script:CippAccessScopeRuleCache.Count -gt 200) {
$script:CippAccessScopeRuleCache = @{}
}
$script:CippAccessScopeRuleCache[$CacheKey] = @{
Rule = $Rule
Expires = $Now.AddMinutes($TtlMinutes)
}

return $Rule
}
Loading