diff --git a/Config/SAMManifest.json b/Config/SAMManifest.json
index 3b304150e7cf8..1f7dd716f19c2 100644
--- a/Config/SAMManifest.json
+++ b/Config/SAMManifest.json
@@ -582,6 +582,34 @@
{
"id": "b7887744-6746-4312-813d-72daeaee7e2d",
"type": "Scope"
+ },
+ {
+ "id": "dd199f4a-f148-40a4-a2ec-f0069cc799ec",
+ "type": "Role"
+ },
+ {
+ "id": "8c026be3-8e26-4774-9372-8d5d6f21daff",
+ "type": "Scope"
+ },
+ {
+ "id": "fee28b28-e1f3-4841-818e-2704dc62245f",
+ "type": "Role"
+ },
+ {
+ "id": "62ade113-f8e0-4bf9-a6ba-5acb31db32fd",
+ "type": "Scope"
+ },
+ {
+ "id": "9e3f62cf-ca93-4989-b6ce-bf83c28f9fe8",
+ "type": "Role"
+ },
+ {
+ "id": "31e08e0a-d3f7-4ca2-ac39-7343fb83e8ad",
+ "type": "Role"
+ },
+ {
+ "id": "1ff1be21-34eb-448c-9ac9-ce1f506b2a68",
+ "type": "Scope"
}
]
},
@@ -689,4 +717,4 @@
"isEnabled": true,
"allProperties": true
}
-}
\ No newline at end of file
+}
diff --git a/Modules/CIPPCore/Public/Get-CIPPTestData.ps1 b/Modules/CIPPCore/Public/Get-CIPPTestData.ps1
index f3b6d1b8f06cb..ed6568c6d364b 100644
--- a/Modules/CIPPCore/Public/Get-CIPPTestData.ps1
+++ b/Modules/CIPPCore/Public/Get-CIPPTestData.ps1
@@ -4,15 +4,66 @@ function Get-CIPPTestData {
Cached wrapper around New-CIPPDbRequest for test functions
.DESCRIPTION
- Returns cached tenant data during test suite execution. The cache is
- backed by CIPP.TestDataCache (static ConcurrentDictionary in C#) so
- it is shared across all PowerShell runspaces within the worker process.
+ Returns cached tenant data during test suite execution. The cache is backed by
+ CIPP.TestDataCache (static ConcurrentDictionary in C#) so it is shared across all
+ PowerShell runspaces within the worker process.
+
+ RECORDS ARE FIELD-PROJECTED. Only the fields listed for $Type in
+ Get-CippTestDataFieldManifest are materialized. Reading an unlisted field returns $null
+ with no error, so the test emits a wrong verdict silently. If you add a field read to a
+ test, add it to the manifest first. A type absent from the manifest is fetched whole,
+ which is the safe default.
+
+ Projection is record-level: a kept field keeps its entire subtree, so
+ `$policy.conditions.users.includeRoles` only requires 'conditions'.
+
+ Records are PSCustomObject and must stay so. Hashtable output changes scalar semantics
+ ($x[0] becomes a key lookup returning $null; $x.Count returns the record's field count
+ instead of 1) and only when a pipeline yields exactly one record — data-dependent, and
+ easily missed in testing.
.PARAMETER TenantFilter
The tenant domain or GUID to filter by
.PARAMETER Type
The data type to retrieve (e.g., Users, Groups, ConditionalAccessPolicies)
+
+ .PARAMETER Fields
+ Optional. Override the manifest for this call: keep only these top-level fields. Prefer
+ the manifest; this is for one-off and diagnostic callers, not tests.
+
+ The field set is part of the cache key — it must be, or callers asking for the same
+ tenant+type with different lists would receive each other's projection. Distinct lists
+ therefore fragment the cache: the same rows parsed once per list, all alive together for
+ the TTL. Per-call-site lists measured several times worse than one shared per-type entry.
+ Fields belong to the type — see Get-CippTestDataFieldManifest.
+
+ .PARAMETER NoProjection
+ Return every field, ignoring the manifest.
+
+ Required for the custom-script sandbox (Get-CippSandboxData): those scripts are
+ customer-authored, so the fields they read are unknowable and a manifest built from our
+ own tests would silently hand them incomplete records. Any caller fetching data on behalf
+ of code we did not write must pass this.
+
+ .EXAMPLE
+ Get-CIPPTestData -TenantFilter $Tenant -Type 'Users'
+ Normal use: the manifest decides the fields. This is what tests should do.
+
+ .EXAMPLE
+ Get-CIPPTestData -TenantFilter $Tenant -Type 'Users' -NoProjection
+ Every field. For callers serving code we did not author.
+
+ .NOTES
+ Verifying a change: "0 errored" proves nothing, because the failure modes here are silent
+ — a broken test still reports success. Diff test verdicts against a baseline, across more
+ than one tenant: a bug that needs exactly one matching record will not show on a tenant
+ that has two.
+
+ Useful endpoints: ExecTestRun then ListTests for verdicts; ListDBCache to confirm a field
+ exists and its real casing before adding it to the manifest (type=_availableTypes lists
+ the types); ListWorkerHealth?Action=CacheDiag for entries, hit rate and a per-type
+ breakdown whose keys carry the projected field list.
#>
[CmdletBinding()]
param(
@@ -20,7 +71,13 @@ function Get-CIPPTestData {
[string]$TenantFilter,
[Parameter(Mandatory = $false)]
- [string]$Type
+ [string]$Type,
+
+ [Parameter(Mandatory = $false)]
+ [string[]]$Fields,
+
+ [Parameter(Mandatory = $false)]
+ [switch]$NoProjection
)
# Enforce tenant lock when running inside custom script execution
@@ -32,14 +89,36 @@ function Get-CIPPTestData {
throw 'TenantFilter is required.'
}
- $CacheKey = '{0}|{1}' -f $TenantFilter, $Type
+ # Resolve the field set: an explicit -Fields wins, otherwise consult the per-type manifest.
+ # -NoProjection suppresses both (the sandbox path must never receive projected records).
+ $EffectiveFields = if ($NoProjection) {
+ $null
+ } elseif ($Fields) {
+ $Fields
+ } else {
+ Get-CippTestDataFieldManifest -Type $Type
+ }
+
+ # Normalize the field set (sorted + de-duplicated) so that callers asking for the same
+ # fields in a different order share one cache entry instead of parsing twice.
+ $NormalizedFields = if ($EffectiveFields) { @($EffectiveFields | Sort-Object -Unique) } else { $null }
+
+ $CacheKey = if ($NormalizedFields) {
+ '{0}|{1}|{2}' -f $TenantFilter, $Type, ($NormalizedFields -join ',')
+ } else {
+ '{0}|{1}' -f $TenantFilter, $Type
+ }
$CachedValue = $null
if ([CIPP.TestDataCache]::TryGet($CacheKey, [ref]$CachedValue)) {
return $CachedValue
}
- $Data = New-CIPPDbRequest -TenantFilter $TenantFilter -Type $Type
+ $Data = if ($NormalizedFields) {
+ New-CIPPDbRequest -TenantFilter $TenantFilter -Type $Type -Fields $NormalizedFields
+ } else {
+ New-CIPPDbRequest -TenantFilter $TenantFilter -Type $Type
+ }
[CIPP.TestDataCache]::Set($CacheKey, $Data)
diff --git a/Modules/CIPPCore/Public/Get-CippDbRoleMembers.ps1 b/Modules/CIPPCore/Public/Get-CippDbRoleMembers.ps1
index 0b5b026df61aa..78b144efa856c 100644
--- a/Modules/CIPPCore/Public/Get-CippDbRoleMembers.ps1
+++ b/Modules/CIPPCore/Public/Get-CippDbRoleMembers.ps1
@@ -1,4 +1,47 @@
function Get-CippDbRoleMembers {
+ <#
+ .SYNOPSIS
+ Resolve the members of a directory role from cached PIM + directoryRole data.
+
+ .DESCRIPTION
+ Merges three sources into one member list, de-duplicated by principal id:
+ Active - PIM roleAssignmentScheduleInstances with assignmentType 'Assigned'
+ Eligible - PIM roleEligibilitySchedules (can activate, not currently active)
+ Direct - directoryRole membership assigned outside PIM
+
+ A principal may be a user, a servicePrincipal, or a group — use '@odata.type' to tell
+ them apart. userPrincipalName is null for anything that isn't a user, and appId is
+ populated only for servicePrincipals.
+
+ .PARAMETER TenantFilter
+ The tenant to resolve members for.
+
+ .PARAMETER RoleTemplateId
+ The role's TEMPLATE id (e.g. Global Administrator = 62e90394-69f5-4237-9190-012177145e10).
+
+ NOT the directoryRole instance id. PIM's roleDefinitionId carries template ids, so passing
+ an instance id (Roles.id) matches nothing and silently returns an empty list. When starting
+ from a Get-CippDbRole record, pass $Role.roleTemplateId — never $Role.id.
+
+ .OUTPUTS
+ PSCustomObject per member:
+ id - principal object id
+ displayName - principal display name
+ userPrincipalName - users only; null for servicePrincipals and groups
+ appId - servicePrincipals only; null otherwise
+ '@odata.type' - '#microsoft.graph.user' | '...servicePrincipal' | '...group'
+ AssignmentType - 'Active' | 'Eligible' | 'Direct'
+ EndDateTime - when the assignment expires; null when it does not
+ IsPermanent - $true when the assignment has no expiry
+
+ .NOTES
+ Depends on the cached records carrying an expanded 'principal' object — the Graph APIs
+ return only principalId by default, so the collectors request $expand=principal. Without
+ it every displayName/userPrincipalName here is null.
+
+ .FUNCTIONALITY
+ Internal
+ #>
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)]
@@ -27,8 +70,12 @@ function Get-CippDbRoleMembers {
id = $member.principalId
displayName = $member.principal.displayName
userPrincipalName = $member.principal.userPrincipalName
+ appId = $member.principal.appId
'@odata.type' = $member.principal.'@odata.type'
AssignmentType = 'Active'
+ EndDateTime = $member.endDateTime
+ # A PIM assignment with no endDateTime never expires.
+ IsPermanent = ($null -eq $member.endDateTime)
}
$AllMembers.Add($memberObj)
}
@@ -39,8 +86,12 @@ function Get-CippDbRoleMembers {
id = $member.principalId
displayName = $member.principal.displayName
userPrincipalName = $member.principal.userPrincipalName
+ appId = $member.principal.appId
'@odata.type' = $member.principal.'@odata.type'
AssignmentType = 'Eligible'
+ # Eligibilities carry their expiry under scheduleInfo, not endDateTime.
+ EndDateTime = $member.scheduleInfo.expiration.endDateTime
+ IsPermanent = ($member.scheduleInfo.expiration.type -eq 'noExpiration')
}
$AllMembers.Add($memberObj)
}
@@ -52,8 +103,12 @@ function Get-CippDbRoleMembers {
id = $member.id
displayName = $member.displayName
userPrincipalName = $member.userPrincipalName
+ appId = $member.appId
'@odata.type' = $member.'@odata.type'
AssignmentType = 'Direct'
+ # directoryRole membership assigned outside PIM has no expiry.
+ EndDateTime = $null
+ IsPermanent = $true
}
$AllMembers.Add($memberObj)
}
diff --git a/Modules/CIPPCore/Public/Get-CippSandboxData.ps1 b/Modules/CIPPCore/Public/Get-CippSandboxData.ps1
index daa7b4a32e143..1d5112b92963f 100644
--- a/Modules/CIPPCore/Public/Get-CippSandboxData.ps1
+++ b/Modules/CIPPCore/Public/Get-CippSandboxData.ps1
@@ -70,7 +70,7 @@ function Get-CippSandboxData {
$Key = if ($Type) { $Type } else { '' }
if (-not $Data.ContainsKey($Key)) {
- $Data[$Key] = @(Get-CIPPTestData -TenantFilter $TenantFilter -Type $Type)
+ $Data[$Key] = @(Get-CIPPTestData -TenantFilter $TenantFilter -Type $Type -NoProjection)
}
}
diff --git a/Modules/CIPPCore/Public/Get-CippTestDataFieldManifest.ps1 b/Modules/CIPPCore/Public/Get-CippTestDataFieldManifest.ps1
new file mode 100644
index 0000000000000..41905a55f7a71
--- /dev/null
+++ b/Modules/CIPPCore/Public/Get-CippTestDataFieldManifest.ps1
@@ -0,0 +1,165 @@
+function Get-CippTestDataFieldManifest {
+ <#
+ .SYNOPSIS
+ Per-type field manifest for Get-CIPPTestData projection.
+
+ .DESCRIPTION
+ Returns the union of top-level fields that every consumer of a given CippReportingDB type
+ reads, or $null for types that must be fetched whole.
+
+ ADDING OR CHANGING A FIELD READ IN A TEST? ADD IT HERE FIRST. Get-CIPPTestData only
+ materializes what is listed here; reading an unlisted field returns $null with no error,
+ and the test emits a wrong compliance verdict silently.
+
+ Rules for editing this table:
+
+ * Top-level names only. Projection is record-level: a kept field keeps its ENTIRE subtree.
+ For `$policy.conditions.users.includeRoles` you add 'conditions' — never 'users'.
+ * When in doubt, add it. Over-inclusion costs a little memory; omission corrupts results.
+ * Matching is case-insensitive, and tests rely on it — some read a field using different
+ casing than the stored JSON. Use the real JSON casing here; do not "fix" the test.
+ * A type absent from this table is fetched whole — the safe default. A new type, or a
+ field nobody listed, degrades to pre-projection behaviour rather than corrupting a
+ verdict. Deliberately absent are types whose tests discover column names at runtime by
+ reflecting over the record, and types small enough that projecting buys nothing.
+ * Verify with a verdict diff across more than one tenant — see Get-CIPPTestData .NOTES.
+ "0 errored" proves nothing; the failure mode is silent.
+
+ CONSUMERS THAT ARE NOT TEST FILES — easy to miss, since no test names these fields:
+
+ Get-CippDbRole ......... Roles
+ Get-CippDbRoleMembers .. RoleAssignmentScheduleInstances, RoleEligibilitySchedules,
+ Roles — including the 'principal' subtree, which no test
+ mentions; omitting it blanks every role member across the
+ privileged-access tests
+ Test-E8AsrRule ......... IntuneConfigurationPolicies — the field contract for the E8 ASR
+ tests, which read nothing themselves
+
+ Any new helper calling Get-CIPPTestData belongs on that list.
+
+ WHY PER-TYPE, NOT PER-CALL-SITE: the field set is part of the Get-CIPPTestData cache key,
+ so per-caller lists fragment the cache — the same rows parsed once per distinct list, all
+ alive together for the TTL. Many call sites share few types, and they mostly want the same
+ large subtrees, so fragmenting measured several times worse than one shared entry.
+
+ .PARAMETER Type
+ The CippReportingDB data type.
+
+ .OUTPUTS
+ [string[]] of field names, or $null meaning "no projection — return every field".
+
+ .EXAMPLE
+ Get-CippTestDataFieldManifest -Type 'Users'
+ Callers do not normally invoke this — Get-CIPPTestData consults it automatically by type.
+
+ .FUNCTIONALITY
+ Internal
+ #>
+ [CmdletBinding()]
+ [OutputType([string[]])]
+ param(
+ [Parameter(Mandatory = $false, Position = 0)]
+ [string]$Type
+ )
+
+ if ([string]::IsNullOrWhiteSpace($Type)) { return $null }
+
+ if (-not $script:CippTestDataFieldManifest) {
+ # Deliberately ABSENT (=> fetched whole), do not "helpfully" add them:
+ # CopilotUsageUserDetail, CopilotUserCountSummary, CopilotUserCountTrend — their tests
+ # discover column names at runtime by reflecting over the record, so no field list
+ # can be known ahead of time (CopilotReady015/016/017).
+ # ExoGlobalQuarantinePolicy — ORCA107 annotates the record via `Select-Object *`, and
+ # the type is tiny, so projecting buys nothing.
+ $script:CippTestDataFieldManifest = @{
+ 'AdminConsentRequestPolicy' = @('isEnabled', 'reviewers', 'notifyReviewers', 'remindersEnabled', 'requestDurationInDays')
+ 'Apps' = @('id', 'appId', 'displayName', 'keyCredentials', 'passwordCredentials', 'signInAudience', 'servicePrincipalLockConfiguration', 'owners', 'web', 'spa', 'publicClient')
+ 'AppsAndServices' = @('id', 'isOfficeStoreEnabled', 'isAppAndServicesTrialEnabled')
+ 'AuthenticationFlowsPolicy' = @('selfServiceSignUp')
+ 'AuthenticationMethodsPolicy' = @('authenticationMethodConfigurations', 'policyMigrationState', 'reportSuspiciousActivitySettings', 'systemCredentialPreferences')
+ 'AuthenticationStrengths' = @('id', 'displayName', 'policyType', 'allowedCombinations')
+ 'AuthorizationPolicy' = @('defaultUserRolePermissions', 'guestUserRoleId', 'allowInvitesFrom', 'allowedToUseSSPR', 'allowedToSignUpEmailBasedSubscriptions', 'allowEmailVerifiedUsersToJoinOrganization', 'permissionGrantPolicyIdsAssignedToDefaultUserRole', 'allowUserConsentForRiskyApps', 'allowedToCreateTenants')
+ 'B2BManagementPolicy' = @('allowInvitesFrom', 'invitationsAllowedAndBlockedDomainsPolicy', 'definition')
+ 'CASMailbox' = @('Identity', 'DisplayName', 'SmtpClientAuthenticationDisabled')
+ 'ConditionalAccessPolicies' = @('id', 'displayName', 'state', 'conditions', 'grantControls', 'sessionControls', 'createdDateTime', 'modifiedDateTime')
+ 'CopilotReadinessActivity' = @('userPrincipalName', 'usesOutlookEmail', 'usesTeamsMeetings', 'usesTeamsChat', 'usesOfficeDocs', 'onQualifiedUpdateChannel', 'hasCopilotLicenseAssigned')
+ 'CrossTenantAccessPolicy' = @('id', 'b2bCollaborationOutbound', 'b2bDirectConnectOutbound', 'tenantRestrictions')
+ 'CsExternalAccessPolicy' = @('EnableFederationAccess', 'EnableTeamsConsumerAccess')
+ 'CsTeamsAppPermissionPolicy' = @('Identity', 'GlobalCatalogAppsType', 'DefaultCatalogAppsType')
+ 'CsTeamsClientConfiguration' = @('AllowDropbox', 'AllowBox', 'AllowGoogleDrive', 'AllowShareFile', 'AllowEgnyte', 'AllowEmailIntoChannel')
+ 'CsTeamsMeetingPolicy' = @('AllowAnonymousUsersToJoinMeeting', 'AllowAnonymousUsersToStartMeeting', 'AutoAdmittedUsers', 'AllowPSTNUsersToBypassLobby', 'MeetingChatEnabledType', 'DesignatedPresenterRoleMode', 'AllowExternalParticipantGiveRequestControl', 'AllowExternalNonTrustedMeetingChat', 'AllowCloudRecording')
+ 'CsTeamsMessagingPolicy' = @('UseB2BInvitesToAddExternalUsers', 'AllowSecurityEndUserReporting')
+ 'CsTenantFederationConfiguration' = @('AllowFederatedUsers', 'AllowedDomains', 'AllowTeamsConsumer')
+ 'DefaultAppManagementPolicy' = @('isEnabled', 'applicationRestrictions', 'servicePrincipalRestrictions')
+ 'DeviceRegistrationPolicy' = @('azureADJoin', 'userDeviceQuota', 'localAdminPassword', 'multiFactorAuthConfiguration')
+ 'DeviceSettings' = @('secureByDefault')
+ 'DirectoryRecommendations' = @('status', 'priority', 'displayName', 'impactType', 'lastModifiedDateTime', 'insights', 'recommendationType', 'applicationDisplayName', 'applicationId')
+ 'DlpCompliancePolicies' = @('Name', 'DisplayName', 'Mode', 'Enabled', 'TeamsLocation', 'TeamsLocationException', 'Workload', 'EnforcementPlanes')
+ 'Domains' = @('id', 'passwordValidityPeriodInDays', 'authenticationType')
+ 'ExoAcceptedDomains' = @('DomainName', 'DomainType', 'SendingFromDomainDisabled')
+ 'ExoAdminAuditLogConfig' = @('UnifiedAuditLogIngestionEnabled', 'AdminAuditLogEnabled')
+ 'ExoAntiPhishPolicies' = @('Name', 'Identity', 'Enabled', 'PhishThresholdLevel', 'EnableMailboxIntelligence', 'EnableMailboxIntelligenceProtection', 'EnableSpoofIntelligence', 'TargetedUserProtectionAction', 'TargetedDomainProtectionAction', 'MailboxIntelligenceProtectionAction', 'AuthenticationFailAction', 'EnableFirstContactSafetyTips', 'EnableSimilarUsersSafetyTips', 'EnableSimilarDomainsSafetyTips', 'EnableUnusualCharactersSafetyTips', 'EnableUnauthenticatedSender', 'ExcludedSenders', 'ExcludedDomains', 'RecipientDomainIs', 'HonorDmarcPolicy')
+ 'ExoAtpPolicyForO365' = @('EnableATPForSPOTeamsODB', 'EnableSafeDocs', 'AllowSafeDocsOpen')
+ 'ExoDkimSigningConfig' = @('Domain', 'Enabled', 'Selector1CNAME', 'Selector2CNAME')
+ 'ExoHostedContentFilterPolicy' = @('Name', 'Identity', 'AllowedSenders', 'AllowedSenderDomains', 'EnableSafeList', 'SpamAction', 'HighConfidenceSpamAction', 'BulkSpamAction', 'PhishSpamAction', 'HighConfidencePhishAction', 'RecipientDomainIs', 'BulkThreshold', 'MarkAsSpamBulkMail', 'QuarantineRetentionPeriod', 'InlineSafetyTipsEnabled', 'IPAllowList', 'PhishZapEnabled', 'SpamZapEnabled',
+ 'IncreaseScoreWithImageLinks', 'IncreaseScoreWithNumericIps', 'IncreaseScoreWithRedirectToOtherPort', 'IncreaseScoreWithBizOrInfoUrls', 'MarkAsSpamEmptyMessages', 'MarkAsSpamJavaScriptInHtml', 'MarkAsSpamFramesInHtml', 'MarkAsSpamObjectTagsInHtml', 'MarkAsSpamEmbedTagsInHtml', 'MarkAsSpamFormTagsInHtml', 'MarkAsSpamWebBugsInHtml', 'MarkAsSpamSensitiveWordList', 'MarkAsSpamFromAddressAuthFail', 'MarkAsSpamNdrBackscatter', 'MarkAsSpamSpfRecordHardFail')
+ 'ExoHostedOutboundSpamFilterPolicy' = @('Identity', 'IsDefault', 'RecipientLimitExternalPerHour', 'RecipientLimitInternalPerHour', 'RecipientLimitPerDay', 'ActionWhenThresholdReached', 'NotifyOutboundSpam', 'NotifyOutboundSpamRecipients', 'BccSuspiciousOutboundMail', 'BccSuspiciousOutboundAdditionalRecipients', 'AutoForwardingMode')
+ 'ExoInboundConnector' = @('Identity', 'Enabled', 'SenderDomains', 'EFSkipLastIP', 'EFSkipIPs', 'EFTestMode', 'EFUsers')
+ 'ExoMalwareFilterPolicies' = @('Name', 'Identity', 'IsDefault', 'EnableFileFilter', 'FileTypes', 'EnableInternalSenderAdminNotifications', 'InternalSenderAdminAddress', 'ZapEnabled', 'Action', 'RecipientDomainIs')
+ 'ExoOrganizationConfig' = @('CustomerLockBoxEnabled', 'BookingsEnabled', 'AuditDisabled', 'ExternalInOutlookEnabled', 'ExternalInOutlook', 'OAuth2ClientProfileEnabled', 'MailTipsAllTipsEnabled', 'MailTipsExternalRecipientsTipsEnabled', 'MailTipsGroupMetricsEnabled', 'MailTipsLargeAudienceThreshold', 'RejectDirectSend')
+ 'ExoPresetSecurityPolicy' = @('Identity', 'State', 'ImpersonationProtectionState', 'EnableMailboxIntelligence', 'EnableMailboxIntelligenceProtection', 'EnableSimilarUsersSafetyTips', 'EnableSimilarDomainsSafetyTips', 'EnableUnusualCharactersSafetyTips')
+ 'ExoProtectionAlert' = @('Name', 'Disabled')
+ 'ExoRemoteDomain' = @('Name', 'DomainName', 'AutoForwardEnabled')
+ 'ExoSafeAttachmentPolicies' = @('Name', 'Identity', 'Enable', 'Action', 'RecipientDomainIs')
+ 'ExoSafeLinksPolicies' = @('Name', 'Identity', 'EnableSafeLinksForEmail', 'EnableSafeLinksForTeams', 'EnableSafeLinksForOffice', 'TrackClicks', 'TrackUserClicks', 'AllowClickThrough', 'ScanUrls', 'EnableForInternalSenders', 'DeliverMessageAfterScan', 'DisableUrlRewrite', 'RecipientDomainIs', 'IsBuiltInProtection')
+ 'ExoSharingPolicy' = @('Name', 'Enabled', 'Domains')
+ 'ExoTenantAllowBlockList' = @('Action', 'ListType', 'Value')
+ 'ExoTransportConfig' = @('SmtpClientAuthenticationDisabled')
+ 'ExoTransportRules' = @('Name', 'State', 'Priority', 'SetSCL', 'SetSpamConfidenceLevel', 'SetHeaderName', 'SetHeaderValue', 'SenderDomainIs')
+ 'FormsSettings' = @('isInOrgFormsPhishingScanEnabled')
+ 'Groups' = @('id', 'displayName', 'mail', 'visibility', 'groupTypes', 'members', 'isAssignableToRole')
+ 'Guests' = @('id', 'displayName', 'userPrincipalName', 'accountEnabled', 'signInActivity', 'createdDateTime', 'sponsors')
+ # URLName is added by the collector (Add-Member), not returned by Graph. It carries the
+ # Graph resource each policy came from (iosManagedAppProtection /
+ # androidManagedAppProtection / targetedManagedAppConfiguration) and is the only
+ # platform discriminator on these records — @odata.type is not preserved in the cache.
+ 'IntuneAppProtectionManagedAppPolicies' = @('URLName', 'displayName', 'assignments')
+ 'IntuneConfigurationPolicies' = @('name', 'platforms', 'technologies', 'templateReference', 'settings', 'assignments')
+ 'IntuneDeviceCompliancePolicies' = @('@odata.type', 'displayName', 'assignments', 'osMinimumVersion', 'bitLockerEnabled', 'storageRequireEncryption')
+ 'IntuneDeviceConfigurations' = @('@odata.type', 'displayName', 'assignments', 'qualityUpdatesDeferralPeriodInDays', 'fileVaultEnabled', 'wiFiSecurityType')
+ 'IntuneDeviceEnrollmentConfigurations' = @('@odata.type', 'displayName', 'priority', 'deviceEnrollmentConfigurationType', 'assignments', 'androidForWorkRestriction', 'androidRestriction', 'iosRestriction', 'macOSRestriction', 'windowsRestriction')
+ 'LicenseOverview' = @('License', 'TotalLicenses', 'CountUsed', 'ServicePlans', 'AssignedUsers', 'TermInfo')
+ 'Mailboxes' = @('UPN', 'UserPrincipalName', 'displayName', 'recipientTypeDetails', 'ExternalDirectoryObjectId', 'AuditEnabled', 'AuditOwner', 'AuditBypassEnabled', 'WhenSoftDeleted', 'LitigationHoldEnabled', 'LicensedForLitigationHold', 'ComplianceTagHoldApplied', 'RetentionPolicy', 'InPlaceHolds')
+ 'ManagedDevices' = @('deviceName', 'lastSyncDateTime', 'operatingSystem', 'osVersion')
+ 'MDEOnboarding' = @('partnerState')
+ 'MFAState' = @('UPN', 'userPrincipalName', 'DisplayName', 'AccountEnabled', 'UserType', 'IsAdmin', 'isLicensed', 'PerUser', 'PerUserMFAState', 'CoveredByCA', 'CoveredBySD', 'MFARegistration', 'MFACapable', 'MFAMethods')
+ 'NamedLocations' = @('@odata.type', 'displayName', 'isTrusted')
+ 'OfficeActivations' = @('userPrincipalName', 'userActivationCounts')
+ 'Organization' = @('onPremisesSyncEnabled', 'onPremisesLastSyncDateTime')
+ 'OwaMailboxPolicy' = @('Identity', 'IsDefault', 'PersonalAccountsEnabled', 'PersonalAccountCalendarsEnabled', 'AdditionalStorageProvidersAvailable')
+ 'ReportSubmissionPolicy' = @('ReportJunkToCustomizedAddress', 'ReportPhishToCustomizedAddress', 'ReportChatMessageEnabled', 'ReportChatMessageToCustomizedAddressEnabled')
+ 'RiskDetections' = @('riskState', 'riskLevel', 'riskEventType', 'riskDetail', 'userPrincipalName', 'userDisplayName', 'detectedDateTime', 'activityDateTime')
+ 'RiskyServicePrincipals' = @('id', 'appId', 'displayName', 'servicePrincipalType', 'riskState', 'riskLevel', 'riskLastUpdatedDateTime')
+ 'RiskyUsers' = @('id', 'userPrincipalName', 'riskState', 'riskLevel', 'riskDetail', 'riskLastUpdatedDateTime')
+ # 'principal' is NOT read by any test file — Get-CippDbRoleMembers reads
+ # $member.principal.displayName/.userPrincipalName. Omitting it would silently blank
+ # every role member across the CIS/E8/ZTNA privileged-access tests.
+ 'RoleAssignmentScheduleInstances' = @('roleDefinitionId', 'assignmentType', 'memberType', 'endDateTime', 'principalId', 'principal')
+ 'RoleEligibilitySchedules' = @('roleDefinitionId', 'principalId', 'principal', 'scheduleInfo')
+ # policyId, not id: this type is sourced from roleManagementPolicyAssignments (only the
+ # assignment carries roleDefinitionId) and the policy is flattened up one level.
+ 'RoleManagementPolicies' = @('policyId', 'scopeId', 'scopeType', 'roleDefinitionId', 'rules', 'effectiveRules')
+ 'Roles' = @('id', 'displayName', 'roleTemplateId', 'members')
+ 'SecureScore' = @('currentScore', 'maxScore', 'createdDateTime', 'controlScores')
+ 'SensitivityLabels' = @('name', 'PolicyName', 'IsValid', 'isActive', 'sensitivity', 'parent', 'hasProtection')
+ 'ServicePrincipalRiskDetections' = @('servicePrincipalId', 'servicePrincipalDisplayName', 'appId', 'activity', 'riskState', 'riskLevel', 'riskEventType', 'detectedDateTime', 'lastUpdatedDateTime')
+ 'ServicePrincipals' = @('id', 'appId', 'displayName', 'accountEnabled', 'keyCredentials', 'passwordCredentials', 'appOwnerOrganizationId', 'servicePrincipalType', 'replyUrls', 'owners', 'appRoleAssignmentRequired', 'preferredSingleSignOnMode')
+ 'Settings' = @('id', 'templateId', 'displayName', 'values', 'isOfficeStoreEnabled', 'isAppAndServicesTrialEnabled', 'isInOrgFormsPhishingScanEnabled')
+ 'SPOTenant' = @('LegacyAuthProtocolsEnabled', 'EnableAzureADB2BIntegration', 'SharingCapability', 'OneDriveSharingCapability', 'PreventExternalUsersFromResharing', 'SharingDomainRestrictionMode', 'SharingAllowedDomainList', 'SharingBlockedDomainList', 'DefaultSharingLinkType', 'DefaultLinkPermission', 'ExternalUserExpirationRequired', 'ExternalUserExpireInDays', 'EmailAttestationRequired', 'EmailAttestationReAuthDays', 'DisallowInfectedFileDownload')
+ 'UserRegistrationDetails' = @('id', 'userPrincipalName', 'userDisplayName', 'isMfaCapable', 'isMfaRegistered', 'methodsRegistered')
+ 'Users' = @('id', 'userPrincipalName', 'displayName', 'accountEnabled', 'userType', 'onPremisesSyncEnabled', 'assignedLicenses', 'assignedPlans', 'signInActivity', 'passwordPolicies')
+ }
+ }
+
+ return $script:CippTestDataFieldManifest[$Type]
+}
diff --git a/Modules/CIPPCore/Public/New-CIPPDbRequest.ps1 b/Modules/CIPPCore/Public/New-CIPPDbRequest.ps1
index 0a3345e5cbacb..77b17558ce962 100644
--- a/Modules/CIPPCore/Public/New-CIPPDbRequest.ps1
+++ b/Modules/CIPPCore/Public/New-CIPPDbRequest.ps1
@@ -4,7 +4,11 @@ function New-CIPPDbRequest {
Query the CIPP Reporting database by partition key
.DESCRIPTION
- Retrieves data from the CippReportingDB table filtered by partition key (tenant)
+ Retrieves data from the CippReportingDB table filtered by partition key (tenant).
+
+ Rows are parsed by CIPP.CippJson (System.Text.Json), not ConvertFrom-Json — see .NOTES.
+ Most callers should use Get-CIPPTestData instead, which adds the shared cache and applies
+ the per-type field manifest automatically. Call this directly only to bypass both.
.PARAMETER TenantFilter
The tenant domain or GUID to filter by (used as partition key)
@@ -12,11 +16,41 @@ function New-CIPPDbRequest {
.PARAMETER Type
Optional. The data type to filter by (e.g., Users, Groups, Devices)
+ .PARAMETER Fields
+ Optional. Keep only these top-level fields on each returned record; everything else is
+ skipped without being materialized. A retained field keeps its ENTIRE subtree — projection
+ never reaches inside a kept value, so `$p.conditions.users.includeRoles` only requires
+ 'conditions'. Omit to return every field. Matching is case-insensitive.
+
+ This is the only memory lever, but its value depends entirely on which fields you keep: it
+ pays where records are large and the read-set is small, and buys almost nothing where the
+ retained subtrees are most of the payload. Measure before assuming a win.
+
+ .NOTES
+ Backed by CIPP.CippJson (System.Text.Json). Output is PSCustomObject with
+ ConvertFrom-Json's [DateTime] coercion and Int64 number semantics preserved deliberately,
+ so this is a drop-in replacement for every existing caller. Matching those semantics costs
+ nothing measurable — do not reintroduce divergence to save a branch.
+
+ Unparseable rows are skipped rather than thrown, matching the -ErrorAction SilentlyContinue
+ on the ConvertFrom-Json this replaced. A row whose Data is a JSON array returns object[],
+ which PowerShell unrolls into the output stream — the same shape the old pipeline produced.
+
+ Without -Fields the parser alone is modestly faster and allocates less, but retains the
+ same live bytes — the parser was never the memory cost. Only -Fields reduces footprint.
+
+ Benchmark on production's runtime (PowerShell 7.4 / .NET 8, Linux container).
+ System.Text.Json timings differ enough on newer runtimes to invert conclusions;
+ allocation numbers are runtime-insensitive.
+
.EXAMPLE
New-CIPPDbRequest -TenantFilter 'contoso.onmicrosoft.com'
.EXAMPLE
New-CIPPDbRequest -TenantFilter 'contoso.onmicrosoft.com' -Type 'Users'
+
+ .EXAMPLE
+ New-CIPPDbRequest -TenantFilter 'contoso.onmicrosoft.com' -Type 'Users' -Fields 'id','displayName'
#>
[CmdletBinding()]
param(
@@ -24,7 +58,10 @@ function New-CIPPDbRequest {
[string]$TenantFilter,
[Parameter(Mandatory = $false)]
- [string]$Type
+ [string]$Type,
+
+ [Parameter(Mandatory = $false)]
+ [string[]]$Fields
)
try {
@@ -69,7 +106,20 @@ function New-CIPPDbRequest {
$Results = Get-CIPPAzDataTableEntity @Table -Filter $Filter
- return ($Results.Data | ConvertFrom-Json -ErrorAction SilentlyContinue)
+ # CippJson replaces `$Results.Data | ConvertFrom-Json`. A row whose Data is a JSON array
+ # returns object[], which PowerShell unrolls into the output stream — the same shape the
+ # pipeline produced before. Bad rows are skipped rather than thrown, matching the
+ # -ErrorAction SilentlyContinue this replaced.
+ $Projection = if ($Fields) { [string[]]$Fields } else { $null }
+ $Output = foreach ($Row in $Results.Data) {
+ if ([string]::IsNullOrWhiteSpace($Row)) { continue }
+ try {
+ [CIPP.CippJson]::ConvertFromJson($Row, $Projection)
+ } catch {
+ Write-Information "Skipping unparseable CippReportingDB row for '$Tenant'/'$Type': $($_.Exception.Message)"
+ }
+ }
+ return $Output
} catch {
Write-LogMessage -API 'CIPPDbRequest' -tenant $TenantFilter -message "Failed to query database: $($_.Exception.Message)" -sev Error
throw
diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleAssignmentScheduleInstances.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleAssignmentScheduleInstances.ps1
index ca5aab39a4bf0..06387748b9dcb 100644
--- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleAssignmentScheduleInstances.ps1
+++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleAssignmentScheduleInstances.ps1
@@ -18,7 +18,17 @@ function Set-CIPPDBCacheRoleAssignmentScheduleInstances {
try {
Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching role assignment schedule instances' -sev Debug
- New-GraphGetRequest -Uri 'https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignmentScheduleInstances' -tenantid $TenantFilter -Stream |
+ # -AsApp is required: the RoleManagement.*.Directory grant is an APPLICATION permission,
+ # so it only appears in an app-only token. The default delegated path (service account +
+ # GDAP) fails with "Attempted to perform an unauthorized operation" because PIM reads via
+ # delegated access additionally need the signed-in user to hold a directory role such as
+ # Privileged Role Administrator, which GDAP does not grant.
+ # $expand=principal is required by Get-CippDbRoleMembers, which reads
+ # $member.principal.displayName/.userPrincipalName/.'@odata.type'. The API returns only
+ # principalId (a GUID) by default, so without this those all resolve to $null and every
+ # role member surfaces with a blank name.
+ $Uri = 'https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignmentScheduleInstances?$expand=principal'
+ New-GraphGetRequest -Uri $Uri -tenantid $TenantFilter -AsApp $true -Stream |
Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'RoleAssignmentScheduleInstances' -AddCount
Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached role assignment schedule instances successfully' -sev Debug
diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleEligibilitySchedules.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleEligibilitySchedules.ps1
index e730bab436fc5..cdef0a857ee3e 100644
--- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleEligibilitySchedules.ps1
+++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleEligibilitySchedules.ps1
@@ -18,7 +18,12 @@ function Set-CIPPDBCacheRoleEligibilitySchedules {
try {
Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching role eligibility schedules' -sev Debug
- New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/roleManagement/directory/roleEligibilitySchedules' -tenantid $TenantFilter -Stream |
+ # -AsApp is required: RoleManagement.*.Directory is an APPLICATION permission and only
+ # lands in an app-only token. The default delegated path returns "unauthorized".
+ # $expand=principal — see the note in Set-CIPPDBCacheRoleAssignmentScheduleInstances;
+ # Get-CippDbRoleMembers reads principal.displayName off these records too.
+ $Uri = 'https://graph.microsoft.com/beta/roleManagement/directory/roleEligibilitySchedules?$expand=principal'
+ New-GraphGetRequest -uri $Uri -tenantid $TenantFilter -AsApp $true -Stream |
Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'RoleEligibilitySchedules' -AddCount
Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached role eligibility schedules successfully' -sev Debug
diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleManagementPolicies.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleManagementPolicies.ps1
index 168e8aeedbf3b..3e68308d27a52 100644
--- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleManagementPolicies.ps1
+++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleManagementPolicies.ps1
@@ -1,13 +1,36 @@
function Set-CIPPDBCacheRoleManagementPolicies {
<#
.SYNOPSIS
- Caches role management policies for a tenant
+ Caches PIM role management policies for a tenant, keyed by the role they apply to
.PARAMETER TenantFilter
The tenant to cache role management policies for
.PARAMETER QueueId
The queue ID to update with total tasks (optional)
+
+ .NOTES
+ Reads roleManagementPolicyAssignments, NOT roleManagementPolicies, because only the
+ assignment carries roleDefinitionId — the policy record does not identify the role it
+ applies to, and the role id is not derivable from it (its id embeds a policy GUID that
+ matches no roleTemplateId). Consumers need to find "the policy for role X", so the
+ assignment is the correct entity.
+ https://learn.microsoft.com/graph/api/policyroot-list-rolemanagementpolicyassignments
+
+ $filter is REQUIRED by this API — it must be scoped to a scopeId and scopeType, and the
+ request errors without it. rules/effectiveRules are navigation properties on the policy,
+ so they need a nested $expand; they are absent from the default response and consumers
+ read both.
+
+ The policy is flattened up one level so cached records expose roleDefinitionId alongside
+ rules/effectiveRules, which is the shape the tests consume. This costs -Stream (the
+ Select-Object has to materialize), but the result set is small (~144 records/tenant).
+
+ -AsApp is required: RoleManagement.*.Directory is an APPLICATION permission and only
+ lands in an app-only token. The default delegated path returns "unauthorized".
+
+ A tenant that has never onboarded PIM returns "MissingProvider: The provider is missing"
+ regardless of query or permissions. That is tenant state, not a bug in this call.
#>
[CmdletBinding()]
param(
@@ -18,7 +41,12 @@ function Set-CIPPDBCacheRoleManagementPolicies {
try {
Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching role management policies' -sev Debug
- New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/policies/roleManagementPolicies' -tenantid $TenantFilter -Stream |
+
+ $Uri = "https://graph.microsoft.com/beta/policies/roleManagementPolicyAssignments?`$filter=scopeId eq '/' and scopeType eq 'DirectoryRole'&`$expand=policy(`$expand=rules,effectiveRules)"
+ New-GraphGetRequest -uri $Uri -tenantid $TenantFilter -AsApp $true |
+ Select-Object -Property policyId, roleDefinitionId, scopeId, scopeType,
+ @{ Name = 'rules'; Expression = { $_.policy.rules } },
+ @{ Name = 'effectiveRules'; Expression = { $_.policy.effectiveRules } } |
Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'RoleManagementPolicies' -AddCount
Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached role management policies successfully' -sev Debug
diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_2.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_2.ps1
index 4684f72618f06..6ba82dcf1b788 100644
--- a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_2.ps1
+++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_2.ps1
@@ -33,8 +33,11 @@ function Invoke-CippTestCIS_1_1_2 {
}
}
+ # roleDefinitionId carries the role's TEMPLATE id, not the directoryRole instance id, so
+ # comparing it to $GA.id never matched and no PIM-assigned admin was ever counted here —
+ # only the direct members above.
foreach ($Assignment in @($RoleAssignmentScheduleInstances)) {
- if ($Assignment.roleDefinitionId -eq $GA.id -and $Assignment.assignmentType -eq 'Assigned' -and $null -eq $Assignment.endDateTime -and $Assignment.principalId) {
+ if ($Assignment.roleDefinitionId -eq $GA.roleTemplateId -and $Assignment.assignmentType -eq 'Assigned' -and $null -eq $Assignment.endDateTime -and $Assignment.principalId) {
[void]$GAUserIds.Add([string]$Assignment.principalId)
}
}
diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_3.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_3.ps1
index fe93d5b980b06..441f577a97d25 100644
--- a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_3.ps1
+++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_3.ps1
@@ -28,8 +28,11 @@ function Invoke-CippTestCIS_1_1_3 {
}
}
+ # roleDefinitionId carries the role's TEMPLATE id, not the directoryRole instance id, so
+ # comparing it to $GA.id never matched and no PIM-assigned admin was ever counted here —
+ # only the direct members above.
foreach ($Assignment in @($RoleAssignmentScheduleInstances)) {
- if ($Assignment.roleDefinitionId -eq $GA.id -and $Assignment.assignmentType -eq 'Assigned' -and $null -eq $Assignment.endDateTime -and $Assignment.principalId) {
+ if ($Assignment.roleDefinitionId -eq $GA.roleTemplateId -and $Assignment.assignmentType -eq 'Assigned' -and $null -eq $Assignment.endDateTime -and $Assignment.principalId) {
[void]$GAMembers.Add([string]$Assignment.principalId)
}
}
diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Devices/Invoke-CippTestZTNA24548.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Devices/Invoke-CippTestZTNA24548.ps1
index 0809843b69697..8196f6053aadd 100644
--- a/Modules/CIPPTests/Public/Tests/ZTNA/Devices/Invoke-CippTestZTNA24548.ps1
+++ b/Modules/CIPPTests/Public/Tests/ZTNA/Devices/Invoke-CippTestZTNA24548.ps1
@@ -9,20 +9,33 @@ function Invoke-CippTestZTNA24548 {
#Tested - Device
try {
- $IosPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneIosAppProtectionPolicies'
+ # App protection policies for every platform live under one type; URLName carries the
+ # Graph resource they came from and is the platform discriminator. This previously read
+ # 'IntuneIosAppProtectionPolicies', a type no collector writes, so the test always skipped.
+ $AllPolicies = @(Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneAppProtectionManagedAppPolicies')
- if (-not $IosPolicies) {
+ # Only skip when the type itself is absent (no Intune licence, or collection has not run).
+ if ($AllPolicies.Count -eq 0) {
Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'No data found in database. This may be due to missing required licenses or data collection not yet completed.' -Risk 'High' -Name 'Data on iOS/iPadOS is protected by app protection policies' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Tenant'
return
}
+ $IosPolicies = @($AllPolicies | Where-Object { $_.URLName -eq 'iosManagedAppProtection' })
+
+ # Data exists but no iOS policy at all — that is a genuine failure of this control, not a
+ # missing-data skip.
+ if ($IosPolicies.Count -eq 0) {
+ Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "❌ No iOS/iPadOS app protection policy exists in this tenant.`n`nApp protection policies were found for other platforms, so Intune data is being collected — there is simply no iOS policy." -Risk 'High' -Name 'Data on iOS/iPadOS is protected by app protection policies' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Tenant'
+ return
+ }
+
$AssignedPolicies = @($IosPolicies | Where-Object { $_.assignments -and $_.assignments.Count -gt 0 })
$Passed = $AssignedPolicies.Count -gt 0
if ($Passed) {
$ResultMarkdown = [System.Text.StringBuilder]::new("✅ At least one iOS app protection policy exists and is assigned.`n`n")
} else {
- $ResultMarkdown = [System.Text.StringBuilder]::new("❌ No iOS app protection policy exists or none are assigned.`n`n")
+ $ResultMarkdown = [System.Text.StringBuilder]::new("❌ iOS app protection policies exist but none are assigned.`n`n")
}
$null = $ResultMarkdown.Append("## iOS App Protection Policies`n`n")
diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Devices/Invoke-CippTestZTNA24549.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Devices/Invoke-CippTestZTNA24549.ps1
index 2837b609f04d7..b2ef4a25e3367 100644
--- a/Modules/CIPPTests/Public/Tests/ZTNA/Devices/Invoke-CippTestZTNA24549.ps1
+++ b/Modules/CIPPTests/Public/Tests/ZTNA/Devices/Invoke-CippTestZTNA24549.ps1
@@ -9,20 +9,33 @@ function Invoke-CippTestZTNA24549 {
#Tested - Device
try {
- $AndroidPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneAndroidAppProtectionPolicies'
+ # App protection policies for every platform live under one type; URLName carries the
+ # Graph resource they came from and is the platform discriminator. This previously read
+ # 'IntuneAndroidAppProtectionPolicies', a type no collector writes, so the test always skipped.
+ $AllPolicies = @(Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneAppProtectionManagedAppPolicies')
- if (-not $AndroidPolicies) {
+ # Only skip when the type itself is absent (no Intune licence, or collection has not run).
+ if ($AllPolicies.Count -eq 0) {
Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'No data found in database. This may be due to missing required licenses or data collection not yet completed.' -Risk 'High' -Name 'Data on Android is protected by app protection policies' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Tenant'
return
}
+ $AndroidPolicies = @($AllPolicies | Where-Object { $_.URLName -eq 'androidManagedAppProtection' })
+
+ # Data exists but no Android policy at all — that is a genuine failure of this control, not
+ # a missing-data skip.
+ if ($AndroidPolicies.Count -eq 0) {
+ Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "❌ No Android app protection policy exists in this tenant.`n`nApp protection policies were found for other platforms, so Intune data is being collected — there is simply no Android policy." -Risk 'High' -Name 'Data on Android is protected by app protection policies' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Tenant'
+ return
+ }
+
$AssignedPolicies = @($AndroidPolicies | Where-Object { $_.assignments -and $_.assignments.Count -gt 0 })
$Passed = $AssignedPolicies.Count -gt 0
if ($Passed) {
$ResultMarkdown = [System.Text.StringBuilder]::new("✅ At least one Android app protection policy exists and is assigned.`n`n")
} else {
- $ResultMarkdown = [System.Text.StringBuilder]::new("❌ No Android app protection policy exists or none are assigned.`n`n")
+ $ResultMarkdown = [System.Text.StringBuilder]::new("❌ Android app protection policies exist but none are assigned.`n`n")
}
$null = $ResultMarkdown.Append("## Android App Protection Policies`n`n")
diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21813.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21813.ps1
index 9f3f82f2e05ae..c43db9e83435a 100644
--- a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21813.ps1
+++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21813.ps1
@@ -24,11 +24,14 @@ function Invoke-CippTestZTNA21813 {
$UserRoleMap = @{}
foreach ($Role in $PrivilegedRoles) {
+ # 'roleTemplateId', not 'templateId' — the Roles cache has no templateId field at all
+ # (description, displayName, id, memberCount, members, roleTemplateId), so both filters
+ # below compared against $null and matched nothing.
$ActiveAssignments = $RoleAssignmentScheduleInstances | Where-Object {
- $_.roleDefinitionId -eq $Role.templateId -and $_.assignmentType -eq 'Assigned'
+ $_.roleDefinitionId -eq $Role.roleTemplateId -and $_.assignmentType -eq 'Assigned'
}
$EligibleAssignments = $RoleEligibilitySchedules | Where-Object {
- $_.roleDefinitionId -eq $Role.templateId
+ $_.roleDefinitionId -eq $Role.roleTemplateId
}
$AllAssignments = @($ActiveAssignments) + @($EligibleAssignments)
@@ -38,7 +41,9 @@ function Invoke-CippTestZTNA21813 {
if (-not $User) { continue }
$UserId = $User.id
- $IsGARole = $Role.templateId -eq $GlobalAdminRoleId
+ # roleTemplateId — $Role.templateId does not exist, so this was always false and
+ # no user was ever classed as a Global Administrator.
+ $IsGARole = $Role.roleTemplateId -eq $GlobalAdminRoleId
if ($IsGARole) {
$AllGAUsers[$UserId] = $User
diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21816.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21816.ps1
index 3705a2cb776e5..23ac636141a5a 100644
--- a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21816.ps1
+++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21816.ps1
@@ -55,7 +55,9 @@ function Invoke-CippTestZTNA21816 {
}
foreach ($Role in $PrivilegedRoles) {
- if ($Role.templateId -eq $GlobalAdminRoleId) { continue }
+ # roleTemplateId — $Role.templateId does not exist, so this guard never fired and the
+ # Global Administrator role was processed here despite being handled separately below.
+ if ($Role.roleTemplateId -eq $GlobalAdminRoleId) { continue }
$RoleMembers = Get-CippDbRoleMembers -TenantFilter $Tenant -RoleTemplateId $Role.RoletemplateId
diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21818.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21818.ps1
index 1ebe2fdddbc7c..1830d7443462c 100644
--- a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21818.ps1
+++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21818.ps1
@@ -68,8 +68,10 @@ function Invoke-CippTestZTNA21818 {
$ExitLoop = $false
foreach ($Role in $PrivilegedRoles) {
+ # roleDefinitionId carries the role's TEMPLATE id — matching it against $Role.id (the
+ # directoryRole instance id) never succeeded.
$Policy = $RoleManagementPolicies | Where-Object {
- $_.scopeId -eq '/' -and $_.scopeType -eq 'DirectoryRole' -and $_.roleDefinitionId -eq $Role.id
+ $_.scopeId -eq '/' -and $_.scopeType -eq 'DirectoryRole' -and $_.roleDefinitionId -eq $Role.roleTemplateId
} | Select-Object -First 1
if (-not $Policy) { continue }
diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21819.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21819.ps1
index af4ca97404ecc..30e4ff7adcfb9 100644
--- a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21819.ps1
+++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21819.ps1
@@ -17,11 +17,15 @@ function Invoke-CippTestZTNA21819 {
return
}
- # Get role management policy for Global Admin
+ # Get role management policy for Global Admin.
+ # This previously matched on effectiveRules.target.targetObjects.id — a property that does
+ # not exist on this response (target is {caller, operations, level, inheritableSettings,
+ # enforcedSettings}), so the policy was never found. roleDefinitionId carries the role's
+ # TEMPLATE id, which is the correct join.
$RoleManagementPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleManagementPolicies'
$GlobalAdminPolicy = $RoleManagementPolicies | Where-Object {
- $_.scopeId -eq '/' -and $_.scopeType -eq 'DirectoryRole' -and $_.effectiveRules.target.targetObjects.id -contains $GlobalAdminRole.id
- }
+ $_.scopeId -eq '/' -and $_.scopeType -eq 'DirectoryRole' -and $_.roleDefinitionId -eq $GlobalAdminRole.roleTemplateId
+ } | Select-Object -First 1
$Passed = 'Failed'
$IsDefaultRecipientsEnabled = 'N/A'
diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21820.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21820.ps1
index e4afd33d43926..ecb621ade66ca 100644
--- a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21820.ps1
+++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21820.ps1
@@ -19,15 +19,15 @@ function Invoke-CippTestZTNA21820 {
# Get all role management policies
$RoleManagementPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleManagementPolicies'
- # Build hashtable for quick policy lookup by role ID
+ # Build hashtable for quick policy lookup by role template ID.
+ # This previously keyed on effectiveRules.target.targetObjects.id — a property that does
+ # not exist on this response (target is {caller, operations, level, inheritableSettings,
+ # enforcedSettings}), so the table was always empty and no policy was ever found.
+ # roleDefinitionId carries the role's TEMPLATE id, which is the correct key.
$PolicyByRoleId = @{}
foreach ($Policy in $RoleManagementPolicies) {
- if ($Policy.scopeId -eq '/' -and $Policy.scopeType -eq 'DirectoryRole') {
- foreach ($RoleId in $Policy.effectiveRules.target.targetObjects.id) {
- if ($RoleId) {
- $PolicyByRoleId[$RoleId] = $Policy
- }
- }
+ if ($Policy.scopeId -eq '/' -and $Policy.scopeType -eq 'DirectoryRole' -and $Policy.roleDefinitionId) {
+ $PolicyByRoleId[$Policy.roleDefinitionId] = $Policy
}
}
@@ -35,7 +35,8 @@ function Invoke-CippTestZTNA21820 {
$Passed = 'Passed'
foreach ($Role in $PrivilegedRoles) {
- $Policy = $PolicyByRoleId[$Role.id]
+ # Template id, not the directoryRole instance id — see the note above.
+ $Policy = $PolicyByRoleId[$Role.roleTemplateId]
if (-not $Policy) {
$RolesWithIssues.Add(@{
diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21835.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21835.ps1
index 0fa5d363f3335..c5f53095d4fe4 100644
--- a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21835.ps1
+++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21835.ps1
@@ -17,9 +17,12 @@ function Invoke-CippTestZTNA21835 {
return
}
- # Get permanent Global Administrator members
+ # Get permanent Global Administrator members.
+ # This previously filtered on AssignmentType -eq 'Permanent', a value Get-CippDbRoleMembers
+ # never emits (it returns Active/Eligible/Direct), so the set was always empty and the test
+ # silently reported no permanent GAs on every tenant. Permanence is IsPermanent.
$PermanentGAMembers = Get-CippDbRoleMembers -TenantFilter $Tenant -RoleTemplateId '62e90394-69f5-4237-9190-012177145e10' | Where-Object {
- $_.AssignmentType -eq 'Permanent' -and $_.'@odata.type' -eq '#microsoft.graph.user'
+ $_.IsPermanent -and $_.'@odata.type' -eq '#microsoft.graph.user'
}
# Get Users data to check sync status
@@ -28,7 +31,8 @@ function Invoke-CippTestZTNA21835 {
$EmergencyAccountCandidates = [System.Collections.Generic.List[object]]::new()
foreach ($Member in $PermanentGAMembers) {
- $User = $Users | Where-Object { $_.id -eq $Member.principalId }
+ # Get-CippDbRoleMembers returns the principal object id as 'id', not 'principalId'.
+ $User = $Users | Where-Object { $_.id -eq $Member.id }
# Only process cloud-only accounts
if ($User -and $User.onPremisesSyncEnabled -ne $true) {
@@ -165,7 +169,9 @@ function Invoke-CippTestZTNA21835 {
$UserSummary = [System.Collections.Generic.List[object]]::new()
foreach ($Member in $PermanentGAMembers) {
- $User = $Users | Where-Object { $_.id -eq $Member.principalId }
+ # 'id', not 'principalId' — see the note above; this lookup silently matched
+ # nothing and every row was skipped by the -not $User guard below.
+ $User = $Users | Where-Object { $_.id -eq $Member.id }
if (-not $User) { continue }
$PortalLink = "https://entra.microsoft.com/#view/Microsoft_AAD_UsersAndTenants/UserProfileMenuBlade/~/overview/userId/$($User.id)"
diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21836.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21836.ps1
index f7c37115662a8..aef4e53a6ce40 100644
--- a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21836.ps1
+++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21836.ps1
@@ -20,16 +20,21 @@ function Invoke-CippTestZTNA21836 {
$WorkloadIdentitiesWithPrivilegedRoles = [System.Collections.Generic.List[object]]::new()
foreach ($Role in $PrivilegedRoles) {
- $RoleMembers = Get-CippDbRoleMembers -TenantFilter $Tenant -RoleTemplateId $Role.id
+ # Must be the role's TEMPLATE id: PIM's roleDefinitionId carries template ids, so
+ # passing $Role.id (the directoryRole instance id) matched nothing and this test found
+ # no workload identities on any tenant.
+ $RoleMembers = Get-CippDbRoleMembers -TenantFilter $Tenant -RoleTemplateId $Role.roleTemplateId
foreach ($Member in $RoleMembers) {
if ($Member.'@odata.type' -eq '#microsoft.graph.servicePrincipal') {
+ # Get-CippDbRoleMembers returns id/displayName/appId — not principalId or
+ # principalDisplayName, which rendered blank here.
$WorkloadIdentitiesWithPrivilegedRoles.Add([PSCustomObject]@{
- PrincipalId = $Member.principalId
- PrincipalDisplayName = $Member.principalDisplayName
+ PrincipalId = $Member.id
+ PrincipalDisplayName = $Member.displayName
AppId = $Member.appId
RoleDisplayName = $Role.displayName
- RoleDefinitionId = $Role.id
+ RoleDefinitionId = $Role.roleTemplateId
AssignmentType = $Member.AssignmentType
})
}
@@ -48,7 +53,7 @@ function Invoke-CippTestZTNA21836 {
$SortedAssignments = $WorkloadIdentitiesWithPrivilegedRoles | Sort-Object -Property PrincipalDisplayName
foreach ($Assignment in $SortedAssignments) {
- $SPLink = "https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Overview/objectId/$($Assignment.PrincipalId)/appId/$($Assignment.AppId)/preferredSingleSignOnMode~/null/servicePrincipalType/Application/fromNav/"
+ $SPLink = "https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Overview/objectId/$($Assignment.PrincipalId)/appId/$($Assignment.AppId)"
$null = $ResultMarkdown.Append("| [$($Assignment.PrincipalDisplayName)]($SPLink) | $($Assignment.RoleDisplayName) | $($Assignment.AssignmentType) |`n")
}
$null = $ResultMarkdown.Append("`n")
diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21899.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21899.ps1
index 0d94a2fb06c43..0a7b4c3c6a755 100644
--- a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21899.ps1
+++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21899.ps1
@@ -25,7 +25,9 @@ function Invoke-CippTestZTNA21899 {
foreach ($R in $NotifRules) {
if (-not $R.notificationRecipients -or $R.notificationRecipients.Count -eq 0) {
$MissingRecipients.Add([PSCustomObject]@{
- PolicyId = $Policy.id
+ # 'policyId' — this type is sourced from roleManagementPolicyAssignments
+ # and carries the policy's id as policyId, not id.
+ PolicyId = $Policy.policyId
ScopeId = $Policy.scopeId
ScopeType = $Policy.scopeType
RuleId = $R.id
diff --git a/Shared/CIPPSharp/CIPPSharp.csproj b/Shared/CIPPSharp/CIPPSharp.csproj
index 9bd6e19ccacde..6c7a3f192bdb0 100644
--- a/Shared/CIPPSharp/CIPPSharp.csproj
+++ b/Shared/CIPPSharp/CIPPSharp.csproj
@@ -14,4 +14,7 @@
bin\false
+
+
+
diff --git a/Shared/CIPPSharp/CIPPTestDataCache.cs b/Shared/CIPPSharp/CIPPTestDataCache.cs
index da2538ec5ba2d..2b15d21c07da8 100644
--- a/Shared/CIPPSharp/CIPPTestDataCache.cs
+++ b/Shared/CIPPSharp/CIPPTestDataCache.cs
@@ -3,7 +3,7 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
-using System.Reflection;
+using System.Management.Automation;
using System.Threading;
using System.Threading.Tasks;
@@ -266,13 +266,6 @@ private static void TryFireBackgroundSweep()
public static double HitRate => (_hits + _misses) > 0
? Math.Round(_hits * 100.0 / (_hits + _misses), 1) : 0;
- // ── PSObject reflection (cached, resolved once at first use) ──
- private static readonly object s_reflectLock = new();
- private static bool s_psResolved;
- private static Type? s_psObjectType;
- private static PropertyInfo? s_psPropsProp; // PSObject.Properties
- private static PropertyInfo? s_psPropName; // PSPropertyInfo.Name
- private static PropertyInfo? s_psPropValue; // PSPropertyInfo.Value
// ── Managed-heap size model (x64 CoreCLR) ──
// The cache stores *parsed PSObject graphs*, whose live heap footprint is
// many times their JSON text size (measured ≈8× on real Graph data). Sizing
@@ -293,40 +286,12 @@ private static void TryFireBackgroundSweep()
// Bound the walk on pathological payloads: full-walk small collections for
// accuracy, but stride-sample very large ones so a 100k-item array can't
- // turn a single Set() into a multi-second reflection storm.
+ // turn a single Set() into a multi-second graph walk.
private const int LargeCollectionThreshold = 512;
private const int LargeCollectionSamples = 256;
private const int MaxDepth = 32;
private const long NodeBudget = 2_000_000; // hard ceiling on nodes visited per Set()
- private static void EnsurePSResolved()
- {
- if (s_psResolved) return;
- lock (s_reflectLock)
- {
- if (s_psResolved) return;
- try
- {
- foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
- {
- var t = asm.GetType("System.Management.Automation.PSObject");
- if (t == null) continue;
- s_psObjectType = t;
- s_psPropsProp = t.GetProperty("Properties");
- var piType = asm.GetType("System.Management.Automation.PSPropertyInfo");
- if (piType != null)
- {
- s_psPropName = piType.GetProperty("Name");
- s_psPropValue = piType.GetProperty("Value");
- }
- break;
- }
- }
- catch { /* SMA not loaded */ }
- s_psResolved = true;
- }
- }
-
private static long Align8(long bytes) => (bytes + 7) & ~7L;
///
@@ -340,7 +305,6 @@ private static void EnsurePSResolved()
private static long EstimateValueSize(object? value, int itemCount)
{
if (value == null) return 0;
- EnsurePSResolved();
try
{
long budget = NodeBudget;
@@ -375,8 +339,8 @@ private static long SizeOf(object? value, int depth, ref long budget)
}
// PSObject → wrapper cost + each NoteProperty (name string + value + overhead)
- if (s_psObjectType != null && s_psObjectType.IsInstanceOfType(value))
- return SizeOfPSObject(value, depth, ref budget);
+ if (value is PSObject pso)
+ return SizeOfPSObject(pso, depth, ref budget);
// Dictionary / Hashtable → base + per-entry overhead + keys + values
if (value is IDictionary dict)
@@ -428,22 +392,20 @@ private static long SizeOf(object? value, int depth, ref long budget)
return ObjectHeader + 32;
}
- private static long SizeOfPSObject(object psObj, int depth, ref long budget)
+ private static long SizeOfPSObject(PSObject psObj, int depth, ref long budget)
{
long total = PSObjectBase;
- if (s_psPropsProp == null || s_psPropName == null || s_psPropValue == null)
- return total;
- if (s_psPropsProp.GetValue(psObj) is not IEnumerable props) return total;
- foreach (var prop in props)
+ foreach (var prop in psObj.Properties)
{
if (--budget <= 0) break;
total += PSNotePropertyOverhead;
try
{
- if (s_psPropName.GetValue(prop) is string name)
- total += Align8(StringBaseOverhead + 2L * name.Length);
- total += SizeOf(s_psPropValue.GetValue(prop), depth + 1, ref budget);
+ var name = prop.Name;
+ if (name != null) total += Align8(StringBaseOverhead + 2L * name.Length);
+ // .Value can throw on script/code properties that evaluate on access.
+ total += SizeOf(prop.Value, depth + 1, ref budget);
}
catch { /* skip properties that throw on access */ }
}
diff --git a/Shared/CIPPSharp/CippJsonConverter.cs b/Shared/CIPPSharp/CippJsonConverter.cs
new file mode 100644
index 0000000000000..a94da1da4ab9e
--- /dev/null
+++ b/Shared/CIPPSharp/CippJsonConverter.cs
@@ -0,0 +1,113 @@
+using System;
+using System.Collections.Generic;
+using System.Management.Automation;
+using System.Text.Json;
+
+namespace CIPP
+{
+ ///
+ /// Fast JSON -> PowerShell converter for the CippReportingDB read path, replacing
+ /// `ConvertFrom-Json` in New-CIPPDbRequest.
+ ///
+ /// COMPATIBILITY: reproduces ConvertFrom-Json's observable semantics, because every caller
+ /// depends on them and any divergence fails *silently*:
+ /// - PSCustomObject records, so `$x[0]`, `$x.Count` and `$x.PSObject.Properties` all keep
+ /// the scalar semantics callers rely on. Hashtable output is cheaper but changes those,
+ /// and only when a pipeline yields exactly one record — do not switch.
+ /// - ISO-8601-looking strings become [DateTime] (tests compare these against [datetime]).
+ /// - Every integer becomes Int64 regardless of magnitude (never Int32).
+ /// Matching these costs nothing measurable, so there is no reason to ship divergence.
+ ///
+ /// PERFORMANCE: without a field list this is modestly faster and allocates less than
+ /// ConvertFrom-Json, but retains the SAME live bytes — the parser was never the memory cost.
+ /// The memory win comes from : not materializing unread fields.
+ ///
+ public static class CippJson
+ {
+ private static readonly JsonDocumentOptions Opts = new JsonDocumentOptions { MaxDepth = 1024 };
+
+ /// Convert a JSON document, materializing every field.
+ public static object? ConvertFromJson(string json) => ConvertFromJson(json, null);
+
+ ///
+ /// Convert a JSON document, keeping only on each RECORD.
+ ///
+ /// Projection applies at the record level only: for an object root, the root's own fields;
+ /// for an array root, each element's own fields. A field that is kept keeps its ENTIRE
+ /// subtree — projection never reaches inside a retained value. Null/empty keeps everything.
+ ///
+ /// The saving therefore scales with how much of the record is dropped: large for
+ /// scalar-only field sets, small when a kept subtree is most of the payload.
+ ///
+ public static object? ConvertFromJson(string json, string[]? fields)
+ {
+ if (string.IsNullOrEmpty(json)) return null;
+
+ HashSet? keep = (fields != null && fields.Length > 0)
+ ? new HashSet(fields, StringComparer.OrdinalIgnoreCase)
+ : null;
+
+ using var doc = JsonDocument.Parse(json, Opts);
+ var root = doc.RootElement;
+
+ // Records live at the root, or one level down if the root is an array.
+ if (root.ValueKind == JsonValueKind.Array)
+ {
+ var rows = new List