From 45bd6c9963f17f3e0431592c994de121aeaa5a57 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:13:47 +0800 Subject: [PATCH 1/3] feat(cache): implement field projection for test data Add field-level projection to Get-CIPPTestData and New-CIPPDbRequest to reduce memory usage by only materializing queried fields. Introduce Get-CippTestDataFieldManifest to manage per-type field whitelists, and replace ConvertFrom-Json with CIPP.CippJson (System.Text.Json-based) for faster parsing. Update Get-CippDbRoleMembers with missing fields (appId, EndDateTime, IsPermanent) required by role member callers. Refactor CIPPTestDataCache to use direct PSObject APIs instead of reflection for heap-size estimation. --- Modules/CIPPCore/Public/Get-CIPPTestData.ps1 | 91 +++++++++- .../CIPPCore/Public/Get-CippDbRoleMembers.ps1 | 55 ++++++ .../CIPPCore/Public/Get-CippSandboxData.ps1 | 2 +- .../Public/Get-CippTestDataFieldManifest.ps1 | 165 ++++++++++++++++++ Modules/CIPPCore/Public/New-CIPPDbRequest.ps1 | 56 +++++- Shared/CIPPSharp/CIPPSharp.csproj | 3 + Shared/CIPPSharp/CIPPTestDataCache.cs | 58 ++---- Shared/CIPPSharp/CippJsonConverter.cs | 113 ++++++++++++ Shared/CIPPSharp/bin/CIPPSharp.dll | Bin 48640 -> 49152 bytes 9 files changed, 485 insertions(+), 58 deletions(-) create mode 100644 Modules/CIPPCore/Public/Get-CippTestDataFieldManifest.ps1 create mode 100644 Shared/CIPPSharp/CippJsonConverter.cs 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/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 c242eca31f4571e538cfb150b451b87035b8867d..b2cd18ede86a7d510a57512e247a06235d5d0dec 100644 GIT binary patch literal 49152 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 literal 48640 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 Hhk?HV`9~DX From ba8232e2f832d6e957b40d48212b1713110dd181 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:14:23 +0800 Subject: [PATCH 2/3] fix(cippdb): cache PIM role data with app token Add the required RoleManagement permissions to SAMManifest and update PIM cache jobs to use app-only Graph calls (`-AsApp`) so role APIs stop failing with unauthorized delegated access. Role assignment and eligibility schedule caching now expands `principal` to preserve member identity fields, and role management policy caching now reads `roleManagementPolicyAssignments` (with required filter/expands) and flattens policy rules/effectiveRules alongside `roleDefinitionId` for role-to-policy lookups. --- Config/SAMManifest.json | 30 ++++++++++++++++- ...DBCacheRoleAssignmentScheduleInstances.ps1 | 12 ++++++- ...et-CIPPDBCacheRoleEligibilitySchedules.ps1 | 7 +++- .../Set-CIPPDBCacheRoleManagementPolicies.ps1 | 32 +++++++++++++++++-- 4 files changed, 76 insertions(+), 5 deletions(-) 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/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 From 98f0778d88a0f16428c4bc4bc9eb362750f580cf Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:15:13 +0800 Subject: [PATCH 3/3] fix: correct field references in identity and device tests Fix multiple bugs in CIPP test functions where incorrect field names or IDs were used when querying role data and policies: - Replace `$GA.id` with `$GA.roleTemplateId` when matching PIM role assignments (CIS_1_1_2, CIS_1_1_3) - Replace `$Role.templateId` with `$Role.roleTemplateId` when filtering roles and policies (ZTNA21813, 21816, 21818, 21820) - Fix app protection policy tests to read from correct data type and distinguish between missing data vs. missing platform-specific policy (ZTNA24548, ZTNA24549) - Replace `IsPermanent` for permanent GA detection instead of `AssignmentType -eq 'Permanent'` (ZTNA21835) - Use correct field names from Get-CippDbRoleMembers: `id` instead of `principalId`, `displayName` instead of `principalDisplayName` (ZTNA21835, ZTNA21836) - Fix role management policy lookup to use `roleDefinitionId` instead of nonexistent `effectiveRules.target.targetObjects.id` (ZTNA21819, ZTNA21820) - Clean up Entra URL construction (ZTNA21836) These changes fix tests that were silently returning false negatives due to incorrect field references. --- .../CIS/Identity/Invoke-CippTestCIS_1_1_2.ps1 | 5 ++++- .../CIS/Identity/Invoke-CippTestCIS_1_1_3.ps1 | 5 ++++- .../ZTNA/Devices/Invoke-CippTestZTNA24548.ps1 | 19 ++++++++++++++++--- .../ZTNA/Devices/Invoke-CippTestZTNA24549.ps1 | 19 ++++++++++++++++--- .../Identity/Invoke-CippTestZTNA21813.ps1 | 11 ++++++++--- .../Identity/Invoke-CippTestZTNA21816.ps1 | 4 +++- .../Identity/Invoke-CippTestZTNA21818.ps1 | 4 +++- .../Identity/Invoke-CippTestZTNA21819.ps1 | 10 +++++++--- .../Identity/Invoke-CippTestZTNA21820.ps1 | 17 +++++++++-------- .../Identity/Invoke-CippTestZTNA21835.ps1 | 14 ++++++++++---- .../Identity/Invoke-CippTestZTNA21836.ps1 | 15 ++++++++++----- .../Identity/Invoke-CippTestZTNA21899.ps1 | 4 +++- 12 files changed, 93 insertions(+), 34 deletions(-) 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