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(); + foreach (var item in root.EnumerateArray()) rows.Add(ReadRecord(item, keep)); + return rows.ToArray(); + } + + return ReadRecord(root, keep); + } + + /// A record: the one level at which projection applies. + private static object? ReadRecord(JsonElement el, HashSet? keep) + { + if (el.ValueKind != JsonValueKind.Object) return ReadValue(el); + + var pso = new PSObject(); + foreach (var p in el.EnumerateObject()) + { + // Skipped fields are never materialized — this is the whole point of projection. + if (keep != null && !keep.Contains(p.Name)) continue; + pso.Properties.Add(new PSNoteProperty(p.Name, ReadValue(p.Value))); // kept => whole subtree + } + return pso; + } + + /// Everything below the record level: materialized in full. + private static object? ReadValue(JsonElement el) + { + switch (el.ValueKind) + { + case JsonValueKind.Object: + var pso = new PSObject(); + foreach (var p in el.EnumerateObject()) + pso.Properties.Add(new PSNoteProperty(p.Name, ReadValue(p.Value))); + return pso; + + case JsonValueKind.Array: + var list = new List(); + foreach (var item in el.EnumerateArray()) list.Add(ReadValue(item)); + return list.ToArray(); + + case JsonValueKind.String: + // ConvertFrom-Json coerces ISO-8601 strings to DateTime; matching that keeps + // date comparisons in tests behaving as they do today. + return el.TryGetDateTime(out var dt) ? dt : (object?)el.GetString(); + + case JsonValueKind.True: return true; + case JsonValueKind.False: return false; + case JsonValueKind.Null: return null; + + case JsonValueKind.Number: + // ConvertFrom-Json yields Int64 for all integers — never narrow to Int32. + if (el.TryGetInt64(out long l)) return l; + return el.GetDouble(); + + default: return null; + } + } + } +} diff --git a/Shared/CIPPSharp/bin/CIPPSharp.dll b/Shared/CIPPSharp/bin/CIPPSharp.dll index c242eca31f457..b2cd18ede86a7 100644 Binary files a/Shared/CIPPSharp/bin/CIPPSharp.dll and b/Shared/CIPPSharp/bin/CIPPSharp.dll differ