From 5959b51abc450584eee1f93b75a4bed34fd22b91 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 28 Jul 2026 21:30:04 +0000 Subject: [PATCH] Merge pull request #99 from CyberDrain/dev fix: Dev to hotfix Synced from CyberDrain/CIPP@6e9f3e596c4fc577c70c09d3315d54bf1d7a5ef5 --- .../Select-CippAllowedTenantData.ps1 | 90 +++++++++++++++++ .../Public/Authentication/Test-CIPPAccess.ps1 | 2 +- .../Start-AuditLogIngestionV2.ps1 | 4 +- .../Start-SchedulerOrchestrator.ps1 | 11 +++ .../Public/Get-CIPPAuthentication.ps1 | 1 + .../Public/Get-CippKeyVaultSecret.ps1 | 1 + .../GraphHelper/Get-AuthorisedRequest.ps1 | 2 +- .../Public/Set-CIPPNotificationConfig.ps1 | 2 + .../Invoke-ExecBackupReplicationConfig.ps1 | 1 + .../Invoke-ListMailboxRules.ps1 | 2 +- .../Spamfilter/Invoke-ListMailQuarantine.ps1 | 2 +- .../Transport/Invoke-ListTransportRules.ps1 | 2 +- .../Users/Invoke-ListJITAdmin.ps1 | 2 +- .../Administration/Users/Invoke-ListUsers.ps1 | 7 ++ .../Identity/Reports/Invoke-ListBasicAuth.ps1 | 2 +- .../Identity/Reports/Invoke-ListMFAUsers.ps1 | 1 + .../Invoke-ListAllTenantDeviceCompliance.ps1 | 4 +- .../HTTP Functions/Invoke-ListLicenses.ps1 | 2 +- .../Security/Invoke-ExecAlertsList.ps1 | 2 +- .../Security/Invoke-ExecIncidentsList.ps1 | 2 +- .../Security/Invoke-ExecMdoAlertsList.ps1 | 2 +- .../Invoke-ListConditionalAccessPolicies.ps1 | 4 +- .../Select-CippAllowedTenantData.Tests.ps1 | 99 +++++++++++++++++++ version_latest.txt | 2 +- 24 files changed, 232 insertions(+), 17 deletions(-) create mode 100644 Modules/CIPPCore/Public/Authentication/Select-CippAllowedTenantData.ps1 create mode 100644 Tests/Private/Select-CippAllowedTenantData.Tests.ps1 diff --git a/Modules/CIPPCore/Public/Authentication/Select-CippAllowedTenantData.ps1 b/Modules/CIPPCore/Public/Authentication/Select-CippAllowedTenantData.ps1 new file mode 100644 index 0000000000000..c31a698162d39 --- /dev/null +++ b/Modules/CIPPCore/Public/Authentication/Select-CippAllowedTenantData.ps1 @@ -0,0 +1,90 @@ +function Select-CippAllowedTenantData { + <# + .SYNOPSIS + Narrow a set of cached rows to the tenants the current caller is allowed to see. + + .DESCRIPTION + A family of cached List*/Exec*List endpoints reads a global cache table by PartitionKey + and returns the rows directly. When a tenant-restricted custom role calls one of these + with tenantFilter=AllTenants, the read must still be narrowed to the caller's allowed + tenants - the same scope Get-Tenants already applies via $script:CippAllowedTenantsStorage. + A direct cache-table read never touches Get-Tenants on a cache hit, so it would otherwise + leak every managed tenant's data. Pipe those rows through this function at the point they + become the response to close that gap. + + This function MUST live in CIPPCore. The $script:CippAllowedTenantsStorage AsyncLocal slot + is CIPPCore module-scoped; a copy defined in CIPPHTTP would read that module's own empty + variable and silently filter nothing (see Get-CippRequestContext). + + The stored scope is a list of customerIds (or $null = unrestricted). Cache rows identify + their tenant by domain name (defaultDomainName, stored on a 'Tenant' property) and/or by + customerId, so allowed customerIds are expanded to every identifier form an allowed tenant + might present, mirroring the match logic in Invoke-ListLogs. + + .PARAMETER InputObject + The rows to filter. Accepts pipeline input. + + .PARAMETER TenantProperty + The property name(s) on each row that identify its tenant. Defaults to 'Tenant' and + 'TenantId'. A row is kept when any of these properties matches an allowed tenant. For the + Lighthouse aggregate use 'organizationId'. + + .PARAMETER AllowPartner + Also keep rows whose Tenant equals 'CIPP' (system/partner rows), mirroring Invoke-ListLogs. + + .EXAMPLE + $Rows = $Rows | Select-CippAllowedTenantData -TenantProperty 'Tenant' + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(ValueFromPipeline = $true)] + [AllowNull()] + $InputObject, + + [string[]]$TenantProperty = @('Tenant', 'TenantId'), + + [switch]$AllowPartner + ) + + begin { + # $null / empty stored scope means the caller is unrestricted - pass everything through + # with zero overhead (no Get-Tenants call). + $AllowedCustomerIds = if ($script:CippAllowedTenantsStorage) { $script:CippAllowedTenantsStorage.Value } else { $null } + $Unrestricted = -not ($AllowedCustomerIds | Where-Object { $_ }) + + if (-not $Unrestricted) { + # Build a case-insensitive set of every identifier a row might carry for an allowed + # tenant. Get-Tenants is already narrowed to the caller's scope by the storage filter. + $AllowedSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Id in $AllowedCustomerIds) { + if ($Id) { [void]$AllowedSet.Add([string]$Id) } + } + foreach ($Tenant in (Get-Tenants -IncludeErrors)) { + foreach ($Value in @($Tenant.customerId, $Tenant.defaultDomainName, $Tenant.initialDomainName)) { + if ($Value) { [void]$AllowedSet.Add([string]$Value) } + } + } + if ($AllowPartner) { [void]$AllowedSet.Add('CIPP') } + } + } + + process { + foreach ($Item in $InputObject) { + if ($null -eq $Item) { continue } + if ($Unrestricted) { + $Item + continue + } + foreach ($Prop in $TenantProperty) { + $Value = $Item.$Prop + if ($Value -and $AllowedSet.Contains([string]$Value)) { + $Item + break + } + } + } + } +} diff --git a/Modules/CIPPCore/Public/Authentication/Test-CIPPAccess.ps1 b/Modules/CIPPCore/Public/Authentication/Test-CIPPAccess.ps1 index 9ca939a19e841..1e247170d8dd0 100644 --- a/Modules/CIPPCore/Public/Authentication/Test-CIPPAccess.ps1 +++ b/Modules/CIPPCore/Public/Authentication/Test-CIPPAccess.ps1 @@ -159,7 +159,7 @@ function Test-CIPPAccess { $swIPCheck = [System.Diagnostics.Stopwatch]::StartNew() if (-not $User.userRoles) { - throw 'Access denied: unable to resolve roles for the authenticated principal.' + throw 'Access denied: unable to resolve roles for the authenticated principal, here is what we know about this user: ' + ($User | ConvertTo-Json -Depth 5) + ' and here is what we know about the request: ' + ($Request | ConvertTo-Json -Depth 5) } $AllowedIPRanges = Get-CIPPRoleIPRanges -Roles $User.userRoles diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogIngestionV2.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogIngestionV2.ps1 index 3dab8b3701149..ca26d3e1ca6b4 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogIngestionV2.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogIngestionV2.ps1 @@ -29,7 +29,7 @@ function Start-AuditLogIngestionV2 { # --- Download tenants: searches awaiting download (State = Created, due) --- $DownloadTenants = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - foreach ($Row in @(Get-CIPPAzDataTableEntity @Ledger -Filter "State eq 'Created'" -Property @('PartitionKey', 'NextAttemptUtc'))) { + foreach ($Row in @(Get-CIPPAzDataTableEntity @Ledger -Filter "State eq 'Created'" -Property @('PartitionKey', 'RowKey', 'NextAttemptUtc'))) { if ($Row.NextAttemptUtc -and ([datetimeoffset]$Row.NextAttemptUtc).UtcDateTime -gt $Now) { continue } if ($Row.PartitionKey) { [void]$DownloadTenants.Add([string]$Row.PartitionKey) } } @@ -37,7 +37,7 @@ function Start-AuditLogIngestionV2 { # --- Process-only tenants: rows pending in the webhook cache (downloaded, not yet processed) --- $CacheTable = Get-CippTable -TableName 'CacheWebhooks' $CacheTenants = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - foreach ($Row in @(Get-CIPPAzDataTableEntity @CacheTable -Property @('PartitionKey'))) { + foreach ($Row in @(Get-CIPPAzDataTableEntity @CacheTable -Property @('PartitionKey', 'RowKey'))) { if ($Row.PartitionKey) { [void]$CacheTenants.Add([string]$Row.PartitionKey) } } diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-SchedulerOrchestrator.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-SchedulerOrchestrator.ps1 index ffe6170137233..dd5445f479cc4 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-SchedulerOrchestrator.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-SchedulerOrchestrator.ps1 @@ -12,7 +12,18 @@ function Start-SchedulerOrchestrator { $Table = Get-CIPPTable -TableName SchedulerConfig $Tenants = Get-CIPPAzDataTableEntity @Table | Where-Object -Property PartitionKey -NE 'WebhookAlert' + $ValidTypes = @('CIPPNotifications', 'webhookcreation') + $Tasks = foreach ($Tenant in $Tenants) { + if ($Tenant.type -notin $ValidTypes) { + if ($Tenant.PartitionKey -eq 'Alert') { + Write-Information "Scheduler: removing legacy classic-alert row for '$($Tenant.tenant)'" + Remove-AzDataTableEntity -Force @Table -Entity $Tenant + } else { + Write-Information "Scheduler: skipping row $($Tenant.PartitionKey)/$($Tenant.RowKey) - no handler for type '$($Tenant.type)'" + } + continue + } if ($Tenant.tenant -ne 'AllTenants') { [pscustomobject]@{ Tenant = $Tenant.tenant diff --git a/Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1 b/Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1 index 8a0f581f748c2..699531c9b0754 100644 --- a/Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1 @@ -1,6 +1,7 @@ function Get-CIPPAuthentication { [CmdletBinding()] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '', Justification = 'Wraps a resolved tenant GUID in a SecureString only to satisfy Set-CippKeyVaultSecret; the value is written to Azure Key Vault (encrypted at rest)')] param ( $APIName = 'Get Keyvault Authentication', [switch]$Force diff --git a/Modules/CIPPCore/Public/Get-CippKeyVaultSecret.ps1 b/Modules/CIPPCore/Public/Get-CippKeyVaultSecret.ps1 index c88ba805076ef..919e6e23dbfa3 100644 --- a/Modules/CIPPCore/Public/Get-CippKeyVaultSecret.ps1 +++ b/Modules/CIPPCore/Public/Get-CippKeyVaultSecret.ps1 @@ -23,6 +23,7 @@ function Get-CippKeyVaultSecret { Get-CippKeyVaultSecret -VaultName 'mykeyvault' -Name 'RefreshToken' -AsPlainText #> [CmdletBinding()] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '', Justification = 'Wraps the value returned by the Key Vault REST call in a SecureString to match the Get-AzKeyVaultSecret return shape')] param( [Parameter(Mandatory = $false)] [string]$VaultName, diff --git a/Modules/CIPPCore/Public/GraphHelper/Get-AuthorisedRequest.ps1 b/Modules/CIPPCore/Public/GraphHelper/Get-AuthorisedRequest.ps1 index 6db6c9566848b..87b1e866447e4 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Get-AuthorisedRequest.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Get-AuthorisedRequest.ps1 @@ -13,7 +13,7 @@ function Get-AuthorisedRequest { $TenantID = $env:TenantID } - if ($Uri -like 'https://graph.microsoft.com/beta/contracts*' -or $Uri -like '*/customers/*' -or $Uri -eq 'https://graph.microsoft.com/v1.0/me/sendMail' -or $Uri -like '*/tenantRelationships/*' -or $Uri -like '*/security/partner/*' -or $Uri -like '*/organization') { + if ($Uri -like 'https://graph.microsoft.com/beta/contracts*' -or $Uri -like '*/customers/*' -or $Uri -eq 'https://graph.microsoft.com/v1.0/me/sendMail' -or $Uri -like '*/tenantRelationships/*' -or $Uri -like '*/security/partner/*' -or $Uri -match '/organization(\?|$)') { return $true } $Tenant = Get-Tenants -IncludeErrors -TenantFilter $TenantID | Where-Object { $_.Excluded -eq $false } diff --git a/Modules/CIPPCore/Public/Set-CIPPNotificationConfig.ps1 b/Modules/CIPPCore/Public/Set-CIPPNotificationConfig.ps1 index 89e767d32a44a..2d28de0649392 100644 --- a/Modules/CIPPCore/Public/Set-CIPPNotificationConfig.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPNotificationConfig.ps1 @@ -1,5 +1,7 @@ function Set-CIPPNotificationConfig { [CmdletBinding()] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '', Justification = 'Wraps a webhook auth config value in a SecureString only to satisfy Set-CippKeyVaultSecret; the value is written to Azure Key Vault (encrypted at rest)')] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingUsernameAndPasswordParams', '', Justification = 'webhookAuthUsername/webhookAuthPassword are stored config values for an outbound webhook integration, not interactive credentials')] param ( $email, $webhook, diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBackupReplicationConfig.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBackupReplicationConfig.ps1 index 1d71ffc2be0cd..b6fb4ae40022b 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBackupReplicationConfig.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBackupReplicationConfig.ps1 @@ -6,6 +6,7 @@ function Invoke-ExecBackupReplicationConfig { CIPP.AppSettings.ReadWrite #> [CmdletBinding()] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '', Justification = 'Wraps a backup SAS URL in a SecureString only to satisfy Set-CippKeyVaultSecret; the value is written to Azure Key Vault (encrypted at rest)')] param($Request, $TriggerMetadata) $Table = Get-CIPPTable -TableName Config diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListMailboxRules.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListMailboxRules.ps1 index f15ac1f6c745e..61dab55a5a3a0 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListMailboxRules.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListMailboxRules.ps1 @@ -88,7 +88,7 @@ function Invoke-ListMailboxRules { $Metadata = [PSCustomObject]@{ QueueId = $RunningQueue.RowKey ?? $null } - $GraphRequest = $Rows | ForEach-Object { + $GraphRequest = $Rows | Select-CippAllowedTenantData -TenantProperty 'Tenant' | ForEach-Object { $NewObj = $_.Rules | ConvertFrom-Json -ErrorAction SilentlyContinue $NewObj | Add-Member -NotePropertyName 'Tenant' -NotePropertyValue $_.Tenant -Force $NewObj diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantine.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantine.ps1 index 6e608766541d5..973b5913d9f77 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantine.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantine.ps1 @@ -74,7 +74,7 @@ function Invoke-ListMailQuarantine { $Metadata = [PSCustomObject]@{ QueueId = $RunningQueue.RowKey ?? $null } - $Messages = $Rows + $Messages = $Rows | Select-CippAllowedTenantData -TenantProperty 'Tenant' foreach ($message in $Messages) { $messageObj = $message.QuarantineMessage | ConvertFrom-Json $messageObj | Add-Member -NotePropertyName 'Tenant' -NotePropertyValue $message.Tenant -Force diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Transport/Invoke-ListTransportRules.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Transport/Invoke-ListTransportRules.ps1 index eeb62913014f7..5d78419baca64 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Transport/Invoke-ListTransportRules.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Transport/Invoke-ListTransportRules.ps1 @@ -63,7 +63,7 @@ function Invoke-ListTransportRules { $Metadata = [PSCustomObject]@{ QueueId = $RunningQueue.RowKey ?? $null } - $Rules = $Rows + $Rules = $Rows | Select-CippAllowedTenantData -TenantProperty 'Tenant' foreach ($rule in $Rules) { $RuleObj = $rule.TransportRule | ConvertFrom-Json $RuleObj | Add-Member -MemberType NoteProperty -Name Tenant -Value $rule.Tenant -Force diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITAdmin.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITAdmin.ps1 index 993bf58eafebb..3c5a66238d963 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITAdmin.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITAdmin.ps1 @@ -100,7 +100,7 @@ } # There is data in the cache, so we will use that Write-Information "Found $($Rows.Count) rows in the cache" - foreach ($row in $Rows) { + foreach ($row in ($Rows | Select-CippAllowedTenantData -TenantProperty 'Tenant')) { $UserObject = $row.JITAdminUser | ConvertFrom-Json $Results.Add( [PSCustomObject]@{ diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUsers.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUsers.ps1 index a24160bb3987b..69396848aabba 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUsers.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUsers.ps1 @@ -83,6 +83,13 @@ Function Invoke-ListUsers { $_ | Add-Member -MemberType NoteProperty -Name 'primDomain' -Value @{value = ($_.userPrincipalName -split '@' | Select-Object -Last 1); label = ($_.userPrincipalName -split '@' | Select-Object -Last 1); } -Force $_ } + } elseif ((Get-CippRequestContext).AllowedTenants) { + # Deprecated cacheusers blob has no reliable per-tenant column, so it cannot be safely + # narrowed for a tenant-restricted caller. Return the deprecation message instead of + # leaking every tenant's users. Unrestricted callers keep the legacy behavior below. + [PSCustomObject]@{ + Message = 'This function has been deprecated for all users, please use ListGraphRequest instead' + } } else { $Table = Get-CIPPTable -TableName 'cacheusers' $Rows = Get-CIPPAzDataTableEntity @Table | Where-Object -Property Timestamp -GT (Get-Date).AddHours(-1) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Reports/Invoke-ListBasicAuth.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Reports/Invoke-ListBasicAuth.ps1 index 7a4a90f64238b..943777494b84c 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Reports/Invoke-ListBasicAuth.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Reports/Invoke-ListBasicAuth.ps1 @@ -71,7 +71,7 @@ Function Invoke-ListBasicAuth { Body = @($GraphRequest) }) } else { - $GraphRequest = $Rows + $GraphRequest = @($Rows | Select-CippAllowedTenantData -TenantProperty 'Tenant') return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::OK Body = @($GraphRequest) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Reports/Invoke-ListMFAUsers.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Reports/Invoke-ListMFAUsers.ps1 index 71a1ecb4ef5eb..ea2a5d97b940b 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Reports/Invoke-ListMFAUsers.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Reports/Invoke-ListMFAUsers.ps1 @@ -63,6 +63,7 @@ function Invoke-ListMFAUsers { } else { Write-Information 'Getting cached MFA state for all tenants' Write-Information "Found $($Rows.Count) rows in cache" + $Rows = $Rows | Select-CippAllowedTenantData -TenantProperty 'Tenant' $Rows = foreach ($Row in $Rows) { if ($Row.CAPolicies -and $Row.CAPolicies -is [string]) { $Row.CAPolicies = try { $Row.CAPolicies | ConvertFrom-Json -ErrorAction Stop } catch { @() } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListAllTenantDeviceCompliance.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListAllTenantDeviceCompliance.ps1 index 9035f74899b22..f9c60d8023c96 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListAllTenantDeviceCompliance.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListAllTenantDeviceCompliance.ps1 @@ -13,7 +13,9 @@ Function Invoke-ListAllTenantDeviceCompliance { $TenantFilter = $Request.Query.TenantFilter try { if ($TenantFilter -eq 'AllTenants') { - $GraphRequest = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/tenantRelationships/managedTenants/managedDeviceCompliances' + # The Lighthouse aggregate returns every managed tenant with no per-caller scoping, so + # narrow it to the tenants this caller is allowed to see (organizationId = customerId). + $GraphRequest = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/tenantRelationships/managedTenants/managedDeviceCompliances' | Select-CippAllowedTenantData -TenantProperty 'organizationId' $StatusCode = [HttpStatusCode]::OK } else { $GraphRequest = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/tenantRelationships/managedTenants/managedDeviceCompliances?`$top=999&`$filter=organizationId eq '$TenantFilter'" diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListLicenses.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListLicenses.ps1 index fc949d8572390..857af7a5054ca 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListLicenses.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListLicenses.ps1 @@ -39,7 +39,7 @@ function Invoke-ListLicenses { Write-Host "Started permissions orchestration with ID = '$InstanceId'" } } else { - $GraphRequest = $Rows | ForEach-Object { + $GraphRequest = $Rows | Select-CippAllowedTenantData -TenantProperty 'Tenant' | ForEach-Object { $LicenseData = $_.License | ConvertFrom-Json -ErrorAction SilentlyContinue foreach ($License in $LicenseData) { $License diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Invoke-ExecAlertsList.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Invoke-ExecAlertsList.ps1 index 29d03e6338bdb..380535ae6b15e 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Invoke-ExecAlertsList.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Invoke-ExecAlertsList.ps1 @@ -88,7 +88,7 @@ function Invoke-ExecAlertsList { QueueId = $RunningQueue.RowKey ?? $null } - $Alerts = $Rows + $Alerts = $Rows | Select-CippAllowedTenantData -TenantProperty 'Tenant' $AlertsObj = foreach ($Alert in $Alerts) { $AlertInfo = $Alert.Alert | ConvertFrom-Json @{ diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Invoke-ExecIncidentsList.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Invoke-ExecIncidentsList.ps1 index 2802192230f7d..cba1fb70f533b 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Invoke-ExecIncidentsList.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Invoke-ExecIncidentsList.ps1 @@ -86,7 +86,7 @@ function Invoke-ExecIncidentsList { $Metadata = [PSCustomObject]@{ QueueId = $RunningQueue.RowKey ?? $null } - $Incidents = $Rows + $Incidents = $Rows | Select-CippAllowedTenantData -TenantProperty 'Tenant' foreach ($incident in $Incidents) { if ($incident.Incident -and (Test-Json -Json $incident.Incident)) { $IncidentObj = $incident.Incident | ConvertFrom-Json diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Invoke-ExecMdoAlertsList.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Invoke-ExecMdoAlertsList.ps1 index bcf7eff190e64..4d7b414539766 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Invoke-ExecMdoAlertsList.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Invoke-ExecMdoAlertsList.ps1 @@ -53,7 +53,7 @@ function Invoke-ExecMDOAlertsList { $Metadata = [PSCustomObject]@{ QueueId = $RunningQueue.RowKey ?? $null } - $Alerts = $Rows + $Alerts = $Rows | Select-CippAllowedTenantData -TenantProperty 'Tenant' foreach ($alert in $Alerts) { ConvertFrom-Json -InputObject $alert.MdoAlert -Depth 10 } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ListConditionalAccessPolicies.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ListConditionalAccessPolicies.ps1 index 70a51d8e26e3e..09ff7e2b6b290 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ListConditionalAccessPolicies.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ListConditionalAccessPolicies.ps1 @@ -235,8 +235,8 @@ function Invoke-ListConditionalAccessPolicies { $Metadata = [PSCustomObject]@{ QueueId = $RunningQueue.RowKey ?? $null } - $Policies = $Rows - # Output all policies from all tenants + $Policies = $Rows | Select-CippAllowedTenantData -TenantProperty 'Tenant' + # Output all policies from all tenants the caller is allowed to see foreach ($policy in $Policies) { ($policy.Policy | ConvertFrom-Json) } diff --git a/Tests/Private/Select-CippAllowedTenantData.Tests.ps1 b/Tests/Private/Select-CippAllowedTenantData.Tests.ps1 new file mode 100644 index 0000000000000..b8f9da7dd9dc4 --- /dev/null +++ b/Tests/Private/Select-CippAllowedTenantData.Tests.ps1 @@ -0,0 +1,99 @@ +# Pester tests for Select-CippAllowedTenantData +# Verifies that cached rows are narrowed to the caller's allowed tenants when a tenant-restricted +# scope is in force, and passed through untouched when the caller is unrestricted. This is the +# guard that stops a tenant-restricted role from reading every tenant's cached data via +# tenantFilter=AllTenants on the direct cache-reader endpoints. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Authentication/Select-CippAllowedTenantData.ps1' + + # Minimal stub so Mock has a command to replace + function Get-Tenants { param($TenantFilter, [switch]$IncludeErrors) } + + # The function reads $script:CippAllowedTenantsStorage. Dot-sourcing puts it in this script + # scope, so create the same AsyncLocal slot here and drive it per-test. + $script:CippAllowedTenantsStorage = [System.Threading.AsyncLocal[object]]::new() + + . $FunctionPath + + # Two allowed tenants (tenantA/tenantB) and one that must never leak (tenantC). + $script:TenantA = [pscustomobject]@{ customerId = '11111111-1111-1111-1111-111111111111'; defaultDomainName = 'tenanta.onmicrosoft.com'; initialDomainName = 'tenanta.onmicrosoft.com' } + $script:TenantB = [pscustomobject]@{ customerId = '22222222-2222-2222-2222-222222222222'; defaultDomainName = 'tenantb.onmicrosoft.com'; initialDomainName = 'tenantb.onmicrosoft.com' } + + # Cache rows carry Tenant = defaultDomainName (the shape written by the queue functions). + $script:MixedRows = @( + [pscustomobject]@{ Tenant = 'tenanta.onmicrosoft.com'; Data = 'A' } + [pscustomobject]@{ Tenant = 'tenantb.onmicrosoft.com'; Data = 'B' } + [pscustomobject]@{ Tenant = 'tenantc.onmicrosoft.com'; Data = 'C-should-not-leak' } + ) +} + +Describe 'Select-CippAllowedTenantData' { + + Context 'Restricted caller (scope set to tenantA + tenantB)' { + BeforeEach { + # Get-Tenants is already narrowed to the caller's scope by the storage filter, so it + # only ever returns the allowed tenants. + Mock -CommandName Get-Tenants -MockWith { @($script:TenantA, $script:TenantB) } + $script:CippAllowedTenantsStorage.Value = @($script:TenantA.customerId, $script:TenantB.customerId) + } + + It 'returns only rows for allowed tenants' { + $Result = $script:MixedRows | Select-CippAllowedTenantData -TenantProperty 'Tenant' + @($Result).Count | Should -Be 2 + $Result.Data | Should -Contain 'A' + $Result.Data | Should -Contain 'B' + } + + It 'never returns a row for a tenant outside the scope' { + $Result = $script:MixedRows | Select-CippAllowedTenantData -TenantProperty 'Tenant' + $Result.Data | Should -Not -Contain 'C-should-not-leak' + } + + It 'matches rows by customerId as well as domain name' { + $Rows = @( + [pscustomobject]@{ TenantId = $script:TenantA.customerId; Data = 'A' } + [pscustomobject]@{ TenantId = '33333333-3333-3333-3333-333333333333'; Data = 'C-should-not-leak' } + ) + $Result = $Rows | Select-CippAllowedTenantData -TenantProperty 'TenantId' + @($Result).Count | Should -Be 1 + $Result.Data | Should -Be 'A' + } + + It 'matches case-insensitively' { + $Rows = @([pscustomobject]@{ Tenant = 'TENANTA.ONMICROSOFT.COM'; Data = 'A' }) + $Result = $Rows | Select-CippAllowedTenantData -TenantProperty 'Tenant' + @($Result).Count | Should -Be 1 + } + + It 'drops partner/system CIPP rows unless -AllowPartner is set' { + $Rows = @([pscustomobject]@{ Tenant = 'CIPP'; Data = 'system' }) + (@($Rows | Select-CippAllowedTenantData -TenantProperty 'Tenant')).Count | Should -Be 0 + (@($Rows | Select-CippAllowedTenantData -TenantProperty 'Tenant' -AllowPartner)).Count | Should -Be 1 + } + } + + Context 'Unrestricted caller (no scope)' { + BeforeEach { + Mock -CommandName Get-Tenants -MockWith { throw 'Get-Tenants must not be called for an unrestricted caller' } + $script:CippAllowedTenantsStorage.Value = $null + } + + It 'passes every row through unchanged' { + $Result = $script:MixedRows | Select-CippAllowedTenantData -TenantProperty 'Tenant' + @($Result).Count | Should -Be 3 + } + + It 'does not call Get-Tenants (zero overhead)' { + $null = $script:MixedRows | Select-CippAllowedTenantData -TenantProperty 'Tenant' + Should -Invoke -CommandName Get-Tenants -Times 0 -Exactly + } + + It 'treats an empty scope array as unrestricted' { + $script:CippAllowedTenantsStorage.Value = @() + $Result = $script:MixedRows | Select-CippAllowedTenantData -TenantProperty 'Tenant' + @($Result).Count | Should -Be 3 + } + } +} diff --git a/version_latest.txt b/version_latest.txt index f52231543ca7c..6ab00fa0dcd21 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.7.2 \ No newline at end of file +10.7.3 \ No newline at end of file