From a245360297f1bc4e9a6ddc9a65f9a8ae5d623a0e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 7 Sep 2026 13:28:35 +0000 Subject: [PATCH] Merge pull request #506 from CyberDrain/dev Dev to release Synced from CyberDrain/CIPP@0e60de03aca689d7deff125dd9ba56246a4188de --- .../CopilotLimitedMode.json | 1 + .../QuarantineRequestAlert.json | 8 +- .../Defender Standards/SpamFilterPolicy.json | 20 + .../Entra (AAD) Standards/AdminSSPR.json | 4 + .../Entra (AAD) Standards/AppDeploy.json | 3 +- .../AuthMethodsPolicyMigration.json | 4 + .../AuthMethodsSettings.json | 5 + .../AuthenticationMethods.json | 212 +- .../BitLockerKeysForOwnedDevice.json | 4 + .../DisableAppCreation.json | 4 + .../Entra (AAD) Standards/DisableEmail.json | 4 + .../Entra (AAD) Standards/DisableGuests.json | 9 +- .../DisableQRCodePin.json | 4 + .../Entra (AAD) Standards/DisableSMS.json | 4 + .../DisableSecurityGroupUsers.json | 4 + .../DisableSelfServiceLicenses.json | 9 +- .../DisableTenantCreation.json | 4 + .../Entra (AAD) Standards/DisableVoice.json | 4 + .../Disablex509Certificate.json | 4 + .../Entra (AAD) Standards/EnableFIDO2.json | 4 + .../EnableHardwareOAuth.json | 4 + .../ExternalComplianceTrusted.json | 41 + .../Entra (AAD) Standards/GuestInvite.json | 4 + .../Entra (AAD) Standards/NudgeMFA.json | 4 + .../Entra (AAD) Standards/OauthConsent.json | 4 + .../OauthConsentLowSec.json | 4 + .../PWcompanionAppAllowedState.json | 4 + .../PWdisplayAppInformationRequiredState.json | 5 + .../SecurityDefaults.json | 2 +- .../Entra (AAD) Standards/SmartLockout.json | 24 +- .../Entra (AAD) Standards/TAP.json | 69 +- .../Entra (AAD) Standards/UndoOauth.json | 4 + .../allowOAuthTokens.json | 4 + .../Entra (AAD) Standards/allowOTPTokens.json | 5 +- .../intuneDeviceRegLocalAdmins.json | 1 + .../intuneRestrictUserDeviceJoin.json | 1 + .../intuneRestrictUserDeviceRegistration.json | 1 + .../Entra (AAD) Standards/laps.json | 1 + .../Exchange Standards/AutoArchive.json | 13 +- .../AutoArchiveMailbox.json | 1 + .../Exchange Standards/AutoExpandArchive.json | 1 + .../Exchange Standards/Bookings.json | 1 + .../CloudMessageRecall.json | 1 + .../Exchange Standards/DisableEWS.json | 1 + .../Exchange Standards/DlpViaDcsEnabled.json | 1 + .../Exchange Standards/EXODirectSend.json | 1 + .../Exchange Standards/EnableMailTips.json | 1 + .../EnableMailboxAuditing.json | 1 + .../Exchange Standards/FocusedInbox.json | 1 + .../Exchange Standards/MessageExpiration.json | 8 +- .../Exchange Standards/OMEBranding.json | 48 +- .../Exchange Standards/OutBoundSpamAlert.json | 17 +- .../Exchange Standards/SendFromAlias.json | 1 + .../Exchange Standards/ShortenMeetings.json | 1 + .../TeamsMeetingsByDefault.json | 1 + .../TwoClickEmailProtection.json | 1 + .../Exchange Standards/UserSubmissions.json | 21 +- .../DisableGuestDirectory.json | 4 + .../EnableCustomerLockbox.json | 1 + .../DefaultPlatformRestrictions.json | 126 +- .../IntuneAppTemplateDeploy.json | 5 +- .../intuneBrandingProfile.json | 60 +- .../Intune Standards/intuneDeviceReg.json | 1 + .../intuneDeviceRetirementDays.json | 14 +- .../Intune Standards/intuneRequireMFA.json | 1 + .../DefaultSharingLink.json | 4 + .../DeletedUserRentention.json | 1 + .../DisableAddShortcutsToOneDrive.json | 1 + .../SharePoint Standards/DisableReshare.json | 1 + .../DisableSharePointLegacyAuth.json | 1 + .../SharePoint Standards/SPAzureB2B.json | 1 + .../SharePoint Standards/SPDirectSharing.json | 4 + .../SPDisableCustomScripts.json | 1 + .../SPDisableLegacyWorkflows.json | 1 + .../SPDisableStoreAccess.json | 1 + .../SPDisallowInfectedFiles.json | 1 + .../SPEmailAttestation.json | 1 + .../SPExternalUserExpiration.json | 7 +- .../SharePoint Standards/SPFileRequests.json | 1 + .../SPGuestPeoplePicker.json | 42 + .../SPOVersionControl.json | 1 + .../SPSyncButtonState.json | 1 + .../SharePoint Standards/disableMacSync.json | 1 + .../sharingCapability.json | 1 + .../SharePoint Standards/unmanagedSync.json | 1 + .../TeamsGlobalMeetingPolicy.json | 36 +- Config/CIPPDBCacheTypes.json | 28 + Config/CountryList.json | 252 + Config/DocsPublishedPages.txt | 11 +- Config/FeatureFlags.json | 13 +- Config/LicensePricingDefaults.csv | 476 + Config/PermissionsTranslator.json | 21327 +++++++++++++--- Config/SAMManifest.json | 8 + .../ListOffboardingProgress.json | 3 + Config/openapi.json | 4871 +++- Config/standards.json | 409 +- .../Activity Triggers/BEC/Push-BECRun.ps1 | 36 +- .../Push-DomainAnalyserTenant.ps1 | 6 +- .../Push-GetCalendarPermissionsBatch.ps1 | 40 +- .../Push-DBCacheOneDriveLongPaths.ps1 | 225 + ...sh-DBCacheOneDriveRootPermissionsBatch.ps1 | 8 +- .../Push-CIPPDBCacheData.ps1 | 28 +- .../Push-CIPPOffboardingComplete.ps1 | 55 +- .../Push-CIPPOffboardingTask.ps1 | 14 +- .../Push-ExecJITAdminListAllTenants.ps1 | 5 +- .../Push-ExecMdoAlertsListAllTenants.ps1 | 4 +- .../Push-ExecOnboardTenantQueue.ps1 | 28 +- .../Push-ExecScheduledCommand.ps1 | 31 +- .../Push-GetMailboxRulesBatch.ps1 | 6 +- ...istConditionalAccessPoliciesAllTenants.ps1 | 8 +- .../Push-DBCacheStorageCleanupScanBatch.ps1 | 248 + .../Push-StoreStorageCleanupScan.ps1 | 89 + .../Alerts/Get-CIPPAlertApnCertExpiry.ps1 | 12 +- .../Alerts/Get-CIPPAlertAppSecretExpiry.ps1 | 2 + .../Alerts/Get-CIPPAlertArchiveQuota.ps1 | 111 + .../Alerts/Get-CIPPAlertDepTokenExpiry.ps1 | 14 +- ...t-CIPPAlertDeviceComplianceGracePeriod.ps1 | 55 + .../Alerts/Get-CIPPAlertExpiringLicenses.ps1 | 6 +- .../Get-CIPPAlertIntuneApprovalRequests.ps1 | 2 +- .../Alerts/Get-CIPPAlertMXRecordChanged.ps1 | 5 + .../Alerts/Get-CIPPAlertNewAppApproval.ps1 | 6 +- .../Alerts/Get-CIPPAlertOneDriveLongPaths.ps1 | 76 + ...-CIPPAlertPermanentActiveAdminAssigned.ps1 | 94 + ...Get-CIPPAlertQuarantineReleaseRequests.ps1 | 8 +- .../Public/Alerts/Get-CIPPAlertQuotaUsed.ps1 | 3 +- .../Get-CIPPAlertUnlicensedOneDriveData.ps1 | 13 +- .../Alerts/Get-CIPPAlertVppTokenExpiry.ps1 | 14 +- .../Public/Add-CIPPApplicationPermission.ps1 | 5 + Modules/CIPPCore/Public/Add-CIPPDbItem.ps1 | 6 +- .../Public/Add-CIPPDelegatedPermission.ps1 | 13 + .../Public/Add-CIPPGDAPRoleTemplate.ps1 | 69 +- .../CIPPCore/Public/Add-CIPPScheduledTask.ps1 | 35 +- .../Add-CIPPAsyncDeploymentStep.ps1 | 66 + .../Get-CIPPAsyncDeployment.ps1 | 3 + .../New-CIPPAsyncDeployment.ps1 | 41 +- .../Set-CIPPAsyncDeploymentStep.ps1 | 46 +- .../Authentication/Get-CIPPHttpFunctions.ps1 | 3 +- .../Authentication/Get-CippApiClient.ps1 | 7 +- .../Get-CippHttpPermissions.ps1 | 35 +- .../Authentication/Set-CIPPAccessRole.ps1 | 6 +- .../Public/Authentication/Test-CIPPAccess.ps1 | 96 +- .../Test-CippHttpPermissionUniverse.ps1 | 27 + .../Test-CippRoleTenantScope.ps1 | 132 + .../Get-CIPPBaselineAntiPhishPolicyState.ps1 | 7 +- ...CIPPBaselineAuthenticationMethodsState.ps1 | 9 +- ...selineDefaultPlatformRestrictionsState.ps1 | 22 + ...IPPBaselineDeployContactTemplatesState.ps1 | 8 +- .../Get-CIPPBaselineDisableGuestsState.ps1 | 27 +- ...-CIPPBaselineDisableSharedMailboxState.ps1 | 10 +- ...BaselineExternalComplianceTrustedState.ps1 | 26 + ...-CIPPBaselineFIDO2PasskeyProfilesState.ps1 | 3 + ...IPPBaselineMailboxRecipientLimitsState.ps1 | 3 +- ...t-CIPPBaselineMalwareFilterPolicyState.ps1 | 14 +- .../Get-CIPPBaselinePhishProtectionState.ps1 | 7 +- ...t-CIPPBaselineSPGuestPeoplePickerState.ps1 | 54 + ...IPPBaselineSendReceiveLimitTenantState.ps1 | 30 +- .../Get-CIPPBaselineSpamFilterPolicyState.ps1 | 13 + .../Get-CIPPBaselineUserSubmissionsState.ps1 | 8 +- .../Baselines/Get-CIPPBaselineWorkItems.ps1 | 133 +- .../Get-CIPPBaselinecalDefaultState.ps1 | 5 + .../Invoke-CIPPBaselineAppDeploy.ps1 | 26 +- ...-CIPPBaselineExternalComplianceTrusted.ps1 | 27 + ...nvoke-CIPPBaselineFIDO2PasskeyProfiles.ps1 | 3 + ...ke-CIPPBaselineIntuneAppTemplateDeploy.ps1 | 8 +- ...Invoke-CIPPBaselineSPGuestPeoplePicker.ps1 | 95 + .../Invoke-CIPPBaselineSPOVersionControl.ps1 | 21 +- .../Baselines/Invoke-CIPPBaselineStandard.ps1 | 18 + .../Invoke-CIPPBaselineUserSubmissions.ps1 | 4 +- Modules/CIPPCore/Public/Clear-CIPPDbCache.ps1 | 107 + .../Public/Clear-CIPPMobileDevice.ps1 | 42 + .../Public/ConvertTo-CIPPCountryCode.ps1 | 64 + .../ConvertTo-CIPPIntunePolicyListItem.ps1 | 11 +- ...nvertTo-CIPPSharePointSiteUsagePayload.ps1 | 61 + .../Public/ConvertTo-SPOAdminListInt64.ps1 | 31 + .../ConvertTo-SPOUsageRootWebTemplate.ps1 | 41 + .../Public/DeltaQueries/Get-DeltaQueryUrl.ps1 | 13 +- .../DeltaQueries/New-GraphDeltaQuery.ps1 | 6 + .../Start-UpdatePermissionsOrchestrator.ps1 | 11 +- .../Start-UserTasksOrchestrator.ps1 | 163 +- .../Timer Functions/Start-TableCleanup.ps1 | 12 + .../Start-UpdateTokensTimer.ps1 | 7 +- .../Timer Functions/Start-UserSyncTimer.ps1 | 61 +- .../Public/Functions/Format-CIPPCAPolicy.ps1 | 21 + .../Functions/Get-CIPPAppServiceSite.ps1 | 52 + .../Public/Functions/Get-CIPPHostname.ps1 | 17 +- .../Functions/Get-CIPPTenantAlignment.ps1 | 57 +- .../Public/Functions/Get-CIPPURLName.ps1 | 3 + .../Invoke-CIPPCustomDomainCertificate.ps1 | 131 + .../Test-CIPPCacheCapabilityError.ps1 | 51 + .../Public/Get-CIPPAuthentication.ps1 | 10 + Modules/CIPPCore/Public/Get-CIPPBackup.ps1 | 6 +- Modules/CIPPCore/Public/Get-CIPPCVEReport.ps1 | 26 +- .../CIPPCore/Public/Get-CIPPDbItemPage.ps1 | 61 + Modules/CIPPCore/Public/Get-CIPPDrift.ps1 | 90 +- .../Get-CIPPDriveItemCloudPathLength.ps1 | 62 + .../CIPPCore/Public/Get-CIPPEdgeAppBody.ps1 | 52 + .../Public/Get-CIPPGroupUsageReport.ps1 | 234 + .../CIPPCore/Public/Get-CIPPGroupsReport.ps1 | 94 +- .../Public/Get-CIPPGuestUsersReport.ps1 | 34 +- ...et-CIPPIntuneAppProtectionPolicyReport.ps1 | 29 +- .../Get-CIPPIntuneApplicationReport.ps1 | 8 +- .../Get-CIPPIntuneCompareExclusions.ps1 | 6 +- .../Get-CIPPIntuneCompliancePolicyReport.ps1 | 10 +- .../CIPPCore/Public/Get-CIPPIntunePolicy.ps1 | 37 + .../Get-CIPPIntuneReusableSettingsReport.ps1 | 6 +- .../Public/Get-CIPPIntuneScriptReport.ps1 | 10 +- .../Public/Get-CIPPJITAdminAllowedRoles.ps1 | 148 + .../CIPPCore/Public/Get-CIPPLAPSPassword.ps1 | 8 +- .../Public/Get-CIPPLastSignInDateTime.ps1 | 31 + .../Public/Get-CIPPLicenseOptimization.ps1 | 297 + .../Public/Get-CIPPLicenseOverview.ps1 | 4 +- .../CIPPCore/Public/Get-CIPPLicensePrice.ps1 | 131 + .../Public/Get-CIPPMFAStateReport.ps1 | 37 +- .../Public/Get-CIPPMailboxRulesReport.ps1 | 9 +- .../Public/Get-CIPPMailboxesReport.ps1 | 30 +- .../CIPPCore/Public/Get-CIPPOutOfOffice.ps1 | 7 +- .../Public/Get-CIPPPagedTableRows.ps1 | 115 + .../Public/Get-CIPPSPOAdminListData.ps1 | 23 +- Modules/CIPPCore/Public/Get-CIPPSPOSite.ps1 | 13 +- .../CIPPCore/Public/Get-CIPPSPOSiteBulk.ps1 | 138 + Modules/CIPPCore/Public/Get-CIPPSPOTenant.ps1 | 12 +- .../Public/Get-CIPPScheduledTaskNextRun.ps1 | 32 + .../Get-CIPPSharePointCopyJobProgress.ps1 | 256 + .../Get-CIPPSharePointCopyJobQueueLogs.ps1 | 88 + ...Get-CIPPSharePointLibraryCopyOperation.ps1 | 60 + ...Get-CIPPSharePointLibraryRootChildUris.ps1 | 80 + .../Get-CIPPSharePointSiteUsageReport.ps1 | 249 +- .../Get-CIPPSharePointSiteUsageRows.ps1 | 189 + .../Get-CIPPTenantAllowBlockListReport.ps1 | 8 +- Modules/CIPPCore/Public/Get-CippDbRole.ps1 | 32 +- .../Public/Get-CippTestDataFieldManifest.ps1 | 6 +- Modules/CIPPCore/Public/Get-DefenderCves.ps1 | 3 + .../CIPPCore/Public/Get-DefenderTvmRaw.ps1 | 3 +- .../GraphHelper/Get-ClassicAPIToken.ps1 | 11 +- .../Public/GraphHelper/Get-GraphToken.ps1 | 6 + .../Public/GraphHelper/Get-Tenants.ps1 | 35 +- .../New-CIPPCertificateAssertion.ps1 | 8 +- .../GraphHelper/New-CIPPMFAConnectorToken.ps1 | 132 + .../GraphHelper/New-GraphBulkRequest.ps1 | 52 +- .../Update-AppManagementPolicy.ps1 | 128 +- .../Public/GraphHelper/Write-LogMessage.ps1 | 8 +- .../GraphRequests/Get-GraphRequestList.ps1 | 92 +- .../Public/Invoke-CIPPCATemplateBatch.ps1 | 19 +- .../Public/Invoke-CIPPDBCacheCollection.ps1 | 29 +- .../Public/Invoke-CIPPOffboardingJob.ps1 | 123 +- .../Invoke-CIPPSharePointCreateCopyJobs.ps1 | 100 + .../CIPPCore/Public/New-CIPPAlertTemplate.ps1 | 34 +- Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 | 32 +- .../Public/New-CIPPGDAPRoleMapping.ps1 | 110 + .../Public/New-CIPPIntuneAppDeployment.ps1 | 26 +- .../Public/New-CIPPIntuneTemplate.ps1 | 23 + .../Public/New-CIPPSharePointLibrary.ps1 | 8 +- .../CIPPCore/Public/New-CIPPTemplateRun.ps1 | 42 +- Modules/CIPPCore/Public/New-CIPPUserTask.ps1 | 28 +- .../Public/New-CippStandardsDriftClone.ps1 | 7 +- .../PIM/Compare-CIPPPIMRoleSettings.ps1 | 152 + .../PIM/ConvertFrom-CIPPPIMPolicyRules.ps1 | 87 + .../PIM/ConvertTo-CIPPPIMPolicyRules.ps1 | 160 + .../PIM/ConvertTo-CIPPPIMRoleSettings.ps1 | 92 + .../Public/PIM/Get-CIPPPIMPolicySummary.ps1 | 81 + .../Public/PIM/Get-CIPPPIMRoleAssignments.ps1 | 301 + .../Public/PIM/Get-CIPPPIMRolePolicies.ps1 | 82 + .../PIM/Get-CIPPPrivilegedRoleTemplateIds.ps1 | 77 + .../PIM/Invoke-CIPPPIMAssignmentAction.ps1 | 306 + .../Public/PIM/New-CIPPPIMScheduleRequest.ps1 | 204 + .../PIM/Repair-CIPPPIMRoleSettingsFloor.ps1 | 118 + .../Public/PIM/Set-CIPPPIMRoleSettings.ps1 | 74 + .../PIM/Test-CIPPPIMRoleSettingsFloor.ps1 | 134 + Modules/CIPPCore/Public/Remove-CIPPDbItem.ps1 | 60 +- .../Public/Remove-CIPPMobileDevice.ps1 | 8 +- .../Public/Remove-CIPPSPOSiteUser.ps1 | 2 +- .../Public/Remove-CIPPUserTeamsPhoneDIDs.ps1 | 54 +- .../Public/Request-CIPPSPOPersonalSite.ps1 | 23 +- .../Resolve-CIPPIntuneTargetedMobileApps.ps1 | 105 + .../Resolve-CIPPSharePointLibraryRootUri.ps1 | 52 + .../Resolve-CIPPSharePointPermissionScope.ps1 | 21 +- .../Resolve-CIPPSharePointRestContext.ps1 | 49 + Modules/CIPPCore/Public/Send-CIPPAlert.ps1 | 33 +- .../Public/Send-CIPPCustomTestAlert.ps1 | 6 +- .../Public/Send-CIPPScheduledTaskAlert.ps1 | 74 +- .../Public/Set-CIPPAuthenticationPolicy.ps1 | 16 +- .../Public/Set-CIPPDefenderASRPolicy.ps1 | 4 +- .../Public/Set-CIPPDefenderAVPolicy.ps1 | 4 +- .../Set-CIPPDefenderCompliancePolicy.ps1 | 8 +- .../Public/Set-CIPPDefenderEDRPolicy.ps1 | 4 +- .../Set-CIPPDefenderExclusionPolicy.ps1 | 4 +- .../CIPPCore/Public/Set-CIPPFeatureFlag.ps1 | 11 +- .../CIPPCore/Public/Set-CIPPIntunePolicy.ps1 | 20 +- .../Public/Set-CIPPNotificationConfig.ps1 | 3 +- .../CIPPCore/Public/Set-CIPPPerUserMFA.ps1 | 2 +- .../Public/Set-CIPPRegistrationCampaign.ps1 | 12 +- .../Public/Set-CIPPSAMCertificate.ps1 | 3 +- Modules/CIPPCore/Public/Set-CIPPSPOSite.ps1 | 11 +- .../CIPPCore/Public/Set-CIPPSPOSiteBulk.ps1 | 128 + Modules/CIPPCore/Public/Set-CIPPSPOTenant.ps1 | 13 +- ...Set-CIPPSharePointLibraryCopyOperation.ps1 | 93 + .../Set-CIPPSharePointObjectPermission.ps1 | 8 +- .../Public/Set-CIPPStandardsCompareField.ps1 | 10 +- .../Public/Standards/Get-CIPPStandards.ps1 | 20 +- .../Get-CIPPStandardsTemplateScope.ps1 | 74 + .../Start-CIPPSharePointLibraryCopy.ps1 | 159 + .../Public/Test-CIPPGDAPGroupMappings.ps1 | 3 + .../Public/Test-CIPPOffboardingRequest.ps1 | 2 +- ...Test-CIPPSharePointLibraryCopyEligible.ps1 | 40 + .../Public/Tools/Import-CommunityTemplate.ps1 | 8 +- .../Push-ExecGenerateReportBuilderReport.ps1 | 29 +- ...Update-CIPPSharePointLibraryCopyStatus.ps1 | 178 + .../Webhooks/Invoke-CIPPWebhookProcessing.ps1 | 6 + .../Webhooks/Test-CIPPAuditLogRules.ps1 | 37 +- .../Set-CIPPDBCacheActiveUserDetail.ps1 | 38 + .../Set-CIPPDBCacheAppRoleAssignments.ps1 | 11 +- ...t-CIPPDBCacheAuthenticationFlowsPolicy.ps1 | 5 + ...CIPPDBCacheAuthenticationMethodsPolicy.ps1 | 5 + ...CIPPDBCacheAutopilotDeploymentProfiles.ps1 | 3 + .../Set-CIPPDBCacheB2BManagementPolicy.ps1 | 17 +- ...CIPPDBCacheComplianceRetentionPolicies.ps1 | 8 + ...et-CIPPDBCacheComplianceRetentionRules.ps1 | 8 + ...t-CIPPDBCacheConditionalAccessPolicies.ps1 | 7 + .../Set-CIPPDBCacheCopilotAdminSettings.ps1 | 18 +- .../Set-CIPPDBCacheCopilotPolicySettings.ps1 | 14 +- .../Set-CIPPDBCacheCsExternalAccessPolicy.ps1 | 5 + ...-CIPPDBCacheCsTeamsAppPermissionPolicy.ps1 | 5 + ...-CIPPDBCacheCsTeamsClientConfiguration.ps1 | 5 + .../Set-CIPPDBCacheCsTeamsMeetingPolicy.ps1 | 5 + ...PPDBCacheCsTeamsMessagingConfiguration.ps1 | 5 + .../Set-CIPPDBCacheCsTeamsMessagingPolicy.ps1 | 5 + ...DBCacheCsTenantFederationConfiguration.ps1 | 5 + .../DBCache/Set-CIPPDBCacheDefenderCVEs.ps1 | 107 +- ...PDBCacheDeviceEnrollmentConfigurations.ps1 | 3 + ...et-CIPPDBCacheDeviceRegistrationPolicy.ps1 | 5 + .../Set-CIPPDBCacheDlpCompliancePolicies.ps1 | 14 + .../Set-CIPPDBCacheExoAcceptedDomains.ps1 | 5 + .../Set-CIPPDBCacheExoAdminAuditLogConfig.ps1 | 5 + .../Set-CIPPDBCacheExoAntiPhishPolicies.ps1 | 10 + .../Set-CIPPDBCacheExoAtpPolicyForO365.ps1 | 15 +- .../Set-CIPPDBCacheExoDkimSigningConfig.ps1 | 5 + ...et-CIPPDBCacheExoDlpSensitiveInfoTypes.ps1 | 15 +- .../Set-CIPPDBCacheExoExternalInOutlook.ps1 | 5 + ...t-CIPPDBCacheExoGlobalQuarantinePolicy.ps1 | 5 + ...DBCacheExoHostedConnectionFilterPolicy.ps1 | 5 + ...IPPDBCacheExoHostedContentFilterPolicy.ps1 | 5 + ...-CIPPDBCacheExoHostedContentFilterRule.ps1 | 5 + ...CacheExoHostedOutboundSpamFilterPolicy.ps1 | 5 + .../Set-CIPPDBCacheExoInboundConnector.ps1 | 5 + .../DBCache/Set-CIPPDBCacheExoLabels.ps1 | 13 +- .../Set-CIPPDBCacheExoMailContacts.ps1 | 19 +- ...et-CIPPDBCacheExoMalwareFilterPolicies.ps1 | 10 + .../Set-CIPPDBCacheExoOMEConfiguration.ps1 | 5 + .../Set-CIPPDBCacheExoOrganizationConfig.ps1 | 5 + .../Set-CIPPDBCacheExoOutboundConnector.ps1 | 5 + ...Set-CIPPDBCacheExoPresetSecurityPolicy.ps1 | 8 + .../Set-CIPPDBCacheExoProtectionAlert.ps1 | 5 + .../Set-CIPPDBCacheExoQuarantinePolicy.ps1 | 10 + .../Set-CIPPDBCacheExoRemoteDomain.ps1 | 5 + ...Set-CIPPDBCacheExoRoleAssignmentPolicy.ps1 | 5 + ...t-CIPPDBCacheExoSafeAttachmentPolicies.ps1 | 24 +- .../Set-CIPPDBCacheExoSafeLinksPolicies.ps1 | 40 +- .../Set-CIPPDBCacheExoSharingPolicy.ps1 | 5 + ...et-CIPPDBCacheExoTeamsProtectionPolicy.ps1 | 5 + .../Set-CIPPDBCacheExoTransportConfig.ps1 | 5 + .../Set-CIPPDBCacheExoTransportRules.ps1 | 5 + .../DBCache/Set-CIPPDBCacheGroupUsage.ps1 | 51 + .../Public/DBCache/Set-CIPPDBCacheGroups.ps1 | 117 +- .../DBCache/Set-CIPPDBCacheHVEAccounts.ps1 | 6 +- ...CIPPDBCacheIntuneAppProtectionPolicies.ps1 | 5 + ...neAppleUserInitiatedEnrollmentProfiles.ps1 | 3 + .../Set-CIPPDBCacheIntuneApplications.ps1 | 5 + ...Set-CIPPDBCacheIntuneAssignmentFilters.ps1 | 3 + .../Set-CIPPDBCacheIntuneBrandingProfile.ps1 | 3 + ...et-CIPPDBCacheIntuneCompliancePolicies.ps1 | 4 + ...CIPPDBCacheIntuneConfigurationPolicies.ps1 | 3 + ...PPDBCacheIntuneDataProcessorOnboarding.ps1 | 3 + ...heIntuneDeviceEnrollmentConfigurations.ps1 | 3 + ...PDBCacheIntuneDeviceManagementSettings.ps1 | 3 + .../Set-CIPPDBCacheIntuneMobileApps.ps1 | 3 + .../Set-CIPPDBCacheIntuneReusableSettings.ps1 | 3 + .../DBCache/Set-CIPPDBCacheIntuneScripts.ps1 | 5 + ...tuneWindowsAutopilotDeploymentProfiles.ps1 | 3 + .../DBCache/Set-CIPPDBCacheMailboxes.ps1 | 15 +- ...t-CIPPDBCacheManagedDeviceCleanupRules.ps1 | 3 + ...PDBCacheMobileDeviceManagementPolicies.ps1 | 2 +- .../Set-CIPPDBCacheOAuth2PermissionGrants.ps1 | 5 + .../Set-CIPPDBCacheOneDriveLongPaths.ps1 | 155 + ...Set-CIPPDBCacheOneDriveRootPermissions.ps1 | 3 + .../DBCache/Set-CIPPDBCacheOneDriveUsage.ps1 | 6 +- .../Set-CIPPDBCacheOwaMailboxPolicy.ps1 | 5 + .../DBCache/Set-CIPPDBCachePIMSettings.ps1 | 14 + ...Set-CIPPDBCachePermissionGrantPolicies.ps1 | 3 +- .../Set-CIPPDBCacheReportSubmissionPolicy.ps1 | 5 + .../Set-CIPPDBCacheReportSubmissionRule.ps1 | 5 + .../DBCache/Set-CIPPDBCacheRiskDetections.ps1 | 3 + .../Set-CIPPDBCacheRiskyServicePrincipals.ps1 | 3 + .../DBCache/Set-CIPPDBCacheRiskyUsers.ps1 | 3 + .../DBCache/Set-CIPPDBCacheSPOSites.ps1 | 115 + .../DBCache/Set-CIPPDBCacheSPOTenant.ps1 | 9 +- .../Set-CIPPDBCacheSensitivityLabels.ps1 | 8 + .../Set-CIPPDBCacheSharePointPermissions.ps1 | 3 + .../Set-CIPPDBCacheSharePointSiteUsage.ps1 | 107 +- .../Set-CIPPDBCacheStorageCleanupScan.ps1 | 151 + .../Set-CIPPDBCacheTeamsResourceAccounts.ps1 | 3 + .../CIPP/Core/Invoke-ExecAzBobbyTables.ps1 | 15 +- .../CIPP/Core/Invoke-ExecCIPPDBCache.ps1 | 13 + .../CIPP/Core/Invoke-ExecCIPPDBCacheAdmin.ps1 | 149 + .../CIPP/Core/Invoke-ExecCippFunction.ps1 | 7 + .../CIPP/Core/Invoke-ExecCloneTemplate.ps1 | 10 +- .../Core/Invoke-ExecDiagnosticsPresets.ps1 | 13 +- .../CIPP/Core/Invoke-ExecEditTemplate.ps1 | 4 +- .../CIPP/Core/Invoke-ExecPartnerWebhook.ps1 | 3 + .../Core/Invoke-ExecServicePrincipals.ps1 | 15 +- .../CIPP/Core/Invoke-ListGraphRequest.ps1 | 34 + .../CIPP/Core/Invoke-ListLogs.ps1 | 404 +- .../CIPP/Core/Invoke-RemoveCippQueue.ps1 | 12 +- .../Invoke-ExecExtensionClearHIBPKey.ps1 | 11 +- .../Invoke-ExecExtensionMapping.ps1 | 14 +- .../Invoke-ExecExtensionNinjaOneQueue.ps1 | 30 +- .../Extensions/Invoke-ExecExtensionSync.ps1 | 3 + .../Invoke-ExecExtensionsConfig.ps1 | 9 +- .../Scheduler/Invoke-ListAsyncDeployment.ps1 | 33 + .../Invoke-ListFunctionParameters.ps1 | 4 +- .../Invoke-ListScheduledItemDetails.ps1 | 36 +- .../Scheduler/Invoke-ListScheduledItems.ps1 | 19 +- .../CIPP/Settings/Invoke-ExecAddTrustedIP.ps1 | 37 +- .../CIPP/Settings/Invoke-ExecApiClient.ps1 | 18 +- .../Settings/Invoke-ExecAppServiceDomains.ps1 | 289 +- .../CIPP/Settings/Invoke-ExecBackendURLs.ps1 | 14 +- .../Settings/Invoke-ExecCippReplacemap.ps1 | 11 +- .../CIPP/Settings/Invoke-ExecCustomData.ps1 | 81 +- .../CIPP/Settings/Invoke-ExecCustomRole.ps1 | 39 +- .../Invoke-ExecMaintenanceScripts.ps1 | 1 + .../Invoke-ExecNotificationConfig.ps1 | 8 + .../Settings/Invoke-ExecOffloadFunctions.ps1 | 15 +- .../CIPP/Settings/Invoke-ExecPartnerMode.ps1 | 8 +- .../Settings/Invoke-ExecPermissionRepair.ps1 | 9 +- .../Settings/Invoke-ExecRefreshMyAccess.ps1 | 1 + .../CIPP/Settings/Invoke-ExecRemoveTenant.ps1 | 12 +- .../Invoke-ExecRunTenantGroupRule.ps1 | 6 +- .../CIPP/Settings/Invoke-ExecSAMRoles.ps1 | 26 +- .../CIPP/Settings/Invoke-ExecTenantGroup.ps1 | 5 +- .../Settings/Invoke-ExecUserBookmarks.ps1 | 14 +- .../CIPP/Settings/Invoke-ExecUserSettings.ps1 | 12 +- .../CIPP/Settings/Invoke-ListCustomRole.ps1 | 15 + .../Settings/Invoke-ListExcludedLicenses.ps1 | 2 +- .../CIPP/Setup/Invoke-ExecCombinedSetup.ps1 | 37 + .../CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 | 42 +- .../CIPP/Setup/Invoke-ExecSamSecretStatus.ps1 | 19 + .../CIPP/Setup/Invoke-ExecTokenExchange.ps1 | 67 +- .../Setup/Invoke-ExecUpdateRefreshToken.ps1 | 9 +- .../Contacts/Invoke-RemoveContact.ps1 | 2 +- .../Invoke-ExecMailboxMobileDevices.ps1 | 13 +- .../Invoke-ExecScheduleForwardingVacation.ps1 | 1 + .../Invoke-ExecScheduleMailboxVacation.ps1 | 1 + .../Invoke-ExecScheduleOOOVacation.ps1 | 1 + .../Administration/Invoke-ListMailboxes.ps1 | 43 +- .../Reports/Invoke-ListMailFlowReports.ps1 | 66 + .../Invoke-AddSpamFilterTemplate.ps1 | 4 +- .../Invoke-ListUserReportedMessage.ps1 | 85 + .../Invoke-ListUserReportedMessages.ps1 | 64 + .../Tools/Invoke-ExecHistoricalSearch.ps1 | 99 + .../Tools/Invoke-ExecMailboxRestore.ps1 | 9 +- .../Tools/Invoke-ListHistoricalSearches.ps1 | 49 + .../Tools/Invoke-ListMessageTrace.ps1 | 215 +- .../Invoke-AddConnectionFilterTemplate.ps1 | 4 +- .../Invoke-AddExConnectorTemplate.ps1 | 8 +- .../Transport/Invoke-AddTransportTemplate.ps1 | 8 +- .../Invoke-ListExConnectorTemplates.ps1 | 6 +- .../Transport/Invoke-RemoveExConnector.ps1 | 2 +- .../Applications/Invoke-AddAppTemplate.ps1 | 11 + .../Applications/Invoke-AddEdgeApp.ps1 | 50 + .../Invoke-ExecDeployAppTemplate.ps1 | 26 +- .../Endpoint/Applications/Invoke-ListApps.ps1 | 6 +- .../Invoke-AddAssignmentFilterTemplate.ps1 | 4 +- ...nvoke-AddIntuneReusableSettingTemplate.ps1 | 4 +- .../Endpoint/MEM/Invoke-AddIntuneTemplate.ps1 | 6 +- .../Endpoint/MEM/Invoke-EditIntuneScript.ps1 | 4 + .../MEM/Invoke-ExecCompareIntunePolicy.ps1 | 89 +- .../MEM/Invoke-ListAppProtectionPolicies.ps1 | 28 +- .../Invoke-ListAppleEnrollmentProfiles.ps1 | 38 +- .../MEM/Invoke-ListCompliancePolicies.ps1 | 8 +- .../Endpoint/MEM/Invoke-ListIntuneScript.ps1 | 6 +- .../MEM/Invoke-ListIntuneTemplates.ps1 | 36 +- .../Groups/Invoke-AddGroupTemplate.ps1 | 4 +- .../Groups/Invoke-EditGroup.ps1 | 2 + .../Groups/Invoke-ListGroups.ps1 | 58 +- .../Invoke-AddPIMRoleSettingsTemplate.ps1 | 140 + .../Roles/Invoke-ExecPIMRoleAssignment.ps1 | 112 + .../Invoke-ListPIMRoleSettingsTemplates.ps1 | 51 + .../Roles/Invoke-ListPIMRoles.ps1 | 115 + .../Roles/Invoke-ListRoleAssignments.ps1 | 76 + .../Invoke-RemovePIMRoleSettingsTemplate.ps1 | 48 + .../Users/Invoke-AddJITRoleTemplate.ps1 | 81 + .../Administration/Users/Invoke-AddUser.ps1 | 1 + .../Users/Invoke-AddUserBulk.ps1 | 1 + .../Users/Invoke-AddUserDefaults.ps1 | 2 + .../Users/Invoke-EditJITRoleTemplate.ps1 | 94 + .../Users/Invoke-ExecJITAdmin.ps1 | 24 + .../Users/Invoke-ExecOffboardUser.ps1 | 74 + .../Users/Invoke-ExecSendPush.ps1 | 170 +- .../Users/Invoke-ListGuestUsers.ps1 | 32 +- .../Users/Invoke-ListJITAdmin.ps1 | 47 +- .../Users/Invoke-ListJITAdminTemplates.ps1 | 6 +- .../Users/Invoke-ListJITAllowedRoles.ps1 | 24 + .../Users/Invoke-ListJITRoleTemplates.ps1 | 47 + .../Users/Invoke-ListNewUserDefaults.ps1 | 6 +- .../Users/Invoke-ListOffboardingProgress.ps1 | 33 + .../Users/Invoke-ListUserCounts.ps1 | 29 +- .../Users/Invoke-ListUserMailboxDetails.ps1 | 51 +- .../Administration/Users/Invoke-ListUsers.ps1 | 12 +- .../Users/Invoke-RemoveJITRoleTemplate.ps1 | 50 + .../Reports/Invoke-ListGroupUsage.ps1 | 29 + .../Reports/Invoke-ListInactiveAccounts.ps1 | 5 +- .../Identity/Reports/Invoke-ListMFAUsers.ps1 | 28 +- .../Invoke-AddDlpCompliancePolicyTemplate.ps1 | 4 +- ...e-AddRetentionCompliancePolicyTemplate.ps1 | 4 +- .../Invoke-ListRetentionCompliancePolicy.ps1 | 55 +- .../Invoke-AddSensitivityLabelTemplate.ps1 | 4 +- .../Incidents/Invoke-ExecMdoAlertsList.ps1 | 4 +- .../Invoke-AddSafeLinksPolicyTemplate.ps1 | 10 +- .../Invoke-ExecNewSafeLinksPolicy.ps1 | 10 + .../Invoke-ExecBulkRemoveSharingLinks.ps1 | 40 +- .../Invoke-ExecEmptySiteRecycleBin.ps1 | 134 + .../Invoke-ExecReactivateSite.ps1 | 81 + .../Invoke-ExecRestoreRecycleBinItems.ps1 | 8 +- .../Invoke-ExecSetSharePointMember.ps1 | 8 +- .../Invoke-ExecSharePointTemplate.ps1 | 12 +- .../Invoke-ExecSiteBrowserLibraryCopy.ps1 | 80 + .../Invoke-ExecSiteBrowserPermissions.ps1 | 8 +- .../Invoke-ListSharePointTemplates.ps1 | 6 +- .../Invoke-ListSharepointSettings.ps1 | 7 +- .../Invoke-ListSiteActivity.ps1 | 6 +- .../Invoke-ListSiteBrowser.ps1 | 67 +- .../Invoke-ListSiteBrowserLibraryCopy.ps1 | 37 + .../Invoke-ListSiteBrowserPermissions.ps1 | 21 +- .../Invoke-ListSiteMembers.ps1 | 8 +- .../Invoke-ListSitePermissions.ps1 | 8 +- .../Invoke-ListSiteRecycleBin.ps1 | 8 +- .../Invoke-ListSiteRecycleBinSummary.ps1 | 97 + .../Invoke-ListSiteRoleDefinitions.ps1 | 8 +- .../Invoke-ListSiteStorageComposition.ps1 | 93 + .../Teams-Sharepoint/Invoke-ListSites.ps1 | 127 +- .../Invoke-ListStorageCleanupScan.ps1 | 131 + .../Administration/Alerts/Invoke-AddAlert.ps1 | 21 +- .../Alerts/Invoke-AddScriptedAlert.ps1 | 76 +- ...oke-ExecScheduleAuditExclusionVacation.ps1 | 1 + .../Alerts/Invoke-ListAlertsQueue.ps1 | 40 +- .../Alerts/Invoke-PublicWebhooks.ps1 | 21 +- .../Alerts/Invoke-RemoveWebhookAlert.ps1 | 9 +- .../Invoke-ExecAddMultiTenantApp.ps1 | 18 +- .../Invoke-ExecAppApprovalTemplate.ps1 | 22 +- .../Invoke-ExecApplication.ps1 | 46 +- .../Tenant/Invoke-AddTenant.ps1 | 14 +- .../Tenant/Invoke-EditTenant.ps1 | 8 +- .../Tenant/Invoke-ExecAddSPN.ps1 | 1 + .../Tenant/Invoke-ExecOnboardTenant.ps1 | 27 +- .../Tenant/Invoke-ExecSendOrgMessage.ps1 | 13 +- .../Invoke-RemoveTenantCapabilitiesCache.ps1 | 4 +- .../Conditional/Invoke-AddCATemplate.ps1 | 4 +- .../Conditional/Invoke-ExecCAExclusion.ps1 | 5 +- .../Conditional/Invoke-ListCAtemplates.ps1 | 20 +- .../Invoke-ListConditionalAccessPolicies.ps1 | 45 +- .../Tenant/GDAP/Invoke-ExecAddGDAPRole.ps1 | 228 +- .../GDAP/Invoke-ExecGDAPAccessAssignment.ps1 | 3 + .../GDAP/Invoke-ExecGDAPInviteApproved.ps1 | 18 +- .../Invoke-ExecGDAPRepairRoleMappings.ps1 | 24 +- .../GDAP/Invoke-ExecGDAPRoleTemplate.ps1 | 64 +- .../Tenant/GDAP/Invoke-ListGDAPRoles.ps1 | 47 + .../Reports/Invoke-ExecLicensePricing.ps1 | 76 + .../Invoke-ListLicenseOptimization.ps1 | 57 + .../Reports/Invoke-ListLicensePricing.ps1 | 41 + .../Reports/Invoke-ListServiceHealth.ps1 | 6 +- .../Standards/Invoke-AddBPATemplate.ps1 | 4 +- .../Standards/Invoke-ExecDriftClone.ps1 | 22 +- .../Standards/Invoke-ExecStandardConvert.ps1 | 17 +- .../Standards/Invoke-ExecStandardsRun.ps1 | 7 +- .../Invoke-ExecUpdateBaselineDeviation.ps1 | 11 +- .../Invoke-ExecUpdateDriftDeviation.ps1 | 46 +- .../Standards/Invoke-ListStandardsCompare.ps1 | 30 +- .../Invoke-listStandardTemplates.ps1 | 11 +- .../Tenant/Tests/Invoke-AddTestReport.ps1 | 6 +- .../Tenant/Tests/Invoke-DeleteTestReport.ps1 | 6 +- .../Tenant/Tests/Invoke-ExecTestRefresh.ps1 | 5 +- .../Tenant/Tests/Invoke-ListTests.ps1 | 8 +- .../Tools/Invoke-ExecGraphExplorerPreset.ps1 | 9 + .../Tools/GitHub/Invoke-ExecCommunityRepo.ps1 | 11 + .../Tools/GitHub/Invoke-ExecGitHubAction.ps1 | 18 +- .../Invoke-ListCommunityRepoTemplates.ps1 | 3 +- .../GitHub/Invoke-ListCommunityRepos.ps1 | 10 +- .../Invoke-CIPPStandardAntiPhishPolicy.ps1 | 7 + .../Invoke-CIPPStandardAppDeploy.ps1 | 2 +- ...voke-CIPPStandardAuthenticationMethods.ps1 | 85 +- ...-CIPPStandardConditionalAccessTemplate.ps1 | 7 +- ...IPPStandardDefaultPlatformRestrictions.ps1 | 103 +- ...oke-CIPPStandardDeployContactTemplates.ps1 | 8 +- .../Invoke-CIPPStandardDisableGuests.ps1 | 87 +- ...nvoke-CIPPStandardDisableInactiveUsers.ps1 | 12 +- ...nvoke-CIPPStandardDisableSharedMailbox.ps1 | 38 +- ...voke-CIPPStandardEnableMailboxAuditing.ps1 | 84 +- ...-CIPPStandardExternalComplianceTrusted.ps1 | 97 + ...nvoke-CIPPStandardFIDO2PasskeyProfiles.ps1 | 10 + .../Invoke-CIPPStandardGroupTemplate.ps1 | 35 +- ...ke-CIPPStandardIntuneAppTemplateDeploy.ps1 | 22 +- .../Invoke-CIPPStandardIntuneTemplate.ps1 | 12 +- .../Standards/Invoke-CIPPStandardMDMScope.ps1 | 2 +- ...voke-CIPPStandardOneDriveLicensedQuota.ps1 | 17 +- .../Invoke-CIPPStandardOutBoundSpamAlert.ps1 | 56 +- .../Invoke-CIPPStandardPIMRoleSettings.ps1 | 197 + ...oke-CIPPStandardQuarantineRequestAlert.ps1 | 37 +- ...Invoke-CIPPStandardSPGuestPeoplePicker.ps1 | 136 + .../Invoke-CIPPStandardSPOVersionControl.ps1 | 60 +- .../Invoke-CIPPStandardSpamFilterPolicy.ps1 | 13 + ...e-CIPPStandardTeamsGlobalMeetingPolicy.ps1 | 29 +- .../Standards/Invoke-CIPPStandardTeamsZAP.ps1 | 15 + .../Invoke-CIPPStandardUserSubmissions.ps1 | 16 +- .../Invoke-CIPPStandardcalDefault.ps1 | 44 +- .../Helpers/ConvertTo-CippMarkdownCell.ps1 | 4 +- .../CIS/Identity/Invoke-CippTestCIS_2_1_1.ps1 | 9 +- .../Identity/Invoke-CippTestCIS_2_1_11.ps1 | 17 +- .../CIS/Identity/Invoke-CippTestCIS_2_1_3.ps1 | 15 +- .../CIS/Identity/Invoke-CippTestCIS_2_1_4.ps1 | 9 +- .../CIS/Identity/Invoke-CippTestCIS_2_1_5.ps1 | 9 +- .../CIS/Identity/Invoke-CippTestCIS_6_1_2.ps1 | 13 +- .../Identity/Invoke-CippTestCIS_7_2_11.ps1 | 14 +- .../CIS/Identity/Invoke-CippTestCIS_7_2_7.ps1 | 18 +- .../Invoke-CippTestGenericTest011.ps1 | 6 +- .../Identity/Invoke-CippTestZTNA21811.ps1 | 15 +- .../Register-CippExtensionScheduledTasks.ps1 | 12 + .../Public/Halo/Get-HaloPriority.ps1 | 19 +- .../Public/Halo/Get-HaloTicketTypeSlaId.ps1 | 53 + .../Public/Halo/New-HaloPSATicket.ps1 | 173 +- .../Public/New-CippExtAlert.ps1 | 64 +- .../NinjaOne/Invoke-NinjaOneTenantSync.ps1 | 31 +- .../Sherweb/Invoke-SherwebMigration.ps1 | 16 +- .../{1.1.8 => 1.1.10}/DNSHealth.psd1 | 14 +- .../{1.1.8 => 1.1.10}/DNSHealth.psm1 | 12 +- .../MailProviders/AppRiver.json | 0 .../MailProviders/BarracudaESS.json | 0 .../MailProviders/Google.json | 0 .../MailProviders/HornetSecurity.json | 0 .../MailProviders/Intermedia.json | 0 .../MailProviders/Microsoft365.json | 0 .../MailProviders/Mimecast.json | 2 +- .../MailProviders/MimecastOffshore.json | 10 + .../{1.1.8 => 1.1.10}/MailProviders/Null.json | 0 .../MailProviders/Proofpoint.json | 0 .../MailProviders/Reflexion.json | 0 .../MailProviders/Sophos.json | 3 +- .../MailProviders/SpamTitan.json | 0 .../MailProviders/SymantecCloud.json | 0 .../MailProviders/_template.json | 0 .../{1.1.8 => 1.1.10}/PSGetModuleInfo.xml | 92 +- Modules/DNSHealth/1.1.10/SevenTinyRsa.dll | Bin 0 -> 5120 bytes Shared/CIPPSharp/CIPPRestClient.cs | 178 +- Shared/CIPPSharp/bin/CIPPSharp.dll | Bin 64512 -> 71168 bytes ...ditionalAccessPoliciesAllTenants.Tests.ps1 | 67 + .../Get-CIPPAlertMXRecordChanged.Tests.ps1 | 87 + .../Get-CIPPAlertOneDriveLongPaths.Tests.ps1 | 73 + ...PPAlertQuarantineReleaseRequests.Tests.ps1 | 116 + Tests/Alerts/Get-CIPPAlertQuotaUsed.Tests.ps1 | 77 + .../Alerts/Invoke-AddScriptedAlert.Tests.ps1 | 125 + Tests/Alerts/Send-CIPPAlert.Psa.Tests.ps1 | 112 + ...cheduledTaskAlert.ResultEnvelope.Tests.ps1 | 162 + .../Send-CIPPScheduledTaskAlert.Tests.ps1 | 64 +- .../Baselines/BaselineDisableGuests.Tests.ps1 | 119 + .../Baselines/BaselineEntraHeavies.Tests.ps1 | 42 + .../Baselines/BaselineExchangeBatch.Tests.ps1 | 39 + .../BaselineOneOffStandards.Tests.ps1 | 26 +- .../BaselineSPGuestPeoplePicker.Tests.ps1 | 118 + .../BaselineSharePointBatch.Tests.ps1 | 19 +- Tests/DBCache/Clear-CIPPDbCache.Tests.ps1 | 208 + Tests/DBCache/OneDriveLongPaths.Tests.ps1 | 328 + ...Push-GetCalendarPermissionsBatch.Tests.ps1 | 134 + .../DBCache/Set-CIPPDBCache.Memory.Tests.ps1 | 175 +- ...et-CIPPDBCacheDefenderCVEs.Flush.Tests.ps1 | 3 +- .../Set-CIPPDBCacheDefenderCVEs.Tests.ps1 | 62 +- .../SharePointSharingLinks.Resume.Tests.ps1 | 10 +- .../Endpoint/Invoke-AddAppTemplate.Tests.ps1 | 171 + ...nvoke-AddPIMRoleSettingsTemplate.Tests.ps1 | 215 + Tests/Endpoint/Invoke-EditTenant.Tests.ps1 | 140 + .../Invoke-ExecAppServiceDomains.Tests.ps1 | 74 + .../Invoke-ExecCippReplacemap.Tests.ps1 | 1 + .../Invoke-ExecEmptySiteRecycleBin.Tests.ps1 | 148 + .../Invoke-ExecOffboardUser.Tests.ps1 | 118 +- .../Invoke-ExecPIMRoleAssignment.Tests.ps1 | 163 + .../Invoke-ExecRefreshMyAccess.Tests.ps1 | 4 + ...nvoke-ExecSiteBrowserLibraryCopy.Tests.ps1 | 105 + ...ke-ListConditionalAccessPolicies.Tests.ps1 | 154 + Tests/Endpoint/Invoke-ListGroups.Tests.ps1 | 133 + .../Endpoint/Invoke-ListGuestUsers.Tests.ps1 | 78 +- .../Invoke-ListLicenseOptimization.Tests.ps1 | 96 + Tests/Endpoint/Invoke-ListLogs.Tests.ps1 | 297 + Tests/Endpoint/Invoke-ListMailboxes.Tests.ps1 | 113 + .../Invoke-ListMessageTrace.Tests.ps1 | 146 + ...ListScheduledItems.TenantDomains.Tests.ps1 | 96 + ...New-CippExtAlert.TicketReference.Tests.ps1 | 193 + .../New-HaloPSATicket.Priority.Tests.ps1 | 159 + .../New-HaloPSATicket.TicketTarget.Tests.ps1 | 169 + ...-ClassicAPIToken.CertificateAuth.Tests.ps1 | 72 + .../Get-GraphToken.CertificateAuth.Tests.ps1 | 110 + .../Get-Tenants.RefreshLoop.Tests.ps1 | 225 + .../New-CIPPMFAConnectorToken.Tests.ps1 | 63 + .../New-GraphBulkRequest.Paging.Tests.ps1 | 115 + ...ManagementPolicy.CertificateOnly.Tests.ps1 | 91 + ...ppManagementPolicy.ExemptionBody.Tests.ps1 | 136 + Tests/Private/Add-CIPPDbItem.Tests.ps1 | 21 + Tests/Private/Format-CIPPCAPolicy.Tests.ps1 | 54 + Tests/Private/Get-CIPPCVEReport.Tests.ps1 | 63 +- Tests/Private/Get-CIPPDbItemPage.Tests.ps1 | 84 + ...Get-CIPPDriveItemCloudPathLength.Tests.ps1 | 36 + Tests/Private/Get-CIPPGroupsReport.Tests.ps1 | 99 + .../Get-CIPPLicenseOptimization.Tests.ps1 | 158 + Tests/Private/Get-CIPPLicensePrice.Tests.ps1 | 151 + .../Private/Get-CIPPPagedTableRows.Tests.ps1 | 200 + ...et-CIPPSharePointCopyJobProgress.Tests.ps1 | 205 + ...PPSharePointLibraryCopyOperation.Tests.ps1 | 117 + Tests/Private/Get-CippApiClient.Tests.ps1 | 102 + .../Private/Get-CippHttpPermissions.Tests.ps1 | 114 + Tests/Private/Get-DefenderCves.Tests.ps1 | 23 +- .../Get-GraphRequestList.Paging.Tests.ps1 | 219 + ...voke-CIPPCustomDomainCertificate.Tests.ps1 | 120 + ...ke-CIPPOffboardingJob.DeleteUser.Tests.ps1 | 77 + ...voke-CIPPOffboardingJob.Progress.Tests.ps1 | 128 + .../Invoke-CIPPPIMAssignmentAction.Tests.ps1 | 220 + ...oke-CIPPSharePointCreateCopyJobs.Tests.ps1 | 47 + .../New-CIPPPIMScheduleRequest.Tests.ps1 | 120 + Tests/Private/New-CIPPUserTask.Tests.ps1 | 33 + ...ffboardingComplete.PostExecution.Tests.ps1 | 115 + ...ush-CIPPOffboardingTask.Progress.Tests.ps1 | 96 + .../Remove-CIPPUserTeamsPhoneDIDs.Tests.ps1 | 90 + .../Repair-CIPPPIMRoleSettingsFloor.Tests.ps1 | 143 + ...esolve-CIPPSharePointRestContext.Tests.ps1 | 46 + .../Set-CIPPAsyncDeploymentStep.Tests.ps1 | 95 + .../Set-CIPPFeatureFlag.Force.Tests.ps1 | 44 + .../Private/Set-CIPPSAMCertificate.Tests.ps1 | 60 + Tests/Private/Start-UserSyncTimer.Tests.ps1 | 183 + ...Test-CIPPAccess.BlockedEndpoints.Tests.ps1 | 219 + .../Test-CIPPAccess.TenantGroupAuth.Tests.ps1 | 87 + .../Test-CIPPPIMRoleSettingsFloor.Tests.ps1 | 197 + ...IPPSharePointLibraryCopyEligible.Tests.ps1 | 26 + ...-CIPPSharePointLibraryCopyStatus.Tests.ps1 | 111 + .../ConvertTo-CippMarkdownCell.Tests.ps1 | 10 + Tests/Reports/Get-CIPPDrift.Tests.ps1 | 41 + .../Reports/Get-CIPPLicenseOverview.Tests.ps1 | 120 + ...CIPPScheduledTask.TenantCoercion.Tests.ps1 | 143 + ...IPPScheduledTask.TenantSelection.Tests.ps1 | 95 + .../Get-CIPPScheduledTaskNextRun.Tests.ps1 | 41 + ...cScheduledCommand.TenantCoercion.Tests.ps1 | 128 + ...pdateTokensTimer.CertificateAuth.Tests.ps1 | 69 + ...erTasksOrchestrator.TenantGroups.Tests.ps1 | 353 + ...tandardConditionalAccessTemplate.Tests.ps1 | 2 +- ...Invoke-CIPPStandardDisableGuests.Tests.ps1 | 230 + ...CIPPStandardDisableSharedMailbox.Tests.ps1 | 228 + ...tandardExternalComplianceTrusted.Tests.ps1 | 62 + ...CIPPStandardFIDO2PasskeyProfiles.Tests.ps1 | 162 + ...Invoke-CIPPStandardGroupTemplate.Tests.ps1 | 53 + ...IPPStandardOneDriveLicensedQuota.Tests.ps1 | 32 +- ...PPStandardQuarantineRequestAlert.Tests.ps1 | 178 + ...ke-CIPPStandardSPOVersionControl.Tests.ps1 | 149 + ...voke-CIPPStandardUserSubmissions.Tests.ps1 | 70 +- ...-CIPPStandardcalDefault.Coverage.Tests.ps1 | 125 + Tests/Static/PIMSecureDirection.Tests.ps1 | 80 + ...nvoke-ExecGDAPRepairRoleMappings.Tests.ps1 | 81 + .../Invoke-ListGDAPRoles.Validate.Tests.ps1 | 97 + .../Tenant/New-CIPPGDAPRoleMapping.Tests.ps1 | 119 + .../Webhooks/Test-CIPPAuditLogRules.Tests.ps1 | 22 +- version_latest.txt | 2 +- 764 files changed, 53153 insertions(+), 7819 deletions(-) create mode 100644 Config/BaselineStandards/Entra (AAD) Standards/ExternalComplianceTrusted.json create mode 100644 Config/BaselineStandards/SharePoint Standards/SPGuestPeoplePicker.json create mode 100644 Config/CountryList.json create mode 100644 Config/LicensePricingDefaults.csv create mode 100644 Config/openapi-overrides/ListOffboardingProgress.json create mode 100644 Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/OneDrive Long Paths/Push-DBCacheOneDriveLongPaths.ps1 create mode 100644 Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Storage Cleanup/Push-DBCacheStorageCleanupScanBatch.ps1 create mode 100644 Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Storage Cleanup/Push-StoreStorageCleanupScan.ps1 create mode 100644 Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertArchiveQuota.ps1 create mode 100644 Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertDeviceComplianceGracePeriod.ps1 create mode 100644 Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertOneDriveLongPaths.ps1 create mode 100644 Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertPermanentActiveAdminAssigned.ps1 create mode 100644 Modules/CIPPCore/Public/AsyncDeployment/Add-CIPPAsyncDeploymentStep.ps1 create mode 100644 Modules/CIPPCore/Public/Authentication/Test-CippHttpPermissionUniverse.ps1 create mode 100644 Modules/CIPPCore/Public/Authentication/Test-CippRoleTenantScope.ps1 create mode 100644 Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineExternalComplianceTrustedState.ps1 create mode 100644 Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSPGuestPeoplePickerState.ps1 create mode 100644 Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExternalComplianceTrusted.ps1 create mode 100644 Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSPGuestPeoplePicker.ps1 create mode 100644 Modules/CIPPCore/Public/Clear-CIPPDbCache.ps1 create mode 100644 Modules/CIPPCore/Public/Clear-CIPPMobileDevice.ps1 create mode 100644 Modules/CIPPCore/Public/ConvertTo-CIPPCountryCode.ps1 create mode 100644 Modules/CIPPCore/Public/ConvertTo-CIPPSharePointSiteUsagePayload.ps1 create mode 100644 Modules/CIPPCore/Public/ConvertTo-SPOAdminListInt64.ps1 create mode 100644 Modules/CIPPCore/Public/ConvertTo-SPOUsageRootWebTemplate.ps1 create mode 100644 Modules/CIPPCore/Public/Functions/Get-CIPPAppServiceSite.ps1 create mode 100644 Modules/CIPPCore/Public/Functions/Invoke-CIPPCustomDomainCertificate.ps1 create mode 100644 Modules/CIPPCore/Public/Functions/Test-CIPPCacheCapabilityError.ps1 create mode 100644 Modules/CIPPCore/Public/Get-CIPPDbItemPage.ps1 create mode 100644 Modules/CIPPCore/Public/Get-CIPPDriveItemCloudPathLength.ps1 create mode 100644 Modules/CIPPCore/Public/Get-CIPPEdgeAppBody.ps1 create mode 100644 Modules/CIPPCore/Public/Get-CIPPGroupUsageReport.ps1 create mode 100644 Modules/CIPPCore/Public/Get-CIPPJITAdminAllowedRoles.ps1 create mode 100644 Modules/CIPPCore/Public/Get-CIPPLastSignInDateTime.ps1 create mode 100644 Modules/CIPPCore/Public/Get-CIPPLicenseOptimization.ps1 create mode 100644 Modules/CIPPCore/Public/Get-CIPPLicensePrice.ps1 create mode 100644 Modules/CIPPCore/Public/Get-CIPPPagedTableRows.ps1 create mode 100644 Modules/CIPPCore/Public/Get-CIPPSPOSiteBulk.ps1 create mode 100644 Modules/CIPPCore/Public/Get-CIPPScheduledTaskNextRun.ps1 create mode 100644 Modules/CIPPCore/Public/Get-CIPPSharePointCopyJobProgress.ps1 create mode 100644 Modules/CIPPCore/Public/Get-CIPPSharePointCopyJobQueueLogs.ps1 create mode 100644 Modules/CIPPCore/Public/Get-CIPPSharePointLibraryCopyOperation.ps1 create mode 100644 Modules/CIPPCore/Public/Get-CIPPSharePointLibraryRootChildUris.ps1 create mode 100644 Modules/CIPPCore/Public/Get-CIPPSharePointSiteUsageRows.ps1 create mode 100644 Modules/CIPPCore/Public/GraphHelper/New-CIPPMFAConnectorToken.ps1 create mode 100644 Modules/CIPPCore/Public/Invoke-CIPPSharePointCreateCopyJobs.ps1 create mode 100644 Modules/CIPPCore/Public/New-CIPPGDAPRoleMapping.ps1 create mode 100644 Modules/CIPPCore/Public/PIM/Compare-CIPPPIMRoleSettings.ps1 create mode 100644 Modules/CIPPCore/Public/PIM/ConvertFrom-CIPPPIMPolicyRules.ps1 create mode 100644 Modules/CIPPCore/Public/PIM/ConvertTo-CIPPPIMPolicyRules.ps1 create mode 100644 Modules/CIPPCore/Public/PIM/ConvertTo-CIPPPIMRoleSettings.ps1 create mode 100644 Modules/CIPPCore/Public/PIM/Get-CIPPPIMPolicySummary.ps1 create mode 100644 Modules/CIPPCore/Public/PIM/Get-CIPPPIMRoleAssignments.ps1 create mode 100644 Modules/CIPPCore/Public/PIM/Get-CIPPPIMRolePolicies.ps1 create mode 100644 Modules/CIPPCore/Public/PIM/Get-CIPPPrivilegedRoleTemplateIds.ps1 create mode 100644 Modules/CIPPCore/Public/PIM/Invoke-CIPPPIMAssignmentAction.ps1 create mode 100644 Modules/CIPPCore/Public/PIM/New-CIPPPIMScheduleRequest.ps1 create mode 100644 Modules/CIPPCore/Public/PIM/Repair-CIPPPIMRoleSettingsFloor.ps1 create mode 100644 Modules/CIPPCore/Public/PIM/Set-CIPPPIMRoleSettings.ps1 create mode 100644 Modules/CIPPCore/Public/PIM/Test-CIPPPIMRoleSettingsFloor.ps1 create mode 100644 Modules/CIPPCore/Public/Resolve-CIPPIntuneTargetedMobileApps.ps1 create mode 100644 Modules/CIPPCore/Public/Resolve-CIPPSharePointLibraryRootUri.ps1 create mode 100644 Modules/CIPPCore/Public/Resolve-CIPPSharePointRestContext.ps1 create mode 100644 Modules/CIPPCore/Public/Set-CIPPSPOSiteBulk.ps1 create mode 100644 Modules/CIPPCore/Public/Set-CIPPSharePointLibraryCopyOperation.ps1 create mode 100644 Modules/CIPPCore/Public/Standards/Get-CIPPStandardsTemplateScope.ps1 create mode 100644 Modules/CIPPCore/Public/Start-CIPPSharePointLibraryCopy.ps1 create mode 100644 Modules/CIPPCore/Public/Test-CIPPSharePointLibraryCopyEligible.ps1 create mode 100644 Modules/CIPPCore/Public/Update-CIPPSharePointLibraryCopyStatus.ps1 create mode 100644 Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheActiveUserDetail.ps1 create mode 100644 Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheGroupUsage.ps1 create mode 100644 Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOneDriveLongPaths.ps1 create mode 100644 Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSPOSites.ps1 create mode 100644 Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheStorageCleanupScan.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCIPPDBCacheAdmin.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ListAsyncDeployment.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Reports/Invoke-ListMailFlowReports.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListUserReportedMessage.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListUserReportedMessages.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Tools/Invoke-ExecHistoricalSearch.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Tools/Invoke-ListHistoricalSearches.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddEdgeApp.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Roles/Invoke-AddPIMRoleSettingsTemplate.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Roles/Invoke-ExecPIMRoleAssignment.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Roles/Invoke-ListPIMRoleSettingsTemplates.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Roles/Invoke-ListPIMRoles.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Roles/Invoke-ListRoleAssignments.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Roles/Invoke-RemovePIMRoleSettingsTemplate.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddJITRoleTemplate.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditJITRoleTemplate.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITAllowedRoles.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITRoleTemplates.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListOffboardingProgress.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-RemoveJITRoleTemplate.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Reports/Invoke-ListGroupUsage.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecEmptySiteRecycleBin.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecReactivateSite.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSiteBrowserLibraryCopy.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteBrowserLibraryCopy.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteRecycleBinSummary.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteStorageComposition.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListStorageCleanupScan.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Reports/Invoke-ExecLicensePricing.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Reports/Invoke-ListLicenseOptimization.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Reports/Invoke-ListLicensePricing.ps1 create mode 100644 Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardExternalComplianceTrusted.ps1 create mode 100644 Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardPIMRoleSettings.ps1 create mode 100644 Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPGuestPeoplePicker.ps1 create mode 100644 Modules/CippExtensions/Public/Halo/Get-HaloTicketTypeSlaId.ps1 rename Modules/DNSHealth/{1.1.8 => 1.1.10}/DNSHealth.psd1 (89%) rename Modules/DNSHealth/{1.1.8 => 1.1.10}/DNSHealth.psm1 (99%) rename Modules/DNSHealth/{1.1.8 => 1.1.10}/MailProviders/AppRiver.json (100%) rename Modules/DNSHealth/{1.1.8 => 1.1.10}/MailProviders/BarracudaESS.json (100%) rename Modules/DNSHealth/{1.1.8 => 1.1.10}/MailProviders/Google.json (100%) rename Modules/DNSHealth/{1.1.8 => 1.1.10}/MailProviders/HornetSecurity.json (100%) rename Modules/DNSHealth/{1.1.8 => 1.1.10}/MailProviders/Intermedia.json (100%) rename Modules/DNSHealth/{1.1.8 => 1.1.10}/MailProviders/Microsoft365.json (100%) rename Modules/DNSHealth/{1.1.8 => 1.1.10}/MailProviders/Mimecast.json (84%) create mode 100644 Modules/DNSHealth/1.1.10/MailProviders/MimecastOffshore.json rename Modules/DNSHealth/{1.1.8 => 1.1.10}/MailProviders/Null.json (100%) rename Modules/DNSHealth/{1.1.8 => 1.1.10}/MailProviders/Proofpoint.json (100%) rename Modules/DNSHealth/{1.1.8 => 1.1.10}/MailProviders/Reflexion.json (100%) rename Modules/DNSHealth/{1.1.8 => 1.1.10}/MailProviders/Sophos.json (68%) rename Modules/DNSHealth/{1.1.8 => 1.1.10}/MailProviders/SpamTitan.json (100%) rename Modules/DNSHealth/{1.1.8 => 1.1.10}/MailProviders/SymantecCloud.json (100%) rename Modules/DNSHealth/{1.1.8 => 1.1.10}/MailProviders/_template.json (100%) rename Modules/DNSHealth/{1.1.8 => 1.1.10}/PSGetModuleInfo.xml (71%) create mode 100644 Modules/DNSHealth/1.1.10/SevenTinyRsa.dll create mode 100644 Tests/ActivityTriggers/Push-ListConditionalAccessPoliciesAllTenants.Tests.ps1 create mode 100644 Tests/Alerts/Get-CIPPAlertMXRecordChanged.Tests.ps1 create mode 100644 Tests/Alerts/Get-CIPPAlertOneDriveLongPaths.Tests.ps1 create mode 100644 Tests/Alerts/Get-CIPPAlertQuarantineReleaseRequests.Tests.ps1 create mode 100644 Tests/Alerts/Get-CIPPAlertQuotaUsed.Tests.ps1 create mode 100644 Tests/Alerts/Invoke-AddScriptedAlert.Tests.ps1 create mode 100644 Tests/Alerts/Send-CIPPAlert.Psa.Tests.ps1 create mode 100644 Tests/Alerts/Send-CIPPScheduledTaskAlert.ResultEnvelope.Tests.ps1 create mode 100644 Tests/Baselines/BaselineDisableGuests.Tests.ps1 create mode 100644 Tests/Baselines/BaselineSPGuestPeoplePicker.Tests.ps1 create mode 100644 Tests/DBCache/Clear-CIPPDbCache.Tests.ps1 create mode 100644 Tests/DBCache/OneDriveLongPaths.Tests.ps1 create mode 100644 Tests/DBCache/Push-GetCalendarPermissionsBatch.Tests.ps1 create mode 100644 Tests/Endpoint/Invoke-AddAppTemplate.Tests.ps1 create mode 100644 Tests/Endpoint/Invoke-AddPIMRoleSettingsTemplate.Tests.ps1 create mode 100644 Tests/Endpoint/Invoke-EditTenant.Tests.ps1 create mode 100644 Tests/Endpoint/Invoke-ExecAppServiceDomains.Tests.ps1 create mode 100644 Tests/Endpoint/Invoke-ExecEmptySiteRecycleBin.Tests.ps1 create mode 100644 Tests/Endpoint/Invoke-ExecPIMRoleAssignment.Tests.ps1 create mode 100644 Tests/Endpoint/Invoke-ExecSiteBrowserLibraryCopy.Tests.ps1 create mode 100644 Tests/Endpoint/Invoke-ListConditionalAccessPolicies.Tests.ps1 create mode 100644 Tests/Endpoint/Invoke-ListGroups.Tests.ps1 create mode 100644 Tests/Endpoint/Invoke-ListLicenseOptimization.Tests.ps1 create mode 100644 Tests/Endpoint/Invoke-ListLogs.Tests.ps1 create mode 100644 Tests/Endpoint/Invoke-ListMailboxes.Tests.ps1 create mode 100644 Tests/Endpoint/Invoke-ListMessageTrace.Tests.ps1 create mode 100644 Tests/Endpoint/Invoke-ListScheduledItems.TenantDomains.Tests.ps1 create mode 100644 Tests/Extensions/New-CippExtAlert.TicketReference.Tests.ps1 create mode 100644 Tests/Extensions/New-HaloPSATicket.Priority.Tests.ps1 create mode 100644 Tests/Extensions/New-HaloPSATicket.TicketTarget.Tests.ps1 create mode 100644 Tests/GraphHelper/Get-ClassicAPIToken.CertificateAuth.Tests.ps1 create mode 100644 Tests/GraphHelper/Get-GraphToken.CertificateAuth.Tests.ps1 create mode 100644 Tests/GraphHelper/Get-Tenants.RefreshLoop.Tests.ps1 create mode 100644 Tests/GraphHelper/New-CIPPMFAConnectorToken.Tests.ps1 create mode 100644 Tests/GraphHelper/New-GraphBulkRequest.Paging.Tests.ps1 create mode 100644 Tests/GraphHelper/Update-AppManagementPolicy.CertificateOnly.Tests.ps1 create mode 100644 Tests/GraphHelper/Update-AppManagementPolicy.ExemptionBody.Tests.ps1 create mode 100644 Tests/Private/Get-CIPPDbItemPage.Tests.ps1 create mode 100644 Tests/Private/Get-CIPPDriveItemCloudPathLength.Tests.ps1 create mode 100644 Tests/Private/Get-CIPPGroupsReport.Tests.ps1 create mode 100644 Tests/Private/Get-CIPPLicenseOptimization.Tests.ps1 create mode 100644 Tests/Private/Get-CIPPLicensePrice.Tests.ps1 create mode 100644 Tests/Private/Get-CIPPPagedTableRows.Tests.ps1 create mode 100644 Tests/Private/Get-CIPPSharePointCopyJobProgress.Tests.ps1 create mode 100644 Tests/Private/Get-CIPPSharePointLibraryCopyOperation.Tests.ps1 create mode 100644 Tests/Private/Get-CippApiClient.Tests.ps1 create mode 100644 Tests/Private/Get-CippHttpPermissions.Tests.ps1 create mode 100644 Tests/Private/Get-GraphRequestList.Paging.Tests.ps1 create mode 100644 Tests/Private/Invoke-CIPPCustomDomainCertificate.Tests.ps1 create mode 100644 Tests/Private/Invoke-CIPPOffboardingJob.DeleteUser.Tests.ps1 create mode 100644 Tests/Private/Invoke-CIPPOffboardingJob.Progress.Tests.ps1 create mode 100644 Tests/Private/Invoke-CIPPPIMAssignmentAction.Tests.ps1 create mode 100644 Tests/Private/Invoke-CIPPSharePointCreateCopyJobs.Tests.ps1 create mode 100644 Tests/Private/New-CIPPPIMScheduleRequest.Tests.ps1 create mode 100644 Tests/Private/Push-CIPPOffboardingComplete.PostExecution.Tests.ps1 create mode 100644 Tests/Private/Push-CIPPOffboardingTask.Progress.Tests.ps1 create mode 100644 Tests/Private/Remove-CIPPUserTeamsPhoneDIDs.Tests.ps1 create mode 100644 Tests/Private/Repair-CIPPPIMRoleSettingsFloor.Tests.ps1 create mode 100644 Tests/Private/Resolve-CIPPSharePointRestContext.Tests.ps1 create mode 100644 Tests/Private/Set-CIPPAsyncDeploymentStep.Tests.ps1 create mode 100644 Tests/Private/Set-CIPPFeatureFlag.Force.Tests.ps1 create mode 100644 Tests/Private/Set-CIPPSAMCertificate.Tests.ps1 create mode 100644 Tests/Private/Start-UserSyncTimer.Tests.ps1 create mode 100644 Tests/Private/Test-CIPPAccess.BlockedEndpoints.Tests.ps1 create mode 100644 Tests/Private/Test-CIPPAccess.TenantGroupAuth.Tests.ps1 create mode 100644 Tests/Private/Test-CIPPPIMRoleSettingsFloor.Tests.ps1 create mode 100644 Tests/Private/Test-CIPPSharePointLibraryCopyEligible.Tests.ps1 create mode 100644 Tests/Private/Update-CIPPSharePointLibraryCopyStatus.Tests.ps1 create mode 100644 Tests/Reports/Get-CIPPLicenseOverview.Tests.ps1 create mode 100644 Tests/Scheduler/Add-CIPPScheduledTask.TenantCoercion.Tests.ps1 create mode 100644 Tests/Scheduler/Add-CIPPScheduledTask.TenantSelection.Tests.ps1 create mode 100644 Tests/Scheduler/Get-CIPPScheduledTaskNextRun.Tests.ps1 create mode 100644 Tests/Scheduler/Push-ExecScheduledCommand.TenantCoercion.Tests.ps1 create mode 100644 Tests/Scheduler/Start-UpdateTokensTimer.CertificateAuth.Tests.ps1 create mode 100644 Tests/Scheduler/Start-UserTasksOrchestrator.TenantGroups.Tests.ps1 create mode 100644 Tests/Standards/Invoke-CIPPStandardDisableGuests.Tests.ps1 create mode 100644 Tests/Standards/Invoke-CIPPStandardDisableSharedMailbox.Tests.ps1 create mode 100644 Tests/Standards/Invoke-CIPPStandardExternalComplianceTrusted.Tests.ps1 create mode 100644 Tests/Standards/Invoke-CIPPStandardFIDO2PasskeyProfiles.Tests.ps1 create mode 100644 Tests/Standards/Invoke-CIPPStandardQuarantineRequestAlert.Tests.ps1 create mode 100644 Tests/Standards/Invoke-CIPPStandardSPOVersionControl.Tests.ps1 create mode 100644 Tests/Standards/Invoke-CIPPStandardcalDefault.Coverage.Tests.ps1 create mode 100644 Tests/Static/PIMSecureDirection.Tests.ps1 create mode 100644 Tests/Tenant/Invoke-ExecGDAPRepairRoleMappings.Tests.ps1 create mode 100644 Tests/Tenant/Invoke-ListGDAPRoles.Validate.Tests.ps1 create mode 100644 Tests/Tenant/New-CIPPGDAPRoleMapping.Tests.ps1 diff --git a/Config/BaselineStandards/Copilot (M365) Standards/CopilotLimitedMode.json b/Config/BaselineStandards/Copilot (M365) Standards/CopilotLimitedMode.json index 27d9612f1627d..9490913e86287 100644 --- a/Config/BaselineStandards/Copilot (M365) Standards/CopilotLimitedMode.json +++ b/Config/BaselineStandards/Copilot (M365) Standards/CopilotLimitedMode.json @@ -1,5 +1,6 @@ { "name": "CopilotLimitedMode", + "disabled": true, "label": "Set Copilot Limited Mode", "cat": "Copilot (M365) Standards", "tag": [], diff --git a/Config/BaselineStandards/Defender Standards/QuarantineRequestAlert.json b/Config/BaselineStandards/Defender Standards/QuarantineRequestAlert.json index fd5d6def28f81..21b64f8f51f0e 100644 --- a/Config/BaselineStandards/Defender Standards/QuarantineRequestAlert.json +++ b/Config/BaselineStandards/Defender Standards/QuarantineRequestAlert.json @@ -36,7 +36,13 @@ "type": "textField", "label": "E-mail to receive the alert", "helperText": "Ignored when the alert state is Removed.", - "required": true + "required": true, + "validators": { + "pattern": { + "value": "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$", + "message": "Must be a valid e-mail address" + } + } }, "AllowExtraAddresses": { "type": "switch", diff --git a/Config/BaselineStandards/Defender Standards/SpamFilterPolicy.json b/Config/BaselineStandards/Defender Standards/SpamFilterPolicy.json index bed69c21a2762..9d2abc8047589 100644 --- a/Config/BaselineStandards/Defender Standards/SpamFilterPolicy.json +++ b/Config/BaselineStandards/Defender Standards/SpamFilterPolicy.json @@ -302,6 +302,26 @@ "required": true, "default": 7 }, + "BulkMovesEnabled": { + "type": "select", + "multiple": false, + "label": "Bulk moves enabled (deliver bulk mail below the threshold to the Promotions folder - Preview)", + "options": [ + { + "label": "Do not configure", + "value": "" + }, + { + "label": "On", + "value": "On" + }, + { + "label": "Off", + "value": "Off" + } + ], + "default": "" + }, "IncreaseScoreWithImageLinks": { "type": "switch", "label": "Increase score with image links", diff --git a/Config/BaselineStandards/Entra (AAD) Standards/AdminSSPR.json b/Config/BaselineStandards/Entra (AAD) Standards/AdminSSPR.json index 66a49b9e5d8ec..6c30b0877796c 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/AdminSSPR.json +++ b/Config/BaselineStandards/Entra (AAD) Standards/AdminSSPR.json @@ -36,6 +36,10 @@ "read": { "cacheType": "AuthorizationPolicy" }, + "writeTarget": "authorizationPolicy", + "writeTargetProperties": { + "allowedToUseSSPR": "%allowSSPR%" + }, "remediate": { "executor": "GraphRequest", "requests": [ diff --git a/Config/BaselineStandards/Entra (AAD) Standards/AppDeploy.json b/Config/BaselineStandards/Entra (AAD) Standards/AppDeploy.json index 6ac7ece8dadd3..49246814cd69e 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/AppDeploy.json +++ b/Config/BaselineStandards/Entra (AAD) Standards/AppDeploy.json @@ -46,7 +46,8 @@ "url": "/api/ListAppApprovalTemplates", "labelField": "TemplateName", "valueField": "TemplateId", - "queryKey": "StdAppApprovalTemplateList" + "queryKey": "StdAppApprovalTemplateList", + "templateView": { "title": "App Approval Template" } } }, "appids": { diff --git a/Config/BaselineStandards/Entra (AAD) Standards/AuthMethodsPolicyMigration.json b/Config/BaselineStandards/Entra (AAD) Standards/AuthMethodsPolicyMigration.json index 27c464b1a58f9..917b063093403 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/AuthMethodsPolicyMigration.json +++ b/Config/BaselineStandards/Entra (AAD) Standards/AuthMethodsPolicyMigration.json @@ -44,6 +44,10 @@ "read": { "cacheType": "AuthenticationMethodsPolicy" }, + "writeTarget": "authenticationMethodsPolicy", + "writeTargetProperties": { + "policyMigrationState": "%migrationState%" + }, "remediate": { "executor": "GraphRequest", "requests": [ diff --git a/Config/BaselineStandards/Entra (AAD) Standards/AuthMethodsSettings.json b/Config/BaselineStandards/Entra (AAD) Standards/AuthMethodsSettings.json index 62bd935a822df..75acc7d80cd86 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/AuthMethodsSettings.json +++ b/Config/BaselineStandards/Entra (AAD) Standards/AuthMethodsSettings.json @@ -81,6 +81,11 @@ "read": { "cacheType": "AuthenticationMethodsPolicy" }, + "writeTarget": "authenticationMethodsPolicy", + "writeTargetProperties": { + "reportSuspiciousActivitySettings.state": "%reportSuspiciousActivity%", + "systemCredentialPreferences.state": "%systemCredential%" + }, "remediate": { "executor": "GraphRequest", "requests": [ diff --git a/Config/BaselineStandards/Entra (AAD) Standards/AuthenticationMethods.json b/Config/BaselineStandards/Entra (AAD) Standards/AuthenticationMethods.json index 75563d8a71bc9..78b500a14c137 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/AuthenticationMethods.json +++ b/Config/BaselineStandards/Entra (AAD) Standards/AuthenticationMethods.json @@ -24,9 +24,24 @@ "compare": "subset", "variables": { "MicrosoftAuthenticatorEnabled": { - "label": "Microsoft Authenticator: manage this method", + "creatable": false, + "options": [ + { + "value": true, + "label": "Enabled" + }, + { + "value": false, + "label": "Disabled" + }, + { + "value": "notConfigured", + "label": "Not Configured" + } + ], "omitWhenBlank": true, - "type": "switch" + "type": "autoComplete", + "label": "Microsoft Authenticator: state (Not Configured = leave as-is)" }, "MicrosoftAuthenticatorGroup": { "label": "Microsoft Authenticator: target group (blank = all users)", @@ -34,9 +49,24 @@ "type": "textField" }, "FIDO2Enabled": { - "label": "FIDO2 Security Keys: manage this method", + "creatable": false, + "options": [ + { + "value": true, + "label": "Enabled" + }, + { + "value": false, + "label": "Disabled" + }, + { + "value": "notConfigured", + "label": "Not Configured" + } + ], "omitWhenBlank": true, - "type": "switch" + "type": "autoComplete", + "label": "FIDO2 Security Keys: state (Not Configured = leave as-is)" }, "FIDO2Group": { "label": "FIDO2 Security Keys: target group (blank = all users)", @@ -44,9 +74,24 @@ "type": "textField" }, "TAPEnabled": { - "label": "Temporary Access Pass: manage this method", + "creatable": false, + "options": [ + { + "value": true, + "label": "Enabled" + }, + { + "value": false, + "label": "Disabled" + }, + { + "value": "notConfigured", + "label": "Not Configured" + } + ], "omitWhenBlank": true, - "type": "switch" + "type": "autoComplete", + "label": "Temporary Access Pass: state (Not Configured = leave as-is)" }, "TAPGroup": { "label": "Temporary Access Pass: target group (blank = all users)", @@ -54,9 +99,24 @@ "type": "textField" }, "SoftwareOathEnabled": { - "label": "Software OATH Tokens: manage this method", + "creatable": false, + "options": [ + { + "value": true, + "label": "Enabled" + }, + { + "value": false, + "label": "Disabled" + }, + { + "value": "notConfigured", + "label": "Not Configured" + } + ], "omitWhenBlank": true, - "type": "switch" + "type": "autoComplete", + "label": "Software OATH Tokens: state (Not Configured = leave as-is)" }, "SoftwareOathGroup": { "label": "Software OATH Tokens: target group (blank = all users)", @@ -64,9 +124,24 @@ "type": "textField" }, "HardwareOathEnabled": { - "label": "Hardware OATH Tokens: manage this method", + "creatable": false, + "options": [ + { + "value": true, + "label": "Enabled" + }, + { + "value": false, + "label": "Disabled" + }, + { + "value": "notConfigured", + "label": "Not Configured" + } + ], "omitWhenBlank": true, - "type": "switch" + "type": "autoComplete", + "label": "Hardware OATH Tokens: state (Not Configured = leave as-is)" }, "HardwareOathGroup": { "label": "Hardware OATH Tokens: target group (blank = all users)", @@ -74,9 +149,24 @@ "type": "textField" }, "SMSEnabled": { - "label": "SMS: manage this method", + "creatable": false, + "options": [ + { + "value": true, + "label": "Enabled" + }, + { + "value": false, + "label": "Disabled" + }, + { + "value": "notConfigured", + "label": "Not Configured" + } + ], "omitWhenBlank": true, - "type": "switch" + "type": "autoComplete", + "label": "SMS: state (Not Configured = leave as-is)" }, "SMSGroup": { "label": "SMS: target group (blank = all users)", @@ -84,9 +174,24 @@ "type": "textField" }, "VoiceEnabled": { - "label": "Voice Call: manage this method", + "creatable": false, + "options": [ + { + "value": true, + "label": "Enabled" + }, + { + "value": false, + "label": "Disabled" + }, + { + "value": "notConfigured", + "label": "Not Configured" + } + ], "omitWhenBlank": true, - "type": "switch" + "type": "autoComplete", + "label": "Voice Call: state (Not Configured = leave as-is)" }, "VoiceGroup": { "label": "Voice Call: target group (blank = all users)", @@ -94,9 +199,24 @@ "type": "textField" }, "EmailEnabled": { - "label": "Email OTP: manage this method", + "creatable": false, + "options": [ + { + "value": true, + "label": "Enabled" + }, + { + "value": false, + "label": "Disabled" + }, + { + "value": "notConfigured", + "label": "Not Configured" + } + ], "omitWhenBlank": true, - "type": "switch" + "type": "autoComplete", + "label": "Email OTP: state (Not Configured = leave as-is)" }, "EmailGroup": { "label": "Email OTP: target group (blank = all users)", @@ -104,9 +224,24 @@ "type": "textField" }, "x509CertificateEnabled": { - "label": "Certificate-Based Authentication: manage this method", + "creatable": false, + "options": [ + { + "value": true, + "label": "Enabled" + }, + { + "value": false, + "label": "Disabled" + }, + { + "value": "notConfigured", + "label": "Not Configured" + } + ], "omitWhenBlank": true, - "type": "switch" + "type": "autoComplete", + "label": "Certificate-Based Authentication: state (Not Configured = leave as-is)" }, "x509CertificateGroup": { "label": "Certificate-Based Authentication: target group (blank = all users)", @@ -114,9 +249,24 @@ "type": "textField" }, "QRCodePinEnabled": { - "label": "QR Code Pin: manage this method", + "creatable": false, + "options": [ + { + "value": true, + "label": "Enabled" + }, + { + "value": false, + "label": "Disabled" + }, + { + "value": "notConfigured", + "label": "Not Configured" + } + ], "omitWhenBlank": true, - "type": "switch" + "type": "autoComplete", + "label": "QR Code Pin: state (Not Configured = leave as-is)" }, "QRCodePinGroup": { "label": "QR Code Pin: target group (blank = all users)", @@ -268,6 +418,28 @@ "cacheType": "AuthenticationMethodsPolicy" }, "prepare": "Get-CIPPBaselineAuthenticationMethodsState", + "writeTarget": "authenticationMethodsPolicy", + "writeTargetProperties": { + "Sms.state": "%SMSEnabled%", + "Voice.state": "%VoiceEnabled%", + "Email.state": "%EmailEnabled%", + "x509Certificate.state": "%x509CertificateEnabled%", + "HardwareOath.state": "%HardwareOathEnabled%", + "SoftwareOath.state": "%SoftwareOathEnabled%", + "QRCodePin.state": "%QRCodePinEnabled%", + "Fido2.state": "%FIDO2Enabled%", + "MicrosoftAuthenticator.state": "%MicrosoftAuthenticatorEnabled%", + "MicrosoftAuthenticator.isSoftwareOathEnabled": "%MicrosoftAuthenticatorSoftwareOath%", + "MicrosoftAuthenticator.featureSettings.displayAppInformationRequiredState.state": "%MicrosoftAuthenticatorDisplayAppInfo%", + "MicrosoftAuthenticator.featureSettings.displayLocationInformationRequiredState.state": "%MicrosoftAuthenticatorDisplayLocation%", + "MicrosoftAuthenticator.featureSettings.companionAppAllowedState.state": "%MicrosoftAuthenticatorCompanionApp%", + "TemporaryAccessPass.state": "%TAPEnabled%", + "TemporaryAccessPass.isUsableOnce": "%TAPUsableOnce%", + "TemporaryAccessPass.defaultLifetimeInMinutes": "%TAPDefaultLifetime%", + "TemporaryAccessPass.minimumLifetimeInMinutes": "%TAPMinLifetime%", + "TemporaryAccessPass.maximumLifetimeInMinutes": "%TAPMaxLifetime%", + "TemporaryAccessPass.defaultLength": "%TAPDefaultLength%" + }, "remediate": { "executor": "AuthenticationMethods" } diff --git a/Config/BaselineStandards/Entra (AAD) Standards/BitLockerKeysForOwnedDevice.json b/Config/BaselineStandards/Entra (AAD) Standards/BitLockerKeysForOwnedDevice.json index bafe1f818baa3..5a63f4856ff5d 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/BitLockerKeysForOwnedDevice.json +++ b/Config/BaselineStandards/Entra (AAD) Standards/BitLockerKeysForOwnedDevice.json @@ -35,6 +35,10 @@ "cacheType": "AuthorizationPolicy", "object": "defaultUserRolePermissions" }, + "writeTarget": "authorizationPolicy", + "writeTargetProperties": { + "defaultUserRolePermissions.allowedToReadBitLockerKeysForOwnedDevice": "%allowed%" + }, "remediate": { "executor": "GraphRequest", "requests": [ diff --git a/Config/BaselineStandards/Entra (AAD) Standards/DisableAppCreation.json b/Config/BaselineStandards/Entra (AAD) Standards/DisableAppCreation.json index 3383a5420aba3..00ababc9eadc2 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/DisableAppCreation.json +++ b/Config/BaselineStandards/Entra (AAD) Standards/DisableAppCreation.json @@ -38,6 +38,10 @@ "cacheType": "AuthorizationPolicy", "object": "defaultUserRolePermissions" }, + "writeTarget": "authorizationPolicy", + "writeTargetProperties": { + "defaultUserRolePermissions.allowedToCreateApps": "%allowAppCreation%" + }, "remediate": { "executor": "GraphRequest", "requests": [ diff --git a/Config/BaselineStandards/Entra (AAD) Standards/DisableEmail.json b/Config/BaselineStandards/Entra (AAD) Standards/DisableEmail.json index c8080154836cb..83b79a9e4df87 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/DisableEmail.json +++ b/Config/BaselineStandards/Entra (AAD) Standards/DisableEmail.json @@ -57,6 +57,10 @@ } ] }, + "writeTarget": "authenticationMethodsPolicy", + "writeTargetProperties": { + "Email.state": "%state%" + }, "remediate": { "executor": "GraphRequest", "requests": [ diff --git a/Config/BaselineStandards/Entra (AAD) Standards/DisableGuests.json b/Config/BaselineStandards/Entra (AAD) Standards/DisableGuests.json index 2af5ca4a2f59a..8af928e5ab5f5 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/DisableGuests.json +++ b/Config/BaselineStandards/Entra (AAD) Standards/DisableGuests.json @@ -6,9 +6,9 @@ "SMB1001 (2.8)" ], "impact": "Medium Impact", - "helpText": "Blocks login for guest users that have not logged in for a number of days. Guests still pending invitation acceptance are included. Accounts an administrator re-enabled in the last 7 days are left alone.", + "helpText": "Blocks login for guest users whose most recent sign-in attempt, interactive or non-interactive, is older than the number of days. Guests that have never signed in are only included when 'Disable accounts that have not yet signed in' is enabled. Accounts an administrator re-enabled in the last 7 days are left alone.", "executiveText": "Automatically disables external guest accounts that haven't been used for a number of days, reducing security risks from dormant accounts while maintaining access for active external collaborators. This helps maintain a clean user directory and reduces potential attack vectors.", - "docsDescription": "Blocks login for guest users that have not logged in for a number of days, and for guests that never accepted their invitation.", + "docsDescription": "Blocks login for guest users whose most recent sign-in attempt, interactive or non-interactive, is older than the number of days. Guests that have never signed in are only included when 'Disable accounts that have not yet signed in' is enabled.", "impactColour": "warning", "addedDate": "2022-10-20", "powershellEquivalent": "Graph API", @@ -32,6 +32,11 @@ "label": "Days of inactivity", "required": true, "default": 90 + }, + "IncludeNeverSignedIn": { + "type": "switch", + "label": "Disable accounts that have not yet signed in", + "default": false } }, "expected": { diff --git a/Config/BaselineStandards/Entra (AAD) Standards/DisableQRCodePin.json b/Config/BaselineStandards/Entra (AAD) Standards/DisableQRCodePin.json index da6cb08e1d0be..e276c3e1bd36f 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/DisableQRCodePin.json +++ b/Config/BaselineStandards/Entra (AAD) Standards/DisableQRCodePin.json @@ -45,6 +45,10 @@ } ] }, + "writeTarget": "authenticationMethodsPolicy", + "writeTargetProperties": { + "QRCodePin.state": "%state%" + }, "remediate": { "executor": "GraphRequest", "requests": [ diff --git a/Config/BaselineStandards/Entra (AAD) Standards/DisableSMS.json b/Config/BaselineStandards/Entra (AAD) Standards/DisableSMS.json index f1a59ff3e82ac..6aa5909df77a9 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/DisableSMS.json +++ b/Config/BaselineStandards/Entra (AAD) Standards/DisableSMS.json @@ -62,6 +62,10 @@ } ] }, + "writeTarget": "authenticationMethodsPolicy", + "writeTargetProperties": { + "Sms.state": "%state%" + }, "remediate": { "executor": "GraphRequest", "requests": [ diff --git a/Config/BaselineStandards/Entra (AAD) Standards/DisableSecurityGroupUsers.json b/Config/BaselineStandards/Entra (AAD) Standards/DisableSecurityGroupUsers.json index cc62a43f463a3..091ef35387d30 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/DisableSecurityGroupUsers.json +++ b/Config/BaselineStandards/Entra (AAD) Standards/DisableSecurityGroupUsers.json @@ -38,6 +38,10 @@ "cacheType": "AuthorizationPolicy", "object": "defaultUserRolePermissions" }, + "writeTarget": "authorizationPolicy", + "writeTargetProperties": { + "defaultUserRolePermissions.allowedToCreateSecurityGroups": "%allowSecurityGroupCreation%" + }, "remediate": { "executor": "GraphRequest", "requests": [ diff --git a/Config/BaselineStandards/Entra (AAD) Standards/DisableSelfServiceLicenses.json b/Config/BaselineStandards/Entra (AAD) Standards/DisableSelfServiceLicenses.json index b381d021c282f..21f7901a1b460 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/DisableSelfServiceLicenses.json +++ b/Config/BaselineStandards/Entra (AAD) Standards/DisableSelfServiceLicenses.json @@ -39,7 +39,14 @@ "cacheType": "SelfServicePurchaseProducts" }, "prepare": "Get-CIPPBaselineDisableSelfServiceLicensesState", + "writeTarget": "authorizationPolicy", + "writeTargetProperties": { + "allowedToSignUpEmailBasedSubscriptions": false + }, "remediate": { - "executor": "DisableSelfServiceLicenses" + "executor": "DisableSelfServiceLicenses", + "refreshCache": [ + "SelfServicePurchaseProducts" + ] } } diff --git a/Config/BaselineStandards/Entra (AAD) Standards/DisableTenantCreation.json b/Config/BaselineStandards/Entra (AAD) Standards/DisableTenantCreation.json index f41e4f23f4270..adfe50c5c4571 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/DisableTenantCreation.json +++ b/Config/BaselineStandards/Entra (AAD) Standards/DisableTenantCreation.json @@ -42,6 +42,10 @@ "cacheType": "AuthorizationPolicy", "object": "defaultUserRolePermissions" }, + "writeTarget": "authorizationPolicy", + "writeTargetProperties": { + "defaultUserRolePermissions.allowedToCreateTenants": "%allowTenantCreation%" + }, "remediate": { "executor": "GraphRequest", "requests": [ diff --git a/Config/BaselineStandards/Entra (AAD) Standards/DisableVoice.json b/Config/BaselineStandards/Entra (AAD) Standards/DisableVoice.json index 098d46ea40f4b..ab42302985c6c 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/DisableVoice.json +++ b/Config/BaselineStandards/Entra (AAD) Standards/DisableVoice.json @@ -62,6 +62,10 @@ } ] }, + "writeTarget": "authenticationMethodsPolicy", + "writeTargetProperties": { + "Voice.state": "%state%" + }, "remediate": { "executor": "GraphRequest", "requests": [ diff --git a/Config/BaselineStandards/Entra (AAD) Standards/Disablex509Certificate.json b/Config/BaselineStandards/Entra (AAD) Standards/Disablex509Certificate.json index 17cb7f2875743..d65ce97280e09 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/Disablex509Certificate.json +++ b/Config/BaselineStandards/Entra (AAD) Standards/Disablex509Certificate.json @@ -45,6 +45,10 @@ } ] }, + "writeTarget": "authenticationMethodsPolicy", + "writeTargetProperties": { + "x509Certificate.state": "%state%" + }, "remediate": { "executor": "GraphRequest", "requests": [ diff --git a/Config/BaselineStandards/Entra (AAD) Standards/EnableFIDO2.json b/Config/BaselineStandards/Entra (AAD) Standards/EnableFIDO2.json index b2fac9b15eb07..f0cabf36c182a 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/EnableFIDO2.json +++ b/Config/BaselineStandards/Entra (AAD) Standards/EnableFIDO2.json @@ -54,6 +54,10 @@ } ] }, + "writeTarget": "authenticationMethodsPolicy", + "writeTargetProperties": { + "Fido2.state": "enabled" + }, "remediate": { "executor": "EnableFIDO2" } diff --git a/Config/BaselineStandards/Entra (AAD) Standards/EnableHardwareOAuth.json b/Config/BaselineStandards/Entra (AAD) Standards/EnableHardwareOAuth.json index e5fae59d7f403..0ff0983a3d818 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/EnableHardwareOAuth.json +++ b/Config/BaselineStandards/Entra (AAD) Standards/EnableHardwareOAuth.json @@ -54,6 +54,10 @@ } ] }, + "writeTarget": "authenticationMethodsPolicy", + "writeTargetProperties": { + "HardwareOath.state": "%state%" + }, "remediate": { "executor": "GraphRequest", "requests": [ diff --git a/Config/BaselineStandards/Entra (AAD) Standards/ExternalComplianceTrusted.json b/Config/BaselineStandards/Entra (AAD) Standards/ExternalComplianceTrusted.json new file mode 100644 index 0000000000000..41076b444cdb0 --- /dev/null +++ b/Config/BaselineStandards/Entra (AAD) Standards/ExternalComplianceTrusted.json @@ -0,0 +1,41 @@ +{ + "name": "ExternalComplianceTrusted", + "label": "Sets the Cross-tenant Access Setting to Trust External Compliant Devices", + "cat": "Entra (AAD) Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Sets the default state for whether device compliance performed in an external tenant is trusted for inbound B2B access.", + "executiveText": "Controls whether guests using compliant devices from their home organization can access this tenant without requiring device compliance to be evaluated again. Trusting external compliance reduces partner friction while preserving device-based access controls.", + "docsDescription": "Grades and sets inboundTrust.isCompliantDeviceAccepted on the default cross-tenant access policy. Remediation reads the live policy and rewrites the merged inboundTrust object, preserving the MFA and hybrid-join trust flags alongside it.", + "impactColour": "info", + "addedDate": "2026-08-25", + "powershellEquivalent": "Update-MgPolicyCrossTenantAccessPolicyDefault", + "recommendedBy": [], + "requiredCapabilities": [ + "AAD_PREMIUM", + "AAD_PREMIUM_P2" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "state": { + "type": "switch", + "label": "Trust compliant devices from external tenants", + "default": false, + "recommended": true + } + }, + "read": { + "cacheType": "CrossTenantAccessPolicy" + }, + "prepare": "Get-CIPPBaselineExternalComplianceTrustedState", + "remediate": { + "executor": "ExternalComplianceTrusted", + "trusted": "%state%" + } +} diff --git a/Config/BaselineStandards/Entra (AAD) Standards/GuestInvite.json b/Config/BaselineStandards/Entra (AAD) Standards/GuestInvite.json index 279c3913c88bc..2e5090ad29ce1 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/GuestInvite.json +++ b/Config/BaselineStandards/Entra (AAD) Standards/GuestInvite.json @@ -57,6 +57,10 @@ "read": { "cacheType": "AuthorizationPolicy" }, + "writeTarget": "authorizationPolicy", + "writeTargetProperties": { + "allowInvitesFrom": "%allowInvitesFrom%" + }, "remediate": { "executor": "GraphRequest", "requests": [ diff --git a/Config/BaselineStandards/Entra (AAD) Standards/NudgeMFA.json b/Config/BaselineStandards/Entra (AAD) Standards/NudgeMFA.json index aa69fd8b8dff2..0e34a0a586d84 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/NudgeMFA.json +++ b/Config/BaselineStandards/Entra (AAD) Standards/NudgeMFA.json @@ -84,6 +84,10 @@ "cacheType": "AuthenticationMethodsPolicy" }, "prepare": "Get-CIPPBaselineNudgeMFAState", + "writeTarget": "authenticationMethodsPolicy", + "writeTargetProperties": { + "registrationEnforcement.authenticationMethodsRegistrationCampaign.state": "%state%" + }, "remediate": { "executor": "NudgeMFA" } diff --git a/Config/BaselineStandards/Entra (AAD) Standards/OauthConsent.json b/Config/BaselineStandards/Entra (AAD) Standards/OauthConsent.json index 156277de11685..02c0984857455 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/OauthConsent.json +++ b/Config/BaselineStandards/Entra (AAD) Standards/OauthConsent.json @@ -30,6 +30,10 @@ "cacheType": "AuthorizationPolicy" }, "prepare": "Get-CIPPBaselineOauthConsentState", + "writeTarget": "authorizationPolicy", + "writeTargetProperties": { + "permissionGrantPolicyIdsAssignedToDefaultUserRole": ["ManagePermissionGrantsForSelf.cipp-consent-policy"] + }, "remediate": { "executor": "OauthConsent" } diff --git a/Config/BaselineStandards/Entra (AAD) Standards/OauthConsentLowSec.json b/Config/BaselineStandards/Entra (AAD) Standards/OauthConsentLowSec.json index 3204b6e6988b1..0bdb8794c40ae 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/OauthConsentLowSec.json +++ b/Config/BaselineStandards/Entra (AAD) Standards/OauthConsentLowSec.json @@ -25,6 +25,10 @@ "read": { "cacheType": "AuthorizationPolicy" }, + "writeTarget": "authorizationPolicy", + "writeTargetProperties": { + "permissionGrantPolicyIdsAssignedToDefaultUserRole": ["ManagePermissionGrantsForSelf.microsoft-user-default-low"] + }, "remediate": { "executor": "GraphRequest", "requests": [ diff --git a/Config/BaselineStandards/Entra (AAD) Standards/PWcompanionAppAllowedState.json b/Config/BaselineStandards/Entra (AAD) Standards/PWcompanionAppAllowedState.json index d4f37adfc7dcb..554f2fa956320 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/PWcompanionAppAllowedState.json +++ b/Config/BaselineStandards/Entra (AAD) Standards/PWcompanionAppAllowedState.json @@ -57,6 +57,10 @@ ], "object": "featureSettings.companionAppAllowedState" }, + "writeTarget": "authenticationMethodsPolicy", + "writeTargetProperties": { + "MicrosoftAuthenticator.featureSettings.companionAppAllowedState.state": "%state%" + }, "remediate": { "executor": "GraphRequest", "requests": [ diff --git a/Config/BaselineStandards/Entra (AAD) Standards/PWdisplayAppInformationRequiredState.json b/Config/BaselineStandards/Entra (AAD) Standards/PWdisplayAppInformationRequiredState.json index c29aaa70ad2dd..60772959f958a 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/PWdisplayAppInformationRequiredState.json +++ b/Config/BaselineStandards/Entra (AAD) Standards/PWdisplayAppInformationRequiredState.json @@ -54,6 +54,11 @@ } ] }, + "writeTarget": "authenticationMethodsPolicy", + "writeTargetProperties": { + "MicrosoftAuthenticator.state": "enabled", + "MicrosoftAuthenticator.featureSettings.displayAppInformationRequiredState.state": "enabled" + }, "remediate": { "executor": "GraphRequest", "requests": [ diff --git a/Config/BaselineStandards/Entra (AAD) Standards/SecurityDefaults.json b/Config/BaselineStandards/Entra (AAD) Standards/SecurityDefaults.json index 8cf4e43bb83db..52d2c159944df 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/SecurityDefaults.json +++ b/Config/BaselineStandards/Entra (AAD) Standards/SecurityDefaults.json @@ -10,7 +10,7 @@ "BlockLegacyAuthentication" ], "impact": "High Impact", - "helpText": "Enables security defaults for the tenant, for newer tenants this is enabled by default. Do not enable this feature if you use Conditional Access.", + "helpText": "Enables security defaults for the tenant, for newer tenants this is enabled by default. Do not enable this feature if you use Conditional Access. Microsoft refuses to enable security defaults while any Conditional Access policy exists ('Conditional access policies are enabled. Please disable and try again.'), so remediation always fails on tenants that have CA policies.", "executiveText": "Activates Microsoft's baseline security configuration that requires multi-factor authentication and blocks legacy authentication methods. This provides essential security protection for organizations without complex conditional access policies, significantly improving security posture with minimal configuration.", "docsDescription": "Enables SD for the tenant, which disables all forms of basic authentication and enforces users to configure MFA. Users are only prompted for MFA when a logon is considered 'suspect' by Microsoft.", "impactColour": "danger", diff --git a/Config/BaselineStandards/Entra (AAD) Standards/SmartLockout.json b/Config/BaselineStandards/Entra (AAD) Standards/SmartLockout.json index d19cef09c82f5..28cf48cbb722e 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/SmartLockout.json +++ b/Config/BaselineStandards/Entra (AAD) Standards/SmartLockout.json @@ -26,12 +26,32 @@ "LockoutDurationInSeconds": { "label": "Lockout Duration (seconds)", "type": "number", - "default": 60 + "default": 60, + "validators": { + "min": { + "value": 5, + "message": "Minimum value is 5" + }, + "max": { + "value": 18000, + "message": "Maximum value is 18000" + } + } }, "LockoutThreshold": { "label": "Lockout Threshold (failed attempts)", "type": "number", - "default": 10 + "default": 10, + "validators": { + "min": { + "value": 1, + "message": "Minimum value is 1" + }, + "max": { + "value": 50, + "message": "Maximum value is 50" + } + } }, "EnableBannedPasswordCheckOnPremises": { "label": "Enable On-Premises Password Protection", diff --git a/Config/BaselineStandards/Entra (AAD) Standards/TAP.json b/Config/BaselineStandards/Entra (AAD) Standards/TAP.json index 2178b0023bc7a..7538c35d42810 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/TAP.json +++ b/Config/BaselineStandards/Entra (AAD) Standards/TAP.json @@ -33,25 +33,77 @@ "type": "number", "label": "Minimum Lifetime (minutes)", "default": 60, - "recommended": 60 + "recommended": 60, + "validators": { + "min": { + "value": 10, + "message": "Minimum value is 10" + }, + "max": { + "value": 43200, + "message": "Maximum value is 43200" + }, + "lessThanOrEqual": { + "field": "MaximumLifetime", + "message": "Minimum lifetime cannot exceed the maximum lifetime" + } + } }, "MaximumLifetime": { "type": "number", "label": "Maximum Lifetime (minutes)", "default": 480, - "recommended": 480 + "recommended": 480, + "validators": { + "min": { + "value": 10, + "message": "Minimum value is 10" + }, + "max": { + "value": 43200, + "message": "Maximum value is 43200" + } + } }, "DefaultLifetime": { "type": "number", "label": "Default Lifetime (minutes)", "default": 60, - "recommended": 60 + "recommended": 60, + "validators": { + "min": { + "value": 10, + "message": "Minimum value is 10" + }, + "max": { + "value": 43200, + "message": "Maximum value is 43200" + }, + "greaterThanOrEqual": { + "field": "MinimumLifetime", + "message": "Default lifetime must be at least the minimum lifetime" + }, + "lessThanOrEqual": { + "field": "MaximumLifetime", + "message": "Default lifetime cannot exceed the maximum lifetime" + } + } }, "TAPLength": { "type": "number", "label": "Length (characters)", "default": 8, - "recommended": 8 + "recommended": 8, + "validators": { + "min": { + "value": 8, + "message": "Minimum value is 8" + }, + "max": { + "value": 48, + "message": "Maximum value is 48" + } + } } }, "expected": { @@ -72,6 +124,15 @@ } ] }, + "writeTarget": "authenticationMethodsPolicy", + "writeTargetProperties": { + "TemporaryAccessPass.state": "enabled", + "TemporaryAccessPass.isUsableOnce": "%isUsableOnce%", + "TemporaryAccessPass.minimumLifetimeInMinutes": "%MinimumLifetime%", + "TemporaryAccessPass.maximumLifetimeInMinutes": "%MaximumLifetime%", + "TemporaryAccessPass.defaultLifetimeInMinutes": "%DefaultLifetime%", + "TemporaryAccessPass.defaultLength": "%TAPLength%" + }, "remediate": { "executor": "GraphRequest", "requests": [ diff --git a/Config/BaselineStandards/Entra (AAD) Standards/UndoOauth.json b/Config/BaselineStandards/Entra (AAD) Standards/UndoOauth.json index 1ccc22241a897..2eafed09695b6 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/UndoOauth.json +++ b/Config/BaselineStandards/Entra (AAD) Standards/UndoOauth.json @@ -22,6 +22,10 @@ "read": { "cacheType": "AuthorizationPolicy" }, + "writeTarget": "authorizationPolicy", + "writeTargetProperties": { + "permissionGrantPolicyIdsAssignedToDefaultUserRole": ["ManagePermissionGrantsForSelf.microsoft-user-default-legacy"] + }, "remediate": { "executor": "GraphRequest", "requests": [ diff --git a/Config/BaselineStandards/Entra (AAD) Standards/allowOAuthTokens.json b/Config/BaselineStandards/Entra (AAD) Standards/allowOAuthTokens.json index bf4af7a7f2419..4bebd678f9916 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/allowOAuthTokens.json +++ b/Config/BaselineStandards/Entra (AAD) Standards/allowOAuthTokens.json @@ -52,6 +52,10 @@ } ] }, + "writeTarget": "authenticationMethodsPolicy", + "writeTargetProperties": { + "SoftwareOath.state": "%state%" + }, "remediate": { "executor": "GraphRequest", "requests": [ diff --git a/Config/BaselineStandards/Entra (AAD) Standards/allowOTPTokens.json b/Config/BaselineStandards/Entra (AAD) Standards/allowOTPTokens.json index 5e4fbb2f59241..bb168a9fa3a5c 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/allowOTPTokens.json +++ b/Config/BaselineStandards/Entra (AAD) Standards/allowOTPTokens.json @@ -40,6 +40,10 @@ } ] }, + "writeTarget": "authenticationMethodsPolicy", + "writeTargetProperties": { + "MicrosoftAuthenticator.isSoftwareOathEnabled": "%isSoftwareOathEnabled%" + }, "remediate": { "executor": "GraphRequest", "requests": [ @@ -48,7 +52,6 @@ "uri": "policies/authenticationMethodsPolicy/authenticationMethodConfigurations/microsoftAuthenticator", "body": { "@odata.type": "#microsoft.graph.microsoftAuthenticatorAuthenticationMethodConfiguration", - "state": "enabled", "isSoftwareOathEnabled": "%isSoftwareOathEnabled%" } } diff --git a/Config/BaselineStandards/Entra (AAD) Standards/intuneDeviceRegLocalAdmins.json b/Config/BaselineStandards/Entra (AAD) Standards/intuneDeviceRegLocalAdmins.json index 88fe41d2eb972..a4279d9f32836 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/intuneDeviceRegLocalAdmins.json +++ b/Config/BaselineStandards/Entra (AAD) Standards/intuneDeviceRegLocalAdmins.json @@ -57,6 +57,7 @@ "cacheType": "DeviceRegistrationPolicy" }, "prepare": "Get-CIPPBaselineDeviceRegistrationPolicyState", + "writeTarget": "deviceRegistrationPolicy", "remediate": { "executor": "DeviceRegistrationPolicy", "set": { diff --git a/Config/BaselineStandards/Entra (AAD) Standards/intuneRestrictUserDeviceJoin.json b/Config/BaselineStandards/Entra (AAD) Standards/intuneRestrictUserDeviceJoin.json index 9543e96ee6f39..f0787627e51db 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/intuneRestrictUserDeviceJoin.json +++ b/Config/BaselineStandards/Entra (AAD) Standards/intuneRestrictUserDeviceJoin.json @@ -48,6 +48,7 @@ "cacheType": "DeviceRegistrationPolicy" }, "prepare": "Get-CIPPBaselineDeviceRegistrationPolicyState", + "writeTarget": "deviceRegistrationPolicy", "remediate": { "executor": "DeviceRegistrationPolicy", "requireAdminConfigurable": "azureADJoin", diff --git a/Config/BaselineStandards/Entra (AAD) Standards/intuneRestrictUserDeviceRegistration.json b/Config/BaselineStandards/Entra (AAD) Standards/intuneRestrictUserDeviceRegistration.json index 2ac7f85115764..29fed26d2957d 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/intuneRestrictUserDeviceRegistration.json +++ b/Config/BaselineStandards/Entra (AAD) Standards/intuneRestrictUserDeviceRegistration.json @@ -42,6 +42,7 @@ "cacheType": "DeviceRegistrationPolicy" }, "prepare": "Get-CIPPBaselineDeviceRegistrationPolicyState", + "writeTarget": "deviceRegistrationPolicy", "remediate": { "executor": "DeviceRegistrationPolicy", "requireAdminConfigurable": "azureADRegistration", diff --git a/Config/BaselineStandards/Entra (AAD) Standards/laps.json b/Config/BaselineStandards/Entra (AAD) Standards/laps.json index 75b3bfe4301ff..6168054d91281 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/laps.json +++ b/Config/BaselineStandards/Entra (AAD) Standards/laps.json @@ -34,6 +34,7 @@ "cacheType": "DeviceRegistrationPolicy" }, "prepare": "Get-CIPPBaselineDeviceRegistrationPolicyState", + "writeTarget": "deviceRegistrationPolicy", "remediate": { "executor": "DeviceRegistrationPolicy", "set": { diff --git a/Config/BaselineStandards/Exchange Standards/AutoArchive.json b/Config/BaselineStandards/Exchange Standards/AutoArchive.json index e3a2121184fd5..367aa1ecfcfc1 100644 --- a/Config/BaselineStandards/Exchange Standards/AutoArchive.json +++ b/Config/BaselineStandards/Exchange Standards/AutoArchive.json @@ -24,7 +24,17 @@ "threshold": { "type": "number", "label": "Auto-Archiving Threshold Percentage (80-100, default 96, 100 disables)", - "default": 96 + "default": 96, + "validators": { + "min": { + "value": 80, + "message": "Exchange only accepts a threshold between 80 and 100" + }, + "max": { + "value": 100, + "message": "Exchange only accepts a threshold between 80 and 100" + } + } } }, "expected": { @@ -33,6 +43,7 @@ "read": { "cacheType": "ExoOrganizationConfig" }, + "writeTarget": "exoOrganizationConfig", "remediate": { "executor": "ExoRequest", "cmdlets": [ diff --git a/Config/BaselineStandards/Exchange Standards/AutoArchiveMailbox.json b/Config/BaselineStandards/Exchange Standards/AutoArchiveMailbox.json index 459024cf3bb57..1892e5c707bf6 100644 --- a/Config/BaselineStandards/Exchange Standards/AutoArchiveMailbox.json +++ b/Config/BaselineStandards/Exchange Standards/AutoArchiveMailbox.json @@ -33,6 +33,7 @@ "read": { "cacheType": "ExoOrganizationConfig" }, + "writeTarget": "exoOrganizationConfig", "remediate": { "executor": "ExoRequest", "cmdlets": [ diff --git a/Config/BaselineStandards/Exchange Standards/AutoExpandArchive.json b/Config/BaselineStandards/Exchange Standards/AutoExpandArchive.json index 4d108ab7dc817..a8286659465a5 100644 --- a/Config/BaselineStandards/Exchange Standards/AutoExpandArchive.json +++ b/Config/BaselineStandards/Exchange Standards/AutoExpandArchive.json @@ -27,6 +27,7 @@ "read": { "cacheType": "ExoOrganizationConfig" }, + "writeTarget": "exoOrganizationConfig", "remediate": { "executor": "ExoRequest", "cmdlets": [ diff --git a/Config/BaselineStandards/Exchange Standards/Bookings.json b/Config/BaselineStandards/Exchange Standards/Bookings.json index b5759e0204b77..59d75b09714a0 100644 --- a/Config/BaselineStandards/Exchange Standards/Bookings.json +++ b/Config/BaselineStandards/Exchange Standards/Bookings.json @@ -39,6 +39,7 @@ "read": { "cacheType": "ExoOrganizationConfig" }, + "writeTarget": "exoOrganizationConfig", "remediate": { "executor": "ExoRequest", "cmdlets": [ diff --git a/Config/BaselineStandards/Exchange Standards/CloudMessageRecall.json b/Config/BaselineStandards/Exchange Standards/CloudMessageRecall.json index 2e69e8dbbd654..7b8a404da0738 100644 --- a/Config/BaselineStandards/Exchange Standards/CloudMessageRecall.json +++ b/Config/BaselineStandards/Exchange Standards/CloudMessageRecall.json @@ -33,6 +33,7 @@ "read": { "cacheType": "ExoOrganizationConfig" }, + "writeTarget": "exoOrganizationConfig", "remediate": { "executor": "ExoRequest", "cmdlets": [ diff --git a/Config/BaselineStandards/Exchange Standards/DisableEWS.json b/Config/BaselineStandards/Exchange Standards/DisableEWS.json index c69f0bd601694..b87fdb66165de 100644 --- a/Config/BaselineStandards/Exchange Standards/DisableEWS.json +++ b/Config/BaselineStandards/Exchange Standards/DisableEWS.json @@ -36,6 +36,7 @@ "read": { "cacheType": "ExoOrganizationConfig" }, + "writeTarget": "exoOrganizationConfig", "remediate": { "executor": "ExoRequest", "cmdlets": [ diff --git a/Config/BaselineStandards/Exchange Standards/DlpViaDcsEnabled.json b/Config/BaselineStandards/Exchange Standards/DlpViaDcsEnabled.json index 3ded26f763ff7..632b19bce5b3c 100644 --- a/Config/BaselineStandards/Exchange Standards/DlpViaDcsEnabled.json +++ b/Config/BaselineStandards/Exchange Standards/DlpViaDcsEnabled.json @@ -33,6 +33,7 @@ "read": { "cacheType": "ExoOrganizationConfig" }, + "writeTarget": "exoOrganizationConfig", "remediate": { "executor": "ExoRequest", "cmdlets": [ diff --git a/Config/BaselineStandards/Exchange Standards/EXODirectSend.json b/Config/BaselineStandards/Exchange Standards/EXODirectSend.json index d11c51ef00c8e..ae57ac165de79 100644 --- a/Config/BaselineStandards/Exchange Standards/EXODirectSend.json +++ b/Config/BaselineStandards/Exchange Standards/EXODirectSend.json @@ -33,6 +33,7 @@ "read": { "cacheType": "ExoOrganizationConfig" }, + "writeTarget": "exoOrganizationConfig", "remediate": { "executor": "ExoRequest", "cmdlets": [ diff --git a/Config/BaselineStandards/Exchange Standards/EnableMailTips.json b/Config/BaselineStandards/Exchange Standards/EnableMailTips.json index a56f93b0d59fa..e74af61b85079 100644 --- a/Config/BaselineStandards/Exchange Standards/EnableMailTips.json +++ b/Config/BaselineStandards/Exchange Standards/EnableMailTips.json @@ -51,6 +51,7 @@ "read": { "cacheType": "ExoOrganizationConfig" }, + "writeTarget": "exoOrganizationConfig", "remediate": { "executor": "ExoRequest", "cmdlets": [ diff --git a/Config/BaselineStandards/Exchange Standards/EnableMailboxAuditing.json b/Config/BaselineStandards/Exchange Standards/EnableMailboxAuditing.json index 69c46dd49b9c0..4054c473cc5f3 100644 --- a/Config/BaselineStandards/Exchange Standards/EnableMailboxAuditing.json +++ b/Config/BaselineStandards/Exchange Standards/EnableMailboxAuditing.json @@ -51,6 +51,7 @@ "read": { "cacheType": "ExoOrganizationConfig" }, + "writeTarget": "exoOrganizationConfig", "remediate": { "executor": "ExoRequest", "cmdlets": [ diff --git a/Config/BaselineStandards/Exchange Standards/FocusedInbox.json b/Config/BaselineStandards/Exchange Standards/FocusedInbox.json index 2eee36981d703..b2d6b56527d1e 100644 --- a/Config/BaselineStandards/Exchange Standards/FocusedInbox.json +++ b/Config/BaselineStandards/Exchange Standards/FocusedInbox.json @@ -33,6 +33,7 @@ "read": { "cacheType": "ExoOrganizationConfig" }, + "writeTarget": "exoOrganizationConfig", "remediate": { "executor": "ExoRequest", "cmdlets": [ diff --git a/Config/BaselineStandards/Exchange Standards/MessageExpiration.json b/Config/BaselineStandards/Exchange Standards/MessageExpiration.json index d2593512570b8..568738b64e39f 100644 --- a/Config/BaselineStandards/Exchange Standards/MessageExpiration.json +++ b/Config/BaselineStandards/Exchange Standards/MessageExpiration.json @@ -25,7 +25,13 @@ "type": "textField", "label": "Message expiration timeout (hh:mm:ss)", "default": "12:00:00", - "recommended": "12:00:00" + "recommended": "12:00:00", + "validators": { + "pattern": { + "value": "^(1\\.00:00:00|(0\\.)?(1[2-9]|2[0-3]):[0-5][0-9]:[0-5][0-9])$", + "message": "Must be a timespan between 12:00:00 (12 hours) and 1.00:00:00 (24 hours)" + } + } } }, "expected": { diff --git a/Config/BaselineStandards/Exchange Standards/OMEBranding.json b/Config/BaselineStandards/Exchange Standards/OMEBranding.json index e7e564b77d032..ebc33062cc1ae 100644 --- a/Config/BaselineStandards/Exchange Standards/OMEBranding.json +++ b/Config/BaselineStandards/Exchange Standards/OMEBranding.json @@ -29,12 +29,24 @@ "BackgroundColor": { "type": "textField", "label": "Background Color (hex, e.g. #ffffff)", - "omitWhenBlank": true + "omitWhenBlank": true, + "validators": { + "pattern": { + "value": "^#[0-9A-Fa-f]{6}$", + "message": "Must be a hex color like #FFFFFF" + } + } }, "LogoUrl": { "type": "textField", "label": "Logo Image URL (max 40KB, 170x70px)", - "omitWhenBlank": true + "omitWhenBlank": true, + "validators": { + "pattern": { + "value": "^https?://.+$", + "message": "Must be a URL starting with http:// or https://" + } + } }, "IntroductionText": { "type": "textField", @@ -49,22 +61,46 @@ "EmailText": { "type": "textField", "label": "Email text below the button", - "omitWhenBlank": true + "omitWhenBlank": true, + "validators": { + "maxLength": { + "value": 1024, + "message": "Maximum length is 1024 characters" + } + } }, "PrivacyStatementUrl": { "type": "textField", "label": "Privacy Statement URL", - "omitWhenBlank": true + "omitWhenBlank": true, + "validators": { + "pattern": { + "value": "^https?://.+$", + "message": "Must be a URL starting with http:// or https://" + } + } }, "DisclaimerText": { "type": "textField", "label": "Disclaimer Statement", - "omitWhenBlank": true + "omitWhenBlank": true, + "validators": { + "maxLength": { + "value": 1024, + "message": "Maximum length is 1024 characters" + } + } }, "PortalText": { "type": "textField", "label": "Portal header text (max 128 chars)", - "omitWhenBlank": true + "omitWhenBlank": true, + "validators": { + "maxLength": { + "value": 128, + "message": "Maximum length is 128 characters" + } + } }, "OTPEnabled": { "type": "autoComplete", diff --git a/Config/BaselineStandards/Exchange Standards/OutBoundSpamAlert.json b/Config/BaselineStandards/Exchange Standards/OutBoundSpamAlert.json index 3cce7f7b8dc13..31ed550810b89 100644 --- a/Config/BaselineStandards/Exchange Standards/OutBoundSpamAlert.json +++ b/Config/BaselineStandards/Exchange Standards/OutBoundSpamAlert.json @@ -39,13 +39,24 @@ "type": "textField", "label": "Outbound spam contact", "required": true + }, + "BccSuspiciousOutboundMail": { + "type": "switch", + "label": "BCC suspicious outbound mail to a mailbox", + "omitWhenBlank": true + }, + "BccSuspiciousOutboundContact": { + "type": "textField", + "label": "BCC recipient for suspicious outbound mail", + "omitWhenBlank": true } }, "expected": { "NotifyOutboundSpam": "%NotifyOutboundSpam%", "NotifyOutboundSpamRecipients": [ "%OutboundSpamContact%" - ] + ], + "BccSuspiciousOutboundMail": "%BccSuspiciousOutboundMail%" }, "read": { "cacheType": "ExoHostedOutboundSpamFilterPolicy", @@ -64,7 +75,9 @@ "params": { "Identity": "Default", "NotifyOutboundSpam": "%NotifyOutboundSpam%", - "NotifyOutboundSpamRecipients": "%OutboundSpamContact%" + "NotifyOutboundSpamRecipients": "%OutboundSpamContact%", + "BccSuspiciousOutboundMail": "%BccSuspiciousOutboundMail%", + "BccSuspiciousOutboundAdditionalRecipients": "%BccSuspiciousOutboundContact%" } } ] diff --git a/Config/BaselineStandards/Exchange Standards/SendFromAlias.json b/Config/BaselineStandards/Exchange Standards/SendFromAlias.json index 574c809dd404f..287173bd632ab 100644 --- a/Config/BaselineStandards/Exchange Standards/SendFromAlias.json +++ b/Config/BaselineStandards/Exchange Standards/SendFromAlias.json @@ -36,6 +36,7 @@ "read": { "cacheType": "ExoOrganizationConfig" }, + "writeTarget": "exoOrganizationConfig", "remediate": { "executor": "ExoRequest", "cmdlets": [ diff --git a/Config/BaselineStandards/Exchange Standards/ShortenMeetings.json b/Config/BaselineStandards/Exchange Standards/ShortenMeetings.json index 9082a37500c34..eda397fe9c206 100644 --- a/Config/BaselineStandards/Exchange Standards/ShortenMeetings.json +++ b/Config/BaselineStandards/Exchange Standards/ShortenMeetings.json @@ -58,6 +58,7 @@ "read": { "cacheType": "ExoOrganizationConfig" }, + "writeTarget": "exoOrganizationConfig", "remediate": { "executor": "ExoRequest", "cmdlets": [ diff --git a/Config/BaselineStandards/Exchange Standards/TeamsMeetingsByDefault.json b/Config/BaselineStandards/Exchange Standards/TeamsMeetingsByDefault.json index 3153192383da8..94c1334099cd3 100644 --- a/Config/BaselineStandards/Exchange Standards/TeamsMeetingsByDefault.json +++ b/Config/BaselineStandards/Exchange Standards/TeamsMeetingsByDefault.json @@ -35,6 +35,7 @@ "OnlineMeetingsByDefaultEnabled": true } }, + "writeTarget": "exoOrganizationConfig", "remediate": { "executor": "ExoRequest", "cmdlets": [ diff --git a/Config/BaselineStandards/Exchange Standards/TwoClickEmailProtection.json b/Config/BaselineStandards/Exchange Standards/TwoClickEmailProtection.json index 5d268d6dba918..beeaf7c8bde81 100644 --- a/Config/BaselineStandards/Exchange Standards/TwoClickEmailProtection.json +++ b/Config/BaselineStandards/Exchange Standards/TwoClickEmailProtection.json @@ -33,6 +33,7 @@ "read": { "cacheType": "ExoOrganizationConfig" }, + "writeTarget": "exoOrganizationConfig", "remediate": { "executor": "ExoRequest", "cmdlets": [ diff --git a/Config/BaselineStandards/Exchange Standards/UserSubmissions.json b/Config/BaselineStandards/Exchange Standards/UserSubmissions.json index d4613f5223e4b..fe4a5ff14ccf7 100644 --- a/Config/BaselineStandards/Exchange Standards/UserSubmissions.json +++ b/Config/BaselineStandards/Exchange Standards/UserSubmissions.json @@ -4,9 +4,9 @@ "cat": "Exchange Standards", "tag": [], "impact": "Medium Impact", - "helpText": "Enables or disables the built-in Report button in Outlook, optionally routing reported messages to a custom mailbox as well as Microsoft.", + "helpText": "Enables or disables the built-in Report button in Outlook, optionally routing reported messages to a custom mailbox as well as, or instead of, Microsoft.", "executiveText": "Governs how employees report suspicious email: enabling the built-in report button routes phishing reports to Microsoft and optionally to the security team, turning users into a detection layer.", - "docsDescription": "Grades the report submission policy and rule against the chosen posture: enabled (reports to Microsoft), enabled with a custom address (all three report types route to it and the rule is enabled), or disabled. The configured address supports tenant %variable% replacement. Remediation creates or updates the default policy and rule, removing the rule when reporting is turned off.", + "docsDescription": "Grades the report submission policy and rule against the chosen posture: enabled (reports to Microsoft), enabled with a custom address (all three report types route to it and the rule is enabled), or disabled. When a custom address is set, the 'Send reported items to' setting controls whether reports also go to Microsoft or to the reporting mailbox only (for third-party phishing report services). The configured address supports tenant %variable% replacement. Remediation creates or updates the default policy and rule, removing the rule when reporting is turned off.", "impactColour": "warning", "addedDate": "2026-08-16", "powershellEquivalent": "New-ReportSubmissionPolicy or Set-ReportSubmissionPolicy", @@ -46,6 +46,23 @@ "omitWhenBlank": true, "type": "textField", "label": "Destination email address (optional)" + }, + "reportDestination": { + "omitWhenBlank": true, + "type": "autoComplete", + "required": false, + "creatable": false, + "options": [ + { + "label": "Microsoft and my reporting mailbox", + "value": "Both" + }, + { + "label": "My reporting mailbox only", + "value": "Mailbox" + } + ], + "label": "Send reported items to (when a destination email address is set)" } }, "read": { diff --git a/Config/BaselineStandards/Global Standards/DisableGuestDirectory.json b/Config/BaselineStandards/Global Standards/DisableGuestDirectory.json index f9230b63c68fe..bb3ee5b94a7af 100644 --- a/Config/BaselineStandards/Global Standards/DisableGuestDirectory.json +++ b/Config/BaselineStandards/Global Standards/DisableGuestDirectory.json @@ -61,6 +61,10 @@ "read": { "cacheType": "AuthorizationPolicy" }, + "writeTarget": "authorizationPolicy", + "writeTargetProperties": { + "guestUserRoleId": "%guestUserRoleId%" + }, "remediate": { "executor": "GraphRequest", "requests": [ diff --git a/Config/BaselineStandards/Global Standards/EnableCustomerLockbox.json b/Config/BaselineStandards/Global Standards/EnableCustomerLockbox.json index b63d5f7c3a0f2..6c2264882e82e 100644 --- a/Config/BaselineStandards/Global Standards/EnableCustomerLockbox.json +++ b/Config/BaselineStandards/Global Standards/EnableCustomerLockbox.json @@ -40,6 +40,7 @@ "read": { "cacheType": "ExoOrganizationConfig" }, + "writeTarget": "exoOrganizationConfig", "remediate": { "executor": "ExoRequest", "cmdlets": [ diff --git a/Config/BaselineStandards/Intune Standards/DefaultPlatformRestrictions.json b/Config/BaselineStandards/Intune Standards/DefaultPlatformRestrictions.json index fc6d8b5ac222a..7a8c90de67ac0 100644 --- a/Config/BaselineStandards/Intune Standards/DefaultPlatformRestrictions.json +++ b/Config/BaselineStandards/Intune Standards/DefaultPlatformRestrictions.json @@ -4,9 +4,9 @@ "cat": "Intune Standards", "tag": [], "impact": "High Impact", - "helpText": "Sets the default enrollment platform restrictions, controlling which device platforms may enroll and whether personally-owned devices of each platform are allowed.", - "executiveText": "Controls which kinds of device can enrol into management, and whether employees may enrol personal devices. This keeps unmanaged or unsupported platforms off corporate resources.", - "docsDescription": "Sets the default device enrollment platform restrictions for Android, Android for Work, iOS, macOS and Windows.", + "helpText": "Sets the default enrollment platform restrictions, controlling which device platforms may enroll, whether personally-owned devices of each platform are allowed, and the minimum/maximum OS version each platform may enroll with.", + "executiveText": "Controls which kinds of device can enrol into management, whether employees may enrol personal devices, and how up to date a device's operating system must be to enrol. This keeps unmanaged, unsupported or out-of-date platforms off corporate resources.", + "docsDescription": "Sets the default device enrollment platform restrictions for Android, Android for Work, iOS, macOS and Windows, including optional minimum and maximum OS version limits per platform.", "impactColour": "warning", "addedDate": "2024-07-15", "powershellEquivalent": "Graph API", @@ -31,6 +31,32 @@ "label": "Block personally owned Android (work profile)", "default": false }, + "osMinimumVersionAndroidForWork": { + "type": "textField", + "label": "Android (work profile) minimum OS version", + "helperText": "Example: 11.0. Leave blank to not enforce a minimum.", + "omitWhenBlank": true, + "default": "", + "validators": { + "pattern": { + "value": "^\\d+(\\.\\d+)*$", + "message": "Must be a version number using digits and dots, e.g. 11.0" + } + } + }, + "osMaximumVersionAndroidForWork": { + "type": "textField", + "label": "Android (work profile) maximum OS version", + "helperText": "Example: 14.0. Leave blank to not enforce a maximum.", + "omitWhenBlank": true, + "default": "", + "validators": { + "pattern": { + "value": "^\\d+(\\.\\d+)*$", + "message": "Must be a version number using digits and dots, e.g. 14.0" + } + } + }, "platformAndroidBlocked": { "type": "switch", "label": "Block Android (device administrator) platform", @@ -41,6 +67,32 @@ "label": "Block personally owned Android (device administrator)", "default": false }, + "osMinimumVersionAndroid": { + "type": "textField", + "label": "Android (device administrator) minimum OS version", + "helperText": "Example: 10.0. Leave blank to not enforce a minimum.", + "omitWhenBlank": true, + "default": "", + "validators": { + "pattern": { + "value": "^\\d+(\\.\\d+)*$", + "message": "Must be a version number using digits and dots, e.g. 10.0" + } + } + }, + "osMaximumVersionAndroid": { + "type": "textField", + "label": "Android (device administrator) maximum OS version", + "helperText": "Example: 13.0. Leave blank to not enforce a maximum.", + "omitWhenBlank": true, + "default": "", + "validators": { + "pattern": { + "value": "^\\d+(\\.\\d+)*$", + "message": "Must be a version number using digits and dots, e.g. 13.0" + } + } + }, "platformiOSBlocked": { "type": "switch", "label": "Block iOS platform", @@ -51,6 +103,32 @@ "label": "Block personally owned iOS", "default": false }, + "osMinimumVersioniOS": { + "type": "textField", + "label": "iOS/iPadOS minimum OS version", + "helperText": "Example: 16.1. Leave blank to not enforce a minimum.", + "omitWhenBlank": true, + "default": "", + "validators": { + "pattern": { + "value": "^\\d+(\\.\\d+)*$", + "message": "Must be a version number using digits and dots, e.g. 16.1" + } + } + }, + "osMaximumVersioniOS": { + "type": "textField", + "label": "iOS/iPadOS maximum OS version", + "helperText": "Example: 18.0. Leave blank to not enforce a maximum.", + "omitWhenBlank": true, + "default": "", + "validators": { + "pattern": { + "value": "^\\d+(\\.\\d+)*$", + "message": "Must be a version number using digits and dots, e.g. 18.0" + } + } + }, "platformMacOSBlocked": { "type": "switch", "label": "Block macOS platform", @@ -70,6 +148,32 @@ "type": "switch", "label": "Block personally owned Windows", "default": false + }, + "osMinimumVersionWindows": { + "type": "textField", + "label": "Windows minimum OS version", + "helperText": "Example: 10.0.19045.0. Leave blank to not enforce a minimum.", + "omitWhenBlank": true, + "default": "", + "validators": { + "pattern": { + "value": "^\\d+(\\.\\d+)*$", + "message": "Must be a version number using digits and dots, e.g. 10.0.19045.0" + } + } + }, + "osMaximumVersionWindows": { + "type": "textField", + "label": "Windows maximum OS version", + "helperText": "Example: 10.0.22631.0. Leave blank to not enforce a maximum.", + "omitWhenBlank": true, + "default": "", + "validators": { + "pattern": { + "value": "^\\d+(\\.\\d+)*$", + "message": "Must be a version number using digits and dots, e.g. 10.0.22631.0" + } + } } }, "read": { @@ -83,17 +187,23 @@ "androidForWorkRestriction": { "@odata.type": "microsoft.graph.deviceEnrollmentPlatformRestriction", "platformBlocked": "%platformAndroidForWorkBlocked%", - "personalDeviceEnrollmentBlocked": "%personalAndroidForWorkBlocked%" + "personalDeviceEnrollmentBlocked": "%personalAndroidForWorkBlocked%", + "osMinimumVersion": "%osMinimumVersionAndroidForWork%", + "osMaximumVersion": "%osMaximumVersionAndroidForWork%" }, "androidRestriction": { "@odata.type": "microsoft.graph.deviceEnrollmentPlatformRestriction", "platformBlocked": "%platformAndroidBlocked%", - "personalDeviceEnrollmentBlocked": "%personalAndroidBlocked%" + "personalDeviceEnrollmentBlocked": "%personalAndroidBlocked%", + "osMinimumVersion": "%osMinimumVersionAndroid%", + "osMaximumVersion": "%osMaximumVersionAndroid%" }, "iosRestriction": { "@odata.type": "microsoft.graph.deviceEnrollmentPlatformRestriction", "platformBlocked": "%platformiOSBlocked%", - "personalDeviceEnrollmentBlocked": "%personaliOSBlocked%" + "personalDeviceEnrollmentBlocked": "%personaliOSBlocked%", + "osMinimumVersion": "%osMinimumVersioniOS%", + "osMaximumVersion": "%osMaximumVersioniOS%" }, "macOSRestriction": { "@odata.type": "microsoft.graph.deviceEnrollmentPlatformRestriction", @@ -103,7 +213,9 @@ "windowsRestriction": { "@odata.type": "microsoft.graph.deviceEnrollmentPlatformRestriction", "platformBlocked": "%platformWindowsBlocked%", - "personalDeviceEnrollmentBlocked": "%personalWindowsBlocked%" + "personalDeviceEnrollmentBlocked": "%personalWindowsBlocked%", + "osMinimumVersion": "%osMinimumVersionWindows%", + "osMaximumVersion": "%osMaximumVersionWindows%" } } } diff --git a/Config/BaselineStandards/Intune Standards/IntuneAppTemplateDeploy.json b/Config/BaselineStandards/Intune Standards/IntuneAppTemplateDeploy.json index 69585a845b6fa..a80fc86166c9a 100644 --- a/Config/BaselineStandards/Intune Standards/IntuneAppTemplateDeploy.json +++ b/Config/BaselineStandards/Intune Standards/IntuneAppTemplateDeploy.json @@ -34,9 +34,10 @@ "required": true, "api": { "url": "/api/ListAppTemplates", - "labelField": "Displayname", + "labelField": "displayName", "valueField": "GUID", - "queryKey": "StdIntuneAppTemplateList" + "queryKey": "StdIntuneAppTemplateList", + "templateView": { "title": "Application Template" } } } }, diff --git a/Config/BaselineStandards/Intune Standards/intuneBrandingProfile.json b/Config/BaselineStandards/Intune Standards/intuneBrandingProfile.json index ebfe909156d3a..aba9ffc4a5e6c 100644 --- a/Config/BaselineStandards/Intune Standards/intuneBrandingProfile.json +++ b/Config/BaselineStandards/Intune Standards/intuneBrandingProfile.json @@ -25,7 +25,13 @@ "type": "textField", "label": "Organization name", "omitWhenBlank": true, - "default": "" + "default": "", + "validators": { + "maxLength": { + "value": 40, + "message": "Maximum length is 40 characters" + } + } }, "showLogo": { "type": "autoComplete", @@ -73,19 +79,41 @@ "type": "textField", "label": "Contact IT name", "omitWhenBlank": true, - "default": "" + "default": "", + "validators": { + "maxLength": { + "value": 40, + "message": "Maximum length is 40 characters" + } + } }, "contactITPhoneNumber": { "type": "textField", "label": "Contact IT phone number", "omitWhenBlank": true, - "default": "" + "default": "", + "validators": { + "pattern": { + "value": "^\\+?(?=(?:[^0-9]*[0-9]){3})[0-9 ()./-]{3,}$", + "message": "Must be a phone number, e.g. +31612345678" + } + } }, "contactITEmailAddress": { "type": "textField", "label": "Contact IT email address", "omitWhenBlank": true, - "default": "" + "default": "", + "validators": { + "pattern": { + "value": "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$", + "message": "Must be a valid e-mail address" + }, + "maxLength": { + "value": 40, + "message": "Maximum length is 40 characters" + } + } }, "contactITNotes": { "type": "textField", @@ -97,19 +125,37 @@ "type": "textField", "label": "Online support site name", "omitWhenBlank": true, - "default": "" + "default": "", + "validators": { + "maxLength": { + "value": 40, + "message": "Maximum length is 40 characters" + } + } }, "onlineSupportSiteUrl": { "type": "textField", "label": "Online support site URL", "omitWhenBlank": true, - "default": "" + "default": "", + "validators": { + "pattern": { + "value": "^https?://.+$", + "message": "Must be a URL starting with http:// or https://" + } + } }, "privacyUrl": { "type": "textField", "label": "Privacy statement URL", "omitWhenBlank": true, - "default": "" + "default": "", + "validators": { + "pattern": { + "value": "^https?://.+$", + "message": "Must be a URL starting with http:// or https://" + } + } } }, "expected": { diff --git a/Config/BaselineStandards/Intune Standards/intuneDeviceReg.json b/Config/BaselineStandards/Intune Standards/intuneDeviceReg.json index 63936bd5cd110..66cf606d304ec 100644 --- a/Config/BaselineStandards/Intune Standards/intuneDeviceReg.json +++ b/Config/BaselineStandards/Intune Standards/intuneDeviceReg.json @@ -44,6 +44,7 @@ "cacheType": "DeviceRegistrationPolicy" }, "prepare": "Get-CIPPBaselineDeviceRegistrationPolicyState", + "writeTarget": "deviceRegistrationPolicy", "remediate": { "executor": "DeviceRegistrationPolicy", "set": { diff --git a/Config/BaselineStandards/Intune Standards/intuneDeviceRetirementDays.json b/Config/BaselineStandards/Intune Standards/intuneDeviceRetirementDays.json index 07b08eb0c81b2..4d70a5e231e99 100644 --- a/Config/BaselineStandards/Intune Standards/intuneDeviceRetirementDays.json +++ b/Config/BaselineStandards/Intune Standards/intuneDeviceRetirementDays.json @@ -28,8 +28,18 @@ "variables": { "days": { "type": "number", - "label": "Maximum days of inactivity before retirement", - "required": true + "label": "Maximum days of inactivity before retirement (30-270)", + "required": true, + "validators": { + "min": { + "value": 30, + "message": "Intune only accepts a retirement window between 30 and 270 days" + }, + "max": { + "value": 270, + "message": "Intune only accepts a retirement window between 30 and 270 days" + } + } } }, "read": { diff --git a/Config/BaselineStandards/Intune Standards/intuneRequireMFA.json b/Config/BaselineStandards/Intune Standards/intuneRequireMFA.json index d507be5cc7fcd..6d9fd11bacf8f 100644 --- a/Config/BaselineStandards/Intune Standards/intuneRequireMFA.json +++ b/Config/BaselineStandards/Intune Standards/intuneRequireMFA.json @@ -27,6 +27,7 @@ "cacheType": "DeviceRegistrationPolicy" }, "prepare": "Get-CIPPBaselineDeviceRegistrationPolicyState", + "writeTarget": "deviceRegistrationPolicy", "remediate": { "executor": "DeviceRegistrationPolicy", "set": { diff --git a/Config/BaselineStandards/SharePoint Standards/DefaultSharingLink.json b/Config/BaselineStandards/SharePoint Standards/DefaultSharingLink.json index 80f3ca3421dd7..8e774517bb255 100644 --- a/Config/BaselineStandards/SharePoint Standards/DefaultSharingLink.json +++ b/Config/BaselineStandards/SharePoint Standards/DefaultSharingLink.json @@ -58,6 +58,10 @@ "read": { "cacheType": "SPOTenant" }, + "writeTarget": "spoTenant", + "writeTargetProperties": { + "DefaultSharingLinkType": "%SharingLinkType%" + }, "remediate": { "executor": "SPOTenant", "properties": { diff --git a/Config/BaselineStandards/SharePoint Standards/DeletedUserRentention.json b/Config/BaselineStandards/SharePoint Standards/DeletedUserRentention.json index 86635bc3e9337..a4b539b179bb8 100644 --- a/Config/BaselineStandards/SharePoint Standards/DeletedUserRentention.json +++ b/Config/BaselineStandards/SharePoint Standards/DeletedUserRentention.json @@ -89,6 +89,7 @@ "read": { "cacheType": "SPOTenant" }, + "writeTarget": "spoTenant", "remediate": { "executor": "GraphRequest", "requests": [ diff --git a/Config/BaselineStandards/SharePoint Standards/DisableAddShortcutsToOneDrive.json b/Config/BaselineStandards/SharePoint Standards/DisableAddShortcutsToOneDrive.json index 76c29a6a249cb..1b0183f977e61 100644 --- a/Config/BaselineStandards/SharePoint Standards/DisableAddShortcutsToOneDrive.json +++ b/Config/BaselineStandards/SharePoint Standards/DisableAddShortcutsToOneDrive.json @@ -33,6 +33,7 @@ "read": { "cacheType": "SPOTenant" }, + "writeTarget": "spoTenant", "remediate": { "executor": "SPOTenant", "properties": { diff --git a/Config/BaselineStandards/SharePoint Standards/DisableReshare.json b/Config/BaselineStandards/SharePoint Standards/DisableReshare.json index 0bbe40a78f6a6..f2f6fe4173a16 100644 --- a/Config/BaselineStandards/SharePoint Standards/DisableReshare.json +++ b/Config/BaselineStandards/SharePoint Standards/DisableReshare.json @@ -42,6 +42,7 @@ "read": { "cacheType": "SPOTenant" }, + "writeTarget": "spoTenant", "remediate": { "executor": "GraphRequest", "requests": [ diff --git a/Config/BaselineStandards/SharePoint Standards/DisableSharePointLegacyAuth.json b/Config/BaselineStandards/SharePoint Standards/DisableSharePointLegacyAuth.json index b3355e0d72c0e..fb78322e38d13 100644 --- a/Config/BaselineStandards/SharePoint Standards/DisableSharePointLegacyAuth.json +++ b/Config/BaselineStandards/SharePoint Standards/DisableSharePointLegacyAuth.json @@ -49,6 +49,7 @@ "read": { "cacheType": "SPOTenant" }, + "writeTarget": "spoTenant", "remediate": { "executor": "GraphRequest", "requests": [ diff --git a/Config/BaselineStandards/SharePoint Standards/SPAzureB2B.json b/Config/BaselineStandards/SharePoint Standards/SPAzureB2B.json index ef02a058885e7..dbc1d713a6f6f 100644 --- a/Config/BaselineStandards/SharePoint Standards/SPAzureB2B.json +++ b/Config/BaselineStandards/SharePoint Standards/SPAzureB2B.json @@ -34,6 +34,7 @@ "read": { "cacheType": "SPOTenant" }, + "writeTarget": "spoTenant", "remediate": { "executor": "SPOTenant", "properties": { diff --git a/Config/BaselineStandards/SharePoint Standards/SPDirectSharing.json b/Config/BaselineStandards/SharePoint Standards/SPDirectSharing.json index 9af5d99d21722..c49f631f03920 100644 --- a/Config/BaselineStandards/SharePoint Standards/SPDirectSharing.json +++ b/Config/BaselineStandards/SharePoint Standards/SPDirectSharing.json @@ -31,6 +31,10 @@ "cacheType": "SPOTenant" }, "prepare": "Get-CIPPBaselineSPDirectSharingState", + "writeTarget": "spoTenant", + "writeTargetProperties": { + "DefaultSharingLinkType": "Direct" + }, "remediate": { "executor": "SPDirectSharing" } diff --git a/Config/BaselineStandards/SharePoint Standards/SPDisableCustomScripts.json b/Config/BaselineStandards/SharePoint Standards/SPDisableCustomScripts.json index e2de4c364f9c3..3e261534e39ff 100644 --- a/Config/BaselineStandards/SharePoint Standards/SPDisableCustomScripts.json +++ b/Config/BaselineStandards/SharePoint Standards/SPDisableCustomScripts.json @@ -30,6 +30,7 @@ "read": { "cacheType": "SPOTenant" }, + "writeTarget": "spoTenant", "remediate": { "executor": "SPOTenant", "properties": { diff --git a/Config/BaselineStandards/SharePoint Standards/SPDisableLegacyWorkflows.json b/Config/BaselineStandards/SharePoint Standards/SPDisableLegacyWorkflows.json index 6b6b48e68d942..9dae8d7cf8529 100644 --- a/Config/BaselineStandards/SharePoint Standards/SPDisableLegacyWorkflows.json +++ b/Config/BaselineStandards/SharePoint Standards/SPDisableLegacyWorkflows.json @@ -29,6 +29,7 @@ "read": { "cacheType": "SPOTenant" }, + "writeTarget": "spoTenant", "remediate": { "executor": "SPOTenant", "properties": { diff --git a/Config/BaselineStandards/SharePoint Standards/SPDisableStoreAccess.json b/Config/BaselineStandards/SharePoint Standards/SPDisableStoreAccess.json index fbdf3cf341344..5c19e0819ef55 100644 --- a/Config/BaselineStandards/SharePoint Standards/SPDisableStoreAccess.json +++ b/Config/BaselineStandards/SharePoint Standards/SPDisableStoreAccess.json @@ -30,6 +30,7 @@ "read": { "cacheType": "SPOTenant" }, + "writeTarget": "spoTenant", "remediate": { "executor": "SPOTenant", "properties": { diff --git a/Config/BaselineStandards/SharePoint Standards/SPDisallowInfectedFiles.json b/Config/BaselineStandards/SharePoint Standards/SPDisallowInfectedFiles.json index 151a3f03f4dd6..b3f34888562bb 100644 --- a/Config/BaselineStandards/SharePoint Standards/SPDisallowInfectedFiles.json +++ b/Config/BaselineStandards/SharePoint Standards/SPDisallowInfectedFiles.json @@ -38,6 +38,7 @@ "read": { "cacheType": "SPOTenant" }, + "writeTarget": "spoTenant", "remediate": { "executor": "SPOTenant", "properties": { diff --git a/Config/BaselineStandards/SharePoint Standards/SPEmailAttestation.json b/Config/BaselineStandards/SharePoint Standards/SPEmailAttestation.json index 2134d650b075e..bf768e2feb056 100644 --- a/Config/BaselineStandards/SharePoint Standards/SPEmailAttestation.json +++ b/Config/BaselineStandards/SharePoint Standards/SPEmailAttestation.json @@ -45,6 +45,7 @@ "read": { "cacheType": "SPOTenant" }, + "writeTarget": "spoTenant", "remediate": { "executor": "SPOTenant", "properties": { diff --git a/Config/BaselineStandards/SharePoint Standards/SPExternalUserExpiration.json b/Config/BaselineStandards/SharePoint Standards/SPExternalUserExpiration.json index 4eb3ad407cb95..ab826a4f6a468 100644 --- a/Config/BaselineStandards/SharePoint Standards/SPExternalUserExpiration.json +++ b/Config/BaselineStandards/SharePoint Standards/SPExternalUserExpiration.json @@ -35,7 +35,11 @@ "days": { "type": "number", "label": "Days until expiration (Default 60)", - "default": 60 + "default": 60, + "validators": { + "min": { "value": 30, "message": "SharePoint accepts 30 to 730 days - lower values are silently ignored" }, + "max": { "value": 730, "message": "SharePoint accepts 30 to 730 days" } + } } }, "expected": { @@ -45,6 +49,7 @@ "read": { "cacheType": "SPOTenant" }, + "writeTarget": "spoTenant", "remediate": { "executor": "SPOTenant", "properties": { diff --git a/Config/BaselineStandards/SharePoint Standards/SPFileRequests.json b/Config/BaselineStandards/SharePoint Standards/SPFileRequests.json index bb42f444ffcee..72558f5b514a5 100644 --- a/Config/BaselineStandards/SharePoint Standards/SPFileRequests.json +++ b/Config/BaselineStandards/SharePoint Standards/SPFileRequests.json @@ -56,6 +56,7 @@ "read": { "cacheType": "SPOTenant" }, + "writeTarget": "spoTenant", "remediate": { "executor": "SPOTenant", "properties": { diff --git a/Config/BaselineStandards/SharePoint Standards/SPGuestPeoplePicker.json b/Config/BaselineStandards/SharePoint Standards/SPGuestPeoplePicker.json new file mode 100644 index 0000000000000..1990aeef1d931 --- /dev/null +++ b/Config/BaselineStandards/SharePoint Standards/SPGuestPeoplePicker.json @@ -0,0 +1,42 @@ +{ + "name": "SPGuestPeoplePicker", + "label": "Show guest users in the SharePoint People Picker", + "cat": "SharePoint Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Controls whether guest (external) users already in the tenant appear as suggestions in the SharePoint and OneDrive People Picker. Enforces the wanted state on BOTH the tenant default and every existing site collection - the two are set independently, so changing the tenant default does not update existing sites. Which sites differ is read from the daily SharePoint cache, and the write sweep runs at most once per 24h per tenant so it never re-runs against a stale cache.", + "executiveText": "Makes existing external collaborators discoverable (or hidden) when sharing SharePoint and OneDrive content, consistently across the whole tenant - the default for new sites and every existing site. This keeps the sharing experience predictable and prevents individual sites from drifting away from the agreed collaboration posture.", + "docsDescription": "Enforces ShowPeoplePickerSuggestionsForGuestUsers at both levels it is set independently: the tenant default and each site collection. Which sites differ is read from the SPOSites reporting cache (populated by the daily SharePoint cache run); the tenant default is read from the cached SharePoint tenant configuration. It reports the tenant default and every differing site as offenders and remediates the tenant default (Set-SPOTenant) and each offending site (Set-SPOSite), sweeping the sites concurrently. The sweep is guarded to once per 24h per tenant (shared with the classic standard) so it is not repeated before the next daily cache run reflects the change and the standard re-evaluates. Guests are not shown by default even when they exist in the tenant, and changing the tenant default does not retroactively change existing sites - which is why both are covered here.", + "impactColour": "info", + "addedDate": "2026-09-03", + "powershellEquivalent": "Set-SPOTenant / Set-SPOSite -ShowPeoplePickerSuggestionsForGuestUsers $true or $false", + "recommendedBy": ["CIPP"], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "showGuests": { + "type": "switch", + "label": "Show guests in the People Picker", + "default": true + } + }, + "expected": { + "offenders": [] + }, + "read": { + "cacheType": "SPOSites" + }, + "prepare": "Get-CIPPBaselineSPGuestPeoplePickerState", + "remediate": { + "executor": "SPGuestPeoplePicker", + "useCertificate": true + } +} diff --git a/Config/BaselineStandards/SharePoint Standards/SPOVersionControl.json b/Config/BaselineStandards/SharePoint Standards/SPOVersionControl.json index 69daff377ec48..74b86ec897293 100644 --- a/Config/BaselineStandards/SharePoint Standards/SPOVersionControl.json +++ b/Config/BaselineStandards/SharePoint Standards/SPOVersionControl.json @@ -52,6 +52,7 @@ "cacheType": "SPOTenant" }, "prepare": "Get-CIPPBaselineSPOVersionControlState", + "writeTarget": "spoTenant", "remediate": { "executor": "SPOVersionControl", "enableAutoTrim": "%EnableAutoTrim%", diff --git a/Config/BaselineStandards/SharePoint Standards/SPSyncButtonState.json b/Config/BaselineStandards/SharePoint Standards/SPSyncButtonState.json index 4aa047be72d00..3ee76016b1df4 100644 --- a/Config/BaselineStandards/SharePoint Standards/SPSyncButtonState.json +++ b/Config/BaselineStandards/SharePoint Standards/SPSyncButtonState.json @@ -33,6 +33,7 @@ "read": { "cacheType": "SPOTenant" }, + "writeTarget": "spoTenant", "remediate": { "executor": "SPOTenant", "properties": { diff --git a/Config/BaselineStandards/SharePoint Standards/disableMacSync.json b/Config/BaselineStandards/SharePoint Standards/disableMacSync.json index 1ae3fc1112a72..ae9e7dbcc05f9 100644 --- a/Config/BaselineStandards/SharePoint Standards/disableMacSync.json +++ b/Config/BaselineStandards/SharePoint Standards/disableMacSync.json @@ -27,6 +27,7 @@ "read": { "cacheType": "SPOTenant" }, + "writeTarget": "spoTenant", "remediate": { "executor": "GraphRequest", "requests": [ diff --git a/Config/BaselineStandards/SharePoint Standards/sharingCapability.json b/Config/BaselineStandards/SharePoint Standards/sharingCapability.json index 458fbf2a640f0..e1765c48fe43b 100644 --- a/Config/BaselineStandards/SharePoint Standards/sharingCapability.json +++ b/Config/BaselineStandards/SharePoint Standards/sharingCapability.json @@ -66,6 +66,7 @@ "read": { "cacheType": "SPOTenant" }, + "writeTarget": "spoTenant", "remediate": { "executor": "SPOTenant", "properties": { diff --git a/Config/BaselineStandards/SharePoint Standards/unmanagedSync.json b/Config/BaselineStandards/SharePoint Standards/unmanagedSync.json index 50cb67bac1597..4913da87e045c 100644 --- a/Config/BaselineStandards/SharePoint Standards/unmanagedSync.json +++ b/Config/BaselineStandards/SharePoint Standards/unmanagedSync.json @@ -51,6 +51,7 @@ "read": { "cacheType": "SPOTenant" }, + "writeTarget": "spoTenant", "remediate": { "executor": "SPOTenant", "properties": { diff --git a/Config/BaselineStandards/Teams Standards/TeamsGlobalMeetingPolicy.json b/Config/BaselineStandards/Teams Standards/TeamsGlobalMeetingPolicy.json index 6de2a8d3b540f..abed7caa5e62e 100644 --- a/Config/BaselineStandards/Teams Standards/TeamsGlobalMeetingPolicy.json +++ b/Config/BaselineStandards/Teams Standards/TeamsGlobalMeetingPolicy.json @@ -84,6 +84,7 @@ "AutoAdmittedUsers": { "type": "autoComplete", "label": "Who can bypass the lobby?", + "helperText": "Teams couples this to the dial-in bypass switch: 'Everyone' requires that switch on, 'Only organizers and co-organizers' requires it off - other pairings are rejected with error 40013.", "omitWhenBlank": true, "options": [ { @@ -117,6 +118,7 @@ "AllowPSTNUsersToBypassLobby": { "type": "switch", "label": "Allow dial-in users to bypass lobby", + "helperText": "Must be on when 'Who can bypass the lobby?' is 'Everyone' and off when it is 'Only organizers and co-organizers' - Teams rejects any other combination.", "default": false, "recommended": false }, @@ -151,6 +153,32 @@ "label": "External participants can give or request control", "default": false, "recommended": false + }, + "AllowExternalNonTrustedMeetingChat": { + "type": "autoComplete", + "label": "External meeting chat", + "helperText": "CIS 8.5.8 recommends Off. Leave blank to keep the tenant's current value.", + "omitWhenBlank": true, + "options": [ + { "label": "Keep the tenant's current value", "value": "" }, + { "label": "Off (CIS recommended)", "value": false }, + { "label": "On", "value": true } + ], + "default": "", + "recommended": false + }, + "AllowCloudRecording": { + "type": "autoComplete", + "label": "Meeting cloud recording", + "helperText": "CIS 8.5.9 recommends Off. Leave blank to keep the tenant's current value.", + "omitWhenBlank": true, + "options": [ + { "label": "Keep the tenant's current value", "value": "" }, + { "label": "Off (CIS recommended)", "value": false }, + { "label": "On", "value": true } + ], + "default": "", + "recommended": false } }, "expected": { @@ -161,7 +189,9 @@ "MeetingChatEnabledType": "%MeetingChatEnabledType%", "DesignatedPresenterRoleMode": "%DesignatedPresenterRoleMode%", "AllowExternalParticipantGiveRequestControl": "%AllowExternalParticipantGiveRequestControl%", - "AllowParticipantGiveRequestControl": "%AllowParticipantGiveRequestControl%" + "AllowParticipantGiveRequestControl": "%AllowParticipantGiveRequestControl%", + "AllowExternalNonTrustedMeetingChat": "%AllowExternalNonTrustedMeetingChat%", + "AllowCloudRecording": "%AllowCloudRecording%" }, "read": { "cacheType": "CsTeamsMeetingPolicy" @@ -179,7 +209,9 @@ "MeetingChatEnabledType": "%MeetingChatEnabledType%", "DesignatedPresenterRoleMode": "%DesignatedPresenterRoleMode%", "AllowExternalParticipantGiveRequestControl": "%AllowExternalParticipantGiveRequestControl%", - "AllowParticipantGiveRequestControl": "%AllowParticipantGiveRequestControl%" + "AllowParticipantGiveRequestControl": "%AllowParticipantGiveRequestControl%", + "AllowExternalNonTrustedMeetingChat": "%AllowExternalNonTrustedMeetingChat%", + "AllowCloudRecording": "%AllowCloudRecording%" } } ] diff --git a/Config/CIPPDBCacheTypes.json b/Config/CIPPDBCacheTypes.json index 07903eacec24b..08da042719e89 100644 --- a/Config/CIPPDBCacheTypes.json +++ b/Config/CIPPDBCacheTypes.json @@ -276,6 +276,7 @@ }, { "type": "OneDriveSiteListing", + "collectedBy": "OneDriveUsage", "friendlyName": "OneDrive Site Listing", "description": "OneDrive personal site listing details used for usage reporting" }, @@ -284,8 +285,14 @@ "friendlyName": "OneDrive Usage", "description": "OneDrive usage statistics" }, + { + "type": "OneDriveLongPaths", + "friendlyName": "OneDrive Long Paths", + "description": "Per-user counts of OneDrive paths that may exceed Windows 260-character sync limits or the cloud 400-character ceiling (no file or folder names stored)" + }, { "type": "SharePointSiteListing", + "collectedBy": "SharePointSiteUsage", "friendlyName": "SharePoint Site Listing", "description": "SharePoint site listing details used for usage reporting" }, @@ -309,6 +316,11 @@ "friendlyName": "SharePoint Permissions", "description": "Site and document library permission assignments, including libraries that no longer inherit and grants to tenant-wide claims such as Everyone except external users" }, + { + "type": "StorageCleanupScan", + "friendlyName": "Storage Cleanup Scan", + "description": "Per-site library version estimates and recycle totals for the storage report cleanup signals. Report-private; not used by other CIPP features." + }, { "type": "OfficeActivations", "friendlyName": "Office Activations", @@ -419,6 +431,11 @@ "friendlyName": "SharePoint Admin Settings", "description": "SharePoint tenant admin settings including sharing capability, site creation, sync, timezone and excluded file extensions" }, + { + "type": "SPOSites", + "friendlyName": "SharePoint Site Settings", + "description": "Per-site SharePoint admin settings (site owner, sharing controls, lifecycle, version policy, People Picker, unmanaged-device access) for every site collection, keyed by site id" + }, { "type": "PeopleInsights", "friendlyName": "People Insights Settings", @@ -516,6 +533,7 @@ }, { "type": "IntuneMobileAppsAll", + "collectedBy": "IntuneApplications", "friendlyName": "All Intune Mobile Apps", "description": "Unfiltered mobile apps list (id, displayName, odata type) for presence checks" }, @@ -591,16 +609,19 @@ }, { "type": "ExoPhishSimOverridePolicy", + "collectedBy": "ExoPhishSimConfig", "friendlyName": "Phishing Simulation Override Policy", "description": "Third-party phishing simulation override policy" }, { "type": "ExoPhishSimOverrideRule", + "collectedBy": "ExoPhishSimConfig", "friendlyName": "Phishing Simulation Override Rule", "description": "Phishing simulation override rule with sender IP ranges and domains" }, { "type": "ExoPhishSimUrlAllowItems", + "collectedBy": "ExoPhishSimConfig", "friendlyName": "Phishing Simulation URL Allow Items", "description": "Advanced delivery URL allow entries for phishing simulations" }, @@ -626,6 +647,7 @@ }, { "type": "DlpComplianceRules", + "collectedBy": "DlpCompliancePolicies", "friendlyName": "DLP Compliance Rules", "description": "Data Loss Prevention compliance rules from the Purview compliance portal" }, @@ -636,6 +658,7 @@ }, { "type": "Fido2Configuration", + "collectedBy": "AuthenticationMethodsPolicy", "friendlyName": "FIDO2 Authentication Method Configuration", "description": "FIDO2 passkey authentication method configuration including passkey profiles" }, @@ -648,5 +671,10 @@ "type": "SelfServicePurchaseProducts", "friendlyName": "Self-Service Purchase Products", "description": "AllowSelfServicePurchase product policies and trial autoclaim policy" + }, + { + "type": "GroupUsage", + "friendlyName": "Group Usage Sources", + "description": "Refreshes every cache type that feeds the group usage report (groups, Conditional Access, Intune, roles, app assignments, licenses, transport rules); writes no rows of its own" } ] diff --git a/Config/CountryList.json b/Config/CountryList.json new file mode 100644 index 0000000000000..49ef2e53ea7d7 --- /dev/null +++ b/Config/CountryList.json @@ -0,0 +1,252 @@ +[ + { "Code": "AF", "Name": "Afghanistan" }, + { "Code": "AX", "Name": "\u00c5land Islands" }, + { "Code": "AL", "Name": "Albania" }, + { "Code": "DZ", "Name": "Algeria" }, + { "Code": "AS", "Name": "American Samoa" }, + { "Code": "AD", "Name": "Andorra" }, + { "Code": "AO", "Name": "Angola" }, + { "Code": "AI", "Name": "Anguilla" }, + { "Code": "AQ", "Name": "Antarctica" }, + { "Code": "AG", "Name": "Antigua and Barbuda" }, + { "Code": "AR", "Name": "Argentina" }, + { "Code": "AM", "Name": "Armenia" }, + { "Code": "AW", "Name": "Aruba" }, + { "Code": "AU", "Name": "Australia" }, + { "Code": "AT", "Name": "Austria" }, + { "Code": "AZ", "Name": "Azerbaijan" }, + { "Code": "BS", "Name": "Bahamas" }, + { "Code": "BH", "Name": "Bahrain" }, + { "Code": "BD", "Name": "Bangladesh" }, + { "Code": "BB", "Name": "Barbados" }, + { "Code": "BY", "Name": "Belarus" }, + { "Code": "BE", "Name": "Belgium" }, + { "Code": "BZ", "Name": "Belize" }, + { "Code": "BJ", "Name": "Benin" }, + { "Code": "BM", "Name": "Bermuda" }, + { "Code": "BT", "Name": "Bhutan" }, + { "Code": "BO", "Name": "Bolivia, Plurinational State of" }, + { "Code": "BQ", "Name": "Bonaire, Sint Eustatius and Saba" }, + { "Code": "BA", "Name": "Bosnia and Herzegovina" }, + { "Code": "BW", "Name": "Botswana" }, + { "Code": "BV", "Name": "Bouvet Island" }, + { "Code": "BR", "Name": "Brazil" }, + { "Code": "IO", "Name": "British Indian Ocean Territory" }, + { "Code": "BN", "Name": "Brunei Darussalam" }, + { "Code": "BG", "Name": "Bulgaria" }, + { "Code": "BF", "Name": "Burkina Faso" }, + { "Code": "BI", "Name": "Burundi" }, + { "Code": "KH", "Name": "Cambodia" }, + { "Code": "CM", "Name": "Cameroon" }, + { "Code": "CA", "Name": "Canada" }, + { "Code": "CV", "Name": "Cape Verde" }, + { "Code": "KY", "Name": "Cayman Islands" }, + { "Code": "CF", "Name": "Central African Republic" }, + { "Code": "TD", "Name": "Chad" }, + { "Code": "CL", "Name": "Chile" }, + { "Code": "CN", "Name": "China" }, + { "Code": "CX", "Name": "Christmas Island" }, + { "Code": "CC", "Name": "Cocos (Keeling) Islands" }, + { "Code": "CO", "Name": "Colombia" }, + { "Code": "KM", "Name": "Comoros" }, + { "Code": "CG", "Name": "Congo" }, + { "Code": "CD", "Name": "Congo, the Democratic Republic of the" }, + { "Code": "CK", "Name": "Cook Islands" }, + { "Code": "CR", "Name": "Costa Rica" }, + { "Code": "CI", "Name": "C\u00f4te d'Ivoire" }, + { "Code": "HR", "Name": "Croatia" }, + { "Code": "CU", "Name": "Cuba" }, + { "Code": "CW", "Name": "Cura\u00e7ao" }, + { "Code": "CY", "Name": "Cyprus" }, + { "Code": "CZ", "Name": "Czech Republic" }, + { "Code": "DK", "Name": "Denmark" }, + { "Code": "DJ", "Name": "Djibouti" }, + { "Code": "DM", "Name": "Dominica" }, + { "Code": "DO", "Name": "Dominican Republic" }, + { "Code": "EC", "Name": "Ecuador" }, + { "Code": "EG", "Name": "Egypt" }, + { "Code": "SV", "Name": "El Salvador" }, + { "Code": "GQ", "Name": "Equatorial Guinea" }, + { "Code": "ER", "Name": "Eritrea" }, + { "Code": "EE", "Name": "Estonia" }, + { "Code": "ET", "Name": "Ethiopia" }, + { "Code": "FK", "Name": "Falkland Islands (Malvinas)" }, + { "Code": "FO", "Name": "Faroe Islands" }, + { "Code": "FJ", "Name": "Fiji" }, + { "Code": "FI", "Name": "Finland" }, + { "Code": "FR", "Name": "France" }, + { "Code": "GF", "Name": "French Guiana" }, + { "Code": "PF", "Name": "French Polynesia" }, + { "Code": "TF", "Name": "French Southern Territories" }, + { "Code": "GA", "Name": "Gabon" }, + { "Code": "GM", "Name": "Gambia" }, + { "Code": "GE", "Name": "Georgia" }, + { "Code": "DE", "Name": "Germany" }, + { "Code": "GH", "Name": "Ghana" }, + { "Code": "GI", "Name": "Gibraltar" }, + { "Code": "GR", "Name": "Greece" }, + { "Code": "GL", "Name": "Greenland" }, + { "Code": "GD", "Name": "Grenada" }, + { "Code": "GP", "Name": "Guadeloupe" }, + { "Code": "GU", "Name": "Guam" }, + { "Code": "GT", "Name": "Guatemala" }, + { "Code": "GG", "Name": "Guernsey" }, + { "Code": "GN", "Name": "Guinea" }, + { "Code": "GW", "Name": "Guinea-Bissau" }, + { "Code": "GY", "Name": "Guyana" }, + { "Code": "HT", "Name": "Haiti" }, + { "Code": "HM", "Name": "Heard Island and McDonald Islands" }, + { "Code": "VA", "Name": "Holy See (Vatican City State)" }, + { "Code": "HN", "Name": "Honduras" }, + { "Code": "HK", "Name": "Hong Kong" }, + { "Code": "HU", "Name": "Hungary" }, + { "Code": "IS", "Name": "Iceland" }, + { "Code": "IN", "Name": "India" }, + { "Code": "ID", "Name": "Indonesia" }, + { "Code": "IR", "Name": "Iran, Islamic Republic of" }, + { "Code": "IQ", "Name": "Iraq" }, + { "Code": "IE", "Name": "Ireland" }, + { "Code": "IM", "Name": "Isle of Man" }, + { "Code": "IL", "Name": "Israel" }, + { "Code": "IT", "Name": "Italy" }, + { "Code": "JM", "Name": "Jamaica" }, + { "Code": "JP", "Name": "Japan" }, + { "Code": "JE", "Name": "Jersey" }, + { "Code": "JO", "Name": "Jordan" }, + { "Code": "KZ", "Name": "Kazakhstan" }, + { "Code": "KE", "Name": "Kenya" }, + { "Code": "KI", "Name": "Kiribati" }, + { "Code": "KP", "Name": "Korea, Democratic People's Republic of" }, + { "Code": "KR", "Name": "Korea, Republic of" }, + { "Code": "XK", "Name": "Kosovo" }, + { "Code": "KW", "Name": "Kuwait" }, + { "Code": "KG", "Name": "Kyrgyzstan" }, + { "Code": "LA", "Name": "Lao People's Democratic Republic" }, + { "Code": "LV", "Name": "Latvia" }, + { "Code": "LB", "Name": "Lebanon" }, + { "Code": "LS", "Name": "Lesotho" }, + { "Code": "LR", "Name": "Liberia" }, + { "Code": "LY", "Name": "Libya" }, + { "Code": "LI", "Name": "Liechtenstein" }, + { "Code": "LT", "Name": "Lithuania" }, + { "Code": "LU", "Name": "Luxembourg" }, + { "Code": "MO", "Name": "Macao" }, + { "Code": "MK", "Name": "Macedonia, the Former Yugoslav Republic of" }, + { "Code": "MG", "Name": "Madagascar" }, + { "Code": "MW", "Name": "Malawi" }, + { "Code": "MY", "Name": "Malaysia" }, + { "Code": "MV", "Name": "Maldives" }, + { "Code": "ML", "Name": "Mali" }, + { "Code": "MT", "Name": "Malta" }, + { "Code": "MH", "Name": "Marshall Islands" }, + { "Code": "MQ", "Name": "Martinique" }, + { "Code": "MR", "Name": "Mauritania" }, + { "Code": "MU", "Name": "Mauritius" }, + { "Code": "YT", "Name": "Mayotte" }, + { "Code": "MX", "Name": "Mexico" }, + { "Code": "FM", "Name": "Micronesia, Federated States of" }, + { "Code": "MD", "Name": "Moldova, Republic of" }, + { "Code": "MC", "Name": "Monaco" }, + { "Code": "MN", "Name": "Mongolia" }, + { "Code": "ME", "Name": "Montenegro" }, + { "Code": "MS", "Name": "Montserrat" }, + { "Code": "MA", "Name": "Morocco" }, + { "Code": "MZ", "Name": "Mozambique" }, + { "Code": "MM", "Name": "Myanmar" }, + { "Code": "NA", "Name": "Namibia" }, + { "Code": "NR", "Name": "Nauru" }, + { "Code": "NP", "Name": "Nepal" }, + { "Code": "NL", "Name": "Netherlands" }, + { "Code": "NC", "Name": "New Caledonia" }, + { "Code": "NZ", "Name": "New Zealand" }, + { "Code": "NI", "Name": "Nicaragua" }, + { "Code": "NE", "Name": "Niger" }, + { "Code": "NG", "Name": "Nigeria" }, + { "Code": "NU", "Name": "Niue" }, + { "Code": "NF", "Name": "Norfolk Island" }, + { "Code": "MP", "Name": "Northern Mariana Islands" }, + { "Code": "NO", "Name": "Norway" }, + { "Code": "OM", "Name": "Oman" }, + { "Code": "PK", "Name": "Pakistan" }, + { "Code": "PW", "Name": "Palau" }, + { "Code": "PS", "Name": "Palestine, State of" }, + { "Code": "PA", "Name": "Panama" }, + { "Code": "PG", "Name": "Papua New Guinea" }, + { "Code": "PY", "Name": "Paraguay" }, + { "Code": "PE", "Name": "Peru" }, + { "Code": "PH", "Name": "Philippines" }, + { "Code": "PN", "Name": "Pitcairn" }, + { "Code": "PL", "Name": "Poland" }, + { "Code": "PT", "Name": "Portugal" }, + { "Code": "PR", "Name": "Puerto Rico" }, + { "Code": "QA", "Name": "Qatar" }, + { "Code": "RE", "Name": "R\u00e9union" }, + { "Code": "RO", "Name": "Romania" }, + { "Code": "RU", "Name": "Russian Federation" }, + { "Code": "RW", "Name": "Rwanda" }, + { "Code": "BL", "Name": "Saint Barth\u00e9lemy" }, + { "Code": "SH", "Name": "Saint Helena, Ascension and Tristan da Cunha" }, + { "Code": "KN", "Name": "Saint Kitts and Nevis" }, + { "Code": "LC", "Name": "Saint Lucia" }, + { "Code": "MF", "Name": "Saint Martin (French part)" }, + { "Code": "PM", "Name": "Saint Pierre and Miquelon" }, + { "Code": "VC", "Name": "Saint Vincent and the Grenadines" }, + { "Code": "WS", "Name": "Samoa" }, + { "Code": "SM", "Name": "San Marino" }, + { "Code": "ST", "Name": "Sao Tome and Principe" }, + { "Code": "SA", "Name": "Saudi Arabia" }, + { "Code": "SN", "Name": "Senegal" }, + { "Code": "RS", "Name": "Serbia" }, + { "Code": "SC", "Name": "Seychelles" }, + { "Code": "SL", "Name": "Sierra Leone" }, + { "Code": "SG", "Name": "Singapore" }, + { "Code": "SX", "Name": "Sint Maarten (Dutch part)" }, + { "Code": "SK", "Name": "Slovakia" }, + { "Code": "SI", "Name": "Slovenia" }, + { "Code": "SB", "Name": "Solomon Islands" }, + { "Code": "SO", "Name": "Somalia" }, + { "Code": "ZA", "Name": "South Africa" }, + { "Code": "GS", "Name": "South Georgia and the South Sandwich Islands" }, + { "Code": "SS", "Name": "South Sudan" }, + { "Code": "ES", "Name": "Spain" }, + { "Code": "LK", "Name": "Sri Lanka" }, + { "Code": "SD", "Name": "Sudan" }, + { "Code": "SR", "Name": "Suriname" }, + { "Code": "SJ", "Name": "Svalbard and Jan Mayen" }, + { "Code": "SZ", "Name": "Swaziland" }, + { "Code": "SE", "Name": "Sweden" }, + { "Code": "CH", "Name": "Switzerland" }, + { "Code": "SY", "Name": "Syrian Arab Republic" }, + { "Code": "TW", "Name": "Taiwan, Province of China" }, + { "Code": "TJ", "Name": "Tajikistan" }, + { "Code": "TZ", "Name": "Tanzania, United Republic of" }, + { "Code": "TH", "Name": "Thailand" }, + { "Code": "TL", "Name": "Timor-Leste" }, + { "Code": "TG", "Name": "Togo" }, + { "Code": "TK", "Name": "Tokelau" }, + { "Code": "TO", "Name": "Tonga" }, + { "Code": "TT", "Name": "Trinidad and Tobago" }, + { "Code": "TN", "Name": "Tunisia" }, + { "Code": "TR", "Name": "Turkey" }, + { "Code": "TM", "Name": "Turkmenistan" }, + { "Code": "TC", "Name": "Turks and Caicos Islands" }, + { "Code": "TV", "Name": "Tuvalu" }, + { "Code": "UG", "Name": "Uganda" }, + { "Code": "UA", "Name": "Ukraine" }, + { "Code": "AE", "Name": "United Arab Emirates" }, + { "Code": "GB", "Name": "United Kingdom" }, + { "Code": "US", "Name": "United States" }, + { "Code": "UM", "Name": "United States Minor Outlying Islands" }, + { "Code": "UY", "Name": "Uruguay" }, + { "Code": "UZ", "Name": "Uzbekistan" }, + { "Code": "VU", "Name": "Vanuatu" }, + { "Code": "VE", "Name": "Venezuela, Bolivarian Republic of" }, + { "Code": "VN", "Name": "Viet Nam" }, + { "Code": "VG", "Name": "Virgin Islands, British" }, + { "Code": "VI", "Name": "Virgin Islands, U.S." }, + { "Code": "WF", "Name": "Wallis and Futuna" }, + { "Code": "EH", "Name": "Western Sahara" }, + { "Code": "YE", "Name": "Yemen" }, + { "Code": "ZM", "Name": "Zambia" }, + { "Code": "ZW", "Name": "Zimbabwe" } +] diff --git a/Config/DocsPublishedPages.txt b/Config/DocsPublishedPages.txt index b9d3e4eecdfd4..1e21236768e88 100644 --- a/Config/DocsPublishedPages.txt +++ b/Config/DocsPublishedPages.txt @@ -1,7 +1,7 @@ # Slugs published on docs.cipp.app, snapshotted from llms.txt. # Generated by build/tools/Update-DocsPublishedPages.ps1 - do not hand-edit. # Read by Get-CippDocsPublishedSet so the docs search index never emits a URL that 404s. -# 427 pages. +# 426 pages. api-documentation/endpoints api-documentation/setup-and-authentication demos/showcases @@ -238,12 +238,12 @@ user-documentation/identity/administration/groups/edit user-documentation/identity/administration/groups/group user-documentation/identity/administration/jit-admin user-documentation/identity/administration/jit-admin-templates -user-documentation/identity/administration/jit-admin-templates/add-jit-admin-template -user-documentation/identity/administration/jit-admin-templates/edit-jit-admin-template +user-documentation/identity/administration/jit-admin-templates/add +user-documentation/identity/administration/jit-admin-templates/edit user-documentation/identity/administration/jit-admin/add user-documentation/identity/administration/offboarding-wizard user-documentation/identity/administration/risky-users -user-documentation/identity/administration/roles +user-documentation/identity/administration/user-defaults user-documentation/identity/administration/users user-documentation/identity/administration/users/patch-wizard user-documentation/identity/administration/users/user @@ -374,8 +374,8 @@ user-documentation/tenant/gdap-management/relationships/relationship/mappings user-documentation/tenant/gdap-management/role-templates user-documentation/tenant/gdap-management/role-templates/add user-documentation/tenant/gdap-management/role-templates/edit +user-documentation/tenant/gdap-management/role-templates/mappings/add user-documentation/tenant/gdap-management/roles -user-documentation/tenant/gdap-management/roles/add user-documentation/tenant/manage user-documentation/tenant/manage/applied-standards user-documentation/tenant/manage/backup @@ -383,7 +383,6 @@ user-documentation/tenant/manage/drift user-documentation/tenant/manage/edit user-documentation/tenant/manage/history user-documentation/tenant/manage/policies-deployed -user-documentation/tenant/manage/user-defaults user-documentation/tenant/reports user-documentation/tenant/reports/application-consent user-documentation/tenant/reports/custom-test-report diff --git a/Config/FeatureFlags.json b/Config/FeatureFlags.json index b68f2c5e6f1d7..75a740f482d1d 100644 --- a/Config/FeatureFlags.json +++ b/Config/FeatureFlags.json @@ -91,7 +91,7 @@ { "Id": "Baselines", "Name": "Baselines", - "Description": "The drift-first baseline engine that replaces classic Standards and Drift. Enables the scheduled baseline run and the Baselines menu, hides the classic Standards and Drift pages, and skips the scheduled classic Standards and Drift runs - the two systems never run side by side.", + "Description": "This is pre-alpha do not enable in production. The drift-first baseline engine that replaces classic Standards and Drift. Enables the scheduled baseline run and the Baselines menu, hides the classic Standards and Drift pages, and skips the scheduled classic Standards and Drift runs - the two systems never run side by side.", "Enabled": false, "AllowUserToggle": true, "Timers": ["9f2c7b1e-4a6d-4c3f-8b9a-5e1d2f7c0a44"], @@ -104,5 +104,16 @@ "/tenant/manage/policies-deployed" ], "Hidden": false + }, + { + "Id": "CertificateAuthentication", + "Name": "Certificate Authentication", + "Description": "Authenticate CIPP's SAM application with the SAM certificate instead of the client secret for every Graph and Exchange Online call (app-only and delegated). Managed from the Setup Wizard - enable it for an existing install (the client secret is kept as a rollback) or provision a secret-less install from scratch.", + "Enabled": false, + "AllowUserToggle": false, + "Timers": [], + "Endpoints": [], + "Pages": [], + "Hidden": true } ] diff --git a/Config/LicensePricingDefaults.csv b/Config/LicensePricingDefaults.csv new file mode 100644 index 0000000000000..30a2ea1a7bcfc --- /dev/null +++ b/Config/LicensePricingDefaults.csv @@ -0,0 +1,476 @@ +skuId,skuPartNumber,Product_Display_Name,MonthlyPrice,Currency +efccb6f7-5641-4e0e-bd10-b4976e1bf68e,EMS,Enterprise Mobility + Security E3,18,AUD +efccb6f7-5641-4e0e-bd10-b4976e1bf68e,EMS,Enterprise Mobility + Security E3,68.9,BRL +efccb6f7-5641-4e0e-bd10-b4976e1bf68e,EMS,Enterprise Mobility + Security E3,16.3,CAD +efccb6f7-5641-4e0e-bd10-b4976e1bf68e,EMS,Enterprise Mobility + Security E3,9.8,CHF +efccb6f7-5641-4e0e-bd10-b4976e1bf68e,EMS,Enterprise Mobility + Security E3,77.9,DKK +efccb6f7-5641-4e0e-bd10-b4976e1bf68e,EMS,Enterprise Mobility + Security E3,10.4,EUR +efccb6f7-5641-4e0e-bd10-b4976e1bf68e,EMS,Enterprise Mobility + Security E3,9.3,GBP +efccb6f7-5641-4e0e-bd10-b4976e1bf68e,EMS,Enterprise Mobility + Security E3,1000,INR +efccb6f7-5641-4e0e-bd10-b4976e1bf68e,EMS,Enterprise Mobility + Security E3,1805,JPY +efccb6f7-5641-4e0e-bd10-b4976e1bf68e,EMS,Enterprise Mobility + Security E3,16300,KRW +efccb6f7-5641-4e0e-bd10-b4976e1bf68e,EMS,Enterprise Mobility + Security E3,122.4,NOK +efccb6f7-5641-4e0e-bd10-b4976e1bf68e,EMS,Enterprise Mobility + Security E3,19.5,NZD +efccb6f7-5641-4e0e-bd10-b4976e1bf68e,EMS,Enterprise Mobility + Security E3,115.1,SEK +efccb6f7-5641-4e0e-bd10-b4976e1bf68e,EMS,Enterprise Mobility + Security E3,385,TWD +efccb6f7-5641-4e0e-bd10-b4976e1bf68e,EMS,Enterprise Mobility + Security E3,12,USD +b05e124f-c7cc-45a0-a6aa-8cf78c946968,EMSPREMIUM,Enterprise Mobility + Security E5,27,AUD +b05e124f-c7cc-45a0-a6aa-8cf78c946968,EMSPREMIUM,Enterprise Mobility + Security E5,103.3,BRL +b05e124f-c7cc-45a0-a6aa-8cf78c946968,EMSPREMIUM,Enterprise Mobility + Security E5,24.5,CAD +b05e124f-c7cc-45a0-a6aa-8cf78c946968,EMSPREMIUM,Enterprise Mobility + Security E5,14.6,CHF +b05e124f-c7cc-45a0-a6aa-8cf78c946968,EMSPREMIUM,Enterprise Mobility + Security E5,116.7,DKK +b05e124f-c7cc-45a0-a6aa-8cf78c946968,EMSPREMIUM,Enterprise Mobility + Security E5,15.6,EUR +b05e124f-c7cc-45a0-a6aa-8cf78c946968,EMSPREMIUM,Enterprise Mobility + Security E5,13.9,GBP +b05e124f-c7cc-45a0-a6aa-8cf78c946968,EMSPREMIUM,Enterprise Mobility + Security E5,1500,INR +b05e124f-c7cc-45a0-a6aa-8cf78c946968,EMSPREMIUM,Enterprise Mobility + Security E5,2704,JPY +b05e124f-c7cc-45a0-a6aa-8cf78c946968,EMSPREMIUM,Enterprise Mobility + Security E5,24400,KRW +b05e124f-c7cc-45a0-a6aa-8cf78c946968,EMSPREMIUM,Enterprise Mobility + Security E5,183.4,NOK +b05e124f-c7cc-45a0-a6aa-8cf78c946968,EMSPREMIUM,Enterprise Mobility + Security E5,29.2,NZD +b05e124f-c7cc-45a0-a6aa-8cf78c946968,EMSPREMIUM,Enterprise Mobility + Security E5,172.4,SEK +b05e124f-c7cc-45a0-a6aa-8cf78c946968,EMSPREMIUM,Enterprise Mobility + Security E5,580,TWD +b05e124f-c7cc-45a0-a6aa-8cf78c946968,EMSPREMIUM,Enterprise Mobility + Security E5,18,USD +4b9405b0-7788-4568-add1-99614e613b69,EXCHANGESTANDARD,Exchange Online (Plan 1),6,AUD +4b9405b0-7788-4568-add1-99614e613b69,EXCHANGESTANDARD,Exchange Online (Plan 1),22.9,BRL +4b9405b0-7788-4568-add1-99614e613b69,EXCHANGESTANDARD,Exchange Online (Plan 1),5.4,CAD +4b9405b0-7788-4568-add1-99614e613b69,EXCHANGESTANDARD,Exchange Online (Plan 1),3.2,CHF +4b9405b0-7788-4568-add1-99614e613b69,EXCHANGESTANDARD,Exchange Online (Plan 1),25.9,DKK +4b9405b0-7788-4568-add1-99614e613b69,EXCHANGESTANDARD,Exchange Online (Plan 1),3.5,EUR +4b9405b0-7788-4568-add1-99614e613b69,EXCHANGESTANDARD,Exchange Online (Plan 1),3.1,GBP +4b9405b0-7788-4568-add1-99614e613b69,EXCHANGESTANDARD,Exchange Online (Plan 1),335,INR +4b9405b0-7788-4568-add1-99614e613b69,EXCHANGESTANDARD,Exchange Online (Plan 1),599,JPY +4b9405b0-7788-4568-add1-99614e613b69,EXCHANGESTANDARD,Exchange Online (Plan 1),5400,KRW +4b9405b0-7788-4568-add1-99614e613b69,EXCHANGESTANDARD,Exchange Online (Plan 1),40.7,NOK +4b9405b0-7788-4568-add1-99614e613b69,EXCHANGESTANDARD,Exchange Online (Plan 1),6.5,NZD +4b9405b0-7788-4568-add1-99614e613b69,EXCHANGESTANDARD,Exchange Online (Plan 1),38.2,SEK +4b9405b0-7788-4568-add1-99614e613b69,EXCHANGESTANDARD,Exchange Online (Plan 1),130,TWD +4b9405b0-7788-4568-add1-99614e613b69,EXCHANGESTANDARD,Exchange Online (Plan 1),4,USD +19ec0d23-8335-4cbd-94ac-6050e30712fa,EXCHANGEENTERPRISE,Exchange Online (Plan 2),12,AUD +19ec0d23-8335-4cbd-94ac-6050e30712fa,EXCHANGEENTERPRISE,Exchange Online (Plan 2),45.8,BRL +19ec0d23-8335-4cbd-94ac-6050e30712fa,EXCHANGEENTERPRISE,Exchange Online (Plan 2),10.9,CAD +19ec0d23-8335-4cbd-94ac-6050e30712fa,EXCHANGEENTERPRISE,Exchange Online (Plan 2),6.5,CHF +19ec0d23-8335-4cbd-94ac-6050e30712fa,EXCHANGEENTERPRISE,Exchange Online (Plan 2),51.7,DKK +19ec0d23-8335-4cbd-94ac-6050e30712fa,EXCHANGEENTERPRISE,Exchange Online (Plan 2),6.9,EUR +19ec0d23-8335-4cbd-94ac-6050e30712fa,EXCHANGEENTERPRISE,Exchange Online (Plan 2),6.2,GBP +19ec0d23-8335-4cbd-94ac-6050e30712fa,EXCHANGEENTERPRISE,Exchange Online (Plan 2),665,INR +19ec0d23-8335-4cbd-94ac-6050e30712fa,EXCHANGEENTERPRISE,Exchange Online (Plan 2),1199,JPY +19ec0d23-8335-4cbd-94ac-6050e30712fa,EXCHANGEENTERPRISE,Exchange Online (Plan 2),10800,KRW +19ec0d23-8335-4cbd-94ac-6050e30712fa,EXCHANGEENTERPRISE,Exchange Online (Plan 2),81.3,NOK +19ec0d23-8335-4cbd-94ac-6050e30712fa,EXCHANGEENTERPRISE,Exchange Online (Plan 2),12.9,NZD +19ec0d23-8335-4cbd-94ac-6050e30712fa,EXCHANGEENTERPRISE,Exchange Online (Plan 2),76.5,SEK +19ec0d23-8335-4cbd-94ac-6050e30712fa,EXCHANGEENTERPRISE,Exchange Online (Plan 2),255,TWD +19ec0d23-8335-4cbd-94ac-6050e30712fa,EXCHANGEENTERPRISE,Exchange Online (Plan 2),8,USD +cdd28e44-67e3-425e-be4c-737fab2899d3,O365_BUSINESS,Microsoft 365 Apps for Business,15.8,AUD +cdd28e44-67e3-425e-be4c-737fab2899d3,O365_BUSINESS,Microsoft 365 Apps for Business,57.3,BRL +cdd28e44-67e3-425e-be4c-737fab2899d3,O365_BUSINESS,Microsoft 365 Apps for Business,14.2,CAD +cdd28e44-67e3-425e-be4c-737fab2899d3,O365_BUSINESS,Microsoft 365 Apps for Business,10.1,CHF +cdd28e44-67e3-425e-be4c-737fab2899d3,O365_BUSINESS,Microsoft 365 Apps for Business,81.9,DKK +cdd28e44-67e3-425e-be4c-737fab2899d3,O365_BUSINESS,Microsoft 365 Apps for Business,11,EUR +cdd28e44-67e3-425e-be4c-737fab2899d3,O365_BUSINESS,Microsoft 365 Apps for Business,9.8,GBP +cdd28e44-67e3-425e-be4c-737fab2899d3,O365_BUSINESS,Microsoft 365 Apps for Business,830,INR +cdd28e44-67e3-425e-be4c-737fab2899d3,O365_BUSINESS,Microsoft 365 Apps for Business,1499,JPY +cdd28e44-67e3-425e-be4c-737fab2899d3,O365_BUSINESS,Microsoft 365 Apps for Business,13200,KRW +cdd28e44-67e3-425e-be4c-737fab2899d3,O365_BUSINESS,Microsoft 365 Apps for Business,127.6,NOK +cdd28e44-67e3-425e-be4c-737fab2899d3,O365_BUSINESS,Microsoft 365 Apps for Business,19.2,NZD +cdd28e44-67e3-425e-be4c-737fab2899d3,O365_BUSINESS,Microsoft 365 Apps for Business,120,SEK +cdd28e44-67e3-425e-be4c-737fab2899d3,O365_BUSINESS,Microsoft 365 Apps for Business,370,TWD +cdd28e44-67e3-425e-be4c-737fab2899d3,O365_BUSINESS,Microsoft 365 Apps for Business,10,USD +c2273bd0-dff7-4215-9ef5-2c7bcfb06425,OFFICESUBSCRIPTION,Microsoft 365 Apps for Enterprise,22.1,AUD +c2273bd0-dff7-4215-9ef5-2c7bcfb06425,OFFICESUBSCRIPTION,Microsoft 365 Apps for Enterprise,80.2,BRL +c2273bd0-dff7-4215-9ef5-2c7bcfb06425,OFFICESUBSCRIPTION,Microsoft 365 Apps for Enterprise,19.8,CAD +c2273bd0-dff7-4215-9ef5-2c7bcfb06425,OFFICESUBSCRIPTION,Microsoft 365 Apps for Enterprise,14.2,CHF +c2273bd0-dff7-4215-9ef5-2c7bcfb06425,OFFICESUBSCRIPTION,Microsoft 365 Apps for Enterprise,114.7,DKK +c2273bd0-dff7-4215-9ef5-2c7bcfb06425,OFFICESUBSCRIPTION,Microsoft 365 Apps for Enterprise,15.4,EUR +c2273bd0-dff7-4215-9ef5-2c7bcfb06425,OFFICESUBSCRIPTION,Microsoft 365 Apps for Enterprise,13.7,GBP +c2273bd0-dff7-4215-9ef5-2c7bcfb06425,OFFICESUBSCRIPTION,Microsoft 365 Apps for Enterprise,1165,INR +c2273bd0-dff7-4215-9ef5-2c7bcfb06425,OFFICESUBSCRIPTION,Microsoft 365 Apps for Enterprise,2098,JPY +c2273bd0-dff7-4215-9ef5-2c7bcfb06425,OFFICESUBSCRIPTION,Microsoft 365 Apps for Enterprise,18500,KRW +c2273bd0-dff7-4215-9ef5-2c7bcfb06425,OFFICESUBSCRIPTION,Microsoft 365 Apps for Enterprise,178.6,NOK +c2273bd0-dff7-4215-9ef5-2c7bcfb06425,OFFICESUBSCRIPTION,Microsoft 365 Apps for Enterprise,26.9,NZD +c2273bd0-dff7-4215-9ef5-2c7bcfb06425,OFFICESUBSCRIPTION,Microsoft 365 Apps for Enterprise,168,SEK +c2273bd0-dff7-4215-9ef5-2c7bcfb06425,OFFICESUBSCRIPTION,Microsoft 365 Apps for Enterprise,515,TWD +c2273bd0-dff7-4215-9ef5-2c7bcfb06425,OFFICESUBSCRIPTION,Microsoft 365 Apps for Enterprise,14,USD +3b555118-da6a-4418-894f-7df1e2096870,O365_BUSINESS_ESSENTIALS,Microsoft 365 Business Basic,10.5,AUD +3b555118-da6a-4418-894f-7df1e2096870,O365_BUSINESS_ESSENTIALS,Microsoft 365 Business Basic,33.4,BRL +3b555118-da6a-4418-894f-7df1e2096870,O365_BUSINESS_ESSENTIALS,Microsoft 365 Business Basic,9.5,CAD +3b555118-da6a-4418-894f-7df1e2096870,O365_BUSINESS_ESSENTIALS,Microsoft 365 Business Basic,5.67,CHF +3b555118-da6a-4418-894f-7df1e2096870,O365_BUSINESS_ESSENTIALS,Microsoft 365 Business Basic,45.27,DKK +3b555118-da6a-4418-894f-7df1e2096870,O365_BUSINESS_ESSENTIALS,Microsoft 365 Business Basic,6.07,EUR +3b555118-da6a-4418-894f-7df1e2096870,O365_BUSINESS_ESSENTIALS,Microsoft 365 Business Basic,5.4,GBP +3b555118-da6a-4418-894f-7df1e2096870,O365_BUSINESS_ESSENTIALS,Microsoft 365 Business Basic,170,INR +3b555118-da6a-4418-894f-7df1e2096870,O365_BUSINESS_ESSENTIALS,Microsoft 365 Business Basic,1049,JPY +3b555118-da6a-4418-894f-7df1e2096870,O365_BUSINESS_ESSENTIALS,Microsoft 365 Business Basic,9500,KRW +3b555118-da6a-4418-894f-7df1e2096870,O365_BUSINESS_ESSENTIALS,Microsoft 365 Business Basic,71.16,NOK +3b555118-da6a-4418-894f-7df1e2096870,O365_BUSINESS_ESSENTIALS,Microsoft 365 Business Basic,11.3,NZD +3b555118-da6a-4418-894f-7df1e2096870,O365_BUSINESS_ESSENTIALS,Microsoft 365 Business Basic,66.91,SEK +3b555118-da6a-4418-894f-7df1e2096870,O365_BUSINESS_ESSENTIALS,Microsoft 365 Business Basic,225,TWD +3b555118-da6a-4418-894f-7df1e2096870,O365_BUSINESS_ESSENTIALS,Microsoft 365 Business Basic,7,USD +cbdc14ab-d96c-4c30-b9f4-6ada7cdc1d46,SPB,Microsoft 365 Business Premium,32.9,AUD +cbdc14ab-d96c-4c30-b9f4-6ada7cdc1d46,SPB,Microsoft 365 Business Premium,126,BRL +cbdc14ab-d96c-4c30-b9f4-6ada7cdc1d46,SPB,Microsoft 365 Business Premium,29.8,CAD +cbdc14ab-d96c-4c30-b9f4-6ada7cdc1d46,SPB,Microsoft 365 Business Premium,17.82,CHF +cbdc14ab-d96c-4c30-b9f4-6ada7cdc1d46,SPB,Microsoft 365 Business Premium,142.27,DKK +cbdc14ab-d96c-4c30-b9f4-6ada7cdc1d46,SPB,Microsoft 365 Business Premium,19.06,EUR +cbdc14ab-d96c-4c30-b9f4-6ada7cdc1d46,SPB,Microsoft 365 Business Premium,16.9,GBP +cbdc14ab-d96c-4c30-b9f4-6ada7cdc1d46,SPB,Microsoft 365 Business Premium,1830,INR +cbdc14ab-d96c-4c30-b9f4-6ada7cdc1d46,SPB,Microsoft 365 Business Premium,3298,JPY +cbdc14ab-d96c-4c30-b9f4-6ada7cdc1d46,SPB,Microsoft 365 Business Premium,29700,KRW +cbdc14ab-d96c-4c30-b9f4-6ada7cdc1d46,SPB,Microsoft 365 Business Premium,223.63,NOK +cbdc14ab-d96c-4c30-b9f4-6ada7cdc1d46,SPB,Microsoft 365 Business Premium,35.6,NZD +cbdc14ab-d96c-4c30-b9f4-6ada7cdc1d46,SPB,Microsoft 365 Business Premium,210.29,SEK +cbdc14ab-d96c-4c30-b9f4-6ada7cdc1d46,SPB,Microsoft 365 Business Premium,705,TWD +cbdc14ab-d96c-4c30-b9f4-6ada7cdc1d46,SPB,Microsoft 365 Business Premium,22,USD +f245ecc8-75af-4f8e-b61f-27d8114de5f3,O365_BUSINESS_PREMIUM,Microsoft 365 Business Standard,21,AUD +f245ecc8-75af-4f8e-b61f-27d8114de5f3,O365_BUSINESS_PREMIUM,Microsoft 365 Business Standard,80.2,BRL +f245ecc8-75af-4f8e-b61f-27d8114de5f3,O365_BUSINESS_PREMIUM,Microsoft 365 Business Standard,19,CAD +f245ecc8-75af-4f8e-b61f-27d8114de5f3,O365_BUSINESS_PREMIUM,Microsoft 365 Business Standard,11.34,CHF +f245ecc8-75af-4f8e-b61f-27d8114de5f3,O365_BUSINESS_PREMIUM,Microsoft 365 Business Standard,90.54,DKK +f245ecc8-75af-4f8e-b61f-27d8114de5f3,O365_BUSINESS_PREMIUM,Microsoft 365 Business Standard,12.13,EUR +f245ecc8-75af-4f8e-b61f-27d8114de5f3,O365_BUSINESS_PREMIUM,Microsoft 365 Business Standard,10.8,GBP +f245ecc8-75af-4f8e-b61f-27d8114de5f3,O365_BUSINESS_PREMIUM,Microsoft 365 Business Standard,860,INR +f245ecc8-75af-4f8e-b61f-27d8114de5f3,O365_BUSINESS_PREMIUM,Microsoft 365 Business Standard,2098,JPY +f245ecc8-75af-4f8e-b61f-27d8114de5f3,O365_BUSINESS_PREMIUM,Microsoft 365 Business Standard,18900,KRW +f245ecc8-75af-4f8e-b61f-27d8114de5f3,O365_BUSINESS_PREMIUM,Microsoft 365 Business Standard,142.31,NOK +f245ecc8-75af-4f8e-b61f-27d8114de5f3,O365_BUSINESS_PREMIUM,Microsoft 365 Business Standard,22.6,NZD +f245ecc8-75af-4f8e-b61f-27d8114de5f3,O365_BUSINESS_PREMIUM,Microsoft 365 Business Standard,133.82,SEK +f245ecc8-75af-4f8e-b61f-27d8114de5f3,O365_BUSINESS_PREMIUM,Microsoft 365 Business Standard,450,TWD +f245ecc8-75af-4f8e-b61f-27d8114de5f3,O365_BUSINESS_PREMIUM,Microsoft 365 Business Standard,14,USD +05e9a617-0261-4cee-bb44-138d3ef5d965,SPE_E3,Microsoft 365 E3,58.4,AUD +05e9a617-0261-4cee-bb44-138d3ef5d965,SPE_E3,Microsoft 365 E3,223.3,BRL +05e9a617-0261-4cee-bb44-138d3ef5d965,SPE_E3,Microsoft 365 E3,52.9,CAD +05e9a617-0261-4cee-bb44-138d3ef5d965,SPE_E3,Microsoft 365 E3,35.32,CHF +05e9a617-0261-4cee-bb44-138d3ef5d965,SPE_E3,Microsoft 365 E3,281.97,DKK +05e9a617-0261-4cee-bb44-138d3ef5d965,SPE_E3,Microsoft 365 E3,37.78,EUR +05e9a617-0261-4cee-bb44-138d3ef5d965,SPE_E3,Microsoft 365 E3,33.5,GBP +05e9a617-0261-4cee-bb44-138d3ef5d965,SPE_E3,Microsoft 365 E3,3245,INR +05e9a617-0261-4cee-bb44-138d3ef5d965,SPE_E3,Microsoft 365 E3,5847,JPY +05e9a617-0261-4cee-bb44-138d3ef5d965,SPE_E3,Microsoft 365 E3,52700,KRW +05e9a617-0261-4cee-bb44-138d3ef5d965,SPE_E3,Microsoft 365 E3,442.03,NOK +05e9a617-0261-4cee-bb44-138d3ef5d965,SPE_E3,Microsoft 365 E3,63.1,NZD +05e9a617-0261-4cee-bb44-138d3ef5d965,SPE_E3,Microsoft 365 E3,416.77,SEK +05e9a617-0261-4cee-bb44-138d3ef5d965,SPE_E3,Microsoft 365 E3,1255,TWD +05e9a617-0261-4cee-bb44-138d3ef5d965,SPE_E3,Microsoft 365 E3,39,USD +dcf0408c-aaec-446c-afd4-43e3683943ea,Microsoft_365_E3_(no_Teams),Microsoft 365 E3 (no Teams),45.6,AUD +dcf0408c-aaec-446c-afd4-43e3683943ea,Microsoft_365_E3_(no_Teams),Microsoft 365 E3 (no Teams),174.3,BRL +dcf0408c-aaec-446c-afd4-43e3683943ea,Microsoft_365_E3_(no_Teams),Microsoft 365 E3 (no Teams),41.3,CAD +dcf0408c-aaec-446c-afd4-43e3683943ea,Microsoft_365_E3_(no_Teams),Microsoft 365 E3 (no Teams),27,GBP +dcf0408c-aaec-446c-afd4-43e3683943ea,Microsoft_365_E3_(no_Teams),Microsoft 365 E3 (no Teams),2535,INR +dcf0408c-aaec-446c-afd4-43e3683943ea,Microsoft_365_E3_(no_Teams),Microsoft 365 E3 (no Teams),4565,JPY +dcf0408c-aaec-446c-afd4-43e3683943ea,Microsoft_365_E3_(no_Teams),Microsoft 365 E3 (no Teams),41100,KRW +dcf0408c-aaec-446c-afd4-43e3683943ea,Microsoft_365_E3_(no_Teams),Microsoft 365 E3 (no Teams),49.3,NZD +dcf0408c-aaec-446c-afd4-43e3683943ea,Microsoft_365_E3_(no_Teams),Microsoft 365 E3 (no Teams),980,TWD +dcf0408c-aaec-446c-afd4-43e3683943ea,Microsoft_365_E3_(no_Teams),Microsoft 365 E3 (no Teams),30.45,USD +c2fe850d-fbbb-4858-b67d-bd0c6e746da3,O365_w/o Teams Bundle_M3,Microsoft 365 E3 EEA (no Teams),28.38,CHF +c2fe850d-fbbb-4858-b67d-bd0c6e746da3,O365_w/o Teams Bundle_M3,Microsoft 365 E3 EEA (no Teams),226.68,DKK +c2fe850d-fbbb-4858-b67d-bd0c6e746da3,O365_w/o Teams Bundle_M3,Microsoft 365 E3 EEA (no Teams),30.36,EUR +c2fe850d-fbbb-4858-b67d-bd0c6e746da3,O365_w/o Teams Bundle_M3,Microsoft 365 E3 EEA (no Teams),355.12,NOK +c2fe850d-fbbb-4858-b67d-bd0c6e746da3,O365_w/o Teams Bundle_M3,Microsoft 365 E3 EEA (no Teams),335.04,SEK +06ebc4ee-1bb5-47dd-8120-11324bc54e06,SPE_E5,Microsoft 365 E5,89.8,AUD +06ebc4ee-1bb5-47dd-8120-11324bc54e06,SPE_E5,Microsoft 365 E5,343.5,BRL +06ebc4ee-1bb5-47dd-8120-11324bc54e06,SPE_E5,Microsoft 365 E5,81.4,CAD +06ebc4ee-1bb5-47dd-8120-11324bc54e06,SPE_E5,Microsoft 365 E5,54.33,CHF +06ebc4ee-1bb5-47dd-8120-11324bc54e06,SPE_E5,Microsoft 365 E5,433.8,DKK +06ebc4ee-1bb5-47dd-8120-11324bc54e06,SPE_E5,Microsoft 365 E5,58.13,EUR +06ebc4ee-1bb5-47dd-8120-11324bc54e06,SPE_E5,Microsoft 365 E5,51.6,GBP +06ebc4ee-1bb5-47dd-8120-11324bc54e06,SPE_E5,Microsoft 365 E5,4990,INR +06ebc4ee-1bb5-47dd-8120-11324bc54e06,SPE_E5,Microsoft 365 E5,8995,JPY +06ebc4ee-1bb5-47dd-8120-11324bc54e06,SPE_E5,Microsoft 365 E5,81100,KRW +06ebc4ee-1bb5-47dd-8120-11324bc54e06,SPE_E5,Microsoft 365 E5,680.04,NOK +06ebc4ee-1bb5-47dd-8120-11324bc54e06,SPE_E5,Microsoft 365 E5,97.1,NZD +06ebc4ee-1bb5-47dd-8120-11324bc54e06,SPE_E5,Microsoft 365 E5,641.18,SEK +06ebc4ee-1bb5-47dd-8120-11324bc54e06,SPE_E5,Microsoft 365 E5,1930,TWD +06ebc4ee-1bb5-47dd-8120-11324bc54e06,SPE_E5,Microsoft 365 E5,60,USD +18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e,Microsoft_365_E5_(no_Teams),Microsoft 365 E5 (no Teams),77,AUD +18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e,Microsoft_365_E5_(no_Teams),Microsoft 365 E5 (no Teams),294.6,BRL +18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e,Microsoft_365_E5_(no_Teams),Microsoft 365 E5 (no Teams),69.8,CAD +18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e,Microsoft_365_E5_(no_Teams),Microsoft 365 E5 (no Teams),45,GBP +18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e,Microsoft_365_E5_(no_Teams),Microsoft 365 E5 (no Teams),4280,INR +18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e,Microsoft_365_E5_(no_Teams),Microsoft 365 E5 (no Teams),7713,JPY +18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e,Microsoft_365_E5_(no_Teams),Microsoft 365 E5 (no Teams),69500,KRW +18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e,Microsoft_365_E5_(no_Teams),Microsoft 365 E5 (no Teams),83.2,NZD +18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e,Microsoft_365_E5_(no_Teams),Microsoft 365 E5 (no Teams),1655,TWD +18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e,Microsoft_365_E5_(no_Teams),Microsoft 365 E5 (no Teams),51.45,USD +3271cf8e-2be5-4a09-a549-70fd05baaa17,O365_w/o_Teams_Bundle_M5,Microsoft 365 E5 EEA (no Teams),47.4,CHF +3271cf8e-2be5-4a09-a549-70fd05baaa17,O365_w/o_Teams_Bundle_M5,Microsoft 365 E5 EEA (no Teams),378.5,DKK +3271cf8e-2be5-4a09-a549-70fd05baaa17,O365_w/o_Teams_Bundle_M5,Microsoft 365 E5 EEA (no Teams),50.71,EUR +3271cf8e-2be5-4a09-a549-70fd05baaa17,O365_w/o_Teams_Bundle_M5,Microsoft 365 E5 EEA (no Teams),593.13,NOK +3271cf8e-2be5-4a09-a549-70fd05baaa17,O365_w/o_Teams_Bundle_M5,Microsoft 365 E5 EEA (no Teams),559.44,SEK +cd2925a3-5076-4233-8931-638a8c94f773,SPE_E5_NOPSTNCONF,Microsoft 365 E5 without Audio Conferencing,89.8,AUD +cd2925a3-5076-4233-8931-638a8c94f773,SPE_E5_NOPSTNCONF,Microsoft 365 E5 without Audio Conferencing,343.5,BRL +cd2925a3-5076-4233-8931-638a8c94f773,SPE_E5_NOPSTNCONF,Microsoft 365 E5 without Audio Conferencing,81.4,CAD +cd2925a3-5076-4233-8931-638a8c94f773,SPE_E5_NOPSTNCONF,Microsoft 365 E5 without Audio Conferencing,54.33,CHF +cd2925a3-5076-4233-8931-638a8c94f773,SPE_E5_NOPSTNCONF,Microsoft 365 E5 without Audio Conferencing,433.8,DKK +cd2925a3-5076-4233-8931-638a8c94f773,SPE_E5_NOPSTNCONF,Microsoft 365 E5 without Audio Conferencing,58.13,EUR +cd2925a3-5076-4233-8931-638a8c94f773,SPE_E5_NOPSTNCONF,Microsoft 365 E5 without Audio Conferencing,51.6,GBP +cd2925a3-5076-4233-8931-638a8c94f773,SPE_E5_NOPSTNCONF,Microsoft 365 E5 without Audio Conferencing,4990,INR +cd2925a3-5076-4233-8931-638a8c94f773,SPE_E5_NOPSTNCONF,Microsoft 365 E5 without Audio Conferencing,8995,JPY +cd2925a3-5076-4233-8931-638a8c94f773,SPE_E5_NOPSTNCONF,Microsoft 365 E5 without Audio Conferencing,81100,KRW +cd2925a3-5076-4233-8931-638a8c94f773,SPE_E5_NOPSTNCONF,Microsoft 365 E5 without Audio Conferencing,680.04,NOK +cd2925a3-5076-4233-8931-638a8c94f773,SPE_E5_NOPSTNCONF,Microsoft 365 E5 without Audio Conferencing,97.1,NZD +cd2925a3-5076-4233-8931-638a8c94f773,SPE_E5_NOPSTNCONF,Microsoft 365 E5 without Audio Conferencing,641.18,SEK +cd2925a3-5076-4233-8931-638a8c94f773,SPE_E5_NOPSTNCONF,Microsoft 365 E5 without Audio Conferencing,1930,TWD +cd2925a3-5076-4233-8931-638a8c94f773,SPE_E5_NOPSTNCONF,Microsoft 365 E5 without Audio Conferencing,60,USD +50f60901-3181-4b75-8a2c-4c8e4c1d5a72,M365_F1_COMM,Microsoft 365 F1,4.5,AUD +50f60901-3181-4b75-8a2c-4c8e4c1d5a72,M365_F1_COMM,Microsoft 365 F1,17.2,BRL +50f60901-3181-4b75-8a2c-4c8e4c1d5a72,M365_F1_COMM,Microsoft 365 F1,4.1,CAD +50f60901-3181-4b75-8a2c-4c8e4c1d5a72,M365_F1_COMM,Microsoft 365 F1,2.43,CHF +50f60901-3181-4b75-8a2c-4c8e4c1d5a72,M365_F1_COMM,Microsoft 365 F1,19.4,DKK +50f60901-3181-4b75-8a2c-4c8e4c1d5a72,M365_F1_COMM,Microsoft 365 F1,2.6,EUR +50f60901-3181-4b75-8a2c-4c8e4c1d5a72,M365_F1_COMM,Microsoft 365 F1,2.3,GBP +50f60901-3181-4b75-8a2c-4c8e4c1d5a72,M365_F1_COMM,Microsoft 365 F1,250,INR +50f60901-3181-4b75-8a2c-4c8e4c1d5a72,M365_F1_COMM,Microsoft 365 F1,449,JPY +50f60901-3181-4b75-8a2c-4c8e4c1d5a72,M365_F1_COMM,Microsoft 365 F1,4100,KRW +50f60901-3181-4b75-8a2c-4c8e4c1d5a72,M365_F1_COMM,Microsoft 365 F1,30.5,NOK +50f60901-3181-4b75-8a2c-4c8e4c1d5a72,M365_F1_COMM,Microsoft 365 F1,4.9,NZD +50f60901-3181-4b75-8a2c-4c8e4c1d5a72,M365_F1_COMM,Microsoft 365 F1,28.68,SEK +50f60901-3181-4b75-8a2c-4c8e4c1d5a72,M365_F1_COMM,Microsoft 365 F1,96.5,TWD +50f60901-3181-4b75-8a2c-4c8e4c1d5a72,M365_F1_COMM,Microsoft 365 F1,3,USD +0666269f-b167-4c5b-a76f-fc574f2b1118,Microsoft_365_F1_EEA_(no_Teams),Microsoft 365 F1 EEA (no Teams),2.02,CHF +0666269f-b167-4c5b-a76f-fc574f2b1118,Microsoft_365_F1_EEA_(no_Teams),Microsoft 365 F1 EEA (no Teams),16.16,DKK +0666269f-b167-4c5b-a76f-fc574f2b1118,Microsoft_365_F1_EEA_(no_Teams),Microsoft 365 F1 EEA (no Teams),2.16,EUR +0666269f-b167-4c5b-a76f-fc574f2b1118,Microsoft_365_F1_EEA_(no_Teams),Microsoft 365 F1 EEA (no Teams),25.41,NOK +0666269f-b167-4c5b-a76f-fc574f2b1118,Microsoft_365_F1_EEA_(no_Teams),Microsoft 365 F1 EEA (no Teams),23.89,SEK +66b55226-6b4f-492c-910c-a3b7a3c9d993,SPE_F1,Microsoft 365 F3,15,AUD +66b55226-6b4f-492c-910c-a3b7a3c9d993,SPE_F1,Microsoft 365 F3,57.3,BRL +66b55226-6b4f-492c-910c-a3b7a3c9d993,SPE_F1,Microsoft 365 F3,13.6,CAD +66b55226-6b4f-492c-910c-a3b7a3c9d993,SPE_F1,Microsoft 365 F3,8.1,CHF +66b55226-6b4f-492c-910c-a3b7a3c9d993,SPE_F1,Microsoft 365 F3,64.67,DKK +66b55226-6b4f-492c-910c-a3b7a3c9d993,SPE_F1,Microsoft 365 F3,8.66,EUR +66b55226-6b4f-492c-910c-a3b7a3c9d993,SPE_F1,Microsoft 365 F3,7.7,GBP +66b55226-6b4f-492c-910c-a3b7a3c9d993,SPE_F1,Microsoft 365 F3,830,INR +66b55226-6b4f-492c-910c-a3b7a3c9d993,SPE_F1,Microsoft 365 F3,1499,JPY +66b55226-6b4f-492c-910c-a3b7a3c9d993,SPE_F1,Microsoft 365 F3,13500,KRW +66b55226-6b4f-492c-910c-a3b7a3c9d993,SPE_F1,Microsoft 365 F3,101.65,NOK +66b55226-6b4f-492c-910c-a3b7a3c9d993,SPE_F1,Microsoft 365 F3,16.2,NZD +66b55226-6b4f-492c-910c-a3b7a3c9d993,SPE_F1,Microsoft 365 F3,95.59,SEK +66b55226-6b4f-492c-910c-a3b7a3c9d993,SPE_F1,Microsoft 365 F3,320,TWD +66b55226-6b4f-492c-910c-a3b7a3c9d993,SPE_F1,Microsoft 365 F3,10,USD +f7ee79a7-7aec-4ca4-9fb9-34d6b930ad87,Microsoft_365_F3_EEA_(no_Teams),Microsoft 365 F3 EEA (no Teams),7.23,CHF +f7ee79a7-7aec-4ca4-9fb9-34d6b930ad87,Microsoft_365_F3_EEA_(no_Teams),Microsoft 365 F3 EEA (no Teams),57.74,DKK +f7ee79a7-7aec-4ca4-9fb9-34d6b930ad87,Microsoft_365_F3_EEA_(no_Teams),Microsoft 365 F3 EEA (no Teams),7.73,EUR +f7ee79a7-7aec-4ca4-9fb9-34d6b930ad87,Microsoft_365_F3_EEA_(no_Teams),Microsoft 365 F3 EEA (no Teams),90.77,NOK +f7ee79a7-7aec-4ca4-9fb9-34d6b930ad87,Microsoft_365_F3_EEA_(no_Teams),Microsoft 365 F3 EEA (no Teams),85.35,SEK +639dec6b-bb19-468b-871c-c5c441c4b0cb,Microsoft_365_Copilot,Microsoft Copilot for Microsoft 365,44.9,AUD +639dec6b-bb19-468b-871c-c5c441c4b0cb,Microsoft_365_Copilot,Microsoft Copilot for Microsoft 365,171.8,BRL +639dec6b-bb19-468b-871c-c5c441c4b0cb,Microsoft_365_Copilot,Microsoft Copilot for Microsoft 365,40.7,CAD +639dec6b-bb19-468b-871c-c5c441c4b0cb,Microsoft_365_Copilot,Microsoft Copilot for Microsoft 365,24.3,CHF +639dec6b-bb19-468b-871c-c5c441c4b0cb,Microsoft_365_Copilot,Microsoft Copilot for Microsoft 365,194,DKK +639dec6b-bb19-468b-871c-c5c441c4b0cb,Microsoft_365_Copilot,Microsoft Copilot for Microsoft 365,26,EUR +639dec6b-bb19-468b-871c-c5c441c4b0cb,Microsoft_365_Copilot,Microsoft Copilot for Microsoft 365,23.1,GBP +639dec6b-bb19-468b-871c-c5c441c4b0cb,Microsoft_365_Copilot,Microsoft Copilot for Microsoft 365,2495,INR +639dec6b-bb19-468b-871c-c5c441c4b0cb,Microsoft_365_Copilot,Microsoft Copilot for Microsoft 365,4497,JPY +639dec6b-bb19-468b-871c-c5c441c4b0cb,Microsoft_365_Copilot,Microsoft Copilot for Microsoft 365,40500,KRW +639dec6b-bb19-468b-871c-c5c441c4b0cb,Microsoft_365_Copilot,Microsoft Copilot for Microsoft 365,305,NOK +639dec6b-bb19-468b-871c-c5c441c4b0cb,Microsoft_365_Copilot,Microsoft Copilot for Microsoft 365,48.5,NZD +639dec6b-bb19-468b-871c-c5c441c4b0cb,Microsoft_365_Copilot,Microsoft Copilot for Microsoft 365,286.8,SEK +639dec6b-bb19-468b-871c-c5c441c4b0cb,Microsoft_365_Copilot,Microsoft Copilot for Microsoft 365,965,TWD +639dec6b-bb19-468b-871c-c5c441c4b0cb,Microsoft_365_Copilot,Microsoft Copilot for Microsoft 365,30,USD +5e1e7702-a2b7-4360-8d07-2f515792896f,MDE_SMB,Microsoft Defender for Business,4.5,AUD +5e1e7702-a2b7-4360-8d07-2f515792896f,MDE_SMB,Microsoft Defender for Business,13.7,BRL +5e1e7702-a2b7-4360-8d07-2f515792896f,MDE_SMB,Microsoft Defender for Business,4.1,CAD +5e1e7702-a2b7-4360-8d07-2f515792896f,MDE_SMB,Microsoft Defender for Business,2.4,CHF +5e1e7702-a2b7-4360-8d07-2f515792896f,MDE_SMB,Microsoft Defender for Business,19.4,DKK +5e1e7702-a2b7-4360-8d07-2f515792896f,MDE_SMB,Microsoft Defender for Business,2.6,EUR +5e1e7702-a2b7-4360-8d07-2f515792896f,MDE_SMB,Microsoft Defender for Business,2.3,GBP +5e1e7702-a2b7-4360-8d07-2f515792896f,MDE_SMB,Microsoft Defender for Business,250,INR +5e1e7702-a2b7-4360-8d07-2f515792896f,MDE_SMB,Microsoft Defender for Business,449,JPY +5e1e7702-a2b7-4360-8d07-2f515792896f,MDE_SMB,Microsoft Defender for Business,4100,KRW +5e1e7702-a2b7-4360-8d07-2f515792896f,MDE_SMB,Microsoft Defender for Business,30.5,NOK +5e1e7702-a2b7-4360-8d07-2f515792896f,MDE_SMB,Microsoft Defender for Business,4.9,NZD +5e1e7702-a2b7-4360-8d07-2f515792896f,MDE_SMB,Microsoft Defender for Business,28.7,SEK +5e1e7702-a2b7-4360-8d07-2f515792896f,MDE_SMB,Microsoft Defender for Business,96.5,TWD +5e1e7702-a2b7-4360-8d07-2f515792896f,MDE_SMB,Microsoft Defender for Business,3,USD +cf6b0d46-4093-4546-a0ab-0b1546dcc10e,Microsoft_Entra_ID_Governance,Microsoft Entra ID Governance,10.5,AUD +cf6b0d46-4093-4546-a0ab-0b1546dcc10e,Microsoft_Entra_ID_Governance,Microsoft Entra ID Governance,40.1,BRL +cf6b0d46-4093-4546-a0ab-0b1546dcc10e,Microsoft_Entra_ID_Governance,Microsoft Entra ID Governance,9.5,CAD +cf6b0d46-4093-4546-a0ab-0b1546dcc10e,Microsoft_Entra_ID_Governance,Microsoft Entra ID Governance,5.7,CHF +cf6b0d46-4093-4546-a0ab-0b1546dcc10e,Microsoft_Entra_ID_Governance,Microsoft Entra ID Governance,45.3,DKK +cf6b0d46-4093-4546-a0ab-0b1546dcc10e,Microsoft_Entra_ID_Governance,Microsoft Entra ID Governance,6.1,EUR +cf6b0d46-4093-4546-a0ab-0b1546dcc10e,Microsoft_Entra_ID_Governance,Microsoft Entra ID Governance,5.4,GBP +cf6b0d46-4093-4546-a0ab-0b1546dcc10e,Microsoft_Entra_ID_Governance,Microsoft Entra ID Governance,580,INR +cf6b0d46-4093-4546-a0ab-0b1546dcc10e,Microsoft_Entra_ID_Governance,Microsoft Entra ID Governance,1049,JPY +cf6b0d46-4093-4546-a0ab-0b1546dcc10e,Microsoft_Entra_ID_Governance,Microsoft Entra ID Governance,9500,KRW +cf6b0d46-4093-4546-a0ab-0b1546dcc10e,Microsoft_Entra_ID_Governance,Microsoft Entra ID Governance,71.2,NOK +cf6b0d46-4093-4546-a0ab-0b1546dcc10e,Microsoft_Entra_ID_Governance,Microsoft Entra ID Governance,11.3,NZD +cf6b0d46-4093-4546-a0ab-0b1546dcc10e,Microsoft_Entra_ID_Governance,Microsoft Entra ID Governance,66.9,SEK +cf6b0d46-4093-4546-a0ab-0b1546dcc10e,Microsoft_Entra_ID_Governance,Microsoft Entra ID Governance,225,TWD +cf6b0d46-4093-4546-a0ab-0b1546dcc10e,Microsoft_Entra_ID_Governance,Microsoft Entra ID Governance,7,USD +078d2b04-f1bd-4111-bbd4-b4b1b354cef4,AAD_PREMIUM,Microsoft Entra ID P1,10.5,AUD +078d2b04-f1bd-4111-bbd4-b4b1b354cef4,AAD_PREMIUM,Microsoft Entra ID P1,40.1,BRL +078d2b04-f1bd-4111-bbd4-b4b1b354cef4,AAD_PREMIUM,Microsoft Entra ID P1,9.5,CAD +078d2b04-f1bd-4111-bbd4-b4b1b354cef4,AAD_PREMIUM,Microsoft Entra ID P1,5.7,CHF +078d2b04-f1bd-4111-bbd4-b4b1b354cef4,AAD_PREMIUM,Microsoft Entra ID P1,45.3,DKK +078d2b04-f1bd-4111-bbd4-b4b1b354cef4,AAD_PREMIUM,Microsoft Entra ID P1,6.1,EUR +078d2b04-f1bd-4111-bbd4-b4b1b354cef4,AAD_PREMIUM,Microsoft Entra ID P1,5.4,GBP +078d2b04-f1bd-4111-bbd4-b4b1b354cef4,AAD_PREMIUM,Microsoft Entra ID P1,580,INR +078d2b04-f1bd-4111-bbd4-b4b1b354cef4,AAD_PREMIUM,Microsoft Entra ID P1,1049,JPY +078d2b04-f1bd-4111-bbd4-b4b1b354cef4,AAD_PREMIUM,Microsoft Entra ID P1,9500,KRW +078d2b04-f1bd-4111-bbd4-b4b1b354cef4,AAD_PREMIUM,Microsoft Entra ID P1,71.2,NOK +078d2b04-f1bd-4111-bbd4-b4b1b354cef4,AAD_PREMIUM,Microsoft Entra ID P1,11.3,NZD +078d2b04-f1bd-4111-bbd4-b4b1b354cef4,AAD_PREMIUM,Microsoft Entra ID P1,66.9,SEK +078d2b04-f1bd-4111-bbd4-b4b1b354cef4,AAD_PREMIUM,Microsoft Entra ID P1,225,TWD +078d2b04-f1bd-4111-bbd4-b4b1b354cef4,AAD_PREMIUM,Microsoft Entra ID P1,7,USD +84a661c4-e949-4bd2-a560-ed7766fcaf2b,AAD_PREMIUM_P2,Microsoft Entra ID P2,15,AUD +84a661c4-e949-4bd2-a560-ed7766fcaf2b,AAD_PREMIUM_P2,Microsoft Entra ID P2,57.3,BRL +84a661c4-e949-4bd2-a560-ed7766fcaf2b,AAD_PREMIUM_P2,Microsoft Entra ID P2,13.6,CAD +84a661c4-e949-4bd2-a560-ed7766fcaf2b,AAD_PREMIUM_P2,Microsoft Entra ID P2,8.1,CHF +84a661c4-e949-4bd2-a560-ed7766fcaf2b,AAD_PREMIUM_P2,Microsoft Entra ID P2,64.7,DKK +84a661c4-e949-4bd2-a560-ed7766fcaf2b,AAD_PREMIUM_P2,Microsoft Entra ID P2,8.7,EUR +84a661c4-e949-4bd2-a560-ed7766fcaf2b,AAD_PREMIUM_P2,Microsoft Entra ID P2,7.7,GBP +84a661c4-e949-4bd2-a560-ed7766fcaf2b,AAD_PREMIUM_P2,Microsoft Entra ID P2,830,INR +84a661c4-e949-4bd2-a560-ed7766fcaf2b,AAD_PREMIUM_P2,Microsoft Entra ID P2,1499,JPY +84a661c4-e949-4bd2-a560-ed7766fcaf2b,AAD_PREMIUM_P2,Microsoft Entra ID P2,13500,KRW +84a661c4-e949-4bd2-a560-ed7766fcaf2b,AAD_PREMIUM_P2,Microsoft Entra ID P2,101.7,NOK +84a661c4-e949-4bd2-a560-ed7766fcaf2b,AAD_PREMIUM_P2,Microsoft Entra ID P2,16.2,NZD +84a661c4-e949-4bd2-a560-ed7766fcaf2b,AAD_PREMIUM_P2,Microsoft Entra ID P2,95.6,SEK +84a661c4-e949-4bd2-a560-ed7766fcaf2b,AAD_PREMIUM_P2,Microsoft Entra ID P2,320,TWD +84a661c4-e949-4bd2-a560-ed7766fcaf2b,AAD_PREMIUM_P2,Microsoft Entra ID P2,10,USD +7e31c0d9-9551-471d-836f-32ee72be4a01,Microsoft_Teams_Enterprise_New,Microsoft Teams Enterprise,12.8,AUD +7e31c0d9-9551-471d-836f-32ee72be4a01,Microsoft_Teams_Enterprise_New,Microsoft Teams Enterprise,49,BRL +7e31c0d9-9551-471d-836f-32ee72be4a01,Microsoft_Teams_Enterprise_New,Microsoft Teams Enterprise,11.6,CAD +7e31c0d9-9551-471d-836f-32ee72be4a01,Microsoft_Teams_Enterprise_New,Microsoft Teams Enterprise,6.6,GBP +7e31c0d9-9551-471d-836f-32ee72be4a01,Microsoft_Teams_Enterprise_New,Microsoft Teams Enterprise,710,INR +7e31c0d9-9551-471d-836f-32ee72be4a01,Microsoft_Teams_Enterprise_New,Microsoft Teams Enterprise,1281,JPY +7e31c0d9-9551-471d-836f-32ee72be4a01,Microsoft_Teams_Enterprise_New,Microsoft Teams Enterprise,11600,KRW +7e31c0d9-9551-471d-836f-32ee72be4a01,Microsoft_Teams_Enterprise_New,Microsoft Teams Enterprise,13.8,NZD +7e31c0d9-9551-471d-836f-32ee72be4a01,Microsoft_Teams_Enterprise_New,Microsoft Teams Enterprise,275,TWD +7e31c0d9-9551-471d-836f-32ee72be4a01,Microsoft_Teams_Enterprise_New,Microsoft Teams Enterprise,8.55,USD +fde42873-30b6-436b-b361-21af5a6b84ae,Teams_Ess,Microsoft Teams Essentials,6,AUD +fde42873-30b6-436b-b361-21af5a6b84ae,Teams_Ess,Microsoft Teams Essentials,22.9,BRL +fde42873-30b6-436b-b361-21af5a6b84ae,Teams_Ess,Microsoft Teams Essentials,5.4,CAD +fde42873-30b6-436b-b361-21af5a6b84ae,Teams_Ess,Microsoft Teams Essentials,3.2,CHF +fde42873-30b6-436b-b361-21af5a6b84ae,Teams_Ess,Microsoft Teams Essentials,25.9,DKK +fde42873-30b6-436b-b361-21af5a6b84ae,Teams_Ess,Microsoft Teams Essentials,3.5,EUR +fde42873-30b6-436b-b361-21af5a6b84ae,Teams_Ess,Microsoft Teams Essentials,3.1,GBP +fde42873-30b6-436b-b361-21af5a6b84ae,Teams_Ess,Microsoft Teams Essentials,115,INR +fde42873-30b6-436b-b361-21af5a6b84ae,Teams_Ess,Microsoft Teams Essentials,599,JPY +fde42873-30b6-436b-b361-21af5a6b84ae,Teams_Ess,Microsoft Teams Essentials,5400,KRW +fde42873-30b6-436b-b361-21af5a6b84ae,Teams_Ess,Microsoft Teams Essentials,40.7,NOK +fde42873-30b6-436b-b361-21af5a6b84ae,Teams_Ess,Microsoft Teams Essentials,6.5,NZD +fde42873-30b6-436b-b361-21af5a6b84ae,Teams_Ess,Microsoft Teams Essentials,38.2,SEK +fde42873-30b6-436b-b361-21af5a6b84ae,Teams_Ess,Microsoft Teams Essentials,130,TWD +fde42873-30b6-436b-b361-21af5a6b84ae,Teams_Ess,Microsoft Teams Essentials,4,USD +e43b5b99-8dfb-405f-9987-dc307f34bcbd,MCOEV,Microsoft Teams Phone Standard,15,AUD +e43b5b99-8dfb-405f-9987-dc307f34bcbd,MCOEV,Microsoft Teams Phone Standard,57.3,BRL +e43b5b99-8dfb-405f-9987-dc307f34bcbd,MCOEV,Microsoft Teams Phone Standard,13.6,CAD +e43b5b99-8dfb-405f-9987-dc307f34bcbd,MCOEV,Microsoft Teams Phone Standard,8.1,CHF +e43b5b99-8dfb-405f-9987-dc307f34bcbd,MCOEV,Microsoft Teams Phone Standard,64.7,DKK +e43b5b99-8dfb-405f-9987-dc307f34bcbd,MCOEV,Microsoft Teams Phone Standard,8.7,EUR +e43b5b99-8dfb-405f-9987-dc307f34bcbd,MCOEV,Microsoft Teams Phone Standard,7.7,GBP +e43b5b99-8dfb-405f-9987-dc307f34bcbd,MCOEV,Microsoft Teams Phone Standard,830,INR +e43b5b99-8dfb-405f-9987-dc307f34bcbd,MCOEV,Microsoft Teams Phone Standard,1499,JPY +e43b5b99-8dfb-405f-9987-dc307f34bcbd,MCOEV,Microsoft Teams Phone Standard,13500,KRW +e43b5b99-8dfb-405f-9987-dc307f34bcbd,MCOEV,Microsoft Teams Phone Standard,101.7,NOK +e43b5b99-8dfb-405f-9987-dc307f34bcbd,MCOEV,Microsoft Teams Phone Standard,16.2,NZD +e43b5b99-8dfb-405f-9987-dc307f34bcbd,MCOEV,Microsoft Teams Phone Standard,95.6,SEK +e43b5b99-8dfb-405f-9987-dc307f34bcbd,MCOEV,Microsoft Teams Phone Standard,320,TWD +e43b5b99-8dfb-405f-9987-dc307f34bcbd,MCOEV,Microsoft Teams Phone Standard,10,USD +4cde982a-ede4-4409-9ae6-b003453c8ea6,Microsoft_Teams_Rooms_Pro,Microsoft Teams Rooms Pro,59.9,AUD +4cde982a-ede4-4409-9ae6-b003453c8ea6,Microsoft_Teams_Rooms_Pro,Microsoft Teams Rooms Pro,229,BRL +4cde982a-ede4-4409-9ae6-b003453c8ea6,Microsoft_Teams_Rooms_Pro,Microsoft Teams Rooms Pro,54.3,CAD +4cde982a-ede4-4409-9ae6-b003453c8ea6,Microsoft_Teams_Rooms_Pro,Microsoft Teams Rooms Pro,32.4,CHF +4cde982a-ede4-4409-9ae6-b003453c8ea6,Microsoft_Teams_Rooms_Pro,Microsoft Teams Rooms Pro,258.7,DKK +4cde982a-ede4-4409-9ae6-b003453c8ea6,Microsoft_Teams_Rooms_Pro,Microsoft Teams Rooms Pro,34.7,EUR +4cde982a-ede4-4409-9ae6-b003453c8ea6,Microsoft_Teams_Rooms_Pro,Microsoft Teams Rooms Pro,30.8,GBP +4cde982a-ede4-4409-9ae6-b003453c8ea6,Microsoft_Teams_Rooms_Pro,Microsoft Teams Rooms Pro,5997,JPY +4cde982a-ede4-4409-9ae6-b003453c8ea6,Microsoft_Teams_Rooms_Pro,Microsoft Teams Rooms Pro,54100,KRW +4cde982a-ede4-4409-9ae6-b003453c8ea6,Microsoft_Teams_Rooms_Pro,Microsoft Teams Rooms Pro,406.6,NOK +4cde982a-ede4-4409-9ae6-b003453c8ea6,Microsoft_Teams_Rooms_Pro,Microsoft Teams Rooms Pro,64.7,NZD +4cde982a-ede4-4409-9ae6-b003453c8ea6,Microsoft_Teams_Rooms_Pro,Microsoft Teams Rooms Pro,382.3,SEK +4cde982a-ede4-4409-9ae6-b003453c8ea6,Microsoft_Teams_Rooms_Pro,Microsoft Teams Rooms Pro,1285,TWD +4cde982a-ede4-4409-9ae6-b003453c8ea6,Microsoft_Teams_Rooms_Pro,Microsoft Teams Rooms Pro,40,USD +18181a46-0d4e-45cd-891e-60aabd171b4e,STANDARDPACK,Office 365 E1,15,AUD +18181a46-0d4e-45cd-891e-60aabd171b4e,STANDARDPACK,Office 365 E1,57.3,BRL +18181a46-0d4e-45cd-891e-60aabd171b4e,STANDARDPACK,Office 365 E1,13.6,CAD +18181a46-0d4e-45cd-891e-60aabd171b4e,STANDARDPACK,Office 365 E1,8.1,CHF +18181a46-0d4e-45cd-891e-60aabd171b4e,STANDARDPACK,Office 365 E1,64.67,DKK +18181a46-0d4e-45cd-891e-60aabd171b4e,STANDARDPACK,Office 365 E1,8.66,EUR +18181a46-0d4e-45cd-891e-60aabd171b4e,STANDARDPACK,Office 365 E1,7.7,GBP +18181a46-0d4e-45cd-891e-60aabd171b4e,STANDARDPACK,Office 365 E1,830,INR +18181a46-0d4e-45cd-891e-60aabd171b4e,STANDARDPACK,Office 365 E1,1499,JPY +18181a46-0d4e-45cd-891e-60aabd171b4e,STANDARDPACK,Office 365 E1,13500,KRW +18181a46-0d4e-45cd-891e-60aabd171b4e,STANDARDPACK,Office 365 E1,101.65,NOK +18181a46-0d4e-45cd-891e-60aabd171b4e,STANDARDPACK,Office 365 E1,16.2,NZD +18181a46-0d4e-45cd-891e-60aabd171b4e,STANDARDPACK,Office 365 E1,95.59,SEK +18181a46-0d4e-45cd-891e-60aabd171b4e,STANDARDPACK,Office 365 E1,320,TWD +18181a46-0d4e-45cd-891e-60aabd171b4e,STANDARDPACK,Office 365 E1,10,USD +f8ced641-8e17-4dc5-b014-f5a2d53f6ac8,Office_365_E1_(no_Teams),Office 365 E1 (no Teams),10.2,AUD +f8ced641-8e17-4dc5-b014-f5a2d53f6ac8,Office_365_E1_(no_Teams),Office 365 E1 (no Teams),38.9,BRL +f8ced641-8e17-4dc5-b014-f5a2d53f6ac8,Office_365_E1_(no_Teams),Office 365 E1 (no Teams),9.2,CAD +f8ced641-8e17-4dc5-b014-f5a2d53f6ac8,Office_365_E1_(no_Teams),Office 365 E1 (no Teams),5.2,GBP +f8ced641-8e17-4dc5-b014-f5a2d53f6ac8,Office_365_E1_(no_Teams),Office 365 E1 (no Teams),565,INR +f8ced641-8e17-4dc5-b014-f5a2d53f6ac8,Office_365_E1_(no_Teams),Office 365 E1 (no Teams),1017,JPY +f8ced641-8e17-4dc5-b014-f5a2d53f6ac8,Office_365_E1_(no_Teams),Office 365 E1 (no Teams),9200,KRW +f8ced641-8e17-4dc5-b014-f5a2d53f6ac8,Office_365_E1_(no_Teams),Office 365 E1 (no Teams),11,NZD +f8ced641-8e17-4dc5-b014-f5a2d53f6ac8,Office_365_E1_(no_Teams),Office 365 E1 (no Teams),220,TWD +f8ced641-8e17-4dc5-b014-f5a2d53f6ac8,Office_365_E1_(no_Teams),Office 365 E1 (no Teams),6.79,USD +6fd2c87f-b296-42f0-b197-1e91e994b900,ENTERPRISEPACK,Office 365 E3,38.9,AUD +6fd2c87f-b296-42f0-b197-1e91e994b900,ENTERPRISEPACK,Office 365 E3,148.9,BRL +6fd2c87f-b296-42f0-b197-1e91e994b900,ENTERPRISEPACK,Office 365 E3,35.3,CAD +6fd2c87f-b296-42f0-b197-1e91e994b900,ENTERPRISEPACK,Office 365 E3,24.56,CHF +6fd2c87f-b296-42f0-b197-1e91e994b900,ENTERPRISEPACK,Office 365 E3,195.65,DKK +6fd2c87f-b296-42f0-b197-1e91e994b900,ENTERPRISEPACK,Office 365 E3,26.27,EUR +6fd2c87f-b296-42f0-b197-1e91e994b900,ENTERPRISEPACK,Office 365 E3,23.3,GBP +6fd2c87f-b296-42f0-b197-1e91e994b900,ENTERPRISEPACK,Office 365 E3,2165,INR +6fd2c87f-b296-42f0-b197-1e91e994b900,ENTERPRISEPACK,Office 365 E3,3898,JPY +6fd2c87f-b296-42f0-b197-1e91e994b900,ENTERPRISEPACK,Office 365 E3,35100,KRW +6fd2c87f-b296-42f0-b197-1e91e994b900,ENTERPRISEPACK,Office 365 E3,305.29,NOK +6fd2c87f-b296-42f0-b197-1e91e994b900,ENTERPRISEPACK,Office 365 E3,44.4,NZD +6fd2c87f-b296-42f0-b197-1e91e994b900,ENTERPRISEPACK,Office 365 E3,289.81,SEK +6fd2c87f-b296-42f0-b197-1e91e994b900,ENTERPRISEPACK,Office 365 E3,905,TWD +6fd2c87f-b296-42f0-b197-1e91e994b900,ENTERPRISEPACK,Office 365 E3,26,USD +46c3a859-c90d-40b3-9551-6178a48d5c18,Office_365_E3_(no_Teams),Office 365 E3 (no Teams),26.1,AUD +46c3a859-c90d-40b3-9551-6178a48d5c18,Office_365_E3_(no_Teams),Office 365 E3 (no Teams),99.9,BRL +46c3a859-c90d-40b3-9551-6178a48d5c18,Office_365_E3_(no_Teams),Office 365 E3 (no Teams),23.7,CAD +46c3a859-c90d-40b3-9551-6178a48d5c18,Office_365_E3_(no_Teams),Office 365 E3 (no Teams),16.7,GBP +46c3a859-c90d-40b3-9551-6178a48d5c18,Office_365_E3_(no_Teams),Office 365 E3 (no Teams),1450,INR +46c3a859-c90d-40b3-9551-6178a48d5c18,Office_365_E3_(no_Teams),Office 365 E3 (no Teams),2616,JPY +46c3a859-c90d-40b3-9551-6178a48d5c18,Office_365_E3_(no_Teams),Office 365 E3 (no Teams),23600,KRW +46c3a859-c90d-40b3-9551-6178a48d5c18,Office_365_E3_(no_Teams),Office 365 E3 (no Teams),30.6,NZD +46c3a859-c90d-40b3-9551-6178a48d5c18,Office_365_E3_(no_Teams),Office 365 E3 (no Teams),635,TWD +46c3a859-c90d-40b3-9551-6178a48d5c18,Office_365_E3_(no_Teams),Office 365 E3 (no Teams),17.45,USD +c7df2760-2c81-4ef7-b578-5b5392b571df,ENTERPRISEPREMIUM,Office 365 E5,61.4,AUD +c7df2760-2c81-4ef7-b578-5b5392b571df,ENTERPRISEPREMIUM,Office 365 E5,234.8,BRL +c7df2760-2c81-4ef7-b578-5b5392b571df,ENTERPRISEPREMIUM,Office 365 E5,55.6,CAD +c7df2760-2c81-4ef7-b578-5b5392b571df,ENTERPRISEPREMIUM,Office 365 E5,38.72,CHF +c7df2760-2c81-4ef7-b578-5b5392b571df,ENTERPRISEPREMIUM,Office 365 E5,308.53,DKK +c7df2760-2c81-4ef7-b578-5b5392b571df,ENTERPRISEPREMIUM,Office 365 E5,41.42,EUR +c7df2760-2c81-4ef7-b578-5b5392b571df,ENTERPRISEPREMIUM,Office 365 E5,36.8,GBP +c7df2760-2c81-4ef7-b578-5b5392b571df,ENTERPRISEPREMIUM,Office 365 E5,3410,INR +c7df2760-2c81-4ef7-b578-5b5392b571df,ENTERPRISEPREMIUM,Office 365 E5,6146,JPY +c7df2760-2c81-4ef7-b578-5b5392b571df,ENTERPRISEPREMIUM,Office 365 E5,55400,KRW +c7df2760-2c81-4ef7-b578-5b5392b571df,ENTERPRISEPREMIUM,Office 365 E5,481.41,NOK +c7df2760-2c81-4ef7-b578-5b5392b571df,ENTERPRISEPREMIUM,Office 365 E5,70.1,NZD +c7df2760-2c81-4ef7-b578-5b5392b571df,ENTERPRISEPREMIUM,Office 365 E5,457,SEK +c7df2760-2c81-4ef7-b578-5b5392b571df,ENTERPRISEPREMIUM,Office 365 E5,1425,TWD +c7df2760-2c81-4ef7-b578-5b5392b571df,ENTERPRISEPREMIUM,Office 365 E5,41,USD +4b585984-651b-448a-9e53-3b10f069cf7f,DESKLESSPACK,Office 365 F3,4.00,USD +c1d032e0-5619-4761-9b5c-75b6831e1711,PBI_PREMIUM_PER_USER,Power BI Premium Per User,35.9,AUD +c1d032e0-5619-4761-9b5c-75b6831e1711,PBI_PREMIUM_PER_USER,Power BI Premium Per User,137.4,BRL +c1d032e0-5619-4761-9b5c-75b6831e1711,PBI_PREMIUM_PER_USER,Power BI Premium Per User,32.6,CAD +c1d032e0-5619-4761-9b5c-75b6831e1711,PBI_PREMIUM_PER_USER,Power BI Premium Per User,19.4,CHF +c1d032e0-5619-4761-9b5c-75b6831e1711,PBI_PREMIUM_PER_USER,Power BI Premium Per User,155.2,DKK +c1d032e0-5619-4761-9b5c-75b6831e1711,PBI_PREMIUM_PER_USER,Power BI Premium Per User,20.8,EUR +c1d032e0-5619-4761-9b5c-75b6831e1711,PBI_PREMIUM_PER_USER,Power BI Premium Per User,18.5,GBP +c1d032e0-5619-4761-9b5c-75b6831e1711,PBI_PREMIUM_PER_USER,Power BI Premium Per User,1995,INR +c1d032e0-5619-4761-9b5c-75b6831e1711,PBI_PREMIUM_PER_USER,Power BI Premium Per User,3598,JPY +c1d032e0-5619-4761-9b5c-75b6831e1711,PBI_PREMIUM_PER_USER,Power BI Premium Per User,32400,KRW +c1d032e0-5619-4761-9b5c-75b6831e1711,PBI_PREMIUM_PER_USER,Power BI Premium Per User,244,NOK +c1d032e0-5619-4761-9b5c-75b6831e1711,PBI_PREMIUM_PER_USER,Power BI Premium Per User,38.8,NZD +c1d032e0-5619-4761-9b5c-75b6831e1711,PBI_PREMIUM_PER_USER,Power BI Premium Per User,229.4,SEK +c1d032e0-5619-4761-9b5c-75b6831e1711,PBI_PREMIUM_PER_USER,Power BI Premium Per User,770,TWD +c1d032e0-5619-4761-9b5c-75b6831e1711,PBI_PREMIUM_PER_USER,Power BI Premium Per User,24,USD +f8a1db68-be16-40ed-86d5-cb42ce701560,POWER_BI_PRO,Power BI Pro,21,AUD +f8a1db68-be16-40ed-86d5-cb42ce701560,POWER_BI_PRO,Power BI Pro,80.2,BRL +f8a1db68-be16-40ed-86d5-cb42ce701560,POWER_BI_PRO,Power BI Pro,19,CAD +f8a1db68-be16-40ed-86d5-cb42ce701560,POWER_BI_PRO,Power BI Pro,11.3,CHF +f8a1db68-be16-40ed-86d5-cb42ce701560,POWER_BI_PRO,Power BI Pro,90.5,DKK +f8a1db68-be16-40ed-86d5-cb42ce701560,POWER_BI_PRO,Power BI Pro,12.1,EUR +f8a1db68-be16-40ed-86d5-cb42ce701560,POWER_BI_PRO,Power BI Pro,10.8,GBP +f8a1db68-be16-40ed-86d5-cb42ce701560,POWER_BI_PRO,Power BI Pro,1165,INR +f8a1db68-be16-40ed-86d5-cb42ce701560,POWER_BI_PRO,Power BI Pro,2098,JPY +f8a1db68-be16-40ed-86d5-cb42ce701560,POWER_BI_PRO,Power BI Pro,18900,KRW +f8a1db68-be16-40ed-86d5-cb42ce701560,POWER_BI_PRO,Power BI Pro,142.3,NOK +f8a1db68-be16-40ed-86d5-cb42ce701560,POWER_BI_PRO,Power BI Pro,22.6,NZD +f8a1db68-be16-40ed-86d5-cb42ce701560,POWER_BI_PRO,Power BI Pro,133.8,SEK +f8a1db68-be16-40ed-86d5-cb42ce701560,POWER_BI_PRO,Power BI Pro,450,TWD +f8a1db68-be16-40ed-86d5-cb42ce701560,POWER_BI_PRO,Power BI Pro,14,USD diff --git a/Config/PermissionsTranslator.json b/Config/PermissionsTranslator.json index 30c15bbb5bdad..5e6ce04fe8aaf 100644 --- a/Config/PermissionsTranslator.json +++ b/Config/PermissionsTranslator.json @@ -1,5425 +1,18594 @@ [ { - "description": "Allows the app to read email metadata and security detection details for all emails in your organization, without a signed-in user.", - "displayName": "Read metadata and detection details for all emails in your organization", - "id": "b48f7ac2-044d-4281-b02f-75db744d6f5f", - "origin": "Application", - "value": "SecurityAnalyzedMessage.Read.All" + "description": "This allows users to manage ADO Pools. ", + "displayName": "Manage AzDevops Pools", + "id": "99c58aeb-f6a2-4999-b722-b052dc773c26", + "origin": "Delegated (1ES Resource Management PPE)", + "value": "manage_ado_pools" }, { - "description": "Allows the app to read email metadata and security detection details, and execute remediation actions like deleting an email, for all emails in your organization, without a signed-in user.", - "displayName": "Read metadata, detection details, and execute remediation actions on all emails in your organization", - "id": "04c55753-2244-4c25-87fc-704ab82a4f69", - "origin": "Application", - "value": "SecurityAnalyzedMessage.ReadWrite.All" + "description": "Allows the app to read and write your organization's SPIFFE trust domains and child resources on behalf of the user.", + "displayName": "Read and write SPIFFE trust domains and child resources", + "id": "8ba47079-8c47-4bfe-b2ce-13f28ef37247", + "origin": "Delegated (Microsoft Graph)", + "value": "SpiffeTrustDomain.ReadWrite.All" }, { - "description": "Allows the app to impersonate the signed-in user to access the Partner Center API.", - "displayName": "Partner Center as User", - "id": "1cebfa2a-fb4d-419e-b5f9-839b4383e05a", - "origin": "Delegated (Microsoft Partner Center)", - "value": "user_impersonation" + "description": "Allows the app to modify the Viva Engage storyline and read all storyline properties on behalf of the signed-in user.", + "displayName": "Read and write all Viva Engage storylines", + "id": "fd1d61cb-4e4b-4d15-a6d2-161348681d84", + "origin": "Delegated (Microsoft Graph)", + "value": "Storyline.ReadWrite.All" }, { - "description": "Allows Exchange Management as app", - "displayName": "Manage Exchange As Application ", - "id": "dc50a0fb-09a3-484d-be87-e023b12c6440", - "origin": "Application (Office 365 Exchange Online)", - "value": "Exchange.ManageAsApp" + "description": "Allows the app to read and write all the subject name registration properties on behalf of the signed-in user.", + "displayName": "Read and write a subject name registration.", + "id": "fb0d7592-1943-4141-a7bc-b7ae45b84ecf", + "origin": "Delegated (Microsoft Graph)", + "value": "SubjectNameRegistration.ReadWrite" }, { - "description": "Allows the app to read a basic set of profile properties of other users in your organization without a signed-in user. Includes display name, first and last name, email address, open extensions, and photo.", - "displayName": "Read all users' basic profiles", - "id": "97235f07-e226-4f63-ace3-39588e11d3a1", - "origin": "Application", - "value": "User.ReadBasic.All" + "description": "Allows the app to read subject rights requests on behalf of the signed-in user", + "displayName": "Read subject rights requests", + "id": "9c3af74c-fd0f-4db4-b17a-71939e2a9d77", + "origin": "Delegated (Microsoft Graph)", + "value": "SubjectRightsRequest.Read.All" }, { - "description": "Allows the app to read all\u00a0class assignments without grades for all users without a signed-in user.", - "displayName": "Read all class assignments without grades", - "id": "6e0a958b-b7fc-4348-b7c4-a6ab9fd3dd0e", - "origin": "Application", - "value": "EduAssignments.ReadBasic.All" + "description": "Allows the app to read and write subject rights requests on behalf of the signed-in user", + "displayName": "Read and write subject rights requests", + "id": "2b8fcc74-bce1-4ae3-a0e8-60c53739299d", + "origin": "Delegated (Microsoft Graph)", + "value": "SubjectRightsRequest.ReadWrite.All" }, { - "description": "Allows the app to create, read, update and delete all\u00a0class assignments without grades for all users without a signed-in user.", - "displayName": "Create, read, update and delete all\u00a0class assignments without grades", - "id": "f431cc63-a2de-48c4-8054-a34bc093af84", - "origin": "Application", - "value": "EduAssignments.ReadWriteBasic.All" + "description": "Allows the app to read all webhook subscriptions on behalf of the signed-in user.", + "displayName": "Read all webhook subscriptions ", + "id": "5f88184c-80bb-4d52-9ff2-757288b2e9b7", + "origin": "Delegated (Microsoft Graph)", + "value": "Subscription.Read.All" }, { - "description": "Allows the app to read all\u00a0class assignments with grades for all users without a signed-in user.", - "displayName": "Read all class assignments with grades", - "id": "4c37e1b6-35a1-43bf-926a-6f30f2cdf585", - "origin": "Application", - "value": "EduAssignments.Read.All" + "description": "Allows the app to read Azure AD synchronization information, on behalf of the signed-in user.", + "displayName": "Read all Azure AD synchronization data", + "id": "7aa02aeb-824f-4fbe-a3f7-611f751f5b55", + "origin": "Delegated (Microsoft Graph)", + "value": "Synchronization.Read.All" }, { - "description": "Allows the app to create, read, update and delete all\u00a0class assignments with grades for all users without a signed-in user.", - "displayName": "Create, read, update and delete all\u00a0class assignments with grades", - "id": "0d22204b-6cad-4dd0-8362-3e3f2ae699d9", - "origin": "Application", - "value": "EduAssignments.ReadWrite.All" + "description": "Allows the app to configure the Azure AD synchronization service, on behalf of the signed-in user.", + "displayName": "Read and write all Azure AD synchronization data", + "id": "7bb27fa3-ea8f-4d67-a916-87715b6188bd", + "origin": "Delegated (Microsoft Graph)", + "value": "Synchronization.ReadWrite.All" }, { - "description": "Allows\u00a0the\u00a0app\u00a0to\u00a0read\u00a0subject\u00a0rights requests\u00a0without a\u00a0signed-in\u00a0user.", - "displayName": "Read\u00a0all subject\u00a0rights requests", - "id": "ee1460f0-368b-4153-870a-4e1ca7e72c42", - "origin": "Application", - "value": "SubjectRightsRequest.Read.All" + "description": "Allows the app to upload bulk user data to the identity synchronization service, on behalf of the signed-in user.", + "displayName": "Upload user data to the identity synchronization service", + "id": "1a2e7420-4e92-4d2b-94cb-fb2952e9ddf7", + "origin": "Delegated (Microsoft Graph)", + "value": "SynchronizationData-User.Upload" }, { - "description": "Allows\u00a0the\u00a0app\u00a0to\u00a0read\u00a0and\u00a0write subject\u00a0rights requests\u00a0without a signed in user.", - "displayName": "Read\u00a0and\u00a0write\u00a0all subject\u00a0rights requests", - "id": "8387eaa4-1a3c-41f5-b261-f888138e6041", - "origin": "Application", - "value": "SubjectRightsRequest.ReadWrite.All" + "description": "Allows the app to read the signed-in user’s tasks and task lists, including any shared with the user. Doesn't include permission to create, delete, or update anything.", + "displayName": "Read user's tasks and task lists", + "id": "f45671fb-e0fe-4b4b-be20-3d3ce43f1bcb", + "origin": "Delegated (Microsoft Graph)", + "value": "Tasks.Read" }, { - "description": "Allows the app to read attack simulation and training data for an organization without a signed-in user.", - "displayName": "Read attack simulation data of an organization", - "id": "93283d0a-6322-4fa8-966b-8c121624760d", - "origin": "Application", - "value": "AttackSimulation.Read.All" + "description": "Allows the app to read tasks a user has permissions to access, including their own and shared tasks.", + "displayName": "Read user and shared tasks", + "id": "88d21fd4-8e5a-4c32-b5e2-4a1c95f34f72", + "origin": "Delegated (Microsoft Graph)", + "value": "Tasks.Read.Shared" }, { - "description": "Allows custom authentication extensions associated with the app to receive HTTP requests triggered by an authentication event. The request can include information about a user, client and resource service principals, and other information about the authentication.", - "displayName": "Receive custom authentication extension HTTP requests", - "id": "214e810f-fda8-4fd7-a475-29461495eb00", - "origin": "Application", - "value": "CustomAuthenticationExtension.Receive.Payload" + "description": "Allows the app to create, read, update, and delete the signed-in user's tasks and task lists, including any shared with the user.", + "displayName": "Create, read, update, and delete user’s tasks and task lists", + "id": "2219042f-cab5-40cc-b0d2-16b1540b4c5f", + "origin": "Delegated (Microsoft Graph)", + "value": "Tasks.ReadWrite" }, { - "description": "Allows the app to read and write your organization's directory access review default policy without a signed-in user.", - "displayName": "Read and write your organization's directory access review default policy", - "id": "77c863fd-06c0-47ce-a7eb-49773e89d319", - "origin": "Application", - "value": "Policy.ReadWrite.AccessReview" + "description": "Allows the app to create, read, update, and delete tasks a user has permissions to, including their own and shared tasks.", + "displayName": "Read and write user and shared tasks", + "id": "c5ddf11b-c114-4886-8558-8a4e557cd52b", + "origin": "Delegated (Microsoft Graph)", + "value": "Tasks.ReadWrite.Shared" }, { - "description": "Allows the app to create groups, read all group properties and memberships, update group properties and memberships, and delete groups. Also allows the app to read and write conversations. All of these operations can be performed by the app without a signed-in user.", - "displayName": "Read and write all groups", - "id": "62a82d76-70ea-41e2-9197-370581804d09", - "origin": "Application", - "value": "Group.ReadWrite.All" + "description": "Allows the app to create teams on behalf of the signed-in user.", + "displayName": "Create teams", + "id": "7825d5d6-6049-4ce7-bdf6-3b8d53f4bcd0", + "origin": "Delegated (Microsoft Graph)", + "value": "Team.Create" }, { - "description": "Allows the app to read group properties and memberships, and read\u00a0conversations for all groups, without a signed-in user.", - "displayName": "Read all groups", - "id": "5b567255-7703-4780-807c-7be8301ae99b", - "origin": "Application", - "value": "Group.Read.All" + "description": "Read the names and descriptions of teams, on behalf of the signed-in user.", + "displayName": "Read the names and descriptions of teams", + "id": "485be79e-c497-4b35-9400-0e3fa7f2a5d4", + "origin": "Delegated (Microsoft Graph)", + "value": "Team.ReadBasic.All" }, { - "description": "Allows the app to read your organization's threat submissions and threat submission policies without a signed-in user. Also allows the app to create new threat submissions without a signed-in user.", - "displayName": "Read and write all of the organization's threat submissions", - "id": "d72bdbf4-a59b-405c-8b04-5995895819ac", - "origin": "Application", - "value": "ThreatSubmission.ReadWrite.All" + "description": "Read the members of teams, on behalf of the signed-in user.", + "displayName": "Read the members of teams", + "id": "2497278c-d82d-46a2-b1ce-39d4cdde5570", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamMember.Read.All" }, { - "description": "Allows an app to read Bookings appointments, businesses, customers, services, and staff without a signed-in user. ", - "displayName": "Read all Bookings related resources.", - "id": "6e98f277-b046-4193-a4f2-6bf6a78cd491", - "origin": "Application", - "value": "Bookings.Read.All" + "description": "Add and remove members from teams, on behalf of the signed-in user. Also allows changing a member's role, for example from owner to non-owner.", + "displayName": "Add and remove members from teams", + "id": "4a06efd2-f825-4e34-813e-82a57b03d1ee", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamMember.ReadWrite.All" }, { - "description": "Allows an app to read and write Bookings appointments and customers, and additionally allows reading businesses, services, and staff without a signed-in user. ", - "displayName": "Read and write all Bookings related resources.", - "id": "9769393e-5a9f-4302-9e3d-7e018ecb64a7", - "origin": "Application", - "value": "BookingsAppointment.ReadWrite.All" + "description": "Allows the app to read your organization's SPIFFE trust domains and child resources on behalf of the user.", + "displayName": "Read SPIFFE trust domains and child resources", + "id": "9b4aa4b1-aaf3-41b7-b743-698b27e77ff6", + "origin": "Delegated (Microsoft Graph)", + "value": "SpiffeTrustDomain.Read.All" }, { - "description": "Allows the application to read any data from Records Management, such as configuration, labels, and policies without the signed in user.", - "displayName": "Read Records Management configuration,\u00a0labels and policies", - "id": "ac3a2b8e-03a3-4da9-9ce0-cbe28bf1accd", - "origin": "Application", - "value": "RecordsManagement.Read.All" + "description": "Add and remove members from all teams, on behalf of the signed-in user. Does not allow adding or removing a member with the owner role. Additionally, does not allow the app to elevate an existing member to the owner role.", + "displayName": "Add and remove members with non-owner role for all teams", + "id": "2104a4db-3a2f-4ea0-9dba-143d457dc666", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamMember.ReadWriteNonOwnerRole.All" }, { - "description": "Allow the application to create, update and delete any data from Records Management, such as configuration, labels, and policies without the signed in user.", - "displayName": "Read and write Records Management configuration, labels and policies", - "id": "eb158f57-df43-4751-8b21-b8932adb3d34", - "origin": "Application", - "value": "RecordsManagement.ReadWrite.All" + "description": "Allows the app to be able to send emails from the user’s mailbox using the SMTP AUTH client submission protocol.", + "displayName": "Send emails from mailboxes using SMTP AUTH.", + "id": "258f6531-6087-4cc4-bb90-092c5fb3ed3f", + "origin": "Delegated (Microsoft Graph)", + "value": "SMTP.Send" }, { - "description": "Allows the app to read details of delegated admin relationships with customers like access details (that includes roles) and the duration as well as specific role assignments to security groups without a signed-in user.", - "displayName": "Read Delegated Admin relationships with customers", - "id": "f6e9e124-4586-492f-adc0-c6f96e4823fd", - "origin": "Application", - "value": "DelegatedAdminRelationship.Read.All" + "description": "Allows the application to edit or delete documents and list items in all site collections on behalf of the signed-in user.", + "displayName": "Edit or delete items in all site collections", + "id": "89fe6a52-be36-487e-b7d8-d061c450a026", + "origin": "Delegated (Microsoft Graph)", + "value": "Sites.ReadWrite.All" }, { - "description": "Allows the app to manage (create-update-terminate) Delegated Admin relationships with customers and role assignments to security groups for active Delegated Admin relationships without a signed-in user.", - "displayName": "Manage Delegated Admin relationships with customers", - "id": "cc13eba4-8cd8-44c6-b4d4-f93237adce58", - "origin": "Application", - "value": "DelegatedAdminRelationship.ReadWrite.All" + "description": "Allows the app to read your tenant's service health information on behalf of the signed-in user. Health information may include service issues or service health overviews.", + "displayName": "Read service health", + "id": "55896846-df78-47a7-aa94-8d3d4442ca7f", + "origin": "Delegated (Microsoft Graph)", + "value": "ServiceHealth.Read.All" }, { - "description": "Allows the app to read and manage the Cloud PC role-based access control (RBAC) settings, without a signed-in user. This includes reading and managing Cloud PC role definitions and memberships.", - "displayName": "Read and write all Cloud PC RBAC settings", - "id": "274d0592-d1b6-44bd-af1d-26d259bcb43a", - "origin": "Application", - "value": "RoleManagement.ReadWrite.CloudPC" + "description": "Allows the app to read your tenant's service announcement messages on behalf of the signed-in user. Messages may include information about new or changed features.", + "displayName": "Read service announcement messages", + "id": "eda39fa6-f8cf-4c3c-a909-432c683e4c9b", + "origin": "Delegated (Microsoft Graph)", + "value": "ServiceMessage.Read.All" }, { - "description": "Allows the app to read the Cloud PC role-based access control (RBAC) settings, without a signed-in user.", - "displayName": "Read Cloud PC RBAC settings", - "id": "031a549a-bb80-49b6-8032-2068448c6a3c", - "origin": "Application", - "value": "RoleManagement.Read.CloudPC" + "description": "Allows the app to update service announcement messages' user status on behalf of the signed-in user. The message status can be marked as read, archive, or favorite.", + "displayName": "Update user status on service announcement messages", + "id": "636e1b0b-1cc2-4b1c-9aa9-4eeed9b9761b", + "origin": "Delegated (Microsoft Graph)", + "value": "ServiceMessageViewpoint.Write" }, { - "description": "Allows the app to read custom security attribute assignments for all principals in the tenant without a signed in user.", - "displayName": "Read custom security attribute assignments", - "id": "3b37c5a4-1226-493d-bec3-5d6c6b866f3f", - "origin": "Application", - "value": "CustomSecAttributeAssignment.Read.All" + "description": "Allows the app to read service principal endpoints", + "displayName": "Read service principal endpoints", + "id": "9f9ce928-e038-4e3b-8faf-7b59049a8ddc", + "origin": "Delegated (Microsoft Graph)", + "value": "ServicePrincipalEndpoint.Read.All" }, { - "description": "Allows the app to read custom security attribute definitions for the tenant without a signed in user.", - "displayName": "Read custom security attribute definitions", - "id": "b185aa14-d8d2-42c1-a685-0f5596613624", - "origin": "Application", - "value": "CustomSecAttributeDefinition.Read.All" + "description": "Allows the app to update service principal endpoints", + "displayName": "Read and update service principal endpoints", + "id": "7297d82c-9546-4aed-91df-3d4f0a9b3ff0", + "origin": "Delegated (Microsoft Graph)", + "value": "ServicePrincipalEndpoint.ReadWrite.All" }, { - "description": "Allows the app to read all external connections without a signed-in user.", - "displayName": "Read all external connections", - "id": "1914711b-a1cb-4793-b019-c2ce0ed21b8c", - "origin": "Application", - "value": "ExternalConnection.Read.All" + "description": "Allows the app to read, write and manage your tenant's SharePoint Cross-Tenant migration settings and tasks, on behalf of the signed-in user.", + "displayName": "Read, write and manage SharePoint Cross-Tenant migration settings and tasks", + "id": "c608c170-08b5-466b-a8fe-0b4074b01613", + "origin": "Delegated (Microsoft Graph)", + "value": "SharePointCrossTenantMigration.Manage.All" }, { - "description": "Allows the app to read and write all external connections without a signed-in user.", - "displayName": "Read and write all external connections", - "id": "34c37bc0-2b40-4d5e-85e1-2365cd256d79", - "origin": "Application", - "value": "ExternalConnection.ReadWrite.All" + "description": "Allows the app to read your tenant's SharePoint Cross-Tenant migration settings and tasks, on behalf of the signed-in user.", + "displayName": "Read SharePoint Cross-Tenant migration settings and tasks", + "id": "00dcb678-f9af-4e73-acb1-4f1657364629", + "origin": "Delegated (Microsoft Graph)", + "value": "SharePointCrossTenantMigration.Read.All" }, { - "description": "Allows the app to read all external items without a signed-in user.", - "displayName": "Read all external items", - "id": "7a7cffad-37d2-4f48-afa4-c6ab129adcc2", - "origin": "Application", - "value": "ExternalItem.Read.All" + "description": "Allows the application to read the tenant-level settings in SharePoint and OneDrive on behalf of the signed-in user.", + "displayName": "Read SharePoint and OneDrive tenant settings", + "id": "2ef70e10-5bfd-4ede-a5f6-67720500b258", + "origin": "Delegated (Microsoft Graph)", + "value": "SharePointTenantSettings.Read.All" }, { - "description": "Allows the app to read and write your organization's cross tenant access policies without a signed-in user.", - "displayName": "Read and write your organization's cross tenant access policies", - "id": "338163d7-f101-4c92-94ba-ca46fe52447c", - "origin": "Application", - "value": "Policy.ReadWrite.CrossTenantAccess" + "description": "Allows the application to read and change the tenant-level settings of SharePoint and OneDrive on behalf of the signed-in user.", + "displayName": "Read and change SharePoint and OneDrive tenant settings", + "id": "aa07f155-3612-49b8-a147-6c590df35536", + "origin": "Delegated (Microsoft Graph)", + "value": "SharePointTenantSettings.ReadWrite.All" }, { - "description": "Allows the app to read and write custom security attribute definitions for the tenant without a signed in user.", - "displayName": "Read and write custom security attribute definitions", - "id": "12338004-21f4-4896-bf5e-b75dfaf1016d", - "origin": "Application", - "value": "CustomSecAttributeDefinition.ReadWrite.All" + "description": "Allows the app to read all the short notes a sign-in user has access to.", + "displayName": "Read short notes of the signed-in user", + "id": "50f66e47-eb56-45b7-aaa2-75057d9afe08", + "origin": "Delegated (Microsoft Graph)", + "value": "ShortNotes.Read" }, { - "description": "Allows the app to read and write custom security attribute assignments for all principals in the tenant without a signed in user.", - "displayName": "Read and write custom security attribute assignments", - "id": "de89b5e4-5b8f-48eb-8925-29c2b33bd8bd", - "origin": "Application", - "value": "CustomSecAttributeAssignment.ReadWrite.All" + "description": "Allows the app to read, create, edit, and delete short notes of a signed-in user.", + "displayName": "Read, create, edit, and delete short notes of the signed-in user", + "id": "328438b7-4c01-4c07-a840-e625a749bb89", + "origin": "Delegated (Microsoft Graph)", + "value": "ShortNotes.ReadWrite" }, { - "description": "Allows the app to read and write to all security incidents, without a signed-in user.", - "displayName": "Read and write to all security incidents", - "id": "34bf0e97-1971-4929-b999-9e2442d941d7", - "origin": "Application", - "value": "SecurityIncident.ReadWrite.All" + "description": "Allows the app to read your organization's sign-in identifiers, on behalf of the signed-in user.", + "displayName": "Read SignInIdentifiers", + "id": "458e1edc-1e75-438c-8c7b-c32115c9d373", + "origin": "Delegated (Microsoft Graph)", + "value": "SignInIdentifier.Read.All" }, { - "description": "Allows the app to read all security incidents, without a signed-in user.", - "displayName": "Read all security incidents", - "id": "45cc0394-e837-488b-a098-1918f48d186c", - "origin": "Application", - "value": "SecurityIncident.Read.All" + "description": "Allows the app to read and write your organization's sign-in identifiers, on behalf of the signed-in user.", + "displayName": "Read and write all sign-in identifiers", + "id": "b4673c3c-7b5a-4012-9826-7c7e3c8db6af", + "origin": "Delegated (Microsoft Graph)", + "value": "SignInIdentifier.ReadWrite.All" }, { - "description": "Allows the app to read and write to all security alerts, without a signed-in user.", - "displayName": "Read and write to all security alerts", - "id": "ed4fca05-be46-441f-9803-1873825f8fdb", - "origin": "Application", - "value": "SecurityAlert.ReadWrite.All" + "description": "Allow the application to create site collections on behalf of the signed in user. Upon creation the application will be granted Sites.Selected(delegated) + FullControl to the newly created site.", + "displayName": "Create Site Collections, on behalf of the signed-in user", + "id": "0e2e68e1-3f32-4e10-9281-f749e097fcbe", + "origin": "Delegated (Microsoft Graph)", + "value": "Sites.Create.All" }, { - "description": "Allows the app to read all security alerts, without a signed-in user.", - "displayName": "Read all security alerts", - "id": "472e4a4d-bb4a-4026-98d1-0b0d74cb74a5", - "origin": "Application", - "value": "SecurityAlert.Read.All" + "description": "Allows the application to have full control of all site collections on behalf of the signed-in user.", + "displayName": "Have full control of all site collections", + "id": "5a54b8b3-347c-476d-8f8e-42d5c7424d29", + "origin": "Delegated (Microsoft Graph)", + "value": "Sites.FullControl.All" + }, + { + "description": "Allows the application to create or delete document libraries and lists in all site collections on behalf of the signed-in user.", + "displayName": "Create, edit, and delete items and lists in all site collections", + "id": "65e50fdc-43b7-4915-933e-e8138f11f40a", + "origin": "Delegated (Microsoft Graph)", + "value": "Sites.Manage.All" + }, + { + "description": "Allows the application to read documents and list items in all site collections on behalf of the signed-in user", + "displayName": "Read items in all site collections", + "id": "205e70e5-aba6-4c52-a976-6d2d46c48043", + "origin": "Delegated (Microsoft Graph)", + "value": "Sites.Read.All" + }, + { + "description": "Allow the application to access a subset of site collections on behalf of the signed-in user. The specific site collections and the permissions granted will be configured in SharePoint Online.", + "displayName": "Access selected Sites, on behalf of the signed-in user", + "id": "f89c84ef-20d0-4b54-87e9-02e856d66d53", + "origin": "Delegated (Microsoft Graph)", + "value": "Sites.Selected" + }, + { + "description": "Allows the app to read all Teams service activity, on behalf of the signed-in user.", + "displayName": "Read all Teams service activity", + "id": "404d76f0-e10e-460a-92be-ef19600c54d1", + "origin": "Delegated (Microsoft Graph)", + "value": "ServiceActivity-Teams.Read.All" + }, + { + "description": "Allows the app to read the signed-in user's teamwork activity feed.", + "displayName": "Read user's teamwork activity feed", + "id": "0e755559-83fb-4b44-91d0-4cc721b9323e", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsActivity.Read" + }, + { + "description": "Allows the app to read, install, upgrade, and uninstall selected Teams apps in chats the signed-in user can access. Gives the ability to manage permission grants for accessing those specific chats' data.", + "displayName": "Manage installation and permission grants of selected Teams apps in chats", + "id": "d1ba22c6-3f02-4c91-addb-bc3399bcca88", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsAppInstallation.ManageSelectedForChat" + }, + { + "description": "Allows a Teams app to read, install, upgrade, and uninstall itself to teams the signed-in user can access.", + "displayName": "Allow the app to manage itself in teams", + "id": "0f4595f7-64b1-4e13-81bc-11a249df07a9", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadWriteSelfForTeam" + }, + { + "description": "Allows a Teams app to read, install, upgrade, and uninstall itself for the signed-in user.", + "displayName": "Allow the Teams app to manage itself for a user", + "id": "207e0cb1-3ce7-4922-b991-5a760c346ebc", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadWriteSelfForUser" + }, + { + "description": "Read all teams' settings, on behalf of the signed-in user.", + "displayName": "Read teams' settings", + "id": "48638b3c-ad68-4383-8ac4-e6880ee6ca57", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamSettings.Read.All" + }, + { + "description": "Read and change all teams' settings, on behalf of the signed-in user.", + "displayName": "Read and change teams' settings", + "id": "39d65650-9d3e-4223-80db-a335590d027e", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamSettings.ReadWrite.All" + }, + { + "description": "Allow the app to read or write/update the policy assignment and unassigment for Teams users for all policy type categories.", + "displayName": "Read and Write Teams policy user assignment and unassigment for all policy types.", + "id": "6997c35c-a586-440c-8a0b-4ffe5d118dc0", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsPolicyUserAssign.ReadWrite.All" + }, + { + "description": "Allows the app to read your tenant's resource accounts on behalf of the signed-in admin user.", + "displayName": "Read Teams resource accounts", + "id": "ea2cbd09-253c-4f69-a0e6-07383c5f07cc", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsResourceAccount.Read.All" + }, + { + "description": "Allows the app to create tabs in any team in Microsoft Teams, on behalf of the signed-in user. This does not grant the ability to read, modify or delete tabs after they are created, or give access to the content inside the tabs.", + "displayName": "Create tabs in Microsoft Teams.", + "id": "a9ff19c2-f369-4a95-9a25-ba9d460efc8e", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsTab.Create" + }, + { + "description": "Read the names and settings of tabs inside any team in Microsoft Teams, on behalf of the signed-in user. This does not give access to the content inside the tabs.", + "displayName": "Read tabs in Microsoft Teams.", + "id": "59dacb05-e88d-4c13-a684-59f1afc8cc98", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsTab.Read.All" + }, + { + "description": "Read and write tabs in any team in Microsoft Teams, on behalf of the signed-in user. This does not give access to the content inside the tabs.", + "displayName": "Read and write tabs in Microsoft Teams.", + "id": "b98bfd41-87c6-45cc-b104-e2de4f0dafb9", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsTab.ReadWrite.All" + }, + { + "description": "Allows a Teams app to read, install, upgrade, and uninstall all tabs in chats the signed-in user can access.", + "displayName": "Allow the Teams app to manage all tabs in chats", + "id": "ee928332-e9c2-4747-b4a0-f8c164b68de6", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsTab.ReadWriteForChat" + }, + { + "description": "Allows a Teams app to read, install, upgrade, and uninstall all tabs to teams the signed-in user can access.", + "displayName": "Allow the Teams app to manage all tabs in teams", + "id": "c975dd04-a06e-4fbb-9704-62daad77bb49", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsTab.ReadWriteForTeam" + }, + { + "description": "Allows a Teams app to read, install, upgrade, and uninstall all tabs for the signed-in user.", + "displayName": "Allow the Teams app to manage all tabs for a user", + "id": "c37c9b61-7762-4bff-a156-afc0005847a0", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsTab.ReadWriteForUser" + }, + { + "description": "Allows a Teams app to read, install, upgrade, and uninstall its own tabs in chats the signed-in user can access.", + "displayName": "Allow the Teams app to manage only its own tabs in chats", + "id": "0c219d04-3abf-47f7-912d-5cca239e90e6", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsTab.ReadWriteSelfForChat" + }, + { + "description": "Allows a Teams app to read, install, upgrade, and uninstall its own tabs to teams the signed-in user can access.", + "displayName": "Allow the Teams app to manage only its own tabs in teams", + "id": "f266662f-120a-4314-b26a-99b08617c7ef", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsTab.ReadWriteSelfForTeam" + }, + { + "description": "Allows a Teams app to read, install, upgrade, and uninstall its own tabs for the signed-in user.", + "displayName": "Allow the Teams app to manage only its own tabs for a user", + "id": "395dfec1-a0b9-465f-a783-8250a430cb8c", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsTab.ReadWriteSelfForUser" + }, + { + "description": "Allows the app to read your tenant's acquired telephone number details on behalf of the signed-in admin user. Acquired telephone numbers may include attributes related to assigned object, emergency location, network site, etc.", + "displayName": "Read Tenant-Acquired Telephone Number Details", + "id": "1bc6eab1-058d-4557-b011-d4c41cec88b7", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsTelephoneNumber.Read.All" + }, + { + "description": "Allows the app to read and modify your tenant's acquired telephone number details on behalf of the signed-in admin user. Acquired telephone numbers may include attributes related to assigned object, emergency location, network site, etc.", + "displayName": "Read and Modify Tenant-Acquired Telephone Number Details", + "id": "424b07a8-1209-4d17-9fe4-9018a93a1024", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsTelephoneNumber.ReadWrite.All" + }, + { + "description": "Allows a Teams app to read, install, upgrade, and uninstall itself in chats the signed-in user can access.", + "displayName": "Allow the Teams app to manage itself in chats", + "id": "0ce33576-30e8-43b7-99e5-62f8569a4002", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadWriteSelfForChat" + }, + { + "description": "Allows the app to create new notifications in users' teamwork activity feeds on behalf of the signed in user. These notifications may not be discoverable or be held or governed by compliance policies.", + "displayName": "Send a teamwork activity as the user", + "id": "7ab1d787-bae7-4d5d-8db6-37ea32df9186", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsActivity.Send" + }, + { + "description": "Allows the app to read, install, upgrade, and uninstall selected Teams apps installed for the signed in user. Does not give the ability to read application-specific settings.", + "displayName": "Manage selected Teams apps installed for a user", + "id": "ea819e27-c92a-4118-b83b-4540b125d744", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadWriteSelectedForUser" + }, + { + "description": "Allows the app to read, install, upgrade, and uninstall selected Teams apps in chats the signed-in user can access. Does not give the ability to read application-specific settings.", + "displayName": "Manage selected Teams apps installed in chats", + "id": "690aa3b6-4b71-41c2-a990-77a8c4768d2b", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadWriteSelectedForChat" + }, + { + "description": "Allows the app to read, install, upgrade, and uninstall Teams apps in teams the signed-in user can access. Gives the ability to manage permission grants for accessing those specific teams' data.", + "displayName": "Manage installation and permission grants of selected Teams apps in teams", + "id": "c67b2d7e-6b80-4218-938a-05e73058e42d", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsAppInstallation.ManageSelectedForTeam" + }, + { + "description": "Allows the app to read, install, upgrade, and uninstall seleected Teams apps in user accounts, on behalf of the signed-in user. Gives the ability to manage permission grants for accessing those specific users' data.", + "displayName": "Manage installation and permission grants of selected Teams apps in users' personal scope", + "id": "830c2bd9-c335-4caf-bf83-c07fa8a23ef1", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsAppInstallation.ManageSelectedForUser" + }, + { + "description": "Allows the app to read the Teams apps that are installed in chats the signed-in user can access. Does not give the ability to read application-specific settings.", + "displayName": "Read installed Teams apps in chats", + "id": "bf3fbf03-f35f-4e93-963e-47e4d874c37a", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadForChat" + }, + { + "description": "Allows the app to read the Teams apps that are installed in teams the signed-in user can access. Does not give the ability to read application-specific settings.", + "displayName": "Read installed Teams apps in teams", + "id": "5248dcb1-f83b-4ec3-9f4d-a4428a961a72", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadForTeam" + }, + { + "description": "Allows the app to read the Teams apps that are installed for the signed-in user. Does not give the ability to read application-specific settings.", + "displayName": "Read user's installed Teams apps", + "id": "c395395c-ff9a-4dba-bc1f-8372ba9dca84", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadForUser" + }, + { + "description": "Allows the app to read the selected Teams apps that are installed in chats the signed-in user can access. Does not give the ability to read application-specific settings.", + "displayName": "Read selected installed Teams apps in chats", + "id": "0f3420c2-c6ec-46de-ab72-fd51267087d5", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadSelectedForChat" + }, + { + "description": "Allows the app to read the selected Teams apps that are installed in teams the signed-in user can access. Does not give the ability to read application-specific settings.", + "displayName": "Read selected installed Teams apps in teams", + "id": "b55df1c0-db20-435b-aef2-afe6ed487e16", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadSelectedForTeam" + }, + { + "description": "Allows the app to read the selected Teams apps that are installed for the signed-in user. Does not give the ability to read application-specific settings.", + "displayName": "Read user's selected installed Teams apps", + "id": "fe2e4e1d-101f-4fb2-9cb1-9d6659db45d4", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadSelectedForUser" + }, + { + "description": "Allows the app to read, install, upgrade, and uninstall Teams apps in chats the signed-in user can access. Gives the ability to manage permission grants for accessing those specific chats' data.", + "displayName": "Manage installed Teams apps in chats", + "id": "e1408a66-8f82-451b-a2f3-3c3e38f7413f", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadWriteAndConsentForChat" + }, + { + "description": "Allows the app to read, install, upgrade, and uninstall Teams apps in teams the signed-in user can access. Gives the ability to manage permission grants for accessing those specific teams' data.", + "displayName": "Manage installed Teams apps in teams", + "id": "946349d5-2a9d-4535-abc0-7beeacaedd1d", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadWriteAndConsentForTeam" + }, + { + "description": "Allows the app to read, install, upgrade, and uninstall Teams apps in user accounts, on behalf of the signed-in user. Gives the ability to manage permission grants for accessing those specific users' data.", + "displayName": "Manage installation and permission grants of Teams apps in users' personal scope", + "id": "2da62c49-dfbd-40df-ba16-fef3529d391c", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadWriteAndConsentForUser" + }, + { + "description": "Allows a Teams app to read, install, upgrade, and uninstall itself in chats the signed-in user can access, and manage its permission grants for accessing those specific chats' data.", + "displayName": "Allow the Teams app to manage itself and its permission grants in chats", + "id": "a0e0e18b-8fb2-458f-8130-da2d7cab9c75", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadWriteAndConsentSelfForChat" + }, + { + "description": "Allows a Teams app to read, install, upgrade, and uninstall itself in teams the signed-in user can access, and manage its permission grants for accessing those specific teams' data.", + "displayName": "Allow the Teams app to manage itself and its permission grants in teams", + "id": "4a6bbf29-a0e1-4a4d-a7d1-cef17f772975", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadWriteAndConsentSelfForTeam" + }, + { + "description": "Allows a Teams app to read, install, upgrade, and uninstall itself in user accounts, and manage its permission grants for accessing those specific users' data, on behalf of the signed-in user.", + "displayName": "Allow the Teams app to manage itself and its permission grants in user accounts", + "id": "7a349935-c54d-44ab-ab66-1b460d315be7", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadWriteAndConsentSelfForUser" + }, + { + "description": "Allows the app to read, install, upgrade, and uninstall Teams apps in chats the signed-in user can access. Does not give the ability to read application-specific settings.", + "displayName": "Manage installed Teams apps in chats", + "id": "aa85bf13-d771-4d5d-a9e6-bca04ce44edf", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadWriteForChat" + }, + { + "description": "Allows the app to read, install, upgrade, and uninstall Teams apps in teams the signed-in user can access. Does not give the ability to read application-specific settings.", + "displayName": "Manage installed Teams apps in teams", + "id": "2e25a044-2580-450d-8859-42eeb6e996c0", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadWriteForTeam" + }, + { + "description": "Allows the app to read, install, upgrade, and uninstall Teams apps installed for the signed-in user. Does not give the ability to read application-specific settings.", + "displayName": "Manage user's installed Teams apps", + "id": "093f8818-d05f-49b8-95bc-9d2a73e9a43c", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadWriteForUser" + }, + { + "description": "Allows the app to read, install, upgrade, and uninstall selected Teams apps in teams the signed-in user can access. Does not give the ability to read application-specific settings.", + "displayName": "Manage selected Teams apps installed in teams", + "id": "9131c833-9a49-4c54-b38f-615ecfc4fc69", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadWriteSelectedForTeam" + }, + { + "description": "Allows the app to read all One Drive service activity, on behalf of the signed-in user.", + "displayName": "Read all One Drive service activity", + "id": "347e3c16-30f3-4ac7-9b52-fc3c053de9c9", + "origin": "Delegated (Microsoft Graph)", + "value": "ServiceActivity-OneDrive.Read.All" + }, + { + "description": "Allows the app to read all Microsoft 365 Web service activity, on behalf of the signed-in user.", + "displayName": "Read all Microsoft 365 Web service activity", + "id": "d74c75b1-d5a9-479d-902d-92f8f99182c1", + "origin": "Delegated (Microsoft Graph)", + "value": "ServiceActivity-Microsoft365Web.Read.All" + }, + { + "description": "Allows the app to read all Exchange service activity, on behalf of the signed-in user.", + "displayName": "Read all Exchange service activity", + "id": "1fe7aa48-9373-4a47-8df3-168335e0f4c9", + "origin": "Delegated (Microsoft Graph)", + "value": "ServiceActivity-Exchange.Read.All" + }, + { + "description": "Allows the app to read your organization's risk prevention providers, on behalf of the signed-in user.", + "displayName": "Read all identity risk prevention providers", + "id": "e197c06f-ae7b-4398-b0a2-89f76ebca159", + "origin": "Delegated (Microsoft Graph)", + "value": "RiskPreventionProviders.Read.All" + }, + { + "description": "Allows the app to read and write your organization's risk prevention providers, on behalf of the signed-in user.", + "displayName": "Read and write all identity risk prevention providers", + "id": "2a7babba-9623-4109-bc9c-79728cf3bb4f", + "origin": "Delegated (Microsoft Graph)", + "value": "RiskPreventionProviders.ReadWrite.All" + }, + { + "description": "Allows the app to read the active role-based access control (RBAC) assignments for your company's directory, on behalf of the signed-in user. This includes reading directory role templates, and directory roles.", + "displayName": "Read all active role assignments for your company's directory", + "id": "344a729c-0285-42c6-9014-f12b9b8d6129", + "origin": "Delegated (Microsoft Graph)", + "value": "RoleAssignmentSchedule.Read.Directory" + }, + { + "description": "Allows the app to read and manage the active role-based access control (RBAC) assignments for your company's directory, on behalf of the signed-in user. This includes managing active directory role membership, and reading directory role templates, directory roles and active memberships.", + "displayName": "Read, update, and delete all active role assignments for your company's directory", + "id": "8c026be3-8e26-4774-9372-8d5d6f21daff", + "origin": "Delegated (Microsoft Graph)", + "value": "RoleAssignmentSchedule.ReadWrite.Directory" + }, + { + "description": "Allows the app to delete the active role-based access control (RBAC) assignments for your company's directory, on behalf of the signed-in user.", + "displayName": "Delete all active role assignments for your company's directory", + "id": "f71cd05c-3fdb-4568-aef2-e1cf62ee20d4", + "origin": "Delegated (Microsoft Graph)", + "value": "RoleAssignmentSchedule.Remove.Directory" + }, + { + "description": "Allows the app to read the eligible role-based access control (RBAC) assignments for your company's directory, on behalf of the signed-in user. This includes reading directory role templates, and directory roles.", + "displayName": "Read all eligible role assignments for your company's directory", + "id": "eb0788c2-6d4e-4658-8c9e-c0fb8053f03d", + "origin": "Delegated (Microsoft Graph)", + "value": "RoleEligibilitySchedule.Read.Directory" + }, + { + "description": "Allows the app to read and manage the eligible role-based access control (RBAC) assignments for your company's directory, on behalf of the signed-in user. This includes managing eligible directory role membership, and reading directory role templates, directory roles and eligible memberships.", + "displayName": "Read, update, and delete all eligible role assignments for your company's directory", + "id": "62ade113-f8e0-4bf9-a6ba-5acb31db32fd", + "origin": "Delegated (Microsoft Graph)", + "value": "RoleEligibilitySchedule.ReadWrite.Directory" + }, + { + "description": "Allows the app to delete the eligible role-based access control (RBAC) assignments for your company's directory, on behalf of the signed-in user.", + "displayName": "Delete all eligible role assignments for your company's directory", + "id": "58ac4fa2-b484-4d6e-ba97-beee2a574220", + "origin": "Delegated (Microsoft Graph)", + "value": "RoleEligibilitySchedule.Remove.Directory" + }, + { + "description": "Allows the app to read the role-based access control (RBAC) settings for all RBAC providers, on behalf of the signed-in user. This includes reading role definitions and role assignments.", + "displayName": "Read role management data for all RBAC providers", + "id": "48fec646-b2ba-4019-8681-8eb31435aded", + "origin": "Delegated (Microsoft Graph)", + "value": "RoleManagement.Read.All" + }, + { + "description": "Allows the app to read the Cloud PC role-based access control (RBAC) settings, on behalf of the signed-in user. This includes reading Cloud PC role definitions and role assignments.", + "displayName": "Read Cloud PC RBAC settings", + "id": "9619b88a-8a25-48a7-9571-d23be0337a79", + "origin": "Delegated (Microsoft Graph)", + "value": "RoleManagement.Read.CloudPC" + }, + { + "description": "Allows the app to read the role-based access control (RBAC) settings for your company's directory, on behalf of the signed-in user. This includes reading M365 Defender role definitions and role assignments.", + "displayName": "Read M365 Defender RBAC configuration", + "id": "dd689728-6eb8-4deb-bd38-2924a935f3de", + "origin": "Delegated (Microsoft Graph)", + "value": "RoleManagement.Read.Defender" + }, + { + "description": "Allows the app to read the role-based access control (RBAC) settings for your company's directory, on behalf of the signed-in user. This includes reading directory role templates, directory roles and memberships.", + "displayName": "Read directory RBAC settings", + "id": "741c54c3-0c1e-44a1-818b-3f97ab4e8c83", + "origin": "Delegated (Microsoft Graph)", + "value": "RoleManagement.Read.Directory" + }, + { + "description": "Allows the app to read the role-based access control (RBAC) settings for your organization's Exchange Online service, on behalf of the signed-in user. This includes reading Exchange management role definitions, role groups, role group membership, role assignments, management scopes, and role assignment policies.", + "displayName": "Read Exchange Online RBAC configuration", + "id": "3bc15058-7858-4141-b24f-ae43b4e80b52", + "origin": "Delegated (Microsoft Graph)", + "value": "RoleManagement.Read.Exchange" + }, + { + "description": "Allows the app to read and manage the Cloud PC role-based access control (RBAC) settings, on behalf of the signed-in user. This includes reading and managing Cloud PC role definitions and role assignments.", + "displayName": "Read and write Cloud PC RBAC settings", + "id": "501d06f8-07b8-4f18-b5c6-c191a4af7a82", + "origin": "Delegated (Microsoft Graph)", + "value": "RoleManagement.ReadWrite.CloudPC" + }, + { + "description": "Allows the app to read the role-based access control (RBAC) settings for your company's directory, on behalf of the signed-in user. This includes reading M365 Defender role definitions and role assignments.", + "displayName": "Read M365 Defender RBAC configuration", + "id": "d8914f8f-9f64-4bd1-b4d3-f5a701ed8457", + "origin": "Delegated (Microsoft Graph)", + "value": "RoleManagement.ReadWrite.Defender" + }, + { + "description": "Allows the app to read and manage the role-based access control (RBAC) settings for your company's directory, on behalf of the signed-in user. This includes instantiating directory roles and managing directory role membership, and reading directory role templates, directory roles and memberships.", + "displayName": "Read and write directory RBAC settings", + "id": "d01b97e9-cbc0-49fe-810a-750afd5527a3", + "origin": "Delegated (Microsoft Graph)", + "value": "RoleManagement.ReadWrite.Directory" + }, + { + "description": "Allows the app to read and manage the role-based access control (RBAC) settings for your organization's Exchange Online service, on behalf of the signed-in user. This includes reading, creating, updating, and deleting Exchange management role definitions, role groups, role group membership, role assignments, management scopes, and role assignment policies.", + "displayName": "Read and write Exchange Online RBAC configuration", + "id": "c1499fe0-52b1-4b22-bed2-7a244e0e879f", + "origin": "Delegated (Microsoft Graph)", + "value": "RoleManagement.ReadWrite.Exchange" + }, + { + "description": "Allows the app to read the resource specific permissions granted on a user account, on behalf of the signed-in user.", + "displayName": "Read resource specific permissions granted on a user account", + "id": "f1d91a8f-88e7-4774-8401-b668d5bca0c5", + "origin": "Delegated (Microsoft Graph)", + "value": "ResourceSpecificPermissionGrant.ReadForUser" + }, + { + "description": "Allows the app to read the role-based access control (RBAC) alerts for your company's directory, on behalf of the signed-in user. This includes reading alert statuses, alert definitions, alert configurations and incidents that lead to an alert.", + "displayName": "Read all alert data for your company's directory", + "id": "cce71173-f76d-446e-97ff-efb2d82e11b1", + "origin": "Delegated (Microsoft Graph)", + "value": "RoleManagementAlert.Read.Directory" + }, + { + "description": "Allows the app to read the resource specific permissions granted on the team, on behalf of the signed-in user.", + "displayName": "Read resource specific permissions granted on a team", + "id": "eafad40c-bf7a-415a-b7f8-acdf5706b58f", + "origin": "Delegated (Microsoft Graph)", + "value": "ResourceSpecificPermissionGrant.ReadForTeam" + }, + { + "description": "Allows the app to read and update admin report settings, such as whether to display concealed information in reports, on behalf of the signed-in user.", + "displayName": "Read and write admin report settings", + "id": "b955410e-7715-4a88-a940-dfd551018df3", + "origin": "Delegated (Microsoft Graph)", + "value": "ReportSettings.ReadWrite.All" + }, + { + "description": "Allows the app to read programs and program controls that the signed-in user has access to in the organization.", + "displayName": "Read all programs that user can access", + "id": "c492a2e1-2f8f-4caa-b076-99bbf6e40fe4", + "origin": "Delegated (Microsoft Graph)", + "value": "ProgramControl.Read.All" + }, + { + "description": "Allows the app to read, update, delete and perform actions on programs and program controls that the signed-in user has access to in the organization.", + "displayName": "Manage all programs that user can access", + "id": "50fd364f-9d93-4ae1-b170-300e87cccf84", + "origin": "Delegated (Microsoft Graph)", + "value": "ProgramControl.ReadWrite.All" + }, + { + "description": "Allows the app to identify Purview data protection, compliance and governance policy scopes defined for all users across tenant.", + "displayName": "Compute Purview policies at tenant scope", + "id": "98f5a27a-539a-48bc-a597-f78e9e1e76bf", + "origin": "Delegated (Microsoft Graph)", + "value": "ProtectionScopes.Compute.All" + }, + { + "description": "Allows the app to identify Purview data protection, compliance and governance policy scopes defined for an individual user.", + "displayName": "Compute Purview policies for an individual user", + "id": "4fc04d16-a9fc-4c5e-8da4-79b6c33638a4", + "origin": "Delegated (Microsoft Graph)", + "value": "ProtectionScopes.Compute.User" + }, + { + "description": "Allows the app to read and query your provisioning log activities, on behalf of the signed-in user.", + "displayName": "Read provisioning log data", + "id": "95aec97b-cf27-4a8d-a67d-42f60b5b38ef", + "origin": "Delegated (Microsoft Graph)", + "value": "ProvisioningLog.Read.All" + }, + { + "description": "Allows the application to read certificate-based authentication configuration such as all public key infrastructures (PKI) and certificate authorities (CA) configured for the organization, on behalf of the signed-in user.", + "displayName": "Read certificate based authentication configurations", + "id": "04a4b2a2-3f26-4fc8-87ee-9c46e68db175", + "origin": "Delegated (Microsoft Graph)", + "value": "PublicKeyInfrastructure.Read.All" + }, + { + "description": "Allows the application to read and write certificate-based authentication configuration such as all public key infrastructures (PKI) and certificate authorities (CA) configured for the organization, on behalf of the signed-in user.", + "displayName": "Read and write certificate based authentication configurations", + "id": "3591b7f3-dba8-4bad-b667-7a64bd4f2b83", + "origin": "Delegated (Microsoft Graph)", + "value": "PublicKeyInfrastructure.ReadWrite.All" + }, + { + "description": "Allows the application to create, read, update and delete pull-print printers and manage member printers on behalf of the signed-in user.", + "displayName": "Create, read, update and delete pull-print printers and manage member printers", + "id": "ef6b83cd-f762-47ff-97d7-6f6f2d0486ea", + "origin": "Delegated (Microsoft Graph)", + "value": "PullPrintPrinter.FullControl.All" + }, + { + "description": "Allows the application to read pull-print printers on behalf of the signed-in user. ", + "displayName": "Read pull-print printers", + "id": "deac7994-79bc-44c9-8828-01c1a9a96618", + "origin": "Delegated (Microsoft Graph)", + "value": "PullPrintPrinter.Read.All" + }, + { + "description": "Allows the application to read and update pull-print printers on behalf of the signed-in user. Does not allow creating or deleting pull-print printers or managing member printers.", + "displayName": "Read and update pull-print printers", + "id": "c628c397-5d7f-4c93-ae0f-4680282fd6d5", + "origin": "Delegated (Microsoft Graph)", + "value": "PullPrintPrinter.ReadWrite.All" + }, + { + "description": "Allows an app to read all question and answer sets that the signed-in user can access.", + "displayName": "Read all Questions and Answers that the user can access.", + "id": "f73fa04f-b9a5-4df9-8843-993ce928925e", + "origin": "Delegated (Microsoft Graph)", + "value": "QnA.Read.All" + }, + { + "description": "Allows the app to get direct access to real-time enriched data in a meeting, on behalf of the signed-in user.", + "displayName": "Access real-time enriched data in a meeting", + "id": "db5d5bae-0c9e-444e-9390-8a5fea98c253", + "origin": "Delegated (Microsoft Graph)", + "value": "RealTimeActivityFeed.Read.All" + }, + { + "description": "Allows the application to read any data from Records Management, such as configuration, labels, and policies on behalf of the signed-in user.", + "displayName": "Read Records Management configuration, labels, and policies", + "id": "07f995eb-fc67-4522-ad66-2b8ca8ea3efd", + "origin": "Delegated (Microsoft Graph)", + "value": "RecordsManagement.Read.All" + }, + { + "description": "Allow the application to create, update and delete any data from Records Management, such as configuration, labels, and policies on behalf of the signed-in user.", + "displayName": "Read and write Records Management configuration, labels, and policies", + "id": "f2833d75-a4e6-40ab-86d4-6dfe73c97605", + "origin": "Delegated (Microsoft Graph)", + "value": "RecordsManagement.ReadWrite.All" + }, + { + "description": "Allows the app to read available properties of remoteTenantGroups, on behalf of the signed-in user.", + "displayName": "Read RemoteTenantGroups information", + "id": "d207fff0-6e36-4360-a23b-495e23b60385", + "origin": "Delegated (Microsoft Graph)", + "value": "RemoteTenantGroups.Read.All" + }, + { + "description": "Allows an app to read all service usage reports on behalf of the signed-in user. Services that provide usage reports include Office 365 and Azure Active Directory.", + "displayName": "Read all usage reports", + "id": "02e97553-ed7b-43d0-ab3c-f8bace0d040c", + "origin": "Delegated (Microsoft Graph)", + "value": "Reports.Read.All" + }, + { + "description": "Allows the app to read admin report settings, such as whether to display concealed information in reports, on behalf of the signed-in user", + "displayName": "Read admin report settings", + "id": "84fac5f4-33a9-4100-aa38-a20c6d29e5e7", + "origin": "Delegated (Microsoft Graph)", + "value": "ReportSettings.Read.All" + }, + { + "description": "Allows the app to read the resource specific permissions granted on the chat, on behalf of the signed-in user.", + "displayName": "Read resource specific permissions granted on a chat", + "id": "cb530fca-534b-4e72-aa74-bca7e8bbd06f", + "origin": "Delegated (Microsoft Graph)", + "value": "ResourceSpecificPermissionGrant.ReadForChat" + }, + { + "description": "Allows the app to read and manage the role-based access control (RBAC) alerts for your company's directory, on behalf of the signed-in user. This includes managing alert settings, initiating alert scans, dismissing alerts, remediating alert incidents, and reading alert statuses, alert definitions, alert configurations and incidents that lead to an alert.", + "displayName": "Read all alert data, configure alerts, and take actions on all alerts for your company's directory", + "id": "435644c6-a5b1-40bf-8f52-fe8e5b53e19c", + "origin": "Delegated (Microsoft Graph)", + "value": "RoleManagementAlert.ReadWrite.Directory" + }, + { + "description": "Allows the app to read policies in Privileged Identity Management for Groups, on behalf of the signed-in user.", + "displayName": "Read all policies in PIM for Groups", + "id": "7e26fdff-9cb1-4e56-bede-211fe0e420e8", + "origin": "Delegated (Microsoft Graph)", + "value": "RoleManagementPolicy.Read.AzureADGroup" + }, + { + "description": "Allows the app to read policies for privileged role-based access control (RBAC) assignments of your company's directory, on behalf of the signed-in user.", + "displayName": "Read all policies for privileged role assignments of your company's directory", + "id": "3de2cdbe-0ff5-47d5-bdee-7f45b4749ead", + "origin": "Delegated (Microsoft Graph)", + "value": "RoleManagementPolicy.Read.Directory" + }, + { + "description": "Allows the app to read the sensors window auditing configuration of the signed in user", + "displayName": "Read sensors window auditing configuration", + "id": "8ff90903-1ecb-4f3a-b8b2-42120374ecd6", + "origin": "Delegated (Microsoft Graph)", + "value": "SecurityIdentitiesAutoConfig.Read.All" + }, + { + "description": "Allows the app to read and write the sensors window auditing configuration of the signed in user", + "displayName": "Read and write sensors window auditing configuration", + "id": "b810fdb4-8733-43bd-9b37-fddb7215c69f", + "origin": "Delegated (Microsoft Graph)", + "value": "SecurityIdentitiesAutoConfig.ReadWrite.All" + }, + { + "description": "Allows the app to read all the identity security health issues of signed user", + "displayName": "Read identity security health issues", + "id": "a0d0da43-a6df-4416-b63d-99c79991aae8", + "origin": "Delegated (Microsoft Graph)", + "value": "SecurityIdentitiesHealth.Read.All" + }, + { + "description": "Allows the app to read and write identity security health issues on behalf of the signed-in user.", + "displayName": "Read and write identity security health issues", + "id": "53e51eec-2d9b-4990-97f3-c9aa5d5652c3", + "origin": "Delegated (Microsoft Graph)", + "value": "SecurityIdentitiesHealth.ReadWrite.All" + }, + { + "description": "Allows the app to read all the identity security sensor migration information of signed user", + "displayName": "Read identity security sensor migration", + "id": "63595162-fcc0-4127-8b1e-bfe90b23a10e", + "origin": "Delegated (Microsoft Graph)", + "value": "SecurityIdentitiesMigration.Read.All" + }, + { + "description": "Allows the app to read and write identity security sensor migration on behalf of the signed-in user.", + "displayName": "Read and write identity security sensor migration", + "id": "741a6ef0-37e6-4b0a-9178-133d94fbc46e", + "origin": "Delegated (Microsoft Graph)", + "value": "SecurityIdentitiesMigration.ReadWrite.All" + }, + { + "description": "Allows the app to read all the identity security sensors of signed user", + "displayName": "Read identity security sensors", + "id": "2c221239-7c5c-4b30-9355-d84663bfcd96", + "origin": "Delegated (Microsoft Graph)", + "value": "SecurityIdentitiesSensors.Read.All" + }, + { + "description": "Allows the app to read and write identity security sensors on behalf of the signed-in user.", + "displayName": "Read and write identity security sensors", + "id": "087c3ad9-c2ca-4b82-9885-d5e25ce9e183", + "origin": "Delegated (Microsoft Graph)", + "value": "SecurityIdentitiesSensors.ReadWrite.All" + }, + { + "description": "Allows the app to read all the identity security available user actions of signed user", + "displayName": "Read identity security available user actions", + "id": "c7d0a939-da1c-4aca-80fa-d0a6cd924801", + "origin": "Delegated (Microsoft Graph)", + "value": "SecurityIdentitiesUserActions.Read.All" + }, + { + "description": "Allows the app to read and write identity security available user actions on behalf of the signed-in user.", + "displayName": "Read and perform identity security available user actions", + "id": "bf230e97-1957-4df6-b3f6-57f9029eacdf", + "origin": "Delegated (Microsoft Graph)", + "value": "SecurityIdentitiesUserActions.ReadWrite.All" + }, + { + "description": "Allows the app to read security incidents, on behalf of the signed-in user.", + "displayName": "Read incidents", + "id": "b9abcc4f-94fc-4457-9141-d20ce80ec952", + "origin": "Delegated (Microsoft Graph)", + "value": "SecurityIncident.Read.All" + }, + { + "description": "Allows the app to read and write security incidents, on behalf of the signed-in user.", + "displayName": "Read and write to incidents", + "id": "128ca929-1a19-45e6-a3b8-435ec44a36ba", + "origin": "Delegated (Microsoft Graph)", + "value": "SecurityIncident.ReadWrite.All" + }, + { + "description": "Allow the app to determine if there is any sensitivity label to be applied automatically to the content or recommended to the user for manual application, on behalf of the signed-in user.", + "displayName": "Evaluate sensitivity labels", + "id": "a4633e44-d355-4474-99df-8c2de6b0e39e", + "origin": "Delegated (Microsoft Graph)", + "value": "SensitivityLabel.Evaluate" + }, + { + "description": "Allows the app to evaluate all sensitivity label.", + "displayName": "Evaluate labels tenant scope.", + "id": "a42e3c42-b31e-4919-b699-696dca5dc9e7", + "origin": "Delegated (Microsoft Graph)", + "value": "SensitivityLabel.Evaluate.All" + }, + { + "description": "Allows the app to get sensitivity labels.", + "displayName": "Get labels user scope.", + "id": "1aeb73ce-68d7-49b7-913a-eedc80844551", + "origin": "Delegated (Microsoft Graph)", + "value": "SensitivityLabel.Read" + }, + { + "description": "Allows the app to get sensitivity labels.", + "displayName": "Get labels app scope.", + "id": "8b377c27-ea19-4863-a948-8a8588c8f2c3", + "origin": "Delegated (Microsoft Graph)", + "value": "SensitivityLabels.Read.All" + }, + { + "description": "Allows the app to export all Sentiment Survey, on behalf of the signed-in user.", + "displayName": "Export all Sentiment Survey", + "id": "df9fd94d-51ff-443d-8f31-ae4dc1b5b8d8", + "origin": "Delegated (Microsoft Graph)", + "value": "SentimentSurvey.Export.All" + }, + { + "description": "Allows the app to read and write identity security available actions on behalf of the signed-in identity.", + "displayName": "Read and perform identity security available actions", + "id": "818229ce-20e4-47bd-92f4-bc94dbb37a56", + "origin": "Delegated (Microsoft Graph)", + "value": "SecurityIdentitiesActions.ReadWrite.All" + }, + { + "description": "Allows the app to read all the identity security available identity accounts", + "displayName": "Read identity security available identity accounts", + "id": "3e9ed69a-a48e-473c-8b97-413016703a37", + "origin": "Delegated (Microsoft Graph)", + "value": "SecurityIdentitiesAccount.Read.All" + }, + { + "description": "Allows the app to read your organization’s security events on behalf of the signed-in user. Also allows the app to update editable properties in security events on behalf of the signed-in user.", + "displayName": "Read and update your organization’s security events", + "id": "6aedf524-7e1c-45a7-bd76-ded8cab8d0fc", + "origin": "Delegated (Microsoft Graph)", + "value": "SecurityEvents.ReadWrite.All" + }, + { + "description": "Allows the app to read your organization’s security events on behalf of the signed-in user.", + "displayName": "Read your organization’s security events", + "id": "64733abd-851e-478a-bffb-e47a14b18235", + "origin": "Delegated (Microsoft Graph)", + "value": "SecurityEvents.Read.All" + }, + { + "description": "Allows the app to read policies in Privileged Identity Management for App Roles, on behalf of the signed-in user.", + "displayName": "Read all policies in PIM for App Roles", + "id": "8b3ffd3b-178e-4aae-be39-ba87585eddb2", + "origin": "Delegated (Microsoft Graph)", + "value": "RoleManagementPolicy.Read.EntraAppRole" + }, + { + "description": "Allows the app to read, update, and delete policies in Privileged Identity Management for Groups, on behalf of the signed-in user.", + "displayName": "Read, update, and delete all policies in PIM for Groups", + "id": "0da165c7-3f15-4236-b733-c0b0f6abe41d", + "origin": "Delegated (Microsoft Graph)", + "value": "RoleManagementPolicy.ReadWrite.AzureADGroup" + }, + { + "description": "Allows the app to read, update, and delete policies for privileged role-based access control (RBAC) assignments of your company's directory, on behalf of the signed-in user.", + "displayName": "Read, update, and delete all policies for privileged role assignments of your company's directory", + "id": "1ff1be21-34eb-448c-9ac9-ce1f506b2a68", + "origin": "Delegated (Microsoft Graph)", + "value": "RoleManagementPolicy.ReadWrite.Directory" + }, + { + "description": "Allows the app to manage policies in Privileged Identity Management for App Roles, on behalf of the signed-in user.", + "displayName": "Manage all policies in PIM for App Roles", + "id": "652ec839-e4ac-4eb5-b545-ecc90eeceb2d", + "origin": "Delegated (Microsoft Graph)", + "value": "RoleManagementPolicy.ReadWrite.EntraAppRole" + }, + { + "description": "Allows the app to read schedule, schedule groups, shifts and associated entities in the Teams or Shifts application on behalf of the signed-in user.", + "displayName": "Read user schedule items", + "id": "fccf6dd8-5706-49fa-811f-69e2e1b585d0", + "origin": "Delegated (Microsoft Graph)", + "value": "Schedule.Read.All" + }, + { + "description": "Allows the app to manage schedule, schedule groups, shifts and associated entities in the Teams or Shifts application on behalf of the signed-in user.", + "displayName": "Read and write user schedule items", + "id": "63f27281-c9d9-4f29-94dd-6942f7f1feb0", + "origin": "Delegated (Microsoft Graph)", + "value": "Schedule.ReadWrite.All" + }, + { + "description": "Allows the app to read/write schedule permissions for a specific role in Shifts application on behalf of the signed-in user.", + "displayName": "Read/Write schedule permissions for a role.", + "id": "07919803-6073-4cd8-bc55-28077db0ee10", + "origin": "Delegated (Microsoft Graph)", + "value": "SchedulePermissions.ReadWrite.All" + }, + { + "description": "Allows the app to read search configuration, on behalf of the signed-in user.", + "displayName": "Read your organization's search configuration", + "id": "7d307522-aa38-4cd0-bd60-90c6f0ac50bd", + "origin": "Delegated (Microsoft Graph)", + "value": "SearchConfiguration.Read.All" + }, + { + "description": "Allows the app to read your tenant's user configurations on behalf of the signed-in admin user. User configuration may include attributes related to user, such as telephone number, assigned policies, etc.", + "displayName": "Read Teams user configurations", + "id": "5c469ce4-dab5-4afd-b9de-14f1ba4004a7", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamsUserConfiguration.Read.All" + }, + { + "description": "Allows the app to read and write search configuration, on behalf of the signed-in user.", + "displayName": "Read and write your organization's search configuration", + "id": "b1a7d408-cab0-47d2-a2a5-a74a3733600d", + "origin": "Delegated (Microsoft Graph)", + "value": "SearchConfiguration.ReadWrite.All" + }, + { + "description": "Allows the app to read or update security actions, on behalf of the signed-in user.", + "displayName": "Read and update your organization's security actions", + "id": "dc38509c-b87d-4da0-bd92-6bec988bac4a", + "origin": "Delegated (Microsoft Graph)", + "value": "SecurityActions.ReadWrite.All" + }, + { + "description": "Allows the app to create security alerts, on behalf of the signed-in user.", + "displayName": "Create security alerts", + "id": "7417b8c6-a088-4c4c-99c7-bca9ab3eb9ba", + "origin": "Delegated (Microsoft Graph)", + "value": "SecurityAlert.Create.All" + }, + { + "description": "Allows the app to read all security alerts, on behalf of the signed-in user.", + "displayName": "Read all security alerts", + "id": "bc257fb8-46b4-4b15-8713-01e91bfbe4ea", + "origin": "Delegated (Microsoft Graph)", + "value": "SecurityAlert.Read.All" + }, + { + "description": "Allows the app to read and write to all security alerts, on behalf of the signed-in user.", + "displayName": "Read and write to all security alerts", + "id": "471f2a7f-2a42-4d45-a2bf-594d0838070d", + "origin": "Delegated (Microsoft Graph)", + "value": "SecurityAlert.ReadWrite.All" + }, + { + "description": "Read email metadata and security detection details on behalf of the signed in user.", + "displayName": "Read metadata and detection details for emails in your organization", + "id": "53e6783e-b127-4a35-ab3a-6a52d80a9077", + "origin": "Delegated (Microsoft Graph)", + "value": "SecurityAnalyzedMessage.Read.All" + }, + { + "description": "Read email metadata, security detection details, and execute remediation actions like deleting an email, on behalf of the signed in user.", + "displayName": "Read metadata, detection details, and execute remediation actions on emails in your organization", + "id": "48eb8c83-6e58-46e7-a6d3-8805822f5940", + "origin": "Delegated (Microsoft Graph)", + "value": "SecurityAnalyzedMessage.ReadWrite.All" + }, + { + "description": "Allows the app to read all Security Copilot signed-in user's resources on behalf of the signed-in user", + "displayName": "Read all Security Copilot resources for the signed-in user", + "id": "84499c31-ac2e-44d3-a0cf-a6c386d4dfe8", + "origin": "Delegated (Microsoft Graph)", + "value": "SecurityCopilotWorkspaces.Read.All" + }, + { + "description": "Allows the app to read and write Security Copilot resources owned by the signed-in user on their behalf.", + "displayName": "Read and write individually owned Security Copilot resources of the signed-in user", + "id": "206291b0-2167-47a7-a640-6cdc1df710ba", + "origin": "Delegated (Microsoft Graph)", + "value": "SecurityCopilotWorkspaces.ReadWrite.All" + }, + { + "description": "Allows the app to read security actions, on behalf of the signed-in user.", + "displayName": "Read your organization's security actions", + "id": "1638cddf-07a4-4de2-8645-69c96cacad73", + "origin": "Delegated (Microsoft Graph)", + "value": "SecurityActions.Read.All" + }, + { + "description": "Allows the app to read the available Teams templates, on behalf of the signed-in user.", + "displayName": "Read available Teams templates", + "id": "cd87405c-5792-4f15-92f7-debc0db6d1d6", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamTemplates.Read" + }, + { + "description": "Allows the app to read the teamwork settings of the organization, on behalf of the signed-in user.", + "displayName": "Read organizational teamwork settings", + "id": "594f4bb6-c083-4cf9-8aa8-213823bdf351", + "origin": "Delegated (Microsoft Graph)", + "value": "Teamwork.Read.All" + }, + { + "description": "Allows the app to read the Teams app settings on behalf of the signed-in user.", + "displayName": "Read Teams app settings", + "id": "44e060c4-bbdc-4256-a0b9-dcc0396db368", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamworkAppSettings.Read.All" + }, + { + "description": "Allows the app to read and write Windows Hello authentication methods of all users in your organization that the signed-in user has access to. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read and write all users' Windows Hello methods.", + "id": "13eae17d-aaa4-47b8-aaee-0eb33c6e2450", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-WindowsHello.ReadWrite.All" + }, + { + "description": "Allows the app to read cloud clipboard data on behalf of the signed-in user.", + "displayName": "Read cloud clipboard items", + "id": "61e8a09a-087f-4e36-8c8c-1c77c5228017", + "origin": "Delegated (Microsoft Graph)", + "value": "UserCloudClipboard.Read" + }, + { + "description": "Allow the app to convert an external user to an internal member user, on behalf of signed-in user.", + "displayName": "Convert an external user to internal memeber user", + "id": "550e695c-7511-40f4-ac79-e8fb9c82552d", + "origin": "Delegated (Microsoft Graph)", + "value": "User-ConvertToInternal.ReadWrite.All" + }, + { + "description": "Allows the app to read the lifecycle information like employeeLeaveDateTime of users in your organization, on behalf of the signed-in user.", + "displayName": "Read all users' lifecycle information", + "id": "ed8d2a04-0374-41f1-aefe-da8ac87ccc87", + "origin": "Delegated (Microsoft Graph)", + "value": "User-LifeCycleInfo.Read.All" + }, + { + "description": "Allows the app to read and write the lifecycle information like employeeLeaveDateTime of users in your organization, on behalf of the signed-in user.", + "displayName": "Read and write all users' lifecycle information", + "id": "7ee7473e-bd4b-4c9f-987c-bd58481f5fa2", + "origin": "Delegated (Microsoft Graph)", + "value": "User-LifeCycleInfo.ReadWrite.All" + }, + { + "description": "Allows the app to read and write secondary mail addresses for all users, on behalf of the signed-in user.", + "displayName": "Read and write secondary mail addresses for users", + "id": "6166886a-9576-433b-8544-658177bdef1d", + "origin": "Delegated (Microsoft Graph)", + "value": "User-Mail.ReadWrite.All" + }, + { + "description": "Allows the app to send, read, update and delete user’s notifications.", + "displayName": "Deliver and manage user's notifications", + "id": "26e2f3e8-b2a1-47fc-9620-89bb5b042024", + "origin": "Delegated (Microsoft Graph)", + "value": "UserNotification.ReadWrite.CreatedByApp" + }, + { + "description": "Allows the app to read and update the on-premises sync behavior of users on behalf of the signed-in user.", + "displayName": "Read and update the on-premises sync behavior of users", + "id": "7ff9afdd-0cdb-439d-a61c-fea3e9339e89", + "origin": "Delegated (Microsoft Graph)", + "value": "User-OnPremisesSyncBehavior.ReadWrite.All" + }, + { + "description": "Allows the app to read and write password profiles and reset passwords for all users, on behalf of the signed-in user.", + "displayName": "Read and write password profiles and reset user passwords", + "id": "56760768-b641-451f-8906-e1b8ab31bca7", + "origin": "Delegated (Microsoft Graph)", + "value": "User-PasswordProfile.ReadWrite.All" + }, + { + "description": "Allows the app to read and write the mobile phone and business phones for all users, on behalf of the signed-in user.", + "displayName": "Read and write user mobile phone and business phones", + "id": "e29d5979-5b06-4a7f-ae24-6a9348d2e1ff", + "origin": "Delegated (Microsoft Graph)", + "value": "User-Phone.ReadWrite.All" + }, + { + "description": "Allows the app to read the teamwork settings of the signed-in user.", + "displayName": "Read user teamwork settings", + "id": "834bcc1c-762f-41b0-bb91-1cdc323ee4bf", + "origin": "Delegated (Microsoft Graph)", + "value": "UserTeamwork.Read" + }, + { + "description": "Allows the app to report the signed-in user's app activity information to Microsoft Timeline.", + "displayName": "Write app activity to users' timeline", + "id": "367492fc-594d-4972-a9b5-0d58c622c91c", + "origin": "Delegated (Microsoft Graph)", + "value": "UserTimelineActivity.Write.CreatedByApp" + }, + { + "description": "Allows the app to read a user's windows settings which are stored in cloud and their values on behalf of the signed-in user.", + "displayName": "Read windows settings for all devices", + "id": "77e07bab-1b34-40a5-bb6c-4b197b3f6027", + "origin": "Delegated (Microsoft Graph)", + "value": "UserWindowsSettings.Read.All" + }, + { + "description": "Allows the app to read and write a user's windows settings which are stored in cloud and their values on behalf of the signed-in user.", + "displayName": "Read and write windows settings for all devices", + "id": "dcb1026d-b7e1-4d31-9f61-6724d5140bf9", + "origin": "Delegated (Microsoft Graph)", + "value": "UserWindowsSettings.ReadWrite.All" + }, + { + "description": "This role can read Verified Id profiles in a tenant.", + "displayName": "Read Verified Id profiles", + "id": "604b2056-41ed-4c56-aad5-1241d4ef7333", + "origin": "Delegated (Microsoft Graph)", + "value": "VerifiedId-Profile.Read.All" + }, + { + "description": "This role can read and write Verified Id profiles in a tenant.", + "displayName": "Read and write Verified Id profiles", + "id": "e4a9cb5e-4767-48f8-9029-decf26a54456", + "origin": "Delegated (Microsoft Graph)", + "value": "VerifiedId-Profile.ReadWrite.All" + }, + { + "description": "Allows an application to read virtual appointments for the signed-in user. Only an organizer or participant user can read their virtual appointments. ", + "displayName": "Read a user's virtual appointments", + "id": "27470298-d3b8-4b9c-aad4-6334312a3eac", + "origin": "Delegated (Microsoft Graph)", + "value": "VirtualAppointment.Read" + }, + { + "description": "Allows the app to read and write the signed-in user's Windows Hello authentication methods. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read and write the signed-in user's Windows Hello authentication methods", + "id": "f11e1db9-d419-4a24-b677-792723ffd727", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-WindowsHello.ReadWrite" + }, + { + "description": "Allows an application to read and write virtual appointments for the signed-in user. Only an organizer or participant user can read and write their virtual appointments. ", + "displayName": "Read and write a user's virtual appointments ", + "id": "2ccc2926-a528-4b17-b8bb-860eed29d64c", + "origin": "Delegated (Microsoft Graph)", + "value": "VirtualAppointment.ReadWrite" + }, + { + "description": "Allows the app to read Windows Hello authentication methods of all users in your organization that the signed-in user has access to. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read all users' Windows Hello methods", + "id": "ff37d46d-b88a-4e0c-85ee-7e26c37b18eb", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-WindowsHello.Read.All" + }, + { + "description": "Allows the app to read and write Temporary Access Pass authentication methods of all users in your organization that the signed-in user has access to. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read and write all users' Temporary Access Pass methods.", + "id": "05de4a66-e51a-4312-842a-30c8094698d2", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-TAP.ReadWrite.All" + }, + { + "description": "Allows the app to read the signed-in user's platform credential authentication methods. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read the signed-in user's platform credential authentication methods", + "id": "9c694582-e8f2-40e2-8353-fb43e2e0f12a", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-PlatformCred.Read" + }, + { + "description": "Allows the app to read platform credentials methods of all users in your organization that the signed-in user has access to. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read all users' platform credentials methods", + "id": "5936156c-f89b-4850-997d-026c4e6ce529", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-PlatformCred.Read.All" + }, + { + "description": "Allows the app to read and write the signed-in user's platform credential authentication methods. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read and write the signed-in user's platform credential authentication methods", + "id": "70327f81-b953-43c9-92d3-131c74e4beb8", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-PlatformCred.ReadWrite" + }, + { + "description": "Allows the app to read and write platform credentials methods of all users in your organization that the signed-in user has access to. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read and write all users' platform credentials methods.", + "id": "cb11bf8c-dde1-4504-b6a5-31e1562b0749", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-PlatformCred.ReadWrite.All" + }, + { + "description": "Allows the app to read the signed-in user's QR authentication methods. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read the signed-in user's QR authentication methods", + "id": "d6893c31-9187-405c-8dfc-f700c8fc161a", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-QR.Read" + }, + { + "description": "Allows the app to read QR authentication methods of all users in your organization that the signed-in user has access to. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read all users' QR methods", + "id": "e4900dfb-ad17-410d-8ddb-7aebd8a6af1a", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-QR.Read.All" + }, + { + "description": "Allows the app to read and write the signed-in user's QR authentication methods. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read and write the signed-in user's QR authentication methods", + "id": "651210da-18ce-4e42-b7db-302ff88e9326", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-QR.ReadWrite" + }, + { + "description": "Allows the app to read and write QR authentication methods of all users in your organization that the signed-in user has access to. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read and write all users' QR methods.", + "id": "db39086a-da7d-4cbd-9ac0-6816f9a80c95", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-QR.ReadWrite.All" + }, + { + "description": "Allows the app to read the keys associated with the user representing a resource account.", + "displayName": "Read the keys associated with the user representing a resource account.", + "id": "c86e40fc-66fd-4d68-802e-b90e3038f5e8", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-ResourceKey.Read.All" + }, + { + "description": "Allows the app to read and delete the keys associated with the user representing a resource account.", + "displayName": "Read and delete the keys associated with the user representing a resource account.", + "id": "e71dec0d-02b4-429d-a49a-030950d45faa", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-ResourceKey.ReadWrite.All" + }, + { + "description": "Allows the app to read the signed-in user's SoftwareOATH authentication methods. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read the signed-in user's SoftwareOATH authentication methods", + "id": "247f2733-6e3d-46ff-a904-f5fd58eb0d97", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-SoftwareOATH.Read" + }, + { + "description": "Allows the app to read SoftwareOATH authentication methods of all users in your organization that the signed-in user has access to. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read all users' SoftwareOATH methods", + "id": "3e366fa0-3097-4eb6-8294-3028f77eea6f", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-SoftwareOATH.Read.All" + }, + { + "description": "Allows the app to read and write the signed-in user's SoftwareOATH authentication methods. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read and write the signed-in user's SoftwareOATH authentication methods", + "id": "16721eb3-4493-4ae1-9542-264d9ffe3ce9", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-SoftwareOATH.ReadWrite" + }, + { + "description": "Allows the app to read and write SoftwareOATH authentication methods of all users in your organization that the signed-in user has access to. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read and write all users' SoftwareOATH methods.", + "id": "5b34c8b5-2396-4b35-b284-83fb6a3e73ce", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-SoftwareOATH.ReadWrite.All" + }, + { + "description": "Allows the app to read the signed-in user's Temporary Access Pass authentication methods. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read the signed-in user's Temporary Access Pass authentication methods", + "id": "84ded88f-26ba-49d6-b776-efec398de692", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-TAP.Read" + }, + { + "description": "Allows the app to read Temporary Access Pass authentication methods of all users in your organization that the signed-in user has access to. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read all users' Temporary Access Pass methods", + "id": "6976c635-c9c2-41e6-a21d-e6913a155273", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-TAP.Read.All" + }, + { + "description": "Allows the app to read and write the signed-in user's Temporary Access Pass authentication methods. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read and write the signed-in user's Temporary Access Pass authentication methods", + "id": "2424436d-902f-4651-a1c7-b3b93147c960", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-TAP.ReadWrite" + }, + { + "description": "Allows the app to read the signed-in user's Windows Hello authentication methods. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read the signed-in user's Windows Hello methods", + "id": "efe2b5aa-3a8e-486c-b0be-cc4d185c1b40", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-WindowsHello.Read" + }, + { + "description": "Allows an application to send notifications for virtual appointments for the signed-in user.", + "displayName": "Send notification regarding virtual appointments for the signed-in user", + "id": "20d02fff-a0ef-49e7-a46e-019d4a6523b7", + "origin": "Delegated (Microsoft Graph)", + "value": "VirtualAppointmentNotification.Send" + }, + { + "description": "Allows the app to read virtual events created by you", + "displayName": "Read your virtual events", + "id": "6b616635-ae58-433a-a918-8c45e4f304dc", + "origin": "Delegated (Microsoft Graph)", + "value": "VirtualEvent.Read" + }, + { + "description": "Allows the app to read and write virtual events for you", + "displayName": "Read and write your virtual events", + "id": "d38d189c-e29b-4344-8b3b-829bfa81380b", + "origin": "Delegated (Microsoft Graph)", + "value": "VirtualEvent.ReadWrite" + }, + { + "description": "Allow the telecom expense management app to send and receive device telecom and Wi-Fi data usage information, including phone number, with Intune to help analyze and manage data usage costs of corporate-owned devices.", + "displayName": "Send and receive device telecom and Wi-Fi data usage information with Microsoft Intune", + "id": "7828b294-fdcc-4ed6-a45a-854364afb21d", + "origin": "Application (Microsoft Intune API)", + "value": "send_data_usage" + }, + { + "description": "Send device attributes to Microsoft Intune.  \r\n", + "displayName": "Send device attributes to Microsoft Intune", + "id": "7b3c62c0-bbe4-4ceb-971b-ecc50a191b3e", + "origin": "Application (Microsoft Intune API)", + "value": "update_device_attributes" + }, + { + "description": "Allow this app to send device risk and threat information to Intune to help determine device compliance with corporate security policy.", + "displayName": "Send device threat information to Microsoft Intune", + "id": "a5438881-186a-48f0-bc41-a93ae8a195fe", + "origin": "Application (Microsoft Intune API)", + "value": "update_device_health" + }, + { + "description": "Grants access to the Intune data warehouse API", + "displayName": "Get data warehouse information from Microsoft Intune", + "id": "d603c21a-d512-4b5a-b552-c233ebbeaf2e", + "origin": "Delegated (Microsoft Intune API)", + "value": "get_data_warehouse" + }, + { + "description": "Allow users to report info on their Apple Device.", + "displayName": "AppleDeviceInfo.ReadWrite", + "id": "830bc8bc-f872-4624-8386-0013e444366d", + "origin": "Delegated (Microsoft Intune Enrollment)", + "value": "AppleDeviceInfo.ReadWrite" + }, + { + "description": "Allows users to search for their custom branding information.", + "displayName": "branding.search", + "id": "17c38fce-d62f-4e61-85d1-d60852e316a2", + "origin": "Delegated (Microsoft Intune IW Service)", + "value": "branding.search" + }, + { + "description": "Allows user to read their targeted AAD Enterprise apps", + "displayName": "Read AAD Enterprise Apps", + "id": "e3fb1dc8-dea4-41fc-bfbe-1148f98e8821", + "origin": "Delegated (Microsoft Intune IW Service)", + "value": "IntuneAADEnterpriseApps.Read" + }, + { + "description": "Allows the app to read read user's OS Recovery Profiles.", + "displayName": "Read user's OS Recovery Profiles", + "id": "65f5fc61-37d4-4b5c-b092-d51e627c11a4", + "origin": "Delegated (Microsoft Intune IW Service)", + "value": "UserOSRecoveryProfiles.Read" + }, + { + "description": "Microsoft Invoicing for Office 365", + "displayName": "Access according to the application's permissions in Microsoft Invoicing", + "id": "d3010f21-1088-4717-b6b6-60f186f268f6", + "origin": "Application (Microsoft Invoicing)", + "value": "app_access" + }, + { + "description": "Microsoft Invoicing for Office 365", + "displayName": "Access as the signed-in user", + "id": "d079d0df-9773-4880-8129-d0f087474a78", + "origin": "Delegated (Microsoft Invoicing)", + "value": "user_impersonation" + }, + { + "description": "create app", + "displayName": "Application Readwrite", + "id": "51f99752-487f-4501-af40-90beb78bdaa2", + "origin": "Delegated (Microsoft IoT Central)", + "value": "Application.ReadWrite.All" + }, + { + "description": "Allows the application full access to the REST APIs provided by IoT Central on behalf of the signed-in user", + "displayName": "Access IoT Central REST APIs as Signed In User", + "id": "73792908-5709-46da-9a68-098589599db6", + "origin": "Delegated (Microsoft IoT Central)", + "value": "user_impersonation" + }, + { + "description": "Sign in to Remote Rendering service", + "displayName": "arrtest.signin", + "id": "49b8cb69-fa3b-4f0c-a198-62506e47dba1", + "origin": "Application (Microsoft Mixed Reality)", + "value": "arrtest.signin" + }, + { + "description": "Sign In to Mixed Reality Services", + "displayName": "Signin", + "id": "b24fe742-e2a6-4995-adbf-aba1516932c5", + "origin": "Application (Microsoft Mixed Reality)", + "value": "mixedreality.signin" + }, + { + "description": "Sign in to synthetics service", + "displayName": "syntest.signin", + "id": "9f56f4b8-4de2-4e83-a632-45d1e4b47400", + "origin": "Delegated (Microsoft Mixed Reality)", + "value": "syntest.signin" + }, + { + "description": "Send SCEP challenges to Intune for certificate request validation. ", + "displayName": "SCEP challenge validation", + "id": "39d724e8-6a34-4930-9a36-364082c35716", + "origin": "Application (Microsoft Intune API)", + "value": "scep_challenge_provider" + }, + { + "description": "Read PFX certificate requests and send certificates to Microsoft Intune.", + "displayName": "PFX certificate management", + "id": "907d16c7-7591-49a4-b523-6fd42e5f2c7e", + "origin": "Application (Microsoft Intune API)", + "value": "pfx_cert_provider" + }, + { + "description": "Allows the app to send partner compliance policies and its Azure AD Group assignment to Microsoft Intune without a signed-in user.", + "displayName": "Manage partner compliance policies with Microsoft Intune.", + "id": "3857e233-c379-404e-85e9-bdbf3a62b28f", + "origin": "Application (Microsoft Intune API)", + "value": "manage_partner_compliance_policy" + }, + { + "description": "Allow this app to receive information about devices (such as compliance and enrollment state) that are managed by Intune.", + "displayName": "Get device state and compliance information from Microsoft Intune", + "id": "7ec88bad-30c7-4928-a005-4455362cfd98", + "origin": "Application (Microsoft Intune API)", + "value": "get_device_compliance" + }, + { + "description": "Allows the app to read all Windows update deployment settings for the organization on behalf of the signed-in user.", + "displayName": "Read all Windows update deployment settings", + "id": "e09fef2d-bf5e-4439-affa-7c48d23bb1c2", + "origin": "Delegated (Microsoft Graph)", + "value": "WindowsUpdates.Read.All" + }, + { + "description": "Allows the app to read and write all Windows update deployment settings for the organization on behalf of the signed-in user.", + "displayName": "Read and write all Windows update deployment settings", + "id": "11776c0c-6138-4db3-a668-ee621bea2555", + "origin": "Delegated (Microsoft Graph)", + "value": "WindowsUpdates.ReadWrite.All" + }, + { + "description": "Allows the app to read workforce integrations, to synchronize data from Microsoft Teams Shifts, on behalf of the signed-in user.", + "displayName": "Read workforce integrations", + "id": "f1ccd5a7-6383-466a-8db8-1a656f7d06fa", + "origin": "Delegated (Microsoft Graph)", + "value": "WorkforceIntegration.Read.All" + }, + { + "description": "Allows the app to manage workforce integrations, to synchronize data from Microsoft Teams Shifts, on behalf of the signed-in user.", + "displayName": "Read and write workforce integrations", + "id": "08c4b377-0d23-4a8b-be2a-23c1c1d88545", + "origin": "Delegated (Microsoft Graph)", + "value": "WorkforceIntegration.ReadWrite.All" + }, + { + "description": "Allows the current signed-in user to read Content Domain information.", + "displayName": "ContentDomain.Read.All", + "id": "bbee328f-fe32-4251-a58c-9b16f9bf50c8", + "origin": "Application (Microsoft Graph Connectors Core)", + "value": "ContentDomain.Read.All" + }, + { + "description": "Allows the current signed in user to update the Content Domain information", + "displayName": "ContentDomain.ReadWrite", + "id": "6662245d-d5f3-42cb-b401-b08c2f424d5f", + "origin": "Application (Microsoft Graph Connectors Core)", + "value": "ContentDomain.ReadWrite" + }, + { + "description": "Read and Write permission of Content Domain Items into all content domain shards. ", + "displayName": "ContentDomainItem.ReadWrite.All", + "id": "175d9ac0-118e-4725-b5a1-be1e16948cf6", + "origin": "Application (Microsoft Graph Connectors Core)", + "value": "ContentDomainItem.ReadWrite.All" + }, + { + "description": "Read and Write permission of Content Domain Items into the content domain shard owned by the application. ", + "displayName": "ContentDomainItem.ReadWrite.OwnedBy", + "id": "83447e6a-d68b-4373-bd75-efab237f20ba", + "origin": "Application (Microsoft Graph Connectors Core)", + "value": "ContentDomainItem.ReadWrite.OwnedBy" + }, + { + "description": "Allows the app to read and write Phone methods of all users in your organization that the signed-in user has access to. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read and write all users' phone methods.", + "id": "48c99302-9a24-4f27-a8a7-acef4debba14", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-Phone.ReadWrite.All" + }, + { + "description": "Read all published labels and label policies for an organization.", + "displayName": "InformationProtectionPolicy.Read.All", + "id": "dfa45ec9-c9fd-4944-93c8-d07af06cfa40", + "origin": "Application (Microsoft Information Protection API)", + "value": "InformationProtectionPolicy.Read.All" + }, + { + "description": "Read user labels and label policies.", + "displayName": "InformationProtectionPolicy.Read", + "id": "e79e5e22-ae68-4744-b17f-95a009916d6e", + "origin": "Delegated (Microsoft Information Protection API)", + "value": "InformationProtectionPolicy.Read" + }, + { + "description": "Read all published labels and label policies for an organization on your behalf.", + "displayName": "InformationProtectionPolicy.Read.All", + "id": "bf59e00b-be0a-4c3d-bf14-ce55d7146a41", + "origin": "Delegated (Microsoft Information Protection API)", + "value": "InformationProtectionPolicy.Read.All" + }, + { + "description": "c", + "displayName": "Read all unified policies of the tenant.", + "id": "8b2071cd-015a-4025-8052-1c0dba2d3f64", + "origin": "Application (Microsoft Information Protection Sync Service)", + "value": "UnifiedPolicy.Tenant.Read" + }, + { + "description": "Read all unified policies a user has access to.", + "displayName": "Read all unified policies a user has access to.", + "id": "34f7024b-1bed-402f-9664-f5316a1e1b4a", + "origin": "Delegated (Microsoft Information Protection Sync Service)", + "value": "UnifiedPolicy.User.Read" + }, + { + "description": "Allows Intune Admins to enroll a Microsoft Tunnel Gateway Agent", + "displayName": "MicrosoftTunnelGatewayEnrollment", + "id": "e323f13a-1fcc-49cc-883f-c6da13ae0542", + "origin": "Delegated (Microsoft Intune)", + "value": "MicrosoftTunnelGatewayEnrollment" + }, + { + "description": "Allows user to view their BitLocker recovery keys", + "displayName": "Read BitLocker recovery keys", + "id": "ecff1a9d-e6bb-4e01-9136-c8825bbfceb3", + "origin": "Delegated (Microsoft Intune AAD BitLocker Recovery Key Integration)", + "value": "IntuneAADBitLockerRecoveryKey.Read" + }, + { + "description": "Grants access to the Intune data warehouse API", + "displayName": "Get data warehouse information from Microsoft Intune", + "id": "3d9dc976-32fb-45a8-90bd-c9f8a850d098", + "origin": "Application (Microsoft Intune API)", + "value": "get_data_warehouse" + }, + { + "description": "Allows the app to read and write all profile photos of users and groups, on behalf of the signed-in user.", + "displayName": "Read and write profile photo of a user or group", + "id": "f5b24df7-511e-48bb-ae88-643f023b55e1", + "origin": "Delegated (Microsoft Graph)", + "value": "ProfilePhoto.ReadWrite.All" + }, + { + "description": "Allows the app to read and write the signed-in user's phone authentication methods. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read and write the signed-in user's phone authentication methods", + "id": "6c4aad61-f76b-46ad-a22c-57d4d3d962af", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-Phone.ReadWrite" + }, + { + "description": "Allows the app to read the signed-in user's phone authentication methods. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read the signed-in user's phone authentication methods", + "id": "43dab3b9-e8b4-424d-8e13-6a2ad2a625fa", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-Phone.Read" + }, + { + "description": "Allows the application to read Tenant Governance settings on behalf of the signed-in user.", + "displayName": "Read Tenant Governance settings", + "id": "4ad3e05f-2467-49d9-baa2-8e4de7bcee9b", + "origin": "Delegated (Microsoft Graph)", + "value": "TenantGovernance-Setting.Read.All" + }, + { + "description": "Allows the application to read Tenant Governance settings and update them on behalf of the signed-in user.", + "displayName": "Read and write Tenant Governance settings", + "id": "135f3533-12fc-4608-97ac-5c5cea64baf0", + "origin": "Delegated (Microsoft Graph)", + "value": "TenantGovernance-Setting.ReadWrite.All" + }, + { + "description": "Allows the app to read the term store data that the signed-in user has access to. This includes all sets, groups and terms in the term store.", + "displayName": "Read term store data", + "id": "297f747b-0005-475b-8fef-c890f5152b38", + "origin": "Delegated (Microsoft Graph)", + "value": "TermStore.Read.All" + }, + { + "description": "Allows the app to read or modify data that the signed-in user has access to. This includes all sets, groups and terms in the term store.", + "displayName": "Read and write term store data", + "id": "6c37c71d-f50f-4bff-8fd3-8a41da390140", + "origin": "Delegated (Microsoft Graph)", + "value": "TermStore.ReadWrite.All" + }, + { + "description": "Allows an app to read your organization's threat assessment requests on behalf of the signed-in user. Also allows the app to create new requests to assess threats received by your organization on behalf of the signed-in user.", + "displayName": "Read and write threat assessment requests", + "id": "cac97e40-6730-457d-ad8d-4852fddab7ad", + "origin": "Delegated (Microsoft Graph)", + "value": "ThreatAssessment.ReadWrite.All" + }, + { + "description": "Allows the app to run hunting queries, on behalf of the signed-in user.", + "displayName": "Run hunting queries", + "id": "b152eca8-ea73-4a48-8c98-1a6742673d99", + "origin": "Delegated (Microsoft Graph)", + "value": "ThreatHunting.Read.All" + }, + { + "description": "Allows the app to read all the indicators for your organization, on behalf of the signed-in user.", + "displayName": "Read all threat indicators", + "id": "9cc427b4-2004-41c5-aa22-757b755e9796", + "origin": "Delegated (Microsoft Graph)", + "value": "ThreatIndicators.Read.All" + }, + { + "description": "Allows the app to create threat indicators, and fully manage those threat indicators (read, update and delete), on behalf of the signed-in user. It cannot update any threat indicators it does not own.", + "displayName": "Manage threat indicators this app creates or owns", + "id": "91e7d36d-022a-490f-a748-f8e011357b42", + "origin": "Delegated (Microsoft Graph)", + "value": "ThreatIndicators.ReadWrite.OwnedBy" + }, + { + "description": "Allows the app to read threat intelligence information, such as indicators, observations, and articles, on behalf of the signed-in user.", + "displayName": "Read all threat intelligence information", + "id": "f266d9c0-ccb9-4fb8-a228-01ac0d8d6627", + "origin": "Delegated (Microsoft Graph)", + "value": "ThreatIntelligence.Read.All" + }, + { + "description": "Allows the app to read the threat submissions and threat submission policies owned by the signed-in user.", + "displayName": "Read threat submissions", + "id": "fd5353c6-26dd-449f-a565-c4e16b9fce78", + "origin": "Delegated (Microsoft Graph)", + "value": "ThreatSubmission.Read" + }, + { + "description": "Allows the app to read your organization's threat submissions and threat submission policies on behalf of the signed-in user.", + "displayName": "Read all threat submissions", + "id": "7083913a-4966-44b6-9886-c5822a5fd910", + "origin": "Delegated (Microsoft Graph)", + "value": "ThreatSubmission.Read.All" + }, + { + "description": "Allows the app to read the threat submissions and threat submission policies owned by the signed-in user. Also allows the app to create new threat submissions on behalf of the signed-in user.", + "displayName": "Read and write threat submissions", + "id": "68a3156e-46c9-443c-b85c-921397f082b5", + "origin": "Delegated (Microsoft Graph)", + "value": "ThreatSubmission.ReadWrite" + }, + { + "description": "Allows the app to read your organization's threat submissions and threat submission policies on behalf of the signed-in user. Also allows the app to create new threat submissions on behalf of the signed-in user.", + "displayName": "Read and write all threat submissions", + "id": "8458e264-4eb9-4922-abe9-768d58f13c7f", + "origin": "Delegated (Microsoft Graph)", + "value": "ThreatSubmission.ReadWrite.All" + }, + { + "description": "Allows the app to read your organization's threat submission policies on behalf of the signed-in user. Also allows the app to create new threat submission policies on behalf of the signed-in user.", + "displayName": "Read and write all threat submission policies", + "id": "059e5840-5353-4c68-b1da-666a033fc5e8", + "origin": "Delegated (Microsoft Graph)", + "value": "ThreatSubmissionPolicy.ReadWrite.All" + }, + { + "description": "Allows the app to read topics data on behalf of the signed-in user.", + "displayName": "Read topic items", + "id": "79c4c76f-409a-4f98-884d-e2c09291ec26", + "origin": "Delegated (Microsoft Graph)", + "value": "Topic.Read.All" + }, + { + "description": "Allows the app to read trust framework key set properties on behalf of the signed-in user.", + "displayName": "Read trust framework key sets", + "id": "7ad34336-f5b1-44ce-8682-31d7dfcd9ab9", + "origin": "Delegated (Microsoft Graph)", + "value": "TrustFrameworkKeySet.Read.All" + }, + { + "description": "Allows the app to read and write trust framework key set properties on behalf of the signed-in user.", + "displayName": "Read and write trust framework key sets", + "id": "39244520-1e7d-4b4a-aee0-57c65826e427", + "origin": "Delegated (Microsoft Graph)", + "value": "TrustFrameworkKeySet.ReadWrite.All" + }, + { + "description": "Allows the application to list, read, create, and update Tenant Governance requests on behalf of the signed-in user.", + "displayName": "Read and write Tenant Governance requests", + "id": "3c7a434e-4e5d-413f-be82-b77ea4ba5a4d", + "origin": "Delegated (Microsoft Graph)", + "value": "TenantGovernance-Request.ReadWrite.All" + }, + { + "description": "Allows the app to read basic unified group properties, memberships and owners of the group the signed-in guest is a member of.", + "displayName": "Read unified group memberships as guest", + "id": "73e75199-7c3e-41bb-9357-167164dbb415", + "origin": "Delegated (Microsoft Graph)", + "value": "UnifiedGroupMember.Read.AsGuest" + }, + { + "description": "Allows the application to list and read all Tenant Governance requests on behalf of the signed-in user.", + "displayName": "Read Tenant Governance requests", + "id": "a924b9f1-7af0-4982-aecf-b6e0e10b2830", + "origin": "Delegated (Microsoft Graph)", + "value": "TenantGovernance-Request.Read.All" + }, + { + "description": "Allows the application to list and read all Tenant Governance relationships on behalf of the signed-in user.", + "displayName": "Read Tenant Governance relationships", + "id": "0b1c2458-4845-477b-a704-3cce8b06bf28", + "origin": "Delegated (Microsoft Graph)", + "value": "TenantGovernance-Relationship.Read.All" + }, + { + "description": "Allows the app to read and write the Teams app settings on behalf of the signed-in user.", + "displayName": "Read and write Teams app settings", + "id": "87c556f0-2bd9-4eed-bd74-5dd8af6eaf7e", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamworkAppSettings.ReadWrite.All" + }, + { + "description": "Create custom emoji on behalf of the signed-in user.", + "displayName": "Create custom emoji", + "id": "72464cd4-58fd-4116-8a9e-b74757574757", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamworkCustomEmoji.Create" + }, + { + "description": "Read custom emoji on behalf of the signed-in user.", + "displayName": "Read custom emoji", + "id": "89b231b1-414e-4dd4-bb87-d59906da4e05", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamworkCustomEmoji.Read" + }, + { + "description": "Allow the app to read the management data for Teams devices on behalf of the signed-in user.", + "displayName": "Read Teams devices", + "id": "b659488b-9d28-4208-b2be-1c6652b3c970", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamworkDevice.Read.All" + }, + { + "description": "Allow the app to read and write the management data for Teams devices on behalf of the signed-in user.", + "displayName": "Read and write Teams devices", + "id": "ddd97ecb-5c31-43db-a235-0ee20e635c40", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamworkDevice.ReadWrite.All" + }, + { + "description": "Allows the app to read the signed-in user's sections (folders) for organizing chats and channels in Teams.", + "displayName": "Read your sections", + "id": "87a3258d-8c34-49e2-ab91-9b8bdbd79177", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamworkSection.Read" + }, + { + "description": "Allows the app to read and write the signed-in user's sections (folders) for organizing chats and channels in Teams.", + "displayName": "Read and write your sections", + "id": "70dbe5e8-39b9-40f3-8c65-3ec7b00ad804", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamworkSection.ReadWrite" + }, + { + "description": "Allows the app to read tags in Teams, on behalf of the signed-in user.", + "displayName": "Read tags in Teams", + "id": "57587d0b-8399-45be-b207-8050cec54575", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamworkTag.Read" + }, + { + "description": "Allows the app to read and write tags in Teams, on behalf of the signed-in user.", + "displayName": "Read and write tags in Teams", + "id": "539dabd7-b5b6-4117-b164-d60cd15a8671", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamworkTag.ReadWrite" + }, + { + "description": "Allows the user to update group chat or channel targeted messages in Microsoft Teams.", + "displayName": "Update targeted messages belonging to the user", + "id": "162354de-2885-4e5a-94fb-2f03019a65a8", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamworkTargetedMessage.ReadWrite" + }, + { + "description": "Allows the app to read all of the possible Teams interactions between the signed-in user and other users", + "displayName": "Read all of the possible Teams interactions between the user and other users", + "id": "b4d26916-07e0-4daf-9096-9f6d9174aa96", + "origin": "Delegated (Microsoft Graph)", + "value": "TeamworkUserInteraction.Read.All" + }, + { + "description": "Allows the application to list and read all Tenant Governance invitations on behalf of the signed-in user.", + "displayName": "Read Tenant Governance invitations", + "id": "fda068e8-0524-485e-8d7f-b5bc29b0dae9", + "origin": "Delegated (Microsoft Graph)", + "value": "TenantGovernance-Invitation.Read.All" + }, + { + "description": "Allows the application to list, read, create, and delete Tenant Governance invitations on behalf of the signed-in user.", + "displayName": "Read and write Tenant Governance invitations", + "id": "42b91635-3803-4af2-a2d5-e91127f9c488", + "origin": "Delegated (Microsoft Graph)", + "value": "TenantGovernance-Invitation.ReadWrite.All" + }, + { + "description": "Allows the application to list and read all Tenant Governance policy templates on behalf of the signed-in user.", + "displayName": "Read Tenant Governance policy templates", + "id": "ad222a15-813d-46b8-8f8d-1976a69a74f3", + "origin": "Delegated (Microsoft Graph)", + "value": "TenantGovernance-PolicyTemplate.Read.All" + }, + { + "description": "Allows the application to list, read, create, update, and delete Tenant Governance policy templates on behalf of the signed-in user.", + "displayName": "Read and write Tenant Governance policy templates", + "id": "7cd0bd21-45fe-4c8e-a549-3c95bd27d185", + "origin": "Delegated (Microsoft Graph)", + "value": "TenantGovernance-PolicyTemplate.ReadWrite.All" + }, + { + "description": "Allows the application to list and read related tenants information on behalf of the signed-in user.", + "displayName": "Read related tenants", + "id": "9caaca93-f090-4b9a-b4bb-17de251354d4", + "origin": "Delegated (Microsoft Graph)", + "value": "TenantGovernance-RelatedTenant.Read.All" + }, + { + "description": "Allows the application to list, read, and refresh related tenants information on behalf of the signed-in user.", + "displayName": "Read and write related tenants", + "id": "e61db2de-de55-461e-942d-52a028ed1076", + "origin": "Delegated (Microsoft Graph)", + "value": "TenantGovernance-RelatedTenant.ReadWrite.All" + }, + { + "description": "Allows the application to list, read, and update Tenant Governance relationships on behalf of the signed-in user.", + "displayName": "Read and write Tenant Governance relationships", + "id": "3fbcd6a3-a9a5-4d69-8a78-acc7d7195180", + "origin": "Delegated (Microsoft Graph)", + "value": "TenantGovernance-Relationship.ReadWrite.All" + }, + { + "description": "Allows the app to create users, on behalf of the signed-in user.", + "displayName": "Create users", + "id": "d8ce6a2a-46ff-438e-96bc-020f23870a55", + "origin": "Delegated (Microsoft Graph)", + "value": "User.Create" + }, + { + "description": "Allows the app to delete and restore all users, on behalf of the signed-in user.", + "displayName": "Delete and restore users", + "id": "4bb440cd-2cf2-4f90-8004-aa2acd2537c5", + "origin": "Delegated (Microsoft Graph)", + "value": "User.DeleteRestore.All" + }, + { + "description": "Allows the app to enable and disable users' accounts, on behalf of the signed-in user.", + "displayName": "Enable and disable user accounts", + "id": "f92e74e7-2563-467f-9dd0-902688cb5863", + "origin": "Delegated (Microsoft Graph)", + "value": "User.EnableDisableAccount.All" + }, + { + "description": "Allows the app to read and write external authentication methods of all users in your organization that the signed-in user has access to. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read and write all users' external methods.", + "id": "9d91805d-0f53-43e3-a0f3-303ad4f3056f", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-External.ReadWrite.All" + }, + { + "description": "Allows the app to read the signed-in user's HardwareOATH authentication methods. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read the signed-in user's HardwareOATH authentication methods", + "id": "ccd2eb40-8874-44e6-8f96-335908b3cfdb", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-HardwareOATH.Read" + }, + { + "description": "Allows the app to read HardwareOATH authentication methods of all users in your organization that the signed-in user has access to. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read all users' HardwareOATH authentication methods", + "id": "acd68c26-c283-4bf4-8b5c-200fc179bdd5", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-HardwareOATH.Read.All" + }, + { + "description": "Allows the app to read and write the signed-in user's HardwareOATH authentication methods. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read and write the signed-in user's HardwareOATH authentication methods", + "id": "147ca97b-6686-4849-b37e-09d9b5ad45fc", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-HardwareOATH.ReadWrite" + }, + { + "description": "Allows the app to read and write HardwareOATH authentication methods of all users in your organization that the signed-in user has access to. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read and write all users' HardwareOATH methods.", + "id": "480643f2-a162-43c5-a670-dc1494fc911b", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-HardwareOATH.ReadWrite.All" + }, + { + "description": "Allows the app to read the signed-in user's Microsoft Authenticator authentication methods. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read the signed-in user's Microsoft Authenticator authentication methods", + "id": "f14a567b-3280-4124-95a0-eca86006967e", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-MicrosoftAuthApp.Read" + }, + { + "description": "Allows the app to read Microsoft authentication methods of all users in your organization that the signed-in user has access to. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read all users' Microsoft authentication methods", + "id": "7b627679-e2fd-4bfd-990e-989e2914d4e6", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-MicrosoftAuthApp.Read.All" + }, + { + "description": "Allows the app to read and write the signed-in user's Microsoft Authenticator authentication methods. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read and write the signed-in user's Microsoft Authenticator authentication methods", + "id": "9f7dfa0c-eb40-42be-8d45-8af4a9219c6f", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-MicrosoftAuthApp.ReadWrite" + }, + { + "description": "Allows the app to read and write Microsoft Authentication methods of all users in your organization that the signed-in user has access to. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read and write all users' Microsoft Authentication methods.", + "id": "1b7322b2-5cb3-4f13-928f-d7ca97c5fba9", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-MicrosoftAuthApp.ReadWrite.All" + }, + { + "description": "Allows the app to read the signed-in user's passkey authentication methods. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read the signed-in user's passkey authentication methods", + "id": "828fcbda-0d26-431d-8bfb-83f217224621", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-Passkey.Read" + }, + { + "description": "Allows the app to read passkey authentication methods of all users in your organization that the signed-in user has access to. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read all users' passkey authentication methods", + "id": "14195339-1fe4-48a7-a0d3-a39eb9fd8958", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-Passkey.Read.All" + }, + { + "description": "Allows the app to read and write the signed-in user's passkey authentication methods. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read and write the signed-in user's passkey authentication methods", + "id": "b2de7db9-10f7-4800-b04c-b5b91e4891d6", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-Passkey.ReadWrite" + }, + { + "description": "Allows the app to read and write passkey authentication methods of all users in your organization that the signed-in user has access to. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read and write all users' passkey methods.", + "id": "64930478-d0ea-4671-ad72-fe0d9821df09", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-Passkey.ReadWrite.All" + }, + { + "description": "Allows the app to read the signed-in user's password authentication methods. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read the signed-in user's password authentication methods", + "id": "7f0f82c3-de19-4ddc-810d-a2206d7637fd", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-Password.Read" + }, + { + "description": "Allows the app to read password authentication methods of all users in your organization that the signed-in user has access to. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read all users' password authentication methods", + "id": "4f69a4e2-2aa0-43a7-ad6b-98b4cda1f23f", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-Password.Read.All" + }, + { + "description": "Allows the app to read and write the signed-in user's password authentication methods. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read and write the signed-in user's password authentication methods", + "id": "60cce20d-d41e-4594-b391-84bbf8cc31f3", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-Password.ReadWrite" + }, + { + "description": "Allows the app to read and write password authentication methods of all users in your organization that the signed-in user has access to. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read and write all users' password methods.", + "id": "7f5b683d-df96-4690-a88d-6e336ed6dc7c", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-Password.ReadWrite.All" + }, + { + "description": "Allows the app to read and write the signed-in user's external authentication methods. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read and write the signed-in user's external authentication methods", + "id": "28c2e8f9-828a-4691-a090-f2f0b7fc07b3", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-External.ReadWrite" + }, + { + "description": "Allows the app to read external authentication methods of all users in your organization that the signed-in user has access to. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read all users' external authentication methods", + "id": "cbca9646-4c34-4cea-8e54-9a7088018820", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-External.Read.All" + }, + { + "description": "Allows the app to read the signed-in user's external authentication methods. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read the signed-in user's external authentication methods", + "id": "d1739827-146b-4f7f-b52c-1c509253aa57", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-External.Read" + }, + { + "description": "Allows the app to read and write email methods of all users in your organization that the signed-in user has access to. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read and write all users' email methods.", + "id": "074f680f-c89e-45be-880e-5d0642860a1c", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-Email.ReadWrite.All" + }, + { + "description": "Allows the app to export data (e.g. customer content or system-generated logs), associated with any user in your company, when the app is used by a privileged user (e.g. a Company Administrator).", + "displayName": "Export user's data", + "id": "405a51b5-8d8d-430b-9842-8be4b0e9f324", + "origin": "Delegated (Microsoft Graph)", + "value": "User.Export.All" + }, + { + "description": "Allows the app to invite guest users to the organization, on behalf of the signed-in user.", + "displayName": "Invite guest users to the organization", + "id": "63dd7cd9-b489-4adf-a28c-ac38b9a0f962", + "origin": "Delegated (Microsoft Graph)", + "value": "User.Invite.All" + }, + { + "description": "Allows the app to read, update and delete identities that are associated with a user's account that the signed-in user has access to. This controls the identities users can sign-in with.", + "displayName": "Manage user identities", + "id": "637d7bec-b31e-4deb-acc9-24275642a2c9", + "origin": "Delegated (Microsoft Graph)", + "value": "User.ManageIdentities.All" + }, + { + "description": "Allows users to sign-in to the app, and allows the app to read the profile of signed-in users. It also allows the app to read basic company information of signed-in users.", + "displayName": "Sign in and read user profile", + "id": "e1fe6dd8-ba31-4d61-89e7-88639da4683d", + "origin": "Delegated (Microsoft Graph)", + "value": "User.Read" + }, + { + "description": "Allows the app to read the full set of profile properties, reports, and managers of other users in your organization, on behalf of the signed-in user.", + "displayName": "Read all users' full profiles", + "id": "a154be20-db9c-4678-8ab7-66f6cc099a59", + "origin": "Delegated (Microsoft Graph)", + "value": "User.Read.All" + }, + { + "description": "Allows the app to read a basic set of profile properties of other users in your organization on behalf of the signed-in user. This includes display name, first and last name, email address and photo.", + "displayName": "Read all users' basic profiles", + "id": "b340eb25-3456-403f-be2f-af7a0d370277", + "origin": "Delegated (Microsoft Graph)", + "value": "User.ReadBasic.All" + }, + { + "description": "Allows the app to read and update users, on behalf of the signed-in user.", + "displayName": "Read and update users", + "id": "f8b099f1-a6ce-4e00-8fff-5f783ff4faeb", + "origin": "Delegated (Microsoft Graph)", + "value": "User.ReadUpdate.All" + }, + { + "description": "Allows the app to read your profile. It also allows the app to update your profile information on your behalf.", + "displayName": "Read and write access to user profile", + "id": "b4e74841-8e56-480b-be8b-910348b18b4c", + "origin": "Delegated (Microsoft Graph)", + "value": "User.ReadWrite" + }, + { + "description": "Allows the app to read phone authentication methods of all users in your organization that the signed-in user has access to. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read all users' phone authentication methods", + "id": "20cf4ae1-09b9-4d29-a6f8-43e1820ce60c", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-Phone.Read.All" + }, + { + "description": "Allows the app to read and write the full set of profile properties, reports, and managers of other users in your organization, on behalf of the signed-in user.", + "displayName": "Read and write all users' full profiles", + "id": "204e0828-b5ca-4ad8-b9f3-f32a958e7cc4", + "origin": "Delegated (Microsoft Graph)", + "value": "User.ReadWrite.All" + }, + { + "description": "Allows the app to read and report the signed-in user's activity in the app.", + "displayName": "Read and write app activity to users' activity feed", + "id": "47607519-5fb1-47d9-99c7-da4b48f369b1", + "origin": "Delegated (Microsoft Graph)", + "value": "UserActivity.ReadWrite.CreatedByApp" + }, + { + "description": "Allows the app to read the signed-in user's authentication methods, including phone numbers and Authenticator app settings. This does not allow the app to see secret information like the signed-in user's passwords, or to sign-in or otherwise use the signed-in user's authentication methods.", + "displayName": "Read user authentication methods.", + "id": "1f6b61c5-2f65-4135-9c9f-31c0f8d32b52", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthenticationMethod.Read" + }, + { + "description": "Allows the app to read authentication methods of all users in your organization that the signed-in user has access to. Authentication methods include things like a user’s phone numbers and Authenticator app settings. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read all users' authentication methods", + "id": "aec28ec7-4d02-4e8c-b864-50163aea77eb", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthenticationMethod.Read.All" + }, + { + "description": "Allows the app to read and write the signed-in user's authentication methods, including phone numbers and Authenticator app settings. This does not allow the app to see secret information like the signed-in user's passwords, or to sign-in or otherwise use the signed-in user's authentication methods. ", + "displayName": "Read and write user authentication methods", + "id": "48971fc1-70d7-4245-af77-0beb29b53ee2", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthenticationMethod.ReadWrite" + }, + { + "description": " Allows the app to read and write authentication methods of all users in your organization that the signed-in user has access to. Authentication methods include things like a user's phone numbers and Authenticator app settings. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read and write all users' authentication methods", + "id": "b7887744-6746-4312-813d-72daeaee7e2d", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthenticationMethod.ReadWrite.All" + }, + { + "description": "Allows the app to read the signed-in user's email authentication methods. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read the signed-in user's email authentication methods", + "id": "12b23cea-90c1-4873-9094-f45c5f290f86", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-Email.Read" + }, + { + "description": "Allows the app to read email methods of all users in your organization that the signed-in user has access to. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read all users' email methods", + "id": "76caaf3a-ebdb-40a3-9299-4196e636f290", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-Email.Read.All" + }, + { + "description": "Allows the app to read and write the signed-in user's email authentication methods. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read and write the signed-in user's email authentication methods", + "id": "696aa421-62dc-4c99-be16-015b23444089", + "origin": "Delegated (Microsoft Graph)", + "value": "UserAuthMethod-Email.ReadWrite" + }, + { + "description": "Allow the app to revoke all sign in sessions for a user, on behalf of a signed-in user.", + "displayName": "Revoke all sign in sessions for a user", + "id": "fc30e98b-8810-4501-81f5-c20a3196387b", + "origin": "Delegated (Microsoft Graph)", + "value": "User.RevokeSessions.All" + }, + { + "description": "Allows the app to read all profile photos of users and groups, on behalf of the signed-in user.", + "displayName": "Read profile photo of a user or group", + "id": "469cd065-729e-4dee-b1fa-d92e0fab6310", + "origin": "Delegated (Microsoft Graph)", + "value": "ProfilePhoto.Read.All" + }, + { + "description": "Allows the app to see your users' basic profile (e.g., name, picture, user name, email address)", + "displayName": "View users' basic profile", + "id": "14dad69e-099b-42c9-810b-d002981feec1", + "origin": "Delegated (Microsoft Graph)", + "value": "profile" + }, + { + "description": "Allows the app to delete time-based eligibility schedules for access to Azure AD groups, on behalf of the signed-in user.", + "displayName": "Delete eligibility schedules for access to Azure AD groups", + "id": "c5ea9ab4-9b41-4c09-a400-53e652fb5096", + "origin": "Delegated (Microsoft Graph)", + "value": "PrivilegedEligibilitySchedule.Remove.AzureADGroup" + }, + { + "description": "Allows the application to manage selected file storage container type registrations on behalf of the signed in user. The user must be a SharePoint Embedded Admin or Global Admin.", + "displayName": "Access selected file storage container type registrations.", + "id": "d1e4f63a-1569-475c-b9b2-bdc140405e38", + "origin": "Delegated (Microsoft Graph)", + "value": "FileStorageContainerTypeReg.Selected" + }, + { + "description": "Allows the app to read and write financials data on behalf of the signed-in user.", + "displayName": "Read and write financials data", + "id": "f534bf13-55d4-45a9-8f3c-c92fe64d6131", + "origin": "Delegated (Microsoft Graph)", + "value": "Financials.ReadWrite.All" + }, + { + "description": "Allows the app to read all goals and export jobs that the signed-in user can access.", + "displayName": "Read all goals and export jobs that a user can access", + "id": "092211d9-ca1a-427b-813e-b79c7653fe71", + "origin": "Delegated (Microsoft Graph)", + "value": "Goals-Export.Read.All" + }, + { + "description": "Allows the app to read goals, create and read export jobs that the signed-in user can access.", + "displayName": "Have full access to all goals and export jobs a user can access", + "id": "2edeb9fd-4228-480c-a26d-2ed52011cf3d", + "origin": "Delegated (Microsoft Graph)", + "value": "Goals-Export.ReadWrite.All" + }, + { + "description": "Allows the app to list groups, and to read their basic properties and manage the MIP label for all label enabled groups on behalf of the signed-in user. ", + "displayName": "Manage the Microsoft Information Protection (MIP) label for M365 and security groups.", + "id": "36263ed6-285e-4f84-b25a-62ec2ba17d29", + "origin": "Delegated (Microsoft Graph)", + "value": "Group.ManageProtection.All" + }, + { + "description": "Allows the app to list groups, and to read their properties and all group memberships on behalf of the signed-in user. Also allows the app to read calendar, conversations, files, and other group content for all groups the signed-in user can access.", + "displayName": "Read all groups", + "id": "5f8c59db-677d-491f-a6b8-5f174b11ec1d", + "origin": "Delegated (Microsoft Graph)", + "value": "Group.Read.All" + }, + { + "description": "Allows the app to create groups and read all group properties and memberships on behalf of the signed-in user. Additionally allows group owners to manage their groups and allows group members to update group content.", + "displayName": "Read and write all groups", + "id": "4e46008b-f24c-477d-8fff-7bb4ec7aafe0", + "origin": "Delegated (Microsoft Graph)", + "value": "Group.ReadWrite.All" + }, + { + "description": "Allows the app to read group conversations that the signed-in user has access to.", + "displayName": "Read group conversations", + "id": "c92fbbc2-50e0-4842-93ef-385c3293ea3d", + "origin": "Delegated (Microsoft Graph)", + "value": "Group-Conversation.Read.All" + }, + { + "description": "Allows the app to read and write group conversations that the signed-in user has access to.", + "displayName": "Read and write group conversations", + "id": "302bcbb5-855a-4e49-ae20-94a331b0281e", + "origin": "Delegated (Microsoft Graph)", + "value": "Group-Conversation.ReadWrite.All" + }, + { + "description": "Allows the app to list groups, read basic group properties and read membership of all groups the signed-in user has access to.", + "displayName": "Read group memberships", + "id": "bc024368-1153-4739-b217-4326f2e966d0", + "origin": "Delegated (Microsoft Graph)", + "value": "GroupMember.Read.All" + }, + { + "description": "Allows the app to list groups, read basic properties, read and update the membership of the groups the signed-in user has access to. Group properties and owners cannot be updated and groups cannot be deleted.", + "displayName": "Read and write group memberships", + "id": "f81125ac-d3b7-4573-a3b2-7099cc39df9e", + "origin": "Delegated (Microsoft Graph)", + "value": "GroupMember.ReadWrite.All" + }, + { + "description": "Allows the app to read and write groups' disableNesting property on behalf of the signed-in user.", + "displayName": "Read and write groups' disableNesting property", + "id": "afc507db-8793-4d2f-999d-6e34cff02b7c", + "origin": "Delegated (Microsoft Graph)", + "value": "Group-NestingSupport.ReadWrite.All" + }, + { + "description": "Allows the app to read and update the on-premises sync behavior of groups on behalf of the signed-in user.", + "displayName": "Read and update the on-premises sync behavior of groups", + "id": "37e00479-5776-4659-aecf-4841ec5d590a", + "origin": "Delegated (Microsoft Graph)", + "value": "Group-OnPremisesSyncBehavior.ReadWrite.All" + }, + { + "description": "Allows the app to read a list of tenant-level or group-specific group settings objects, on behalf of the signed-in user.", + "displayName": "Read all group settings that user can access", + "id": "2eb2bc92-94ef-4c6b-b4ab-2a09bc975e0e", + "origin": "Delegated (Microsoft Graph)", + "value": "GroupSettings.Read.All" + }, + { + "description": "Allows the app to create, read, update, and delete on the list of tenant-level or group-specific group settings objects that you have access to in the organization, on behalf of the signed-in user.", + "displayName": "Read and write all group settings that user can access", + "id": "c1691a6d-99e2-4cfa-b4b5-9e4d67dc0f36", + "origin": "Delegated (Microsoft Graph)", + "value": "GroupSettings.ReadWrite.All" + }, + { + "description": "Allows the app to read all Cross-Tenant Identity Synchronization properties on Groups, on behalf of the signed-in user.", + "displayName": "Read all Group Cross-Tenant Identity Synchronization properties", + "id": "f7c0661b-4247-48ac-a371-05ff047614c6", + "origin": "Delegated (Microsoft Graph)", + "value": "Group-XTenantIdentitySync.Read.All" + }, + { + "description": "Allows the app to read all scenario health monitoring alerts", + "displayName": "Read all scenario health monitoring alerts", + "id": "74b4ff32-4917-4536-a66d-38a4861e6220", + "origin": "Delegated (Microsoft Graph)", + "value": "HealthMonitoringAlert.Read.All" + }, + { + "description": "Allows the application to manage file storage container type registrations on behalf of the signed in user. The user must be a SharePoint Embedded Admin or Global Admin.", + "displayName": "Manage file storage container type registrations on behalf of the signed in user", + "id": "c319a7df-930e-44c0-a43b-7e5e9c7f4f24", + "origin": "Delegated (Microsoft Graph)", + "value": "FileStorageContainerTypeReg.Manage.All" + }, + { + "description": "Allows the app to read and write all scenario monitoring alerts, on behalf of the signed-in user.", + "displayName": "Read and write all scenario monitoring alerts", + "id": "b7c60f27-2195-4d5f-96a7-6b98bdfd9664", + "origin": "Delegated (Microsoft Graph)", + "value": "HealthMonitoringAlert.ReadWrite.All" + }, + { + "description": "Allows the application to manage file storage container types on behalf of the signed in user. The user must be a SharePoint Embedded Admin or Global Admin.", + "displayName": "Manage file storage container types on behalf of the signed in user", + "id": "8e6ec84c-5fcd-4cc7-ac8a-2296efc0ed9b", + "origin": "Delegated (Microsoft Graph)", + "value": "FileStorageContainerType.Manage.All" + }, + { + "description": "Allows the application to utilize the file storage container administration capabilities on behalf of an administrator user.", + "displayName": "Manage all file storage containers", + "id": "527b6d64-cdf5-4b8b-b336-4aa0b8ca2ce5", + "origin": "Delegated (Microsoft Graph)", + "value": "FileStorageContainer.Manage.All" + }, + { + "description": "Allows the app to read all external connections on behalf of a signed-in user. The signed-in user must be an administrator.", + "displayName": "Read all external connections", + "id": "a38267a5-26b6-4d76-9493-935b7599116b", + "origin": "Delegated (Microsoft Graph)", + "value": "ExternalConnection.Read.All" + }, + { + "description": "Allows the app to read and write all external connections on behalf of a signed-in user. The signed-in user must be an administrator.", + "displayName": "Read and write all external connections", + "id": "bbbbd9b3-3566-4931-ac37-2b2180d9e334", + "origin": "Delegated (Microsoft Graph)", + "value": "ExternalConnection.ReadWrite.All" + }, + { + "description": "Allows the app to read and write settings of external connections on behalf of a signed-in user. The signed-in user must be an administrator. The app can only read and write settings of connections that it is authorized to.", + "displayName": "Read and write external connections", + "id": "4082ad95-c812-4f02-be92-780c4c4f1830", + "origin": "Delegated (Microsoft Graph)", + "value": "ExternalConnection.ReadWrite.OwnedBy" + }, + { + "description": "Allow the app to read external datasets and content, on behalf of the signed-in user.", + "displayName": "Read items in external datasets", + "id": "922f9392-b1b7-483c-a4be-0089be7704fb", + "origin": "Delegated (Microsoft Graph)", + "value": "ExternalItem.Read.All" + }, + { + "description": "Allows the app to read and write all external items on behalf of a signed-in user. The signed-in user must be an administrator.", + "displayName": "Read and write all external items", + "id": "b02c54f8-eb48-4c50-a9f0-a149e5a2012f", + "origin": "Delegated (Microsoft Graph)", + "value": "ExternalItem.ReadWrite.All" + }, + { + "description": "Allows the app to read and write external items on behalf of a signed-in user. The signed-in user must be an administrator. The app can only read external items of the connection that it is authorized to.", + "displayName": "Read and write external items", + "id": "4367b9d7-cee7-4995-853c-a0bdfe95c1f9", + "origin": "Delegated (Microsoft Graph)", + "value": "ExternalItem.ReadWrite.OwnedBy" + }, + { + "description": "Allows the app to read available properties of external user profiles, on behalf of the signed-in user.", + "displayName": "Read external user profiles", + "id": "47167bec-55a7-4caf-9ecc-8d4566e3cfb1", + "origin": "Delegated (Microsoft Graph)", + "value": "ExternalUserProfile.Read.All" + }, + { + "description": "Allows the app to read and write available properties of external user profiles, on behalf of the signed-in user.", + "displayName": "Read and write external user profiles", + "id": "c6068dc7-a791-46a4-a811-b8228e6649ab", + "origin": "Delegated (Microsoft Graph)", + "value": "ExternalUserProfile.ReadWrite.All" + }, + { + "description": "Allows the app to read your family information, members and their basic profile.", + "displayName": "Read your family info", + "id": "3a1e4806-a744-4c70-80fc-223bf8582c46", + "origin": "Delegated (Microsoft Graph)", + "value": "Family.Read" + }, + { + "description": "Allows the app to read the signed-in user's files.", + "displayName": "Read user files", + "id": "10465720-29dd-4523-a11a-6a75c743c9d9", + "origin": "Delegated (Microsoft Graph)", + "value": "Files.Read" + }, + { + "description": "Allows the app to read all files the signed-in user can access.", + "displayName": "Read all files that user can access", + "id": "df85f4d6-205c-4ac5-a5ea-6bf408dba283", + "origin": "Delegated (Microsoft Graph)", + "value": "Files.Read.All" + }, + { + "description": "(Preview) Allows the app to read files that the user selects. The app has access for several hours after the user selects a file.", + "displayName": "Read files that the user selects (preview)", + "id": "5447fe39-cb82-4c1a-b977-520e67e724eb", + "origin": "Delegated (Microsoft Graph)", + "value": "Files.Read.Selected" + }, + { + "description": "Allows the app to read, create, update and delete the signed-in user's files.", + "displayName": "Have full access to user files", + "id": "5c28f0bf-8a70-41f1-8ab2-9032436ddb65", + "origin": "Delegated (Microsoft Graph)", + "value": "Files.ReadWrite" + }, + { + "description": "Allows the app to read, create, update and delete all files the signed-in user can access.", + "displayName": "Have full access to all files user can access", + "id": "863451e7-0667-486c-a5d6-d135439485f0", + "origin": "Delegated (Microsoft Graph)", + "value": "Files.ReadWrite.All" + }, + { + "description": "(Preview) Allows the app to read, create, update and delete files in the application's folder.", + "displayName": "Have full access to the application's folder (preview)", + "id": "8019c312-3263-48e6-825e-2b833497195b", + "origin": "Delegated (Microsoft Graph)", + "value": "Files.ReadWrite.AppFolder" + }, + { + "description": "(Preview) Allows the app to read and write files that the user selects. The app has access for several hours after the user selects a file.", + "displayName": "Read and write files that the user selects (preview)", + "id": "17dde5bd-8c17-420f-a486-969730c1b827", + "origin": "Delegated (Microsoft Graph)", + "value": "Files.ReadWrite.Selected" + }, + { + "description": "Allow the application to access files explicitly permissioned to the application on behalf of the signed in user. The specific files and the permissions granted will be configured in SharePoint Online or OneDrive.", + "displayName": "Access selected Files, on behalf of the signed-in user", + "id": "ef2779dc-ef1b-4211-8310-8a0ac2450081", + "origin": "Delegated (Microsoft Graph)", + "value": "Files.SelectedOperations.Selected" + }, + { + "description": "Allows the application to utilize the file storage container platform to manage containers on behalf of the signed in user. The specific file storage containers and the permissions granted to them will be configured in Microsoft 365 by the developer of each container type.", + "displayName": "Access selected file storage containers", + "id": "085ca537-6565-41c2-aca7-db852babc212", + "origin": "Delegated (Microsoft Graph)", + "value": "FileStorageContainer.Selected" + }, + { + "description": "Allows the app to read all scenario health monitoring alert configurations", + "displayName": "Read all scenario health monitoring alert configurations", + "id": "fb873030-8626-47e6-96ff-8a5bff3b725f", + "origin": "Delegated (Microsoft Graph)", + "value": "HealthMonitoringAlertConfig.Read.All" + }, + { + "description": "Allows the app to read and write all scenario monitoring alert configurations, on behalf of the signed-in user.", + "displayName": "Read and write all scenario monitoring alert configurations.", + "id": "b3e5ebc6-1c23-4337-8286-3f27165addb4", + "origin": "Delegated (Microsoft Graph)", + "value": "HealthMonitoringAlertConfig.ReadWrite.All" + }, + { + "description": "Allows the app to read identity notification settings, email templates, and prerequisites on behalf of the signed-in user.", + "displayName": "Read identity notification settings and templates", + "id": "59cd3e28-aa9c-4f72-a734-1b592eb06853", + "origin": "Delegated (Microsoft Graph)", + "value": "IdentityNotifications.Read.All" + }, + { + "description": "Allows the app to read reference definitions on behalf of the signed-in user.", + "displayName": "View reference definitions", + "id": "a3f96ffe-cb84-40a8-ac85-582d7ef97c2a", + "origin": "Delegated (Microsoft Graph)", + "value": "IndustryData-ReferenceDefinition.Read.All" + }, + { + "description": "Allows the app to read and write reference definitions on behalf of the signed-in user.", + "displayName": "Manage reference definitions", + "id": "a757d430-be6d-430f-af57-28aabe79d247", + "origin": "Delegated (Microsoft Graph)", + "value": "IndustryData-ReferenceDefinition.ReadWrite.All" + }, + { + "description": "Allows the app to read current and previous IndustryData runs on behalf of the signed-in user.", + "displayName": "View current and previous runs", + "id": "92685235-50c4-4702-b2c8-36043db6fa79", + "origin": "Delegated (Microsoft Graph)", + "value": "IndustryData-Run.Read.All" + }, + { + "description": "Allows the app to view and start IndustryData runs on behalf of the signed-in user.", + "displayName": "View and start runs", + "id": "f03a6d0e-0989-460f-80b2-e57c8561763e", + "origin": "Delegated (Microsoft Graph)", + "value": "IndustryData-Run.Start" + }, + { + "description": "Allows the app to read source system definitions on behalf of the signed-in user.", + "displayName": "View source system definitions", + "id": "49b7016c-89ae-41e7-bd6f-b7170c5490bf", + "origin": "Delegated (Microsoft Graph)", + "value": "IndustryData-SourceSystem.Read.All" + }, + { + "description": "Allows the app to read and write source system definitions on behalf of the signed-in user.", + "displayName": "Manage source system definitions", + "id": "9599f005-05d6-4ea7-b1b1-4929768af5d0", + "origin": "Delegated (Microsoft Graph)", + "value": "IndustryData-SourceSystem.ReadWrite.All" + }, + { + "description": "Allows the app to read time period definitions on behalf of the signed-in user.", + "displayName": "Read time period definitions", + "id": "c9d51f28-8ccd-42b2-a836-fd8fe9ebf2ae", + "origin": "Delegated (Microsoft Graph)", + "value": "IndustryData-TimePeriod.Read.All" + }, + { + "description": "Allows the app to read and write time period definitions on behalf of the signed-in user.", + "displayName": "Manage time period definitions", + "id": "b6d56528-3032-4f9d-830f-5a24a25e6661", + "origin": "Delegated (Microsoft Graph)", + "value": "IndustryData-TimePeriod.ReadWrite.All" + }, + { + "description": "Allows the app to read the configurations applicable to the signed-in user for protecting organizational data, on behalf of the signed-in user.", + "displayName": "Read configurations for protecting organizational data applicable to the user", + "id": "12f4bffb-b598-413c-984b-db99728f8b54", + "origin": "Delegated (Microsoft Graph)", + "value": "InformationProtectionConfig.Read" + }, + { + "description": "Allows an app to read information protection sensitivity labels and label policy settings, on behalf of the signed-in user.", + "displayName": "Read user sensitivity labels and label policies.", + "id": "4ad84827-5578-4e18-ad7a-86530b12f884", + "origin": "Delegated (Microsoft Graph)", + "value": "InformationProtectionPolicy.Read" + }, + { + "description": "Allows an app to read user metrics insights, such as daily and monthly active users, on behalf of the signed-in user.", + "displayName": "Read user metrics insights", + "id": "7d249730-51a3-4180-8ec1-214f144f1bff", + "origin": "Delegated (Microsoft Graph)", + "value": "Insights-UserMetric.Read.All" + }, + { + "description": "Allows the app to read data for the learner's assignments in the organization's directory, on behalf of the signed-in user.", + "displayName": "Read user's assignments", + "id": "ac08cdae-e845-41db-adf9-5899a0ec9ef6", + "origin": "Delegated (Microsoft Graph)", + "value": "LearningAssignedCourse.Read" + }, + { + "description": "Allows the app to read learning content in the organization's directory, on behalf of the signed-in user.", + "displayName": "Read learning content", + "id": "ea4c1fd9-6a9f-4432-8e5d-86e06cc0da77", + "origin": "Delegated (Microsoft Graph)", + "value": "LearningContent.Read.All" + }, + { + "description": "Allows the app to manage learning content in the organization's directory, on behalf of the signed-in user.", + "displayName": "Manage learning content", + "id": "53cec1c4-a65f-4981-9dc1-ad75dbf1c077", + "origin": "Delegated (Microsoft Graph)", + "value": "LearningContent.ReadWrite.All" + }, + { + "description": "Allows the app to read data for the learning provider in the organization's directory, on behalf of the signed-in user.", + "displayName": "Read learning provider", + "id": "dd8ce36f-9245-45ea-a99e-8ac398c22861", + "origin": "Delegated (Microsoft Graph)", + "value": "LearningProvider.Read" + }, + { + "description": "Allows the app to create, update, read, and delete data for the learning provider in the organization's directory, on behalf of the signed-in user.", + "displayName": "Manage learning provider", + "id": "40c2eb57-abaf-49f5-9331-e90fd01f7130", + "origin": "Delegated (Microsoft Graph)", + "value": "LearningProvider.ReadWrite" + }, + { + "description": "Allows the app to read data for the learner's self-initiated courses in the organization's directory, on behalf of the signed-in user.", + "displayName": "Read user's self-initiated courses", + "id": "f6403ef7-4a96-47be-a190-69ba274c3f11", + "origin": "Delegated (Microsoft Graph)", + "value": "LearningSelfInitiatedCourse.Read" + }, + { + "description": "Allows the app to read and write outbound data flows on behalf of the signed-in user.", + "displayName": "Manage outbound flow definitions", + "id": "aeb68e0b-e562-4a1f-b6dd-3484ad0cbb4b", + "origin": "Delegated (Microsoft Graph)", + "value": "IndustryData-OutboundFlow.ReadWrite.All" + }, + { + "description": "Allows the app to read outbound data flows on behalf of the signed-in user.", + "displayName": "View outbound flow definitions", + "id": "4741a003-8952-4be4-9217-33a0ac327122", + "origin": "Delegated (Microsoft Graph)", + "value": "IndustryData-OutboundFlow.Read.All" + }, + { + "description": "Allows the app to read and write inbound data flows on behalf of the signed-in user.", + "displayName": "Manage inbound flow definitions", + "id": "97044676-2cec-40ee-bd70-38df444c9e70", + "origin": "Delegated (Microsoft Graph)", + "value": "IndustryData-InboundFlow.ReadWrite.All" + }, + { + "description": "Allows the app to read inbound data flows on behalf of the signed-in user.", + "displayName": "View inbound flow definitions", + "id": "cb0774da-a605-42af-959c-32f438fb38f4", + "origin": "Delegated (Microsoft Graph)", + "value": "IndustryData-InboundFlow.Read.All" + }, + { + "description": "Allows the app to read and write identity notification settings, customize email templates, and send test emails on behalf of the signed-in user.", + "displayName": "Read and write identity notification settings and templates", + "id": "c9c9fdea-4ecc-4d82-a2ea-3feff3489275", + "origin": "Delegated (Microsoft Graph)", + "value": "IdentityNotifications.ReadWrite.All" + }, + { + "description": "Allows the app to read your organization’s identity (authentication) providers’ properties on behalf of the user.", + "displayName": "Read identity providers", + "id": "43781733-b5a7-4d1b-98f4-e8edff23e1a9", + "origin": "Delegated (Microsoft Graph)", + "value": "IdentityProvider.Read.All" + }, + { + "description": "Allows the app to read and write your organization’s identity (authentication) providers’ properties on behalf of the user.", + "displayName": "Read and write identity providers", + "id": "f13ce604-1677-429f-90bd-8a10b9f01325", + "origin": "Delegated (Microsoft Graph)", + "value": "IdentityProvider.ReadWrite.All" + }, + { + "description": "Allows the app to read identity risk event information for all users in your organization on behalf of the signed-in user.", + "displayName": "Read identity risk event information", + "id": "8f6a01e7-0391-4ee5-aa22-a3af122cef27", + "origin": "Delegated (Microsoft Graph)", + "value": "IdentityRiskEvent.Read.All" + }, + { + "description": "Allows the app to read and update identity risk event information for all users in your organization on behalf of the signed-in user. Update operations include confirming risk event detections. ", + "displayName": "Read and write risk event information", + "id": "9e4862a5-b68f-479e-848a-4e07e25c9916", + "origin": "Delegated (Microsoft Graph)", + "value": "IdentityRiskEvent.ReadWrite.All" + }, + { + "description": "Allows the app to read risky agents information in your organization, on behalf of the signed-in user.", + "displayName": "Read risky agents information", + "id": "3215c57f-3faa-4295-95c2-6f14a5bc6124", + "origin": "Delegated (Microsoft Graph)", + "value": "IdentityRiskyAgent.Read.All" + }, + { + "description": "Allows the app to read and update identity risky agents information for all agents in your organization on behalf of the signed-in user. Update operations include dismissing risky agents.", + "displayName": "Read and write risky agents information", + "id": "d343bdeb-db6a-4e06-97da-9dafc2d61c60", + "origin": "Delegated (Microsoft Graph)", + "value": "IdentityRiskyAgent.ReadWrite.All" + }, + { + "description": "Allows the app to read all identity risky service principal information for your organization, on behalf of the signed-in user.", + "displayName": "Read all identity risky service principal information", + "id": "ea5c4ab0-5a73-4f35-8272-5d5337884e5d", + "origin": "Delegated (Microsoft Graph)", + "value": "IdentityRiskyServicePrincipal.Read.All" + }, + { + "description": "Allows the app to search the email message trace on behalf of the signed-in user.", + "displayName": "Search the email message trace", + "id": "b2e7d27e-14e7-41ad-bb15-a88ceb9c3e90", + "origin": "Delegated (Microsoft Graph)", + "value": "ExchangeMessageTrace.Read.All" + }, + { + "description": "Allows the app to read and update identity risky service principal information for all service principals in your organization, on behalf of the signed-in user. Update operations include dismissing risky service principals.", + "displayName": "Read and write all identity risky service principal information", + "id": "bb6f654c-d7fd-4ae3-85c3-fc380934f515", + "origin": "Delegated (Microsoft Graph)", + "value": "IdentityRiskyServicePrincipal.ReadWrite.All" + }, + { + "description": "Allows the app to read and update identity risky user information for all users in your organization on behalf of the signed-in user. Update operations include dismissing risky users.", + "displayName": "Read and write risky user information", + "id": "e0a7cdbb-08b0-4697-8264-0069786e9674", + "origin": "Delegated (Microsoft Graph)", + "value": "IdentityRiskyUser.ReadWrite.All" + }, + { + "description": "Allows the app to read your organization's user flows, on behalf of the signed-in user.", + "displayName": "Read all identity user flows", + "id": "2903d63d-4611-4d43-99ce-a33f3f52e343", + "origin": "Delegated (Microsoft Graph)", + "value": "IdentityUserFlow.Read.All" + }, + { + "description": "Allows the app to read or write your organization's user flows, on behalf of the signed-in user.", + "displayName": "Read and write all identity user flows", + "id": "281892cc-4dbf-4e3a-b6cc-b21029bb4e82", + "origin": "Delegated (Microsoft Graph)", + "value": "IdentityUserFlow.ReadWrite.All" + }, + { + "description": "Allows the app to have the same access to mailboxes as the signed-in user via IMAP protocol.", + "displayName": "Read and write access to mailboxes via IMAP.", + "id": "652390e4-393a-48de-9484-05f9b1212954", + "origin": "Delegated (Microsoft Graph)", + "value": "IMAP.AccessAsUser.All" + }, + { + "description": "Allows the app to read basic Industry Data service and resource information on behalf of the signed-in user.", + "displayName": "Read basic Industry Data service and resource definitions", + "id": "60382b96-1f5e-46ea-a544-0407e489e588", + "origin": "Delegated (Microsoft Graph)", + "value": "IndustryData.ReadBasic.All" + }, + { + "description": "Allows the app to read data connectors on behalf of the signed-in user.", + "displayName": "View data connector definitions", + "id": "d19c0de5-7ecb-4aba-b090-da35ebcd5425", + "origin": "Delegated (Microsoft Graph)", + "value": "IndustryData-DataConnector.Read.All" + }, + { + "description": "Allows the app to read and write data connectors on behalf of the signed-in user.", + "displayName": "Manage data connector definitions", + "id": "5ce933ac-3997-4280-aed0-cc072e5c062a", + "origin": "Delegated (Microsoft Graph)", + "value": "IndustryData-DataConnector.ReadWrite.All" + }, + { + "description": "Allows the app to upload data files to a data connector on behalf of the signed-in user.", + "displayName": "Upload files to a data connector", + "id": "fc47391d-ab2c-410f-9059-5600f7af660d", + "origin": "Delegated (Microsoft Graph)", + "value": "IndustryData-DataConnector.Upload" + }, + { + "description": "Allows the app to read identity risky user information for all users in your organization on behalf of the signed-in user.", + "displayName": "Read identity risky user information", + "id": "d04bb851-cb7c-4146-97c7-ca3e71baf56c", + "origin": "Delegated (Microsoft Graph)", + "value": "IdentityRiskyUser.Read.All" + }, + { + "description": "Allows an app to read license assignments for users and groups, on behalf of the signed-in user.", + "displayName": "Read all license assignments.", + "id": "f395577a-0960-456b-979f-7228de0c5996", + "origin": "Delegated (Microsoft Graph)", + "value": "LicenseAssignment.Read.All" + }, + { + "description": "Allows the app to have the same access to mailboxes as the signed-in user via Exchange Web Services.", + "displayName": "Access mailboxes as the signed-in user via Exchange Web Services", + "id": "9769c687-087d-48ac-9cb3-c37dde652038", + "origin": "Delegated (Microsoft Graph)", + "value": "EWS.AccessAsUser.All" + }, + { + "description": "Allows the app to read your organization's authentication event listeners on behalf of the signed-in user.", + "displayName": "Read your organization's authentication event listeners", + "id": "f7dd3bed-5eec-48da-bc73-1c0ef50bc9a1", + "origin": "Delegated (Microsoft Graph)", + "value": "EventListener.Read.All" + }, + { + "description": "Allows the app to read a user's list of devices on behalf of the signed-in user.", + "displayName": "Read user devices", + "id": "11d4cd79-5ba5-460f-803f-e22c8ab85ccd", + "origin": "Delegated (Microsoft Graph)", + "value": "Device.Read" + }, + { + "description": "Allows the app to read your organization's devices' configuration information on behalf of the signed-in user.", + "displayName": "Read all devices", + "id": "951183d1-1a61-466f-a6d1-1fde911bfd95", + "origin": "Delegated (Microsoft Graph)", + "value": "Device.Read.All" + }, + { + "description": "Allows the app to read device local credential properties including passwords, on behalf of the signed-in user.", + "displayName": "Read device local credential passwords", + "id": "280b3b69-0437-44b1-bc20-3b2fca1ee3e9", + "origin": "Delegated (Microsoft Graph)", + "value": "DeviceLocalCredential.Read.All" + }, + { + "description": "Allows the app to read device local credential properties excluding passwords, on behalf of the signed-in user.", + "displayName": "Read device local credential properties", + "id": "9917900e-410b-4d15-846e-42a357488545", + "origin": "Delegated (Microsoft Graph)", + "value": "DeviceLocalCredential.ReadBasic.All" + }, + { + "description": "Allows the app to read the properties, group assignments and status of apps, app configurations and app protection policies managed by Microsoft Intune.", + "displayName": "Read Microsoft Intune apps", + "id": "4edf5f54-4666-44af-9de9-0144fb4b6e8c", + "origin": "Delegated (Microsoft Graph)", + "value": "DeviceManagementApps.Read.All" + }, + { + "description": "Allows the app to read and write the properties, group assignments and status of apps, app configurations and app protection policies managed by Microsoft Intune.", + "displayName": "Read and write Microsoft Intune apps", + "id": "7b3f05d5-f68c-4b8d-8c59-a2ecd12f24af", + "origin": "Delegated (Microsoft Graph)", + "value": "DeviceManagementApps.ReadWrite.All" + }, + { + "description": "Allows the app to read certification authority information on behalf of the signed-in user.", + "displayName": "Read Microsoft Cloud PKI objects", + "id": "ac5c8443-d999-471f-9247-ce92cf5c5560", + "origin": "Delegated (Microsoft Graph)", + "value": "DeviceManagementCloudCA.Read.All" + }, + { + "description": "Allows the app to read and write certification authority information on behalf of the signed-in user.", + "displayName": "Read and write Microsoft Cloud PKI objects", + "id": "93028c58-65aa-48db-a706-1fe4ada325ec", + "origin": "Delegated (Microsoft Graph)", + "value": "DeviceManagementCloudCA.ReadWrite.All" + }, + { + "description": "Allows the app to read properties of Microsoft Intune-managed device configuration and device compliance policies and their assignment to groups.", + "displayName": "Read Microsoft Intune Device Configuration and Policies", + "id": "f1493658-876a-4c87-8fa7-edb559b3476a", + "origin": "Delegated (Microsoft Graph)", + "value": "DeviceManagementConfiguration.Read.All" + }, + { + "description": "Allows the app to read and write properties of Microsoft Intune-managed device configuration and device compliance policies and their assignment to groups.", + "displayName": "Read and write Microsoft Intune Device Configuration and Policies", + "id": "0883f392-0a7a-443d-8c76-16a6d39c7b63", + "origin": "Delegated (Microsoft Graph)", + "value": "DeviceManagementConfiguration.ReadWrite.All" + }, + { + "description": "Allows the app to read properties of Microsoft Intune-managed deployment plans and their ring configurations.", + "displayName": "Read Microsoft Intune Deployment Plans", + "id": "700bfe0b-b3bd-4fa4-bec2-6849edd7fb7b", + "origin": "Delegated (Microsoft Graph)", + "value": "DeviceManagementDeploymentPlans.Read.All" + }, + { + "description": "Allows the app to read and write properties of Microsoft Intune-managed deployment plans and their ring configurations.", + "displayName": "Read and write Microsoft Intune Deployment Plans", + "id": "9d95843d-67b9-48b2-8e20-e42372db8549", + "origin": "Delegated (Microsoft Graph)", + "value": "DeviceManagementDeploymentPlans.ReadWrite.All" + }, + { + "description": "Allows the app to perform remote high impact actions such as wiping the device or resetting the passcode on devices managed by Microsoft Intune.", + "displayName": "Perform user-impacting remote actions on Microsoft Intune devices", + "id": "3404d2bf-2b13-457e-a330-c24615765193", + "origin": "Delegated (Microsoft Graph)", + "value": "DeviceManagementManagedDevices.PrivilegedOperations.All" + }, + { + "description": "Allows the app to read the properties of devices managed by Microsoft Intune.", + "displayName": "Read Microsoft Intune devices", + "id": "314874da-47d6-4978-88dc-cf0d37f0bb82", + "origin": "Delegated (Microsoft Graph)", + "value": "DeviceManagementManagedDevices.Read.All" + }, + { + "description": "Allows the app to read and write the properties of devices managed by Microsoft Intune. Does not allow high impact operations such as remote wipe and password reset on the device’s owner.", + "displayName": "Read and write Microsoft Intune devices", + "id": "44642bfe-8385-4adc-8fc6-fe3cb2c375c3", + "origin": "Delegated (Microsoft Graph)", + "value": "DeviceManagementManagedDevices.ReadWrite.All" + }, + { + "description": "Allows the app to read the properties relating to the Microsoft Intune Role-Based Access Control (RBAC) settings.", + "displayName": "Read Microsoft Intune RBAC settings", + "id": "49f0cc30-024c-4dfd-ab3e-82e137ee5431", + "origin": "Delegated (Microsoft Graph)", + "value": "DeviceManagementRBAC.Read.All" + }, + { + "description": "Allows the app to read and write the properties relating to the Microsoft Intune Role-Based Access Control (RBAC) settings.", + "displayName": "Read and write Microsoft Intune RBAC settings", + "id": "0c5e8a55-87a6-4556-93ab-adc52c4d862d", + "origin": "Delegated (Microsoft Graph)", + "value": "DeviceManagementRBAC.ReadWrite.All" + }, + { + "description": "Allows the app to create device objects based on device templates owned by the signed-in user, on behalf of the signed in user.", + "displayName": "Create devices based on owned device templates", + "id": "edc92e89-a987-48a9-911a-a7b1967dd7b1", + "origin": "Delegated (Microsoft Graph)", + "value": "Device.CreateFromOwnedTemplate" + }, + { + "description": "Allows the app to read Microsoft Intune device compliance scripts, device management scripts, device shell scripts, device custom attribute shell scripts and device health scripts on behalf of the signed in user.", + "displayName": "Read Microsoft Intune Scripts", + "id": "d32381d8-ee89-4220-9c83-b672aa68d404", + "origin": "Delegated (Microsoft Graph)", + "value": "DeviceManagementScripts.Read.All" + }, + { + "description": "Allows the app to launch another app or communicate with another app on a user's device on behalf of the signed-in user.", + "displayName": "Communicate with user devices", + "id": "bac3b9c2-b516-4ef4-bd3b-c2ef73d8d804", + "origin": "Delegated (Microsoft Graph)", + "value": "Device.Command" + }, + { + "description": "Allows the app to read delegated permission grants, on behalf of the signed in user.", + "displayName": "Read delegated permission grants", + "id": "a197cdc4-a8e8-4d49-9d35-4ca7c83887b4", + "origin": "Delegated (Microsoft Graph)", + "value": "DelegatedPermissionGrant.Read.All" + }, + { + "description": "Allows the application to list and query user profile information associated with the current tenant on behalf of the signed-in user. It also permits the application to export and remove external user data (e.g. customer content or system-generated logs), associated with the current tenant on behalf of the signed-in user.", + "displayName": "Read shared cross-tenant user profile and export or delete data", + "id": "eed0129d-dc60-4f30-8641-daf337a39ffd", + "origin": "Delegated (Microsoft Graph)", + "value": "CrossTenantUserProfileSharing.ReadWrite" + }, + { + "description": "Allows the application to list and query any shared user profile information associated with the current tenant on behalf of the signed-in user. It also permits the application to export and remove external user data (e.g. customer content or system-generated logs), for any user associated with the current tenant on behalf of the signed-in user.", + "displayName": "Read all shared cross-tenant user profiles and export or delete their data", + "id": "64dfa325-cbf8-48e3-938d-51224a0cac01", + "origin": "Delegated (Microsoft Graph)", + "value": "CrossTenantUserProfileSharing.ReadWrite.All" + }, + { + "description": "Allows the app to read your organization's custom authentication extensions on behalf of the signed-in user.", + "displayName": "Read your organization's custom authentication extensions", + "id": "b2052569-c98c-4f36-a5fb-43e5c111e6d0", + "origin": "Delegated (Microsoft Graph)", + "value": "CustomAuthenticationExtension.Read.All" + }, + { + "description": "Allows the app to read or write your organization's custom authentication extensions on behalf of the signed-in user.", + "displayName": "Read and write your organization's custom authentication extensions", + "id": "8dfcf82f-15d0-43b3-bc78-a958a13a5792", + "origin": "Delegated (Microsoft Graph)", + "value": "CustomAuthenticationExtension.ReadWrite.All" + }, + { + "description": "Allows the app to read custom detection rules on behalf of the signed-in user.", + "displayName": "Read custom detection rules", + "id": "b13ff42e-f321-4d7d-a462-141c46a1b832", + "origin": "Delegated (Microsoft Graph)", + "value": "CustomDetection.Read.All" + }, + { + "description": "Allows the app to read and write custom detection rules on behalf of the signed-in user.", + "displayName": "Read and write custom detection rules", + "id": "c34088fb-0649-4714-af0b-bcbfec155897", + "origin": "Delegated (Microsoft Graph)", + "value": "CustomDetection.ReadWrite.All" + }, + { + "description": "Allows the app to read custom security attribute assignments for all principals in the tenant on behalf of a signed in user.", + "displayName": "Read custom security attribute assignments", + "id": "b46ffa80-fe3d-4822-9a1a-c200932d54d0", + "origin": "Delegated (Microsoft Graph)", + "value": "CustomSecAttributeAssignment.Read.All" + }, + { + "description": "Allows the app to read and write custom security attribute assignments for all principals in the tenant on behalf of a signed in user.", + "displayName": "Read and write custom security attribute assignments", + "id": "ca46335e-8453-47cd-a001-8459884efeae", + "origin": "Delegated (Microsoft Graph)", + "value": "CustomSecAttributeAssignment.ReadWrite.All" + }, + { + "description": "Allows the app to read audit logs for events that contain information about custom security attributes, on behalf of the signed-in user.", + "displayName": "Read custom security attribute audit logs", + "id": "1fcdeaab-b519-44dd-bffc-ed1fd15a24e0", + "origin": "Delegated (Microsoft Graph)", + "value": "CustomSecAttributeAuditLogs.Read.All" + }, + { + "description": "Allows the app to read custom security attribute definitions for the tenant on behalf of a signed in user.", + "displayName": "Read custom security attribute definitions", + "id": "ce026878-a0ff-4745-a728-d4fedd086c07", + "origin": "Delegated (Microsoft Graph)", + "value": "CustomSecAttributeDefinition.Read.All" + }, + { + "description": "Allows the app to read and write custom security attribute definitions for the tenant on behalf of a signed in user.", + "displayName": "Read and write custom security attribute definitions", + "id": "8b0160d4-5743-482b-bb27-efc0a485ca4a", + "origin": "Delegated (Microsoft Graph)", + "value": "CustomSecAttributeDefinition.ReadWrite.All" + }, + { + "description": "Allows the app to read the provisioning configuration of all active custom security attributes on behalf of a signed in user.", + "displayName": "Read the provisioning configuration of all active custom security attributes", + "id": "9ddd870d-077c-49e7-b3e3-6b3012a8a880", + "origin": "Delegated (Microsoft Graph)", + "value": "CustomSecAttributeProvisioning.Read.All" + }, + { + "description": "Allows the app to read and edit the provisioning configuration of all active custom security attributes on behalf of a signed in user.", + "displayName": "Read and edit the provisioning configuration of all active custom security attributes", + "id": "1140d9e4-6776-433e-a9e4-b9831adbb2e0", + "origin": "Delegated (Microsoft Graph)", + "value": "CustomSecAttributeProvisioning.ReadWrite.All" + }, + { + "description": "Read custom tags data on behalf of the signed-in user", + "displayName": "Read all custom tags data", + "id": "de6ea87d-10bd-467c-8682-d525a0c61b89", + "origin": "Delegated (Microsoft Graph)", + "value": "CustomTags.Read.All" + }, + { + "description": "Read and write custom tags data on behalf of the signed-in user", + "displayName": "Read and write custom tags data", + "id": "2f1bbe0a-f34b-4efb-9edb-8db8dcb50eca", + "origin": "Delegated (Microsoft Graph)", + "value": "CustomTags.ReadWrite.All" + }, + { + "description": "Allows the app to read details of delegated admin relationships with customers like access details (that includes roles) and the duration as well as specific role assignments to security groups on behalf of the signed-in user.", + "displayName": "Read Delegated Admin relationships with customers", + "id": "0c0064ea-477b-4130-82a5-4c2cc4ff68aa", + "origin": "Delegated (Microsoft Graph)", + "value": "DelegatedAdminRelationship.Read.All" + }, + { + "description": "Allows the app to manage (create-update-terminate) Delegated Admin relationships with customers as well as role assignments to security groups for active Delegated Admin relationships on behalf of the signed-in user.", + "displayName": "Manage Delegated Admin relationships with customers", + "id": "885f682f-a990-4bad-a642-36736a74b0c7", + "origin": "Delegated (Microsoft Graph)", + "value": "DelegatedAdminRelationship.ReadWrite.All" + }, + { + "description": "Allows the app to manage permission grants for delegated permissions exposed by any API (including Microsoft Graph), on behalf of the signed in user.", + "displayName": "Manage all delegated permission grants", + "id": "41ce6ca6-6826-4807-84f1-1c82854f7ee5", + "origin": "Delegated (Microsoft Graph)", + "value": "DelegatedPermissionGrant.ReadWrite.All" + }, + { + "description": "Allows the app to read and write Microsoft Intune device compliance scripts, device management scripts, device shell scripts, device custom attribute shell scripts and device health scripts on behalf of the signed in user.", + "displayName": "Read and write Microsoft Intune Scripts", + "id": "8b9d79d0-ad75-4566-8619-f7500ecfcebe", + "origin": "Delegated (Microsoft Graph)", + "value": "DeviceManagementScripts.ReadWrite.All" + }, + { + "description": "Allows the app to read Microsoft Intune service properties including device enrollment and third party service connection configuration.", + "displayName": "Read Microsoft Intune configuration", + "id": "8696daa5-bce5-4b2e-83f9-51b6defc4e1e", + "origin": "Delegated (Microsoft Graph)", + "value": "DeviceManagementServiceConfig.Read.All" + }, + { + "description": "Allows the app to read and write Microsoft Intune service properties including device enrollment and third party service connection configuration.", + "displayName": "Read and write Microsoft Intune configuration", + "id": "662ed50a-ac44-4eef-ad86-62eed9be2a29", + "origin": "Delegated (Microsoft Graph)", + "value": "DeviceManagementServiceConfig.ReadWrite.All" + }, + { + "description": "Allows the app to read and write user's modules and resources on behalf of the signed-in user.", + "displayName": "Read and write the user's class modules and resources", + "id": "4793c53b-df34-44fd-8d26-d15c517732f5", + "origin": "Delegated (Microsoft Graph)", + "value": "EduCurricula.ReadWrite" + }, + { + "description": "Allows the app to read the structure of schools and classes in an organization's roster and education-specific information about users to be read on behalf of the user.", + "displayName": "Read users' view of the roster", + "id": "a4389601-22d9-4096-ac18-36a927199112", + "origin": "Delegated (Microsoft Graph)", + "value": "EduRoster.Read" + }, + { + "description": "Allows the app to read a limited subset of the properties from the structure of schools and classes in an organization's roster and a limited subset of properties about users to be read on behalf of the user. Includes name, status, education role, email address and photo.", + "displayName": "Read a limited subset of users' view of the roster", + "id": "5d186531-d1bf-4f07-8cea-7c42119e1bd9", + "origin": "Delegated (Microsoft Graph)", + "value": "EduRoster.ReadBasic" + }, + { + "description": "Allows the app to read and write the structure of schools and classes in an organization's roster and education-specific information about users to be read and written on behalf of the user.", + "displayName": "Read and write users' view of the roster", + "id": "359e19a6-e3fa-4d7f-bcab-d28ec592b51e", + "origin": "Delegated (Microsoft Graph)", + "value": "EduRoster.ReadWrite" + }, + { + "description": "Allows the app to read your users' primary email address", + "displayName": "View users' email address", + "id": "64a6cdd6-aab1-4aaf-94b8-3cc8405e90d0", + "origin": "Delegated (Microsoft Graph)", + "value": "email" + }, + { + "description": "Allows the app to read Viva Engage conversations, and to read their properties on behalf of the signed-in user.", + "displayName": "Read all Viva Engage conversations", + "id": "c55541d9-2cdd-4fad-8ead-0c08fae5b0c8", + "origin": "Delegated (Microsoft Graph)", + "value": "EngagementConversation.Read.All" + }, + { + "description": "Allows the app to create Viva Engage conversations and read all conversation properties on behalf of the signed-in user.", + "displayName": "Read and write all Viva Engage conversations", + "id": "ebbfd079-1634-4640-8618-68b19ebbed1d", + "origin": "Delegated (Microsoft Graph)", + "value": "EngagementConversation.ReadWrite.All" + }, + { + "description": "Allows the app to read Viva Engage Teams QA conversations, and to read their properties on behalf of the signed-in user.", + "displayName": "Read all Viva Engage Teams QA conversations", + "id": "58c5819e-29bd-4400-ad52-82cd82a63fbd", + "origin": "Delegated (Microsoft Graph)", + "value": "EngagementMeetingConversation.Read.All" + }, + { + "description": "Allows the app to list a user's Viva Engage roles, on behalf of the signed-in user.", + "displayName": "Read a user's Viva Engage roles ", + "id": "9f1da0fc-345c-4dfb-bab5-5215a073a417", + "origin": "Delegated (Microsoft Graph)", + "value": "EngagementRole.Read" + }, + { + "description": "Allows the app to list all Viva Engage roles and role memberships on behalf of the signed-in user.", + "displayName": "Read all Viva Engage roles and role memberships", + "id": "3cad91a5-8413-4c4a-acfe-dfeb83d1366d", + "origin": "Delegated (Microsoft Graph)", + "value": "EngagementRole.Read.All" + }, + { + "description": "Allows the app to assign Viva Engage role to a user, and remove a Viva Engage role from a user behalf of the signed-in user.", + "displayName": "Modify Viva Engage role membership", + "id": "4905982d-6459-4ccd-949c-949fefc0a8f2", + "origin": "Delegated (Microsoft Graph)", + "value": "EngagementRole.ReadWrite.All" + }, + { + "description": "Allows the app to read access packages and related entitlement management resources on behalf of the signed-in user.", + "displayName": "Read all entitlement management resources", + "id": "5449aa12-1393-4ea2-a7c7-d0e06c1a56b2", + "origin": "Delegated (Microsoft Graph)", + "value": "EntitlementManagement.Read.All" + }, + { + "description": "Allows the app to request access to and management of access packages and related entitlement management resources on behalf of the signed-in user.", + "displayName": "Read and write entitlement management resources", + "id": "ae7a573d-81d7-432b-ad44-4ed5c9d89038", + "origin": "Delegated (Microsoft Graph)", + "value": "EntitlementManagement.ReadWrite.All" + }, + { + "description": "Allows the app to manage self-service entitlement management resources on behalf of the signed-in user. This includes operations such as requesting access and approving access of others.", + "displayName": "Read and write entitlement management resources related to self-service operations", + "id": "e9fdcbbb-8807-410f-b9ec-8d5468c7c2ac", + "origin": "Delegated (Microsoft Graph)", + "value": "EntitlementMgmt-SubjectAccess.ReadWrite" + }, + { + "description": "Allows the app to list the all the snapshots, jobs and enumerate the changes of a specific preview job, on behalf of the signed-in user.", + "displayName": "Read Preview jobs and snapshots", + "id": "a6ea9dd7-4dd9-4484-a80a-ac9ad981dcf1", + "origin": "Delegated (Microsoft Graph)", + "value": "EntraBackup.Read.All" + }, + { + "description": "Allows the app to list the all the snapshots, create a preview job and enumerate the changes of a specific preview job, on behalf of the signed-in user.", + "displayName": "Create a preview job, read preview job and snapshots", + "id": "cef123a8-c18c-4eba-852e-d90cfbf67c91", + "origin": "Delegated (Microsoft Graph)", + "value": "EntraBackup.ReadWrite.Preview" + }, + { + "description": "Allows the app to list the all the snapshots, create a recovery job and enumerate the changes of a specific recovery job, on behalf of the signed-in user.", + "displayName": "Create preview and recovery job, read recovery job and snapshots", + "id": "8269c6ff-41d7-4172-a783-b2ce38322e42", + "origin": "Delegated (Microsoft Graph)", + "value": "EntraBackup.ReadWrite.Recovery" + }, + { + "description": "Allows the app to read the user's modules and resources on behalf of the signed-in user.", + "displayName": "Read the user's class modules and resources", + "id": "484859e8-b9e2-4e92-b910-84db35dadd29", + "origin": "Delegated (Microsoft Graph)", + "value": "EduCurricula.Read" + }, + { + "description": "Allows the app to read and write assignments without grades on behalf of the user.", + "displayName": "Read and write users' class assignments without grades", + "id": "2ef770a1-622a-47c4-93ee-28d6adbed3a0", + "origin": "Delegated (Microsoft Graph)", + "value": "EduAssignments.ReadWriteBasic" + }, + { + "description": "Allows the app to read and write assignments and their grades on behalf of the user.", + "displayName": "Read and write users' class assignments and their grades", + "id": "2f233e90-164b-4501-8bce-31af2559a2d3", + "origin": "Delegated (Microsoft Graph)", + "value": "EduAssignments.ReadWrite" + }, + { + "description": "Allows the app to read assignments without grades on behalf of the user.", + "displayName": "Read users' class assignments without grades", + "id": "c0b0103b-c053-4b2e-9973-9f3a544ec9b8", + "origin": "Delegated (Microsoft Graph)", + "value": "EduAssignments.ReadBasic" + }, + { + "description": "Allows the app to create device templates on behalf of the signed in user. The user is marked as owners of the created device template. As a member of owners, the user will be allowed to manage devices created from the template.", + "displayName": "Create device templates", + "id": "0b1717ff-3e42-4a73-8c29-e6b2e1093960", + "origin": "Delegated (Microsoft Graph)", + "value": "DeviceTemplate.Create" + }, + { + "description": "Allows the app to read all device templates, on behalf of the signed in user.", + "displayName": "Read all device templates", + "id": "2bcae0b0-aa93-48e4-a9e4-855482dffdcd", + "origin": "Delegated (Microsoft Graph)", + "value": "DeviceTemplate.Read.All" + }, + { + "description": "Allows the app to create, read, update and delete the device template, on behalf of the signed in user. It also allows the app to add or remove owners on any device template.", + "displayName": "Read and write all device templates", + "id": "2d372e98-f1ae-406c-a157-2ea83f6f5e4a", + "origin": "Delegated (Microsoft Graph)", + "value": "DeviceTemplate.ReadWrite.All" + }, + { + "description": "Allows the app to have the same access to information in the directory as the signed-in user.", + "displayName": "Access directory as the signed in user", + "id": "0e263e50-5827-48a4-b97c-d940288653c7", + "origin": "Delegated (Microsoft Graph)", + "value": "Directory.AccessAsUser.All" + }, + { + "description": "Allows the app to read data in your organization's directory, such as users, groups and apps.", + "displayName": "Read directory data", + "id": "06da0dbc-49e2-44d2-8312-53f166ab848a", + "origin": "Delegated (Microsoft Graph)", + "value": "Directory.Read.All" + }, + { + "description": "Allows the app to read and write data in your organization's directory, such as users, and groups. It does not allow the app to delete users or groups, or reset user passwords.", + "displayName": "Read and write directory data", + "id": "c5366453-9fb0-48a5-a156-24f0c49a4b84", + "origin": "Delegated (Microsoft Graph)", + "value": "Directory.ReadWrite.All" + }, + { + "description": "Allows the app to read Azure AD recommendations, on behalf of the signed-in user.", + "displayName": "Read Azure AD recommendations", + "id": "34d3bd24-f6a6-468c-b67c-0c365c1d6410", + "origin": "Delegated (Microsoft Graph)", + "value": "DirectoryRecommendations.Read.All" + }, + { + "description": "Allows the app to read and update Azure AD recommendations, on behalf of the signed-in user.", + "displayName": "Read and update Azure AD recommendations", + "id": "f37235e8-90a0-4189-93e2-e55b53867ccd", + "origin": "Delegated (Microsoft Graph)", + "value": "DirectoryRecommendations.ReadWrite.All" + }, + { + "description": "Allows the app to read or write your organization's authentication event listeners on behalf of the signed-in user.", + "displayName": "Read and write your organization's authentication event listeners", + "id": "d11625a6-fe21-4fc6-8d3d-063eba5525ad", + "origin": "Delegated (Microsoft Graph)", + "value": "EventListener.ReadWrite.All" + }, + { + "description": "Allows the app to read all domain properties on behalf of the signed-in user.", + "displayName": "Read domains.", + "id": "2f9ee017-59c1-4f1d-9472-bd5529a7b311", + "origin": "Delegated (Microsoft Graph)", + "value": "Domain.Read.All" + }, + { + "description": "Allows the app to read internal federation configuration for a domain.", + "displayName": "Read internal federation configuration for a domain.", + "id": "33203a2a-a761-40f0-8a7c-a7e74a9f8ac6", + "origin": "Delegated (Microsoft Graph)", + "value": "Domain-InternalFederation.Read.All" + }, + { + "description": "Allows the app to create, read, update and delete internal federation configuration for a domain.", + "displayName": "Create, read, update and delete internal federation configuration for a domain.", + "id": "857bd3ea-490e-4284-88a7-a7de1893b6ee", + "origin": "Delegated (Microsoft Graph)", + "value": "Domain-InternalFederation.ReadWrite.All" + }, + { + "description": "Allows the app to have the same access to mailboxes as the signed-in user via Exchange ActiveSync.", + "displayName": "Access mailboxes via Exchange ActiveSync", + "id": "ff91d191-45a0-43fd-b837-bd682c4a0b0f", + "origin": "Delegated (Microsoft Graph)", + "value": "EAS.AccessAsUser.All" + }, + { + "description": "Allows the app to read eDiscovery objects such as cases, custodians, review sets and other related objects on behalf of the signed-in user.", + "displayName": "Read all eDiscovery objects", + "id": "99201db3-7652-4d5a-809a-bdb94f85fe3c", + "origin": "Delegated (Microsoft Graph)", + "value": "eDiscovery.Read.All" + }, + { + "description": "Allows the app to read and write eDiscovery objects such as cases, custodians, review sets and other related objects on behalf of the signed-in user.", + "displayName": "Read and write all eDiscovery objects", + "id": "acb8f680-0834-4146-b69e-4ab1b39745ad", + "origin": "Delegated (Microsoft Graph)", + "value": "eDiscovery.ReadWrite.All" + }, + { + "description": "Read the state and settings of all Microsoft education apps on behalf of the user.", + "displayName": "Read education app settings", + "id": "8523895c-6081-45bf-8a5d-f062a2f12c9f", + "origin": "Delegated (Microsoft Graph)", + "value": "EduAdministration.Read" + }, + { + "description": "Manage the state and settings of all Microsoft education apps on behalf of the user.", + "displayName": "Manage education app settings", + "id": "63589852-04e3-46b4-bae9-15d5b1050748", + "origin": "Delegated (Microsoft Graph)", + "value": "EduAdministration.ReadWrite" + }, + { + "description": "Allows the app to read assignments and their grades on behalf of the user.", + "displayName": "Read users' class assignments and their grades", + "id": "091460c9-9c4a-49b2-81ef-1f3d852acce2", + "origin": "Delegated (Microsoft Graph)", + "value": "EduAssignments.Read" + }, + { + "description": "Allows the app to read and write all domain properties on behalf of the signed-in user. Also allows the app to add, verify and remove domains.", + "displayName": "Read and write domains", + "id": "0b5d694c-a244-4bde-86e6-eb5cd07730fe", + "origin": "Delegated (Microsoft Graph)", + "value": "Domain.ReadWrite.All" + }, + { + "description": "Allows the Application to read and write the user's data pertaining to itself in the Intune Mobile Application Management service", + "displayName": "Read and Write the User's App Management data", + "id": "3c7192af-9629-4473-9276-d35e4e4b36c5", + "origin": "Delegated (Microsoft Mobile Application Management)", + "value": "DeviceManagementManagedApps.ReadWrite" + }, + { + "description": "Allows an app to manage license assignments for users and groups, on behalf of the signed-in user.", + "displayName": "Manage all license assignments", + "id": "f55016cc-149c-447e-8f21-7cf3ec1d6350", + "origin": "Delegated (Microsoft Graph)", + "value": "LicenseAssignment.ReadWrite.All" + }, + { + "description": "Allows the app to read, create, update and delete identity lifecycle policies for agent identities that the signed-in user has access to in the organization.", + "displayName": "Read and write identity lifecycle policies for agent identities", + "id": "f2292ca5-46fc-4195-9b4d-16491bf9bf7f", + "origin": "Delegated (Microsoft Graph)", + "value": "LifecyclePolicies-AgentId.ReadWrite.All" + }, + { + "description": "Allows the app to read and write the authentication flow policies, on behalf of the signed-in user.", + "displayName": "Read and write authentication flow policies", + "id": "edb72de9-4252-4d03-a925-451deef99db7", + "origin": "Delegated (Microsoft Graph)", + "value": "Policy.ReadWrite.AuthenticationFlows" + }, + { + "description": "Allows the app to read and write the authentication method policies, on behalf of the signed-in user. ", + "displayName": "Read and write authentication method policies", + "id": "7e823077-d88e-468f-a337-e18f1f0e6c7c", + "origin": "Delegated (Microsoft Graph)", + "value": "Policy.ReadWrite.AuthenticationMethod" + }, + { + "description": "Allows the app to read and write your organization's authorization policy on behalf of the signed-in user. For example, authorization policies can control some of the permissions that the out-of-the-box user role has by default.", + "displayName": "Read and write your organization's authorization policy", + "id": "edd3c878-b384-41fd-95ad-e7407dd775be", + "origin": "Delegated (Microsoft Graph)", + "value": "Policy.ReadWrite.Authorization" + }, + { + "description": "Allows the app to read and write your organization's B2BManagement policies on behalf of the signed-in user.", + "displayName": "Read and write your organization's B2BManagement policies", + "id": "723c4a0c-85b0-4a02-bb2a-c9eb07959de9", + "origin": "Delegated (Microsoft Graph)", + "value": "Policy.ReadWrite.B2BManagementPolicy" + }, + { + "description": "Allows the app to read and write your organization's conditional access policies on behalf of the signed-in user.", + "displayName": "Read and write your organization's conditional access policies", + "id": "ad902697-1014-4ef5-81ef-2b4301988e8c", + "origin": "Delegated (Microsoft Graph)", + "value": "Policy.ReadWrite.ConditionalAccess" + }, + { + "description": "Allows the app to read and write your organization's consent requests policy on behalf of the signed-in user.", + "displayName": "Read and write consent request policy", + "id": "4d135e65-66b8-41a8-9f8b-081452c91774", + "origin": "Delegated (Microsoft Graph)", + "value": "Policy.ReadWrite.ConsentRequest" + }, + { + "description": "Allows the app to read and write your organization's cross-tenant access policies and configuration for automatic user consent settings to suppress consent prompts for users of the other tenant on behalf of the signed-in user.", + "displayName": "Read and write your organization's cross tenant access policies", + "id": "014b43d0-6ed4-4fc6-84dc-4b6f7bae7d85", + "origin": "Delegated (Microsoft Graph)", + "value": "Policy.ReadWrite.CrossTenantAccess" + }, + { + "description": "Allows the app to read and write your organization's M365 cross tenant access capabilities on behalf of the signed-in user.", + "displayName": "Read and write your organization's M365 cross tenant access capabilities", + "id": "9ef7463f-1d39-406f-89ea-3483a4645e1c", + "origin": "Delegated (Microsoft Graph)", + "value": "Policy.ReadWrite.CrossTenantCapability" + }, + { + "description": "Allows the app to read and write your organization's device configuration policies on behalf of the signed-in user. For example, device registration policy can limit initial provisioning controls using quota restrictions, additional authentication and authorization checks.", + "displayName": "Read and write your organization's device configuration policies", + "id": "40b534c3-9552-4550-901b-23879c90bcf9", + "origin": "Delegated (Microsoft Graph)", + "value": "Policy.ReadWrite.DeviceConfiguration" + }, + { + "description": "Allows the application to read and update the organization's external identities policy on behalf of the signed-in user. For example, external identities policy controls if users invited to access resources in your organization via B2B collaboration or B2B direct connect are allowed to self-service leave.", + "displayName": "Read and write your organization's external identities policy", + "id": "b5219784-1215-45b5-b3f1-88fe1081f9c0", + "origin": "Delegated (Microsoft Graph)", + "value": "Policy.ReadWrite.ExternalIdentities" + }, + { + "description": "Allows the app to read and write your organization's feature rollout policies on behalf of the signed-in user. Includes abilities to assign and remove users and groups to rollout of a specific feature.", + "displayName": "Read and write your organization's feature rollout policies", + "id": "92a38652-f13b-4875-bc77-6e1dbb63e1b2", + "origin": "Delegated (Microsoft Graph)", + "value": "Policy.ReadWrite.FeatureRollout" + }, + { + "description": "Allows the application to read and update the organization's federated token validation policy on behalf of the signed-in user.", + "displayName": "Read and write your organization's federated token validation policy", + "id": "be1be369-4540-4ac9-8928-79de99f70d8f", + "origin": "Delegated (Microsoft Graph)", + "value": "Policy.ReadWrite.FedTokenValidation" + }, + { + "description": "Allows the app to read and write your organization’s identity protection policy on behalf of the signed-in user.", + "displayName": "Read and write your organization’s identity protection policy ", + "id": "7256e131-3efb-4323-9854-cf41c6021770", + "origin": "Delegated (Microsoft Graph)", + "value": "Policy.ReadWrite.IdentityProtection" + }, + { + "description": "Allows the app to read and write your organization's mobility management policies on behalf of the signed-in user. For example, a mobility management policy can set the enrollment scope for a given mobility management application.", + "displayName": "Read and write your organization's mobility management policies", + "id": "a8ead177-1889-4546-9387-f25e658e2a79", + "origin": "Delegated (Microsoft Graph)", + "value": "Policy.ReadWrite.MobilityManagement" + }, + { + "description": "Allows the app to manage policies related to consent and permission grants for applications, on behalf of the signed-in user.", + "displayName": "Manage consent and permission grant policies", + "id": "2672f8bb-fd5e-42e0-85e1-ec764dd2614e", + "origin": "Delegated (Microsoft Graph)", + "value": "Policy.ReadWrite.PermissionGrant" + }, + { + "description": "Allows the application to read and update the organization's recovery policy on behalf of the signed-in user.", + "displayName": "Read and write your organization's recovery policy", + "id": "1e7a2f4c-e602-4b1b-9547-304dd65c4cc2", + "origin": "Delegated (Microsoft Graph)", + "value": "Policy.ReadWrite.Recovery" + }, + { + "description": "Allows the app to read and write your organization's security defaults policy on behalf of the signed-in user.", + "displayName": "Read and write your organization's security defaults policy", + "id": "0b2a744c-2abf-4f1e-ad7e-17a087e2be99", + "origin": "Delegated (Microsoft Graph)", + "value": "Policy.ReadWrite.SecurityDefaults" + }, + { + "description": "Allows the app to read and write your organization's application configuration policies on behalf of the signed-in user. This includes policies such as activityBasedTimeoutPolicy, claimsMappingPolicy, homeRealmDiscoveryPolicy, tokenIssuancePolicy and tokenLifetimePolicy.", + "displayName": "Read and write your organization's application configuration policies", + "id": "b27add92-efb2-4f16-84f5-8108ba77985c", + "origin": "Delegated (Microsoft Graph)", + "value": "Policy.ReadWrite.ApplicationConfiguration" + }, + { + "description": "Allows the app to read and write your organization's trust framework policies on behalf of the signed-in user.", + "displayName": "Read and write your organization's trust framework policies", + "id": "cefba324-1a70-4a6e-9c1d-fd670b7ae392", + "origin": "Delegated (Microsoft Graph)", + "value": "Policy.ReadWrite.TrustFramework" + }, + { + "description": "Allows the app to read and write your organization's directory access review default policy on behalf of the signed-in user.", + "displayName": "Read and write your organization's directory access review default policy", + "id": "4f5bc9c8-ea54-4772-973a-9ca119cb0409", + "origin": "Delegated (Microsoft Graph)", + "value": "Policy.ReadWrite.AccessReview" + }, + { + "description": "Allows the app to read policies related to consent and permission grants for applications, on behalf of the signed-in user.", + "displayName": "Read consent and permission grant policies", + "id": "414de6ea-2d92-462f-b120-6e2a809a6d01", + "origin": "Delegated (Microsoft Graph)", + "value": "Policy.Read.PermissionGrant" + }, + { + "description": "Allows the app to read available properties of pending external user profiles, on behalf of the signed-in user.", + "displayName": "Read pending external user profiles", + "id": "d88fd3fb-53d3-4c1c-8c39-787fcac2ed7a", + "origin": "Delegated (Microsoft Graph)", + "value": "PendingExternalUserProfile.Read.All" + }, + { + "description": "Allows the app to read and write available properties of pending external user profiles, on behalf of the signed-in user.", + "displayName": "Read and write pending external user profiles", + "id": "93a1fb28-c908-4826-904e-0c74ad352b73", + "origin": "Delegated (Microsoft Graph)", + "value": "PendingExternalUserProfile.ReadWrite.All" + }, + { + "description": "Allows the app to read a ranked list of relevant people of the signed-in user. The list includes local contacts, contacts from social networking, your organization's directory, and people from recent communications (such as email and Skype).", + "displayName": "Read users' relevant people lists", + "id": "ba47897c-39ec-4d83-8086-ee8256fa737d", + "origin": "Delegated (Microsoft Graph)", + "value": "People.Read" + }, + { + "description": "Allows the app to read a scored list of relevant people of the signed-in user or other users in the signed-in user's organization. The list can include local contacts, contacts from social networking, your organization's directory, and people from recent communications (such as email and Skype).", + "displayName": "Read all users' relevant people lists", + "id": "b89f9189-71a5-4e70-b041-9887f0bc7e4a", + "origin": "Delegated (Microsoft Graph)", + "value": "People.Read.All" + }, + { + "description": "Allows the application to read tenant-wide people settings on behalf of the signed-in user.", + "displayName": "Read tenant-wide people settings", + "id": "ec762c5f-388b-4b16-8693-ac1efbc611bc", + "origin": "Delegated (Microsoft Graph)", + "value": "PeopleSettings.Read.All" + }, + { + "description": "Allows the application to read and write tenant-wide people settings on behalf of the signed-in user.", + "displayName": "Read and write tenant-wide people settings", + "id": "e67e6727-c080-415e-b521-e3f35d5248e9", + "origin": "Delegated (Microsoft Graph)", + "value": "PeopleSettings.ReadWrite.All" + }, + { + "description": "Allows the app to read your company's places (conference rooms and room lists) for calendar events and other applications, on behalf of the signed-in user.", + "displayName": "Read all company places", + "id": "cb8f45a0-5c2e-4ea1-b803-84b870a7d7ec", + "origin": "Delegated (Microsoft Graph)", + "value": "Place.Read.All" + }, + { + "description": "Allows the app to manage organization places (conference rooms and room lists) for calendar events and other applications, on behalf of the signed-in user.", + "displayName": "Read and write organization places", + "id": "4c06a06a-098a-4063-868e-5dfee3827264", + "origin": "Delegated (Microsoft Graph)", + "value": "Place.ReadWrite.All" + }, + { + "description": "Allows the app to read all workplace devices, on behalf of the signed-in user.", + "displayName": "Read all workplace devices", + "id": "4c7f93d2-6b0b-4e05-91aa-87842f0a2142", + "origin": "Delegated (Microsoft Graph)", + "value": "PlaceDevice.Read.All" + }, + { + "description": "Allows the app to read and write all workplace devices, on behalf of the signed-in user.", + "displayName": "Read and write all workplace devices", + "id": "eafd6a71-e95a-4f8a-bb6e-fb84ab7fbd9e", + "origin": "Delegated (Microsoft Graph)", + "value": "PlaceDevice.ReadWrite.All" + }, + { + "description": "Allows the app to read your organization's policies on behalf of the signed-in user.", + "displayName": "Read your organization's policies", + "id": "572fea84-0151-49b2-9301-11cb16974376", + "origin": "Delegated (Microsoft Graph)", + "value": "Policy.Read.All" + }, + { + "description": "Allows the app to read the authentication method policies, on behalf of the signed-in user. ", + "displayName": "Read authentication method policies", + "id": "a6ff13ac-1851-4993-8ca9-a671d70de2d5", + "origin": "Delegated (Microsoft Graph)", + "value": "Policy.Read.AuthenticationMethod" + }, + { + "description": "Allows the app to read your organization's B2BManagement policies on behalf of the signed-in user.", + "displayName": "Read your organization's B2BManagement policies", + "id": "4b293250-121d-4cb4-acc7-5280438c18a6", + "origin": "Delegated (Microsoft Graph)", + "value": "Policy.Read.B2BManagementPolicy" + }, + { + "description": "Allows the app to read your organization's conditional access policies on behalf of the signed-in user.", + "displayName": "Read your organization's conditional access policies", + "id": "633e0fce-8c58-4cfb-9495-12bbd5a24f7c", + "origin": "Delegated (Microsoft Graph)", + "value": "Policy.Read.ConditionalAccess" + }, + { + "description": "Allows the app to read your organization's cross tenant access policies on behalf of the signed-in user.", + "displayName": "Read your organization's cross tenant access policies", + "id": "b337372a-8b4d-428d-9cd8-3d7363865736", + "origin": "Delegated (Microsoft Graph)", + "value": "Policy.Read.CrossTenantAccess" + }, + { + "description": "Allows the app to read your organization's device configuration policies on behalf of the signed-in user. For example, device registration policy can limit initial provisioning controls using quota restrictions, additional authentication and authorization checks.", + "displayName": "Read your organization's device configuration policies", + "id": "3616a4b0-6746-49c4-a678-4c237599074d", + "origin": "Delegated (Microsoft Graph)", + "value": "Policy.Read.DeviceConfiguration" + }, + { + "description": "Allows the app to read your organization’s identity protection policy on behalf of the signed-in user.", + "displayName": "Read your organization’s identity protection policy", + "id": "d146432f-b803-4ed4-8d42-ba74193a6ede", + "origin": "Delegated (Microsoft Graph)", + "value": "Policy.Read.IdentityProtection" + }, + { + "description": "Allows the application to read the organization's recovery policy on behalf of the signed-in user.", + "displayName": "Read your organization's recovery policy", + "id": "61faa1e9-0931-4f9a-94ba-bc2e3505c685", + "origin": "Delegated (Microsoft Graph)", + "value": "Policy.Read.Recovery" + }, + { + "description": "Allows the app to have the same access to mailboxes as the signed-in user via POP protocol.", + "displayName": "Read and write access to mailboxes via POP.", + "id": "d7b7f2d9-0f45-4ea1-9d42-e50810c06991", + "origin": "Delegated (Microsoft Graph)", + "value": "POP.AccessAsUser.All" + }, + { + "description": "Allows the app to read preauthorization grants for service principals on behalf of the signed-in user.", + "displayName": "Read all preauthorization grants", + "id": "9c98cbde-410c-4719-9058-166504f17863", + "origin": "Delegated (Microsoft Graph)", + "value": "PreAuthorizationGrant.Read.All" + }, + { + "description": "Allows the app to read presence information on behalf of the signed-in user. Presence information includes activity, availability, status note, calendar out-of-office message, timezone and location.", + "displayName": "Read user's presence information", + "id": "76bc735e-aecd-4a1d-8b4c-2b915deabb79", + "origin": "Delegated (Microsoft Graph)", + "value": "Presence.Read" + }, + { + "description": "Allows the app to read time-based assignment and just-in-time elevation (including scheduled elevation) of Azure AD built-in and custom administrative roles, on behalf of the signed-in user.", + "displayName": "Read privileged access to Azure AD", + "id": "b3a539c9-59cb-4ad5-825a-041ddbdc2bdb", + "origin": "Delegated (Microsoft Graph)", + "value": "PrivilegedAccess.Read.AzureAD" + }, + { + "description": "Allows the app to read time-based assignment and just-in-time elevation (including scheduled elevation) of Azure AD groups, on behalf of the signed-in user.", + "displayName": "Read privileged access to Azure AD groups", + "id": "d329c81c-20ad-4772-abf9-3f6fdb7e5988", + "origin": "Delegated (Microsoft Graph)", + "value": "PrivilegedAccess.Read.AzureADGroup" + }, + { + "description": "Allows the app to read time-based assignment and just-in-time elevation of Azure resources (like your subscriptions, resource groups, storage, compute) on behalf of the signed-in user.", + "displayName": "Read privileged access to Azure resources", + "id": "1d89d70c-dcac-4248-b214-903c457af83a", + "origin": "Delegated (Microsoft Graph)", + "value": "PrivilegedAccess.Read.AzureResources" + }, + { + "description": "Allows the app to request and manage just in time elevation (including scheduled elevation) of users to Azure AD built-in administrative roles, on behalf of signed-in users.", + "displayName": "Read and write privileged access to Azure AD", + "id": "3c3c74f5-cdaa-4a97-b7e0-4e788bfcfb37", + "origin": "Delegated (Microsoft Graph)", + "value": "PrivilegedAccess.ReadWrite.AzureAD" + }, + { + "description": "Allows the app to request and manage time-based assignment and just-in-time elevation (including scheduled elevation) of Azure AD groups, on behalf of the signed-in user.", + "displayName": "Read and write privileged access to Azure AD groups", + "id": "32531c59-1f32-461f-b8df-6f8a3b89f73b", + "origin": "Delegated (Microsoft Graph)", + "value": "PrivilegedAccess.ReadWrite.AzureADGroup" + }, + { + "description": "Allows the app to request and manage time-based assignment and just-in-time elevation of user privileges to manage Azure resources (like subscriptions, resource groups, storage, compute) on behalf of the signed-in users.", + "displayName": "Read and write privileged access to Azure resources", + "id": "a84a9652-ffd3-496e-a991-22ba5529156a", + "origin": "Delegated (Microsoft Graph)", + "value": "PrivilegedAccess.ReadWrite.AzureResources" + }, + { + "description": "Allows the app to read Privileged Access (PIM) custom extensions for your organization, without a signed-in user.", + "displayName": "Read Privileged Access (PIM) custom extensions", + "id": "bc04fe80-7e6a-4154-8b8f-d1e3465613bf", + "origin": "Delegated (Microsoft Graph)", + "value": "PrivilegedAccess-CustomExt.Read.All" + }, + { + "description": "Allows the app to read and write Privileged Access (PIM) custom extensions for your organization, without a signed-in user.", + "displayName": "Read and write Privileged Access (PIM) custom extensions", + "id": "157efa76-20fd-4db4-876e-90c049322467", + "origin": "Delegated (Microsoft Graph)", + "value": "PrivilegedAccess-CustomExt.ReadWrite.All" + }, + { + "description": "Allows the app to read time-based assignment schedules for access to Azure AD groups, on behalf of the signed-in user.", + "displayName": "Read assignment schedules for access to Azure AD groups", + "id": "02a32cc4-7ab5-4b58-879a-0586e0f7c495", + "origin": "Delegated (Microsoft Graph)", + "value": "PrivilegedAssignmentSchedule.Read.AzureADGroup" + }, + { + "description": "Allows the app to read time-based assignment schedules for permission grants for application permissions to any API (including Microsoft Graph) and application assignments for any app, on behalf of the signed-in user.", + "displayName": "Read assignment schedules for app permission grants and app role assignments", + "id": "d5767d44-e1c1-4fc7-8fb1-7daa58df022a", + "origin": "Delegated (Microsoft Graph)", + "value": "PrivilegedAssignmentSchedule.Read.EntraAppRole" + }, + { + "description": "Allows the app to read, create, and delete time-based assignment schedules for access to Azure AD groups, on behalf of the signed-in user.", + "displayName": "Read, create, and delete assignment schedules for access to Azure AD groups", + "id": "06dbc45d-6708-4ef0-a797-f797ee68bf4b", + "origin": "Delegated (Microsoft Graph)", + "value": "PrivilegedAssignmentSchedule.ReadWrite.AzureADGroup" + }, + { + "description": "Allows the app to read, create, and delete time-based assignment schedules for permission grants for application permissions to any API (including Microsoft Graph) and application assignments for any app, on behalf of the signed-in user.", + "displayName": "Read, create, and delete assignment schedules for app permission grants and app role assignments", + "id": "e07122a7-d275-4a27-a2f5-eb62349edae0", + "origin": "Delegated (Microsoft Graph)", + "value": "PrivilegedAssignmentSchedule.ReadWrite.EntraAppRole" + }, + { + "description": "Allows the app to delete time-based assignment schedules for access to Azure AD groups, on behalf of the signed-in user.", + "displayName": "Delete assignment schedules for access to Azure AD groups", + "id": "ca5fe595-68ff-4dfd-907d-4509501a0e49", + "origin": "Delegated (Microsoft Graph)", + "value": "PrivilegedAssignmentSchedule.Remove.AzureADGroup" + }, + { + "description": "Allows the app to read time-based eligibility schedules for access to Azure AD groups, on behalf of the signed-in user.", + "displayName": "Read eligibility schedules for access to Azure AD groups", + "id": "8f44f93d-ecef-46ae-a9bf-338508d44d6b", + "origin": "Delegated (Microsoft Graph)", + "value": "PrivilegedEligibilitySchedule.Read.AzureADGroup" + }, + { + "description": "Allows the app to read time-based eligibility schedules for permission grants for application permissions to any API (including Microsoft Graph) and application assignments for any app, on behalf of the signed-in user.", + "displayName": "Read eligibility schedules for app permission grants and app role assignments", + "id": "9b9eb231-5483-4f3c-89e9-9d5048dafe9d", + "origin": "Delegated (Microsoft Graph)", + "value": "PrivilegedEligibilitySchedule.Read.EntraAppRole" + }, + { + "description": "Allows the app to read, create, and delete time-based eligibility schedules for access to Azure AD groups, on behalf of the signed-in user.", + "displayName": "Read, create, and delete eligibility schedules for access to Azure AD groups", + "id": "ba974594-d163-484e-ba39-c330d5897667", + "origin": "Delegated (Microsoft Graph)", + "value": "PrivilegedEligibilitySchedule.ReadWrite.AzureADGroup" + }, + { + "description": "Allows the app to read, create, and delete time-based eligibility schedules for permission grants for application permissions to any API (including Microsoft Graph) and application assignments for any app, on behalf of the signed-in user.", + "displayName": "Read, create, and delete eligibility schedules for app permission grants and app role assignments", + "id": "f7ff1cb0-e255-4bb3-b24a-6708c60c5418", + "origin": "Delegated (Microsoft Graph)", + "value": "PrivilegedEligibilitySchedule.ReadWrite.EntraAppRole" + }, + { + "description": "Allows the application to read and write tenant-wide print settings on behalf of the signed-in user.", + "displayName": "Read and write tenant-wide print settings", + "id": "9ccc526a-c51c-4e5c-a1fd-74726ef50b8f", + "origin": "Delegated (Microsoft Graph)", + "value": "PrintSettings.ReadWrite.All" + }, + { + "description": "Allows the application to read tenant-wide print settings on behalf of the signed-in user.", + "displayName": "Read tenant-wide print settings", + "id": "490f32fd-d90f-4dd7-a601-ff6cdc1a3f6c", + "origin": "Delegated (Microsoft Graph)", + "value": "PrintSettings.Read.All" + }, + { + "description": "Allows the application to read and update the metadata of print jobs on behalf of the signed-in user. Does not allow access to print job document content.", + "displayName": "Read and write basic information of print jobs", + "id": "3a0db2f6-0d2a-4c19-971b-49109b19ad3d", + "origin": "Delegated (Microsoft Graph)", + "value": "PrintJob.ReadWriteBasic.All" + }, + { + "description": "Allows the application to read and update the metadata of print jobs that the signed-in user created. Does not allow access to print job document content.", + "displayName": "Read and write basic information of user's print jobs", + "id": "6f2d22f2-1cb6-412c-a17c-3336817eaa82", + "origin": "Delegated (Microsoft Graph)", + "value": "PrintJob.ReadWriteBasic" + }, + { + "description": "Allows the app to read presence information of all users in the directory on behalf of the signed-in user. Presence information includes activity, availability, status note, calendar out-of-office message, timezone and location.", + "displayName": "Read presence information of all users in your organization", + "id": "9c7a330d-35b3-4aa1-963d-cb2b9f927841", + "origin": "Delegated (Microsoft Graph)", + "value": "Presence.Read.All" + }, + { + "description": "Allows the app to read the presence information and write activity and availability on behalf of the signed-in user. Presence information includes activity, availability, status note, calendar out-of-office message, timezone and location.", + "displayName": "Read and write a user's presence information", + "id": "8d3c54a7-cf58-4773-bf81-c0cd6ad522bb", + "origin": "Delegated (Microsoft Graph)", + "value": "Presence.ReadWrite" + }, + { + "description": "Allows the application to read print connectors on behalf of the signed-in user.", + "displayName": "Read print connectors", + "id": "d69c2d6d-4f72-4f99-a6b9-663e32f8cf68", + "origin": "Delegated (Microsoft Graph)", + "value": "PrintConnector.Read.All" + }, + { + "description": "Allows the application to read and write print connectors on behalf of the signed-in user.", + "displayName": "Read and write print connectors", + "id": "79ef9967-7d59-4213-9c64-4b10687637d8", + "origin": "Delegated (Microsoft Graph)", + "value": "PrintConnector.ReadWrite.All" + }, + { + "description": "Allows the application to create (register) printers on behalf of the signed-in user. ", + "displayName": "Register printers ", + "id": "90c30bed-6fd1-4279-bf39-714069619721", + "origin": "Delegated (Microsoft Graph)", + "value": "Printer.Create" + }, + { + "description": "Allows the application to create (register), read, update, and delete (unregister) printers on behalf of the signed-in user. ", + "displayName": "Register, read, update, and unregister printers", + "id": "93dae4bd-43a1-4a23-9a1a-92957e1d9121", + "origin": "Delegated (Microsoft Graph)", + "value": "Printer.FullControl.All" + }, + { + "description": "Allows the application to read printers on behalf of the signed-in user. ", + "displayName": "Read printers", + "id": "3a736c8a-018e-460a-b60c-863b2683e8bf", + "origin": "Delegated (Microsoft Graph)", + "value": "Printer.Read.All" + }, + { + "description": "Allows the application to read and update printers on behalf of the signed-in user. Does not allow creating (registering) or deleting (unregistering) printers.", + "displayName": "Read and update printers", + "id": "89f66824-725f-4b8f-928e-e1c5258dc565", + "origin": "Delegated (Microsoft Graph)", + "value": "Printer.ReadWrite.All" + }, + { + "description": "Allows the app to read security alerts and update status of alerts of customer with CSP relationship on behalf of the partner signed-in user.", + "displayName": "Read security alerts and update status of security alerts of customer with CSP relationship", + "id": "0cd2c1f6-94a1-4075-ab8c-0b1aff2e1ad5", + "origin": "Delegated (Microsoft Graph)", + "value": "PartnerSecurity.ReadWrite.All" + }, + { + "description": "Allows the application to read printer shares on behalf of the signed-in user. ", + "displayName": "Read printer shares", + "id": "ed11134d-2f3f-440d-a2e1-411efada2502", + "origin": "Delegated (Microsoft Graph)", + "value": "PrinterShare.Read.All" + }, + { + "description": "Allows the application to read and update printer shares on behalf of the signed-in user. ", + "displayName": "Read and write printer shares", + "id": "06ceea37-85e2-40d7-bec3-91337a46038f", + "origin": "Delegated (Microsoft Graph)", + "value": "PrinterShare.ReadWrite.All" + }, + { + "description": "Allows the application to create print jobs on behalf of the signed-in user and upload document content to print jobs that the signed-in user created.", + "displayName": "Create print jobs", + "id": "21f0d9c0-9f13-48b3-94e0-b6b231c7d320", + "origin": "Delegated (Microsoft Graph)", + "value": "PrintJob.Create" + }, + { + "description": "Allows the application to read the metadata and document content of print jobs that the signed-in user created.", + "displayName": "Read user's print jobs", + "id": "248f5528-65c0-4c88-8326-876c7236df5e", + "origin": "Delegated (Microsoft Graph)", + "value": "PrintJob.Read" + }, + { + "description": "Allows the application to read the metadata and document content of print jobs on behalf of the signed-in user. ", + "displayName": "Read print jobs", + "id": "afdd6933-a0d8-40f7-bd1a-b5d778e8624b", + "origin": "Delegated (Microsoft Graph)", + "value": "PrintJob.Read.All" + }, + { + "description": "Allows the application to read the metadata of print jobs that the signed-in user created. Does not allow access to print job document content.", + "displayName": "Read basic information of user's print jobs", + "id": "6a71a747-280f-4670-9ca0-a9cbf882b274", + "origin": "Delegated (Microsoft Graph)", + "value": "PrintJob.ReadBasic" + }, + { + "description": "Allows the application to read the metadata of print jobs on behalf of the signed-in user. Does not allow access to print job document content.", + "displayName": "Read basic information of print jobs", + "id": "04ce8d60-72ce-4867-85cf-6d82f36922f3", + "origin": "Delegated (Microsoft Graph)", + "value": "PrintJob.ReadBasic.All" + }, + { + "description": "Allows the application to read and update the metadata and document content of print jobs that the signed-in user created.", + "displayName": "Read and write user's print jobs", + "id": "b81dd597-8abb-4b3f-a07a-820b0316ed04", + "origin": "Delegated (Microsoft Graph)", + "value": "PrintJob.ReadWrite" + }, + { + "description": "Allows the application to read and update the metadata and document content of print jobs on behalf of the signed-in user. ", + "displayName": "Read and write print jobs", + "id": "036b9544-e8c5-46ef-900a-0646cc42b271", + "origin": "Delegated (Microsoft Graph)", + "value": "PrintJob.ReadWrite.All" + }, + { + "description": "Allows the application to read basic information about printer shares on behalf of the signed-in user. Does not allow reading access control information.", + "displayName": "Read basic information about printer shares", + "id": "5fa075e9-b951-4165-947b-c63396ff0a37", + "origin": "Delegated (Microsoft Graph)", + "value": "PrinterShare.ReadBasic.All" + }, + { + "description": "Allows the app to read identity lifecycle policies for agent identities that the signed-in user has access to in the organization.", + "displayName": "Read identity lifecycle policies for agent identities", + "id": "65857db0-62ac-4279-aa73-c2b5dab186f5", + "origin": "Delegated (Microsoft Graph)", + "value": "LifecyclePolicies-AgentId.Read.All" + }, + { + "description": "Allows the app to read security alerts of customer with CSP relationship on behalf of the partner signed-in user.", + "displayName": "Read security alerts of customer with CSP relationship", + "id": "5567b981-0bf1-4796-9038-0648b46e116d", + "origin": "Delegated (Microsoft Graph)", + "value": "PartnerSecurity.Read.All" + }, + { + "description": "Allows the app to read and write organization-wide Microsoft To Do settings on behalf of the signed-in user.", + "displayName": "Read and write organization-wide Microsoft To Do settings", + "id": "087502c2-5263-433e-abe3-8f77231a0627", + "origin": "Delegated (Microsoft Graph)", + "value": "OrgSettings-Todo.ReadWrite.All" + }, + { + "description": "Allows the app to create, read, update, and delete email, including contents of non-draft emails in user mailboxes, on behalf of the signed-in user. Does not include permission to send mail.", + "displayName": "Read and write the user's mail, including modifying existing non-draft mails", + "id": "f3af82f6-18e0-4a41-8dc8-a03c11854a8d", + "origin": "Delegated (Microsoft Graph)", + "value": "Mail-Advanced.ReadWrite" + }, + { + "description": "Allows the app to create, read, update, and delete mail including contents of non-draft emails for all mails a user has permission to access, on behalf of the signed-in user. This includes their own and shared mail. Does not include permission to send mail.", + "displayName": "Read and write all mail the user can access, including modifying existing non-draft mails", + "id": "bebf0bb6-2ff3-4295-a17d-f3561da294fb", + "origin": "Delegated (Microsoft Graph)", + "value": "Mail-Advanced.ReadWrite.Shared" + }, + { + "description": "Allows the app to read user's UserConfiguration objects, on behalf of the the signed-in user.", + "displayName": "Read user's UserConfiguration objects", + "id": "dce2e6fc-0f4b-40da-94e2-14b4477f3d92", + "origin": "Delegated (Microsoft Graph)", + "value": "MailboxConfigItem.Read" + }, + { + "description": "Allows the app to create, read, update and delete user's UserConfiguration objects, on behalf of the the signed-in user.", + "displayName": "Read and write user's UserConfiguration objects", + "id": "7d461784-7715-4b09-9f90-91a6d8722652", + "origin": "Delegated (Microsoft Graph)", + "value": "MailboxConfigItem.ReadWrite" + }, + { + "description": "Allows the app to read the user's mailbox folders, on behalf of the signed-in user.", + "displayName": "Read a user's mailbox folders", + "id": "52dc2051-4958-4636-8f2a-281d39c6981c", + "origin": "Delegated (Microsoft Graph)", + "value": "MailboxFolder.Read" + }, + { + "description": "Allows the app to read and write the user's mailbox folders, on behalf of the signed-in user.", + "displayName": "Read and write a user's mailbox folders", + "id": "077fde41-7e0b-4c5b-bcd1-e9d743a30c80", + "origin": "Delegated (Microsoft Graph)", + "value": "MailboxFolder.ReadWrite" + }, + { + "description": "Allows the app to export the user's mailbox items, on behalf of the the signed-in user.", + "displayName": "Export a user's mailbox items", + "id": "58d3e7fa-3ce9-4a0c-9baa-0971f64709d9", + "origin": "Delegated (Microsoft Graph)", + "value": "MailboxItem.Export" + }, + { + "description": "Allows the app to export and import the user's mailbox items, on behalf of the the signed-in user.", + "displayName": "Export and import a user's mailbox items", + "id": "df96e8a0-f4e1-4ecf-8d83-a429f822cbd6", + "origin": "Delegated (Microsoft Graph)", + "value": "MailboxItem.ImportExport" + }, + { + "description": "Allows the app to read the user's mailbox items, on behalf of the signed-in user.", + "displayName": "Read a user's mailbox items", + "id": "82305458-296d-4edd-8b0b-74dd74c34526", + "origin": "Delegated (Microsoft Graph)", + "value": "MailboxItem.Read" + }, + { + "description": "Allows the app to read, write, and delete the user's mailbox items, on behalf of the signed-in user.", + "displayName": "Read and write your mailbox items", + "id": "ec1ade38-5268-4bc8-87fa-b230e42f7a88", + "origin": "Delegated (Microsoft Graph)", + "value": "MailboxItem.ReadWrite" + }, + { + "description": "Allows the app to the read user's mailbox settings. Does not include permission to send mail.", + "displayName": "Read user mailbox settings", + "id": "87f447af-9fa4-4c32-9dfa-4a57a73d18ce", + "origin": "Delegated (Microsoft Graph)", + "value": "MailboxSettings.Read" + }, + { + "description": "Allows the app to create, read, update, and delete user's mailbox settings. Does not include permission to send mail.", + "displayName": "Read and write user mailbox settings", + "id": "818c620a-27a9-40bd-a6a5-d96f7d610b4b", + "origin": "Delegated (Microsoft Graph)", + "value": "MailboxSettings.ReadWrite" + }, + { + "description": "Allows the app to read mail tips on behalf of the signed-in user for mailboxes they have access to. Mail tips include automatic replies, mailbox status, custom tips, and delivery information.", + "displayName": "Read mail tips for mailboxes you can access", + "id": "4776cae1-54bd-4dfd-823c-e5861ed49a98", + "origin": "Delegated (Microsoft Graph)", + "value": "MailTips.ReadBasic.Shared" + }, + { + "description": "Allows the app to read all managed tenant information on behalf of the signed-in user.", + "displayName": "Read all managed tenant information", + "id": "dc34164e-6c4a-41a0-be89-3ae2fbad7cd3", + "origin": "Delegated (Microsoft Graph)", + "value": "ManagedTenants.Read.All" + }, + { + "description": "Allows the app to read and write all managed tenant information on behalf of the signed-in user.", + "displayName": "Read and write all managed tenant information", + "id": "b31fa710-c9b3-4d9e-8f5e-8036eecddab9", + "origin": "Delegated (Microsoft Graph)", + "value": "ManagedTenants.ReadWrite.All" + }, + { + "description": "Allows the app to read the memberships of hidden groups and administrative units on behalf of the signed-in user, for those hidden groups and administrative units that the signed-in user has access to.", + "displayName": "Read hidden memberships", + "id": "f6a3db3e-f7e8-4ed2-a414-557c8c9830be", + "origin": "Delegated (Microsoft Graph)", + "value": "Member.Read.Hidden" + }, + { + "description": "Allows the app to read multi-tenant organization details and tenants on behalf of the signed-in user.", + "displayName": "Read multi-tenant organization details and tenants", + "id": "526aa72a-5878-49fe-bf4e-357973af9b06", + "origin": "Delegated (Microsoft Graph)", + "value": "MultiTenantOrganization.Read.All" + }, + { + "description": "Allows the app to send mail as the signed-in user, including sending on-behalf of others.", + "displayName": "Send mail on behalf of others", + "id": "a367ab51-6b49-43bf-a716-a1fb06d2a174", + "origin": "Delegated (Microsoft Graph)", + "value": "Mail.Send.Shared" + }, + { + "description": "Allows the app to read multi-tenant organization basic details and active tenants on behalf of the signed-in user.", + "displayName": "Read multi-tenant organization basic details and active tenants", + "id": "225db56b-15b2-4daa-acb3-0eec2bbe4849", + "origin": "Delegated (Microsoft Graph)", + "value": "MultiTenantOrganization.ReadBasic.All" + }, + { + "description": "Allows the app to send mail as users in the organization.", + "displayName": "Send mail as a user ", + "id": "e383f46e-2787-4529-855e-0e479a3ffac0", + "origin": "Delegated (Microsoft Graph)", + "value": "Mail.Send" + }, + { + "description": "Allows the app to create, read, update, and delete email in user mailboxes. Does not include permission to send mail.", + "displayName": "Read and write access to user mail ", + "id": "024d486e-b451-40bb-833d-3e66d98c5c73", + "origin": "Delegated (Microsoft Graph)", + "value": "Mail.ReadWrite" + }, + { + "description": "Allows the app to read identity lifecycle policies for external guests on behalf of the signed-in user.", + "displayName": "Read identity lifecycle policies for external guests", + "id": "1bbb7916-b98a-449f-8ee4-c68bfcba5724", + "origin": "Delegated (Microsoft Graph)", + "value": "LifecyclePolicies-Guests.Read.All" + }, + { + "description": "Allows the app to create, update, and delete identity lifecycle policies for external guests on behalf of the signed-in user.", + "displayName": "Read and write identity lifecycle policies for external guests", + "id": "d9ec82ed-63db-4905-b1b3-859b74d2bbf5", + "origin": "Delegated (Microsoft Graph)", + "value": "LifecyclePolicies-Guests.ReadWrite.All" + }, + { + "description": "Allows the app to list and read all workflows, tasks and related lifecycle workflows resources on behalf of the signed-in user.", + "displayName": "Read all lifecycle workflows resources", + "id": "9bcb9916-765a-42af-bf77-02282e26b01a", + "origin": "Delegated (Microsoft Graph)", + "value": "LifecycleWorkflows.Read.All" + }, + { + "description": "Allows the app to create, update, list, read and delete all workflows, tasks and related lifecycle workflows resources on behalf of the signed-in user.", + "displayName": "Read and write all lifecycle workflows resources", + "id": "84b9d731-7db8-4454-8c90-fd9e95350179", + "origin": "Delegated (Microsoft Graph)", + "value": "LifecycleWorkflows.ReadWrite.All" + }, + { + "description": "Allows the app to read all Lifecycle workflows custom task extensions on behalf of a signed-in user.", + "displayName": "Read all Lifecycle workflows custom task extensions", + "id": "2973a298-1d69-4f87-8d30-7025f0ec19d7", + "origin": "Delegated (Microsoft Graph)", + "value": "LifecycleWorkflows-CustomExt.Read.All" + }, + { + "description": "Allows the app to create, update, list, read and delete all Lifecycle workflows custom task extensions on behalf of a signed-in user.", + "displayName": "Read and write all Lifecycle workflows custom task extensions", + "id": "ef6bafb1-3019-4a22-a332-103aff92225f", + "origin": "Delegated (Microsoft Graph)", + "value": "LifecycleWorkflows-CustomExt.ReadWrite.All" + }, + { + "description": "Allows the app to read all Lifecycle workflows reports on behalf of a signed-in user.", + "displayName": "Read all Lifecycle workflows reports", + "id": "4d3d7f81-163f-426a-8432-5638d2e82083", + "origin": "Delegated (Microsoft Graph)", + "value": "LifecycleWorkflows-Reports.Read.All" + }, + { + "description": "Allows the app to run workflows on-demand on behalf of a signed-in user.", + "displayName": "Run workflows on-demand in Lifecycle workflows", + "id": "df1c25b3-072c-45cd-8403-c63441e4cca1", + "origin": "Delegated (Microsoft Graph)", + "value": "LifecycleWorkflows-Workflow.Activate" + }, + { + "description": "Allows the app to list and read all workflows and tasks on behalf of a signed-in user.", + "displayName": "Read all workflows in Lifecycle workflows", + "id": "7fabe5bd-2e47-4e61-b924-327117024e18", + "origin": "Delegated (Microsoft Graph)", + "value": "LifecycleWorkflows-Workflow.Read.All" + }, + { + "description": "Allows the app to list all workflows on behalf of a signed-in user.", + "displayName": "List all workflows in Lifecycle workflows", + "id": "789c445d-433c-4575-a1fc-367a58a1bd4a", + "origin": "Delegated (Microsoft Graph)", + "value": "LifecycleWorkflows-Workflow.ReadBasic.All" + }, + { + "description": "Allows the app to create, update, list, read and delete all workflows and tasks in lifecycle workflows on behalf of a signed-in user.", + "displayName": "Read and write all workflows in Lifecycle workflows", + "id": "29e49f0c-a053-4cc5-a4b1-7da0c8c1e643", + "origin": "Delegated (Microsoft Graph)", + "value": "LifecycleWorkflows-Workflow.ReadWrite.All" + }, + { + "description": "Allow the application to access a subset of listitems on behalf of the signed in user. The specific listitems and the permissions granted will be configured in SharePoint Online.", + "displayName": "Access selected ListItems, on behalf of the signed-in user", + "id": "d6d361b3-211a-4191-9fa7-15f72de4aac4", + "origin": "Delegated (Microsoft Graph)", + "value": "ListItems.SelectedOperations.Selected" + }, + { + "description": "Allow the application to access a subset of lists on behalf of the signed in user. The specific lists and the permissions granted will be configured in SharePoint Online.", + "displayName": "Access selected Lists, on behalf of the signed-in user", + "id": "033b51ee-d6fa-4add-b627-ee680c7212b5", + "origin": "Delegated (Microsoft Graph)", + "value": "Lists.SelectedOperations.Selected" + }, + { + "description": "Allows the app to read the signed-in user's mailbox.", + "displayName": "Read user mail ", + "id": "570282fd-fa5c-430d-a7fd-fc8dc98a9dca", + "origin": "Delegated (Microsoft Graph)", + "value": "Mail.Read" + }, + { + "description": "Allows the app to read mail a user can access, including their own and shared mail.", + "displayName": "Read user and shared mail", + "id": "7b9103a5-4610-446b-9670-80643382c1fa", + "origin": "Delegated (Microsoft Graph)", + "value": "Mail.Read.Shared" + }, + { + "description": "Allows the app to read email in the signed-in user's mailbox except body, previewBody, attachments and any extended properties.", + "displayName": "Read user basic mail", + "id": "a4b8392a-d8d1-4954-a029-8e668a39a170", + "origin": "Delegated (Microsoft Graph)", + "value": "Mail.ReadBasic" + }, + { + "description": "Allows the app to read mail the signed-in user can access, including their own and shared mail, except for body, bodyPreview, uniqueBody, attachments, extensions, and any extended properties.", + "displayName": "Read user and shared basic mail", + "id": "b11fa0e7-fdb7-4dc9-b1f1-59facd463480", + "origin": "Delegated (Microsoft Graph)", + "value": "Mail.ReadBasic.Shared" + }, + { + "description": "Allows the app to create, read, update, and delete mail a user has permission to access, including their own and shared mail. Does not include permission to send mail.", + "displayName": "Read and write user and shared mail", + "id": "5df07973-7d5d-46ed-9847-1271055cbd51", + "origin": "Delegated (Microsoft Graph)", + "value": "Mail.ReadWrite.Shared" + }, + { + "description": "Allows the app to read and write multi-tenant organization details and tenants on behalf of the signed-in user.", + "displayName": "Read and write multi-tenant organization details and tenants", + "id": "77af1528-84f3-4023-8d90-d219cd433108", + "origin": "Delegated (Microsoft Graph)", + "value": "MultiTenantOrganization.ReadWrite.All" + }, + { + "description": "Allows the app to read configuration used for OAuth 2.0 mutual-TLS client authentication, on behalf of the signed-in user. This includes reading trusted certificate authorities.", + "displayName": "Read all configurations used for mutual-TLS client authentication.", + "id": "51ae584e-e736-4718-897b-10af70f8e3cc", + "origin": "Delegated (Microsoft Graph)", + "value": "MutualTlsOauthConfiguration.Read.All" + }, + { + "description": "Allows the app to read and update configuration used for OAuth 2.0 mutual-TLS client authentication, on behalf of the signed-in user. This includes adding and updating trusted certificate authorities.", + "displayName": "Read and write all configurations used for mutual-TLS client authentication.", + "id": "a51115bc-f64f-498f-bcee-00dcd28f4a03", + "origin": "Delegated (Microsoft Graph)", + "value": "MutualTlsOauthConfiguration.ReadWrite.All" + }, + { + "description": "Allows the app to read and write all on-premises directory synchronization information for the organization, on behalf of the signed-in user.", + "displayName": "Read and write all on-premises directory synchronization information", + "id": "c2d95988-7604-4ba1-aaed-38a5f82a51c7", + "origin": "Delegated (Microsoft Graph)", + "value": "OnPremDirectorySynchronization.ReadWrite.All" + }, + { + "description": "Allows the app to manage hybrid identity service configuration by creating, viewing, updating and deleting on-premises published resources, on-premises agents and agent groups, on behalf of the signed-in user.", + "displayName": "Manage on-premises published resources", + "id": "8c4d5184-71c2-4bf8-bb9d-bc3378c9ad42", + "origin": "Delegated (Microsoft Graph)", + "value": "OnPremisesPublishingProfiles.ReadWrite.All" + }, + { + "description": "Allows users to sign in to the app with their work or school accounts and allows the app to see basic user profile information.", + "displayName": "Sign users in", + "id": "37f7f235-527c-4136-accd-4a02d197296e", + "origin": "Delegated (Microsoft Graph)", + "value": "openid" + }, + { + "description": "Allows the app to read the organization and related resources, on behalf of the signed-in user. Related resources include things like subscribed skus and tenant branding information.", + "displayName": "Read organization information", + "id": "4908d5b9-3fb2-4b1e-9336-1888b7937185", + "origin": "Delegated (Microsoft Graph)", + "value": "Organization.Read.All" + }, + { + "description": "Allows the app to read and write the organization and related resources, on behalf of the signed-in user. Related resources include things like subscribed skus and tenant branding information.", + "displayName": "Read and write organization information", + "id": "46ca0847-7e6b-426e-9775-ea810a948356", + "origin": "Delegated (Microsoft Graph)", + "value": "Organization.ReadWrite.All" + }, + { + "description": "Allows the app to read the organizational branding information, on behalf of the signed-in user.", + "displayName": "Read organizational branding information", + "id": "9082f138-6f02-4f3a-9f4d-5f3c2ce5c688", + "origin": "Delegated (Microsoft Graph)", + "value": "OrganizationalBranding.Read.All" + }, + { + "description": "Allows the app to read and write the organizational branding information, on behalf of the signed-in user.", + "displayName": "Read and write organizational branding information", + "id": "15ce63de-b141-4c9a-a9a5-241bf27c6aaf", + "origin": "Delegated (Microsoft Graph)", + "value": "OrganizationalBranding.ReadWrite.All" + }, + { + "description": "Allows the app to read all organizational contacts on behalf of the signed-in user. These contacts are managed by the organization and are different from a user's personal contacts.", + "displayName": "Read organizational contacts", + "id": "08432d1b-5911-483c-86df-7980af5cdee0", + "origin": "Delegated (Microsoft Graph)", + "value": "OrgContact.Read.All" + }, + { + "description": "Allows the app to read organization-wide apps and services settings on behalf of the signed-in user.", + "displayName": "Read organization-wide apps and services settings", + "id": "1e9b7a7e-4d64-44ff-acf5-2e9651c1519f", + "origin": "Delegated (Microsoft Graph)", + "value": "OrgSettings-AppsAndServices.Read.All" + }, + { + "description": "Allows the app to read and write organization-wide apps and services settings on behalf of the signed-in user.", + "displayName": "Read and write organization-wide apps and services settings", + "id": "c167b0e7-47c0-48e8-9eee-9892f58018fa", + "origin": "Delegated (Microsoft Graph)", + "value": "OrgSettings-AppsAndServices.ReadWrite.All" + }, + { + "description": "Allows the app to read organization-wide Dynamics customer voice settings on behalf of the signed-in user.", + "displayName": "Read organization-wide Dynamics customer voice settings", + "id": "9862d930-5aec-4a98-8d4f-7277a8db9bcb", + "origin": "Delegated (Microsoft Graph)", + "value": "OrgSettings-DynamicsVoice.Read.All" + }, + { + "description": "Allows the app to read and write organization-wide Dynamics customer voice settings on behalf of the signed-in user.", + "displayName": "Read and write organization-wide Dynamics customer voice settings", + "id": "4cea26fb-6967-4234-82c4-c044414743f8", + "origin": "Delegated (Microsoft Graph)", + "value": "OrgSettings-DynamicsVoice.ReadWrite.All" + }, + { + "description": "Allows the app to read organization-wide Microsoft Forms settings on behalf of the signed-in user.", + "displayName": "Read organization-wide Microsoft Forms settings", + "id": "210051a0-1ffc-435c-ae76-02d226d05752", + "origin": "Delegated (Microsoft Graph)", + "value": "OrgSettings-Forms.Read.All" + }, + { + "description": "Allows the app to read and write organization-wide Microsoft Forms settings on behalf of the signed-in user.", + "displayName": "Read and write organization-wide Microsoft Forms settings", + "id": "346c19ff-3fb2-4e81-87a0-bac9e33990c1", + "origin": "Delegated (Microsoft Graph)", + "value": "OrgSettings-Forms.ReadWrite.All" + }, + { + "description": "Allows the app to read organization-wide Microsoft 365 apps installation settings on behalf of the signed-in user.", + "displayName": "Read organization-wide Microsoft 365 apps installation settings", + "id": "8cbdb9f6-9c2e-451a-814d-ec606e5d0212", + "origin": "Delegated (Microsoft Graph)", + "value": "OrgSettings-Microsoft365Install.Read.All" + }, + { + "description": "Allows the app to read and write organization-wide Microsoft 365 apps installation settings on behalf of the signed-in user.", + "displayName": "Read and write organization-wide Microsoft 365 apps installation settings", + "id": "1ff35e91-19eb-42d8-aa2d-cc9891127ae5", + "origin": "Delegated (Microsoft Graph)", + "value": "OrgSettings-Microsoft365Install.ReadWrite.All" + }, + { + "description": "Allows the app to read organization-wide Microsoft To Do settings on behalf of the signed-in user.", + "displayName": "Read organization-wide Microsoft To Do settings", + "id": "7ff96f41-f022-45ba-acd8-ef3f03063d6b", + "origin": "Delegated (Microsoft Graph)", + "value": "OrgSettings-Todo.Read.All" + }, + { + "description": "Allows the app to read all on-premises directory synchronization information for the organization, on behalf of the signed-in user.", + "displayName": "Read all on-premises directory synchronization information", + "id": "f6609722-4100-44eb-b747-e6ca0536989d", + "origin": "Delegated (Microsoft Graph)", + "value": "OnPremDirectorySynchronization.Read.All" + }, + { + "description": "Allows the app to read all transcripts of online meetings, on behalf of the signed-in user.", + "displayName": "Read all transcripts of online meetings.", + "id": "30b87d18-ebb1-45db-97f8-82ccb1f0190c", + "origin": "Delegated (Microsoft Graph)", + "value": "OnlineMeetingTranscript.Read.All" + }, + { + "description": "Allows the app to read and create online meetings on behalf of the signed-in user.", + "displayName": "Read and create user's online meetings", + "id": "a65f2972-a4f8-4f5e-afd7-69ccb046d5dc", + "origin": "Delegated (Microsoft Graph)", + "value": "OnlineMeetings.ReadWrite" + }, + { + "description": "Allows the app to read online meeting details on behalf of the signed-in user.", + "displayName": "Read user's online meetings", + "id": "9be106e1-f4e3-4df5-bdff-e4bc531cbe43", + "origin": "Delegated (Microsoft Graph)", + "value": "OnlineMeetings.Read" + }, + { + "description": "Allows the app to read all network access information on behalf of the signed-in user.", + "displayName": "Read all network access information", + "id": "2f7013e0-ab4e-447f-a5e1-5d419950692d", + "origin": "Delegated (Microsoft Graph)", + "value": "NetworkAccess.Read.All" + }, + { + "description": "Allows the app to read and write all network access information and configuration settings on behalf of the signed-in user.", + "displayName": "Read and write all network access information", + "id": "ae2df9c5-f18d-4ec4-a51b-bdeb807f177b", + "origin": "Delegated (Microsoft Graph)", + "value": "NetworkAccess.ReadWrite.All" + }, + { + "description": "Allows the app to read your organization's branches for network access on behalf of the signed-in user.", + "displayName": "Read properties of branches for network access", + "id": "4051c7fc-b429-4804-8d80-8f1f8c24a6f7", + "origin": "Delegated (Microsoft Graph)", + "value": "NetworkAccessBranch.Read.All" + }, + { + "description": "Allows the app to read and write your organization's branches for network access on behalf of the signed-in user.", + "displayName": "Read and write properties of branches for network access", + "id": "b8a36cc2-b810-461a-baa4-a7281e50bd5c", + "origin": "Delegated (Microsoft Graph)", + "value": "NetworkAccessBranch.ReadWrite.All" + }, + { + "description": "Allows the app to read your organization's security and routing network access policies on behalf of the signed-in user.", + "displayName": "Read security and routing policies for network access", + "id": "ba22922b-752c-446f-89d7-a2d92398fceb", + "origin": "Delegated (Microsoft Graph)", + "value": "NetworkAccessPolicy.Read.All" + }, + { + "description": "Allows the app to read and write your organization's security and routing network access policies on behalf of the signed-in user.", + "displayName": "Read and write security and routing policies for network access", + "id": "b1fbad0f-ef6e-42ed-8676-bca7fa3e7291", + "origin": "Delegated (Microsoft Graph)", + "value": "NetworkAccessPolicy.ReadWrite.All" + }, + { + "description": "Allows the app to read all network access reports on behalf of the signed-in user.", + "displayName": "Read all network access reports", + "id": "b0c61509-cfc3-42bd-9bd4-66d81785fee4", + "origin": "Delegated (Microsoft Graph)", + "value": "NetworkAccess-Reports.Read.All" + }, + { + "description": "Allows the app to read the titles of OneNote notebooks and sections and to create new pages, notebooks, and sections on behalf of the signed-in user.", + "displayName": "Create user OneNote notebooks", + "id": "9d822255-d64d-4b7a-afdb-833b9a97ed02", + "origin": "Delegated (Microsoft Graph)", + "value": "Notes.Create" + }, + { + "description": "Allows the app to read all of billing data from Microsoft for your company's tenant, on behalf of the signed-in user. This includes reading billed and unbilled Usage and Invoice reconciliation data.", + "displayName": "Read all billing data for your company's tenant", + "id": "8804798e-5934-4e30-8ce3-ef88257cecd4", + "origin": "Delegated (Microsoft Graph)", + "value": "PartnerBilling.Read.All" + }, + { + "description": "Allows the app to read OneNote notebooks on behalf of the signed-in user.", + "displayName": "Read user OneNote notebooks", + "id": "371361e4-b9e2-4a3f-8315-2a301a3b0a3d", + "origin": "Delegated (Microsoft Graph)", + "value": "Notes.Read" + }, + { + "description": "Allows the app to read, share, and modify OneNote notebooks on behalf of the signed-in user.", + "displayName": "Read and write user OneNote notebooks", + "id": "615e26af-c38a-4150-ae3e-c3b0d4cb1d6a", + "origin": "Delegated (Microsoft Graph)", + "value": "Notes.ReadWrite" + }, + { + "description": "Allows the app to read, share, and modify OneNote notebooks that the signed-in user has access to in the organization.", + "displayName": "Read and write all OneNote notebooks that user can access", + "id": "64ac0503-b4fa-45d9-b544-71a463f05da0", + "origin": "Delegated (Microsoft Graph)", + "value": "Notes.ReadWrite.All" + }, + { + "description": "This is deprecated! Do not use! This permission no longer has any effect. You can safely consent to it. No additional privileges will be granted to the app.", + "displayName": "Limited notebook access (deprecated)", + "id": "ed68249d-017c-4df5-9113-e684c7f8760b", + "origin": "Delegated (Microsoft Graph)", + "value": "Notes.ReadWrite.CreatedByApp" + }, + { + "description": "Allows the app to deliver its notifications on behalf of signed-in users. Also allows the app to read, update, and delete the user's notification items for this app.", + "displayName": "Deliver and manage user notifications for this app", + "id": "89497502-6e42-46a2-8cb2-427fd3df970a", + "origin": "Delegated (Microsoft Graph)", + "value": "Notifications.ReadWrite.CreatedByApp" + }, + { + "description": "Allows the app to see and update the data you gave it access to, even when users are not currently using the app. This does not give the app any additional permissions.", + "displayName": "Maintain access to data you have given it access to", + "id": "7427e0e9-2fba-42fe-b0c0-848c9e6a8182", + "origin": "Delegated (Microsoft Graph)", + "value": "offline_access" + }, + { + "description": "Allows the app to read all AI Insights for online meetings, on behalf of the signed-in user.", + "displayName": "Read all AI Insights for online meetings.", + "id": "166741d6-eeb8-46fe-91f4-817d2af7bc88", + "origin": "Delegated (Microsoft Graph)", + "value": "OnlineMeetingAiInsight.Read.All" + }, + { + "description": "Allows the app to read online meeting artifacts on behalf of the signed-in user.", + "displayName": "Read user's online meeting artifacts", + "id": "110e5abb-a10c-4b59-8b55-9b4daa4ef743", + "origin": "Delegated (Microsoft Graph)", + "value": "OnlineMeetingArtifact.Read.All" + }, + { + "description": "Allows the app to read all recordings of online meetings, on behalf of the signed-in user.", + "displayName": "Read all recordings of online meetings.", + "id": "190c2bb6-1fdd-4fec-9aa2-7d571b5e1fe3", + "origin": "Delegated (Microsoft Graph)", + "value": "OnlineMeetingRecording.Read.All" + }, + { + "description": "Allows the app to read OneNote notebooks that the signed-in user has access to in the organization.", + "displayName": "Read all OneNote notebooks that user can access", + "id": "dfabfca6-ee36-4db2-8208-7a28381419b3", + "origin": "Delegated (Microsoft Graph)", + "value": "Notes.Read.All" + }, + { + "description": "Allows the app to uninstall Microsoft Entra Connect Sync Agent and offboard SSPR for the tenant", + "displayName": "Read, write and manage Microsoft Entra Connect Sync Agent", + "id": "69201c67-737b-4a20-8f16-e0c8c64e0b0e", + "origin": "Application (Microsoft password reset service)", + "value": "PasswordWriteback.OffboardClient.All" + }, + { + "description": "Allows the app to refresh and recreate on-premises configuration for Microsoft self-service password reset.", + "displayName": "Read, write and manage self-service password reset writeback configuration", + "id": "fc7e8088-95b5-453e-8bef-b17ecfec5ba3", + "origin": "Application (Microsoft password reset service)", + "value": "PasswordWriteback.RefreshClient.All" + }, + { + "description": "Allows the app to register a newer version of on-premises Microsoft Entra Connect Sync Agent.", + "displayName": "Read, write and manage Microsoft Entra Connect Sync Agent", + "id": "e006e431-a65b-4f3e-8808-77d29d4c5f1a", + "origin": "Application (Microsoft password reset service)", + "value": "PasswordWriteback.RegisterClientVersion.All" + }, + { + "description": " ", + "displayName": "PaginatedReport.Execute (retired)", + "id": "4aaafe5b-1b27-4403-88f2-e3ebbb8bccc5", + "origin": "Delegated (Power BI Service)", + "value": "PaginatedReport.Execute.All" + }, + { + "description": "Allows reading paginated reports on the user’s behalf.", + "displayName": "Read paginated reports", + "id": "e93694df-fa72-4011-aad8-f3648588c762", + "origin": "Delegated (Power BI Service)", + "value": "PaginatedReport.Read.All" + }, + { + "description": "Allows modifying paginated reports on the user’s behalf.", + "displayName": "Read and write paginated reports", + "id": "a405c0f7-5d2f-4e19-8db7-7323bae0b3c3", + "origin": "Delegated (Power BI Service)", + "value": "PaginatedReport.ReadWrite.All" + }, + { + "description": " ", + "displayName": "PaginatedReport.Reshare (retired)", + "id": "b510092a-d399-45f3-b1d5-4e2d34c87997", + "origin": "Delegated (Power BI Service)", + "value": "PaginatedReport.Reshare.All" + }, + { + "description": "The app can deploy content in all pipelines and pipeline stages in deployment pipelines, which the signed in user has access to.", + "displayName": "Deploy in all pipelines", + "id": "652d7d02-6ff0-4cf7-9516-cf77d33a3ae4", + "origin": "Delegated (Power BI Service)", + "value": "Pipeline.Deploy" + }, + { + "description": "The app can view all deployment pipelines that the signed in user has access to.", + "displayName": "View all pipelines", + "id": "dbe6434c-63f0-42bb-be8e-122ec1bad4d2", + "origin": "Delegated (Power BI Service)", + "value": "Pipeline.Read.All" + }, + { + "description": "The app can view and edit all deployment pipelines that the signed in user has access to.", + "displayName": "Read and write all pipelines", + "id": "199f155b-cccd-4be4-bbe6-ca9a867b24b4", + "origin": "Delegated (Power BI Service)", + "value": "Pipeline.ReadWrite.All" + }, + { + "description": "Allows the app to read basic properties of Entra ID identities in your organization that are known to Microsoft Fabric, on behalf of the signed-in user. This includes display names and email addresses of users, service principals and security groups.", + "displayName": "Read Entra ID identities basic properties", + "id": "8eb59948-47ce-4224-8cb1-f2a1ddb35822", + "origin": "Delegated (Power BI Service)", + "value": "PrincipalDetails.ReadBasic.All" + }, + { + "description": " ", + "displayName": "Reflex.Execute (retired)", + "id": "784ff746-e33b-4449-ba4e-081158296c6c", + "origin": "Delegated (Power BI Service)", + "value": "Reflex.Execute.All" + }, + { + "description": "Allows reading Reflexes on the user’s behalf.", + "displayName": "Read Reflexes", + "id": "781c41ac-e316-4a17-b470-cafd75f5c010", + "origin": "Delegated (Power BI Service)", + "value": "Reflex.Read.All" + }, + { + "description": "Allows modifying Reflexes on the user’s behalf.", + "displayName": "Read and write Reflexes", + "id": "99cac2a4-5c59-45dc-83bd-5303fda5d49d", + "origin": "Delegated (Power BI Service)", + "value": "Reflex.ReadWrite.All" + }, + { + "description": " ", + "displayName": "Reflex.Reshare (retired)", + "id": "004969fb-6ad0-4ca6-aa15-246f2bc7ba3c", + "origin": "Delegated (Power BI Service)", + "value": "Reflex.Reshare.All" + }, + { + "description": " ", + "displayName": "Report.Execute (retired)", + "id": "b0a64161-0e6a-4f7c-aa14-a1f9413136f3", + "origin": "Delegated (Power BI Service)", + "value": "Report.Execute.All" + }, + { + "description": "Allows reading reports on the user’s behalf.", + "displayName": "Read reports", + "id": "4ae1bf56-f562-4747-b7bc-2fa0874ed46f", + "origin": "Delegated (Power BI Service)", + "value": "Report.Read.All" + }, + { + "description": "Allows modifying reports on the user’s behalf.", + "displayName": "Read and write reports", + "id": "7504609f-c495-4c64-8542-686125a5a36f", + "origin": "Delegated (Power BI Service)", + "value": "Report.ReadWrite.All" + }, + { + "description": " ", + "displayName": "Report.Reshare (retired)", + "id": "54f4913d-63d6-4256-a270-f16a6222e625", + "origin": "Delegated (Power BI Service)", + "value": "Report.Reshare.All" + }, + { + "description": " ", + "displayName": "RetailDataManager.Execute (retired)", + "id": "5793b2af-bc3b-4f1d-bbb1-9a3e359dd8e1", + "origin": "Delegated (Power BI Service)", + "value": "RetailDataManager.Execute.All" + }, + { + "description": " ", + "displayName": "OrgApp.Reshare (retired)", + "id": "02d9fa8b-e936-407b-a8f2-9b8fa5bda3bc", + "origin": "Delegated (Power BI Service)", + "value": "OrgApp.Reshare.All" + }, + { + "description": "Allows reading retail data manager items on the user’s behalf.", + "displayName": "Read retail data manager items", + "id": "1ddcbaa9-c4cf-4684-9048-e04a12739a8c", + "origin": "Delegated (Power BI Service)", + "value": "RetailDataManager.Read.All" + }, + { + "description": "Allows modifying org apps on the user’s behalf.", + "displayName": "Read and write org apps", + "id": "38536678-0d9f-4f82-88e5-a13a78d1d209", + "origin": "Delegated (Power BI Service)", + "value": "OrgApp.ReadWrite.All" + }, + { + "description": "Allows the app to make API calls that read and write OneLake metadata, on your behalf.", + "displayName": "Make API calls that read and write OneLake metadata", + "id": "ada1b44b-4474-40ed-b32c-e3543dccec0e", + "origin": "Delegated (Power BI Service)", + "value": "OneLake.ReadWrite.All" + }, + { + "description": "Allows executing ML experiments on the user’s behalf.", + "displayName": "Execute ML experiments", + "id": "3101f5b2-b314-4bbd-a1f6-8a05f94f33ea", + "origin": "Delegated (Power BI Service)", + "value": "MLExperiment.Execute.All" + }, + { + "description": "Allows reading ML experiments on the user’s behalf.", + "displayName": "Read ML experiments", + "id": "179809f0-8b05-4f65-bdd0-920fb4945c33", + "origin": "Delegated (Power BI Service)", + "value": "MLExperiment.Read.All" + }, + { + "description": "Allows modifying ML experiments on the user’s behalf.", + "displayName": "Read and write ML experiments", + "id": "e4f65fe4-b466-4254-89ea-2fcdc8d8ac49", + "origin": "Delegated (Power BI Service)", + "value": "MLExperiment.ReadWrite.All" + }, + { + "description": " ", + "displayName": "MLExperiment.Reshare (retired)", + "id": "27296d33-1a83-44d6-9665-63f4902781f9", + "origin": "Delegated (Power BI Service)", + "value": "MLExperiment.Reshare.All" + }, + { + "description": "Allows executing ML models on the user’s behalf.", + "displayName": "Execute ML models", + "id": "6b03f425-0a8e-4c54-ba35-df73806f1396", + "origin": "Delegated (Power BI Service)", + "value": "MLModel.Execute.All" + }, + { + "description": "Allows reading ML models on the user’s behalf.", + "displayName": "Read ML models", + "id": "5d9a285a-0847-4aa7-a9db-4991dedc2b53", + "origin": "Delegated (Power BI Service)", + "value": "MLModel.Read.All" + }, + { + "description": "Allows modifying ML models on the user’s behalf.", + "displayName": "Read and write ML models", + "id": "2cb667b2-c449-4f2d-a1ad-e0ffa27b5d75", + "origin": "Delegated (Power BI Service)", + "value": "MLModel.ReadWrite.All" + }, + { + "description": "Allows resharing ML models on the user’s behalf.", + "displayName": "Reshare ML models", + "id": "5a93e9d0-4312-4fad-bbb5-44c74a75083a", + "origin": "Delegated (Power BI Service)", + "value": "MLModel.Reshare.All" + }, + { + "description": " ", + "displayName": "MountedDataFactory.Execute (retired)", + "id": "aeae5f51-8e10-4970-97f6-8bc2664df3a1", + "origin": "Delegated (Power BI Service)", + "value": "MountedDataFactory.Execute.All" + }, + { + "description": "Allows reading Azure Data Factories on the user’s behalf.", + "displayName": "Read Azure Data Factories", + "id": "7aaf3c81-a937-4309-a1bc-812e1102a837", + "origin": "Delegated (Power BI Service)", + "value": "MountedDataFactory.Read.All" + }, + { + "description": "Allows modifying Azure Data Factories on the user’s behalf.", + "displayName": "Read and write Azure Data Factories", + "id": "63ec6016-8d37-4b46-bee2-39ad0ded84d6", + "origin": "Delegated (Power BI Service)", + "value": "MountedDataFactory.ReadWrite.All" + }, + { + "description": " ", + "displayName": "MountedDataFactory.Reshare (retired)", + "id": "da46e639-2366-4e0a-a190-3fb3aac31e45", + "origin": "Delegated (Power BI Service)", + "value": "MountedDataFactory.Reshare.All" + }, + { + "description": "Allows executing notebooks on the user’s behalf.", + "displayName": "Execute notebooks", + "id": "3e801746-e22a-4fcb-a3f5-315ace8e165a", + "origin": "Delegated (Power BI Service)", + "value": "Notebook.Execute.All" + }, + { + "description": "Allows reading notebooks on the user’s behalf.", + "displayName": "Read notebooks", + "id": "0a25ca24-b130-4a32-affd-29d640b63f14", + "origin": "Delegated (Power BI Service)", + "value": "Notebook.Read.All" + }, + { + "description": "Allows modifying notebooks on the user’s behalf.", + "displayName": "Read and write notebooks", + "id": "b02aa3b5-6fb3-48b8-803a-57bdef45d20c", + "origin": "Delegated (Power BI Service)", + "value": "Notebook.ReadWrite.All" + }, + { + "description": " ", + "displayName": "Notebook.Reshare (retired)", + "id": "9ff20ed3-e70b-486e-9686-006be349a5d6", + "origin": "Delegated (Power BI Service)", + "value": "Notebook.Reshare.All" + }, + { + "description": "Allows the app to make API calls that read OneLake metadata on your behalf.", + "displayName": "Make API calls that read OneLake metadata", + "id": "547211ef-7223-404f-8519-fee52fda6402", + "origin": "Delegated (Power BI Service)", + "value": "OneLake.Read.All" + }, + { + "description": "Allows reading org apps on the user’s behalf.", + "displayName": "Read org apps", + "id": "d27d5544-b17d-471f-9a02-ef09d6720508", + "origin": "Delegated (Power BI Service)", + "value": "OrgApp.Read.All" + }, + { + "description": "Allows modifying retail data manager items on the user’s behalf.", + "displayName": "Read and write retail data manager items", + "id": "e87305de-874c-4fb9-a7c1-fff664dc5d6e", + "origin": "Delegated (Power BI Service)", + "value": "RetailDataManager.ReadWrite.All" + }, + { + "description": " ", + "displayName": "RetailDataManager.Reshare (retired)", + "id": "d95f0afe-0ace-431b-9741-b29d7a02e19b", + "origin": "Delegated (Power BI Service)", + "value": "RetailDataManager.Reshare.All" + }, + { + "description": " ", + "displayName": "Scorecard.Execute (retired)", + "id": "dc75a12a-fef2-436d-8311-fed5b7e3a9d0", + "origin": "Delegated (Power BI Service)", + "value": "Scorecard.Execute.All" + }, + { + "description": "The app can view and edit all storage accounts registered with Power BI that the signed in user is an admin of.", + "displayName": "Read and write all storage accounts", + "id": "27789c5b-aca8-4cb6-94b8-bcc8964dd8ad", + "origin": "Delegated (Power BI Service)", + "value": "StorageAccount.ReadWrite.All" + }, + { + "description": "The app can view all content in the tenant if the signed in user is in the Global administrator or Power BI service administrator role.", + "displayName": "View all content in tenant", + "id": "01944dba-21df-426f-bb8c-796488be96ad", + "origin": "Delegated (Power BI Service)", + "value": "Tenant.Read.All" + }, + { + "description": "The app can create, edit, view, and delete all content in the tenant if the signed in user is in the Global administrator or Power BI service administrator role.", + "displayName": "Read and write all content in tenant", + "id": "d594897b-76e7-4b2b-984b-b4adff35e109", + "origin": "Delegated (Power BI Service)", + "value": "Tenant.ReadWrite.All" + }, + { + "description": "Allows executing user data function items on the user’s behalf.", + "displayName": "Execute user data function items", + "id": "2a34e79d-bc8c-40b7-8053-22549c4f8a8d", + "origin": "Delegated (Power BI Service)", + "value": "UserDataFunction.Execute.All" + }, + { + "description": "Allows reading user data function items on the user’s behalf.", + "displayName": "Read user data function items", + "id": "64f9ad72-16e9-49c9-b691-1cb7545560c3", + "origin": "Delegated (Power BI Service)", + "value": "UserDataFunction.Read.All" + }, + { + "description": "Allows modifying user data function items on the user’s behalf.", + "displayName": "Read and write user data function items", + "id": "9a69fd02-6f0f-4945-bd56-33a4a01f887d", + "origin": "Delegated (Power BI Service)", + "value": "UserDataFunction.ReadWrite.All" + }, + { + "description": "The app can view and edit any user settings and the user-specific state associated with content the signed in user has access to.", + "displayName": "Read and write user settings and state", + "id": "b43e1ada-25ee-416f-bd5c-512976ddc74b", + "origin": "Delegated (Power BI Service)", + "value": "UserState.ReadWrite.All" + }, + { + "description": "Allows executing variable libraries on the user’s behalf.", + "displayName": "Execute variable libraries", + "id": "2c1729df-8c12-449b-ae71-2e4acea26919", + "origin": "Delegated (Power BI Service)", + "value": "VariableLibrary.Execute.All" + }, + { + "description": "Allows reading variable libraries on the user’s behalf.", + "displayName": "Read variable libraries", + "id": "ea662897-fca3-4698-84f4-6acc2fb3a7ea", + "origin": "Delegated (Power BI Service)", + "value": "VariableLibrary.Read.All" + }, + { + "description": "Allows modifying variable libraries on the user’s behalf.", + "displayName": "Read and write variable libraries", + "id": "43e2cb94-fe45-449d-aa4d-b1f473b98c53", + "origin": "Delegated (Power BI Service)", + "value": "VariableLibrary.ReadWrite.All" + }, + { + "description": " ", + "displayName": "VariableLibrary.Reshare (retired)", + "id": "79649b78-0856-46fa-902e-b634299dbc3c", + "origin": "Delegated (Power BI Service)", + "value": "VariableLibrary.Reshare.All" + }, + { + "description": "Allows executing warehouses on the user’s behalf.", + "displayName": "Execute warehouses", + "id": "d17eaf78-91ce-4314-9101-868b933996fb", + "origin": "Delegated (Power BI Service)", + "value": "Warehouse.Execute.All" + }, + { + "description": "Allows the app to create and manage external data shares for all warehouses, on behalf of the signed-in user.", + "displayName": "Allows the app to create and manage external data shares for all warehouses", + "id": "b102c99e-b723-4ac3-beb8-8813448ccfa7", + "origin": "Delegated (Power BI Service)", + "value": "Warehouse.ExternalDataShare.All" + }, + { + "description": "Allows reading warehouses on the user’s behalf.", + "displayName": "Read warehouses", + "id": "6f4dd5b6-1369-4aef-a71b-8a734a9e0a20", + "origin": "Delegated (Power BI Service)", + "value": "Warehouse.Read.All" + }, + { + "description": "Allows modifying warehouses on the user’s behalf.", + "displayName": "Read and write warehouses", + "id": "35735863-502b-4a11-8f65-b0bbe7ec8e95", + "origin": "Delegated (Power BI Service)", + "value": "Warehouse.ReadWrite.All" + }, + { + "description": " ", + "displayName": "Warehouse.Reshare (retired)", + "id": "d7629fc2-75c0-412f-9c11-052513f055ae", + "origin": "Delegated (Power BI Service)", + "value": "Warehouse.Reshare.All" + }, + { + "description": "Allows the app to make API calls that require restore permissions on all Warehouses, on behalf of the signed-in user.", + "displayName": "Make API calls that require restore permissions on all Warehouses", + "id": "7da32ee4-ec68-43a4-b13a-b5385ad9770e", + "origin": "Delegated (Power BI Service)", + "value": "Warehouse.Restore.All" + }, + { + "description": "The app can view all storage accounts registered with Power BI that the signed in user is an admin of.", + "displayName": "View all storage accounts", + "id": "e677843f-76d8-44d3-bcdb-ec40dea919e7", + "origin": "Delegated (Power BI Service)", + "value": "StorageAccount.Read.All" + }, + { + "description": " ", + "displayName": "SQLEndpoint.Reshare (retired)", + "id": "15a808d7-2065-4357-8b76-47f701df3575", + "origin": "Delegated (Power BI Service)", + "value": "SQLEndpoint.Reshare.All" + }, + { + "description": "Allows modifying SQL endpoints on the user’s behalf.", + "displayName": "Read and write SQL endpoints", + "id": "e5c15c39-f5e8-45b2-b858-edba09abc583", + "origin": "Delegated (Power BI Service)", + "value": "SQLEndpoint.ReadWrite.All" + }, + { + "description": "Allows reading SQL endpoints on the user’s behalf.", + "displayName": "Read SQL endpoints", + "id": "dbc7f8f6-3822-41e6-aebd-5a79e2ddc72a", + "origin": "Delegated (Power BI Service)", + "value": "SQLEndpoint.Read.All" + }, + { + "description": "Allows the app to make API calls that require read permissions on all scorecards, on behalf of the signed-in user.", + "displayName": "Make API calls that require read permissions on all scorecards", + "id": "a74298d9-12f6-45f5-808d-7907af21179c", + "origin": "Delegated (Power BI Service)", + "value": "Scorecard.Read.All" + }, + { + "description": " ", + "displayName": "Scorecard.ReadWrite (retired)", + "id": "b13a1be2-e407-47b5-8429-12f4ad4f9fcd", + "origin": "Delegated (Power BI Service)", + "value": "Scorecard.ReadWrite.All" + }, + { + "description": " ", + "displayName": "Scorecard.Reshare (retired)", + "id": "62816fca-afaa-44af-abd6-9e8f604d8dbb", + "origin": "Delegated (Power BI Service)", + "value": "Scorecard.Reshare.All" + }, + { + "description": " ", + "displayName": "SemanticModel.Execute (retired)", + "id": "42b94671-298c-48a0-a55d-077e48186883", + "origin": "Delegated (Power BI Service)", + "value": "SemanticModel.Execute.All" + }, + { + "description": "Allows the app to create and manage external data shares for all semantic models, on behalf of the signed-in user.", + "displayName": "Allows the app to create and manage external data shares for all semantic models", + "id": "5cf1e703-06da-4688-8f2a-29e77c25489a", + "origin": "Delegated (Power BI Service)", + "value": "SemanticModel.ExternalDataShare.All" + }, + { + "description": "Allows reading semantic models on the user’s behalf.", + "displayName": "Read semantic models", + "id": "d2090f0b-c876-45ea-b6fa-785e7ef84788", + "origin": "Delegated (Power BI Service)", + "value": "SemanticModel.Read.All" + }, + { + "description": "Allows modifying semantic models on the user’s behalf.", + "displayName": "Read and write semantic models", + "id": "9e7c970c-1dc5-482d-b2a1-2a4fed211921", + "origin": "Delegated (Power BI Service)", + "value": "SemanticModel.ReadWrite.All" + }, + { + "description": " ", + "displayName": "SemanticModel.Reshare (retired)", + "id": "868c9b47-9e35-4c69-bac3-042213ef72a3", + "origin": "Delegated (Power BI Service)", + "value": "SemanticModel.Reshare.All" + }, + { + "description": " ", + "displayName": "MirroredDatabase.Reshare (retired)", + "id": "c18991d0-0a42-4567-983a-1993bc79f327", + "origin": "Delegated (Power BI Service)", + "value": "MirroredDatabase.Reshare.All" + }, + { + "description": "Allows executing spark job definitions on the user’s behalf.", + "displayName": "Execute spark job definitions", + "id": "3492d2fc-251d-4a2b-8be4-97f06fd6d0d4", + "origin": "Delegated (Power BI Service)", + "value": "SparkJobDefinition.Execute.All" + }, + { + "description": "Allows modifying spark job definitions on the user’s behalf.", + "displayName": "Read and write spark job definitions", + "id": "ec20a3e3-8c0b-4d75-8d1b-a9ffdbbe2519", + "origin": "Delegated (Power BI Service)", + "value": "SparkJobDefinition.ReadWrite.All" + }, + { + "description": " ", + "displayName": "SparkJobDefinition.Reshare (retired)", + "id": "49dd4a50-f26f-4cc8-b895-227eb620861d", + "origin": "Delegated (Power BI Service)", + "value": "SparkJobDefinition.Reshare.All" + }, + { + "description": " ", + "displayName": "SQLDatabase.Execute (retired)", + "id": "f87561dd-6f31-48ab-bcca-d3cec4564562", + "origin": "Delegated (Power BI Service)", + "value": "SQLDatabase.Execute.All" + }, + { + "description": "Allows the app to create and manage external data shares for all SQL Databases, on behalf of the signed-in user.", + "displayName": "Allows the app to create and manage external data shares for all SQL Databases", + "id": "e90f72dc-f418-4844-82db-ea22d405cfc1", + "origin": "Delegated (Power BI Service)", + "value": "SQLDatabase.ExternalDataShare.All" + }, + { + "description": "Allows reading SQL databases on the user’s behalf.", + "displayName": "Read SQL databases", + "id": "1cc6e528-a407-4de1-883e-e36b91e09379", + "origin": "Delegated (Power BI Service)", + "value": "SQLDatabase.Read.All" + }, + { + "description": "Allows modifying SQL databases on the user’s behalf.", + "displayName": "Read and write SQL databases", + "id": "e4a4166c-b39f-4956-96ee-52ae6f1242e8", + "origin": "Delegated (Power BI Service)", + "value": "SQLDatabase.ReadWrite.All" + }, + { + "description": " ", + "displayName": "SQLDatabase.Reshare (retired)", + "id": "7cbb226f-a463-4f7e-b085-d11f68dac9ee", + "origin": "Delegated (Power BI Service)", + "value": "SQLDatabase.Reshare.All" + }, + { + "description": " ", + "displayName": "SQLEndpoint.Execute (retired)", + "id": "aa70d616-e57e-4a5a-84cd-07de4250dd2e", + "origin": "Delegated (Power BI Service)", + "value": "SQLEndpoint.Execute.All" + }, + { + "description": "Allows reading spark job definitions on the user’s behalf.", + "displayName": "Read spark job definitions", + "id": "beaf3087-05af-4060-a0a5-29779c902004", + "origin": "Delegated (Power BI Service)", + "value": "SparkJobDefinition.Read.All" + }, + { + "description": " ", + "displayName": "WarehouseSnapshot.Execute (retired)", + "id": "f132046a-f99f-4e2f-af2c-bcc6690050a5", + "origin": "Delegated (Power BI Service)", + "value": "WarehouseSnapshot.Execute.All" + }, + { + "description": "Allows modifying mirrored databases on the user’s behalf.", + "displayName": "Read and write mirrored databases", + "id": "2eb0ab4e-195e-45ec-9eb3-3b9842bea4f4", + "origin": "Delegated (Power BI Service)", + "value": "MirroredDatabase.ReadWrite.All" + }, + { + "description": "Allows the app to create and manage external data shares for all mirrored Databases, on behalf of the signed-in user.", + "displayName": "Allows the app to create and manage external data shares for all mirrored Databases", + "id": "eb433b13-ec6d-487b-8411-b5fadda75072", + "origin": "Delegated (Power BI Service)", + "value": "MirroredDatabase.ExternalDataShare.All" + }, + { + "description": "Allows modifying eventhouses on the user’s behalf.", + "displayName": "Read and write eventhouses", + "id": "b13393d0-9253-4ca8-be5a-be145f337ea3", + "origin": "Delegated (Power BI Service)", + "value": "Eventhouse.ReadWrite.All" + }, + { + "description": " ", + "displayName": "Eventhouse.Reshare (retired)", + "id": "1318ed2f-75ad-4748-b33a-d49044214bdb", + "origin": "Delegated (Power BI Service)", + "value": "Eventhouse.Reshare.All" + }, + { + "description": " ", + "displayName": "Eventstream.Execute (retired)", + "id": "110e2f5f-6226-4c3f-8d02-4b30b33e5fd1", + "origin": "Delegated (Power BI Service)", + "value": "Eventstream.Execute.All" + }, + { + "description": "Allows reading eventstreams on the user’s behalf.", + "displayName": "Read eventstreams", + "id": "5ce2a0b7-2512-440d-bf05-d5db590cc4c7", + "origin": "Delegated (Power BI Service)", + "value": "Eventstream.Read.All" + }, + { + "description": "Allows modifying eventstreams on the user’s behalf.", + "displayName": "Read and write eventstreams", + "id": "bd305576-f504-4e9a-81d4-d16c7eb5334b", + "origin": "Delegated (Power BI Service)", + "value": "Eventstream.ReadWrite.All" + }, + { + "description": " ", + "displayName": "Eventstream.Reshare (retired)", + "id": "ff4d87c4-a161-49a8-a886-fe14bf6a2203", + "origin": "Delegated (Power BI Service)", + "value": "Eventstream.Reshare.All" + }, + { + "description": "Allows the app to accept the external data share invitation on behalf of the signed-in user.", + "displayName": "Allows the app to accept the external data share invitation", + "id": "f4e8a89a-fa13-4aac-abd3-925cfe74dc66", + "origin": "Delegated (Power BI Service)", + "value": "ExternalDataShare.Accept.All" + }, + { + "description": "Allows retrieving item definitions, user identifiers, and other restricted metadata required for embedding Fabric items on the user’s behalf. To embed, the app must also be granted the appropriate contextual scope(s), such as Item.Read.All, Notebook.Read.All, or Workspace.Read.All.", + "displayName": "Embed Fabric items, retrieve user identifiers and restricted metadata", + "id": "04994edc-428b-482b-af95-ee1575dde39b", + "origin": "Delegated (Power BI Service)", + "value": "Fabric.Embed" + }, + { + "description": "Allows the app to extend Fabric with new item types and have restricted access to Fabric items, user identifiers and other metadata, on behalf of the signed-in user. Protecting exports with sensitivity labels, enforcement of regional boundaries and some other Fabric capabilities are not available to partner items.", + "displayName": "Extend Fabric with new item types", + "id": "7ba630b9-8110-4e27-8d17-81e5f2218787", + "origin": "Delegated (Power BI Service)", + "value": "Fabric.Extend" + }, + { + "description": "Allow partner Fabric items managed by this app to run in iframes with the relaxed sandbox enabling additional operations.", + "displayName": "Run partner Fabric items in iframes with relaxed sandbox protection.", + "id": "0a7d02f8-6c5f-4f1f-91e0-0650efd2436b", + "origin": "Delegated (Power BI Service)", + "value": "Fabric.Extend.IframeSandbox" + }, + { + "description": "The app can view all gateways that the signed in user is an admin of.", + "displayName": "View all gateways", + "id": "d2e42f6b-2baf-4ff4-83ef-51e66321516e", + "origin": "Delegated (Power BI Service)", + "value": "Gateway.Read.All" + }, + { + "description": "The app can view and edit all gateways that the signed in user is an admin of.", + "displayName": "Read and write all gateways", + "id": "ddb3ca45-a192-477d-acb2-46bf9dc586de", + "origin": "Delegated (Power BI Service)", + "value": "Gateway.ReadWrite.All" + }, + { + "description": "Allows executing graph instances on the user’s behalf.", + "displayName": "Execute graph instances", + "id": "d7fd01f6-c485-406a-ad50-27ea1c711589", + "origin": "Delegated (Power BI Service)", + "value": "GraphInstance.Execute.All" + }, + { + "description": "Allows reading graph instances on the user’s behalf.", + "displayName": "Read graph instances", + "id": "dc55c1dd-468c-4d7f-877a-9414f4c79c61", + "origin": "Delegated (Power BI Service)", + "value": "GraphInstance.Read.All" + }, + { + "description": "Allows modifying graph instances on the user’s behalf.", + "displayName": "Read and write graph instances", + "id": "6523f613-2a20-4d18-8b05-4d1bce5b7b07", + "origin": "Delegated (Power BI Service)", + "value": "GraphInstance.ReadWrite.All" + }, + { + "description": " ", + "displayName": "GraphInstance.Reshare (retired)", + "id": "ce5cc3da-13ac-4c07-9b8d-f5c5811d91d0", + "origin": "Delegated (Power BI Service)", + "value": "GraphInstance.Reshare.All" + }, + { + "description": "Allows the app to make API calls that executes requests on all API for GraphQL items, on behalf of the signed-in user.", + "displayName": "Make API calls that executes requests on all API for GraphQL items", + "id": "fc011432-d782-46d3-8143-f0328911e0a3", + "origin": "Delegated (Power BI Service)", + "value": "GraphQL.Execute.All" + }, + { + "description": "Allows reading eventhouses on the user’s behalf.", + "displayName": "Read eventhouses", + "id": "cd1718e4-3e09-4381-a6e1-183e245f8613", + "origin": "Delegated (Power BI Service)", + "value": "Eventhouse.Read.All" + }, + { + "description": "Allows executing GraphQLApis on the user’s behalf.", + "displayName": "Execute GraphQLApis", + "id": "cece14a0-0fa7-4de3-b458-69bd0cfea634", + "origin": "Delegated (Power BI Service)", + "value": "GraphQLApi.Execute.All" + }, + { + "description": " ", + "displayName": "Eventhouse.Execute (retired)", + "id": "0a5f551e-003b-482b-b5fb-7124a9510ba1", + "origin": "Delegated (Power BI Service)", + "value": "Eventhouse.Execute.All" + }, + { + "description": "Allows modifying environment items on the user’s behalf.", + "displayName": "Read and write environment items", + "id": "995d4201-6a2d-45c6-bad2-9f2aba89298d", + "origin": "Delegated (Power BI Service)", + "value": "Environment.ReadWrite.All" + }, + { + "description": "Allows executing dataflows on the user’s behalf.", + "displayName": "Execute dataflows", + "id": "529939f7-18e3-4be4-ba92-b01894a4fadf", + "origin": "Delegated (Power BI Service)", + "value": "Dataflow.Execute.All" + }, + { + "description": "Allows reading dataflows on the user’s behalf.", + "displayName": "Read dataflows", + "id": "f9759906-80a4-4f4a-b010-24b832bc6a30", + "origin": "Delegated (Power BI Service)", + "value": "Dataflow.Read.All" + }, + { + "description": "Allows modifying dataflows on the user’s behalf.", + "displayName": "Read and write dataflows", + "id": "ddd37690-e119-40c5-a821-3746ea6125c4", + "origin": "Delegated (Power BI Service)", + "value": "Dataflow.ReadWrite.All" + }, + { + "description": " ", + "displayName": "Dataflow.Reshare (retired)", + "id": "ad056abd-4839-4bb1-ba68-ffcf8194a869", + "origin": "Delegated (Power BI Service)", + "value": "Dataflow.Reshare.All" + }, + { + "description": " ", + "displayName": "Datamart.Execute (retired)", + "id": "17cddc84-5ff7-4b6a-a017-632384c5e063", + "origin": "Delegated (Power BI Service)", + "value": "Datamart.Execute.All" + }, + { + "description": "Allows reading datamarts on the user’s behalf.", + "displayName": "Read datamarts", + "id": "91f75836-b68c-4fff-84db-4372412a2c82", + "origin": "Delegated (Power BI Service)", + "value": "Datamart.Read.All" + }, + { + "description": "Allows modifying datamarts on the user’s behalf.", + "displayName": "Read and write datamarts", + "id": "6098cd04-d625-4e1c-91d6-f7888f645256", + "origin": "Delegated (Power BI Service)", + "value": "Datamart.ReadWrite.All" + }, + { + "description": " ", + "displayName": "Datamart.Reshare (retired)", + "id": "44abf802-73c1-42f4-a26f-915b4c27fa8f", + "origin": "Delegated (Power BI Service)", + "value": "Datamart.Reshare.All" + }, + { + "description": "Allows the app to make API calls that require execute permissions on all data pipelines, on behalf of the signed-in user.", + "displayName": "Make API calls that require execute permissions all data pipelines", + "id": "7e010a28-f5fa-4e63-b03d-2dc25cba9d2e", + "origin": "Delegated (Power BI Service)", + "value": "DataPipeline.Execute.All" + }, + { + "description": "Allows the app to make API calls that require read permissions on all data pipelines, on behalf of the signed-in user.", + "displayName": "Make API calls that require read permissions on all data pipelines", + "id": "a61cf2d1-8b81-4518-b2bb-a24e0831c17a", + "origin": "Delegated (Power BI Service)", + "value": "DataPipeline.Read.All" + }, + { + "description": "Allows the app to make API calls that require read and write permissions on all data pipelines, on behalf of the signed-in user.", + "displayName": "Make API calls that require read and write permissions on all data pipelines", + "id": "e0c0aef0-3eab-49ca-9662-50cc7bd13bfb", + "origin": "Delegated (Power BI Service)", + "value": "DataPipeline.ReadWrite.All" + }, + { + "description": " ", + "displayName": "DataPipeline.Reshare (retired)", + "id": "9afd59c7-4e4d-4a5d-b2aa-2777debb5cd9", + "origin": "Delegated (Power BI Service)", + "value": "DataPipeline.Reshare.All" + }, + { + "description": "The app can view all datasets for the signed in user and any datasets that the user has access to.", + "displayName": "View all datasets", + "id": "7f33e027-4039-419b-938e-2f8ca153e68e", + "origin": "Delegated (Power BI Service)", + "value": "Dataset.Read.All" + }, + { + "description": "The app can view and write to all datasets for the signed in user and any datasets that the user has access to.", + "displayName": "Read and write all datasets", + "id": "322b68b2-0804-416e-86a5-d772c567b6e6", + "origin": "Delegated (Power BI Service)", + "value": "Dataset.ReadWrite.All" + }, + { + "description": "Allows the app to make API calls to register and manage 3rd party Service Fabric workloads, on your behalf.", + "displayName": "Create and Manage Developer Experience functionality", + "id": "b23bb8c4-af74-49b0-91dd-79ffb83cddb9", + "origin": "Delegated (Power BI Service)", + "value": "DevX.ReadWrite.All" + }, + { + "description": " ", + "displayName": "Environment.Execute (retired)", + "id": "7a638d43-6e2d-4bf0-bffd-477785a2c721", + "origin": "Delegated (Power BI Service)", + "value": "Environment.Execute.All" + }, + { + "description": "Allows reading environment items on the user’s behalf.", + "displayName": "Read environment items", + "id": "80a4f621-10a7-45e5-a961-79e9b81831d0", + "origin": "Delegated (Power BI Service)", + "value": "Environment.Read.All" + }, + { + "description": " ", + "displayName": "Environment.Reshare (retired)", + "id": "72e814f5-a6b9-4316-b2ca-d07f426c178c", + "origin": "Delegated (Power BI Service)", + "value": "Environment.Reshare.All" + }, + { + "description": "Allows reading GraphQLApis on the user’s behalf.", + "displayName": "Read GraphQLApis", + "id": "deae611f-920b-422f-805e-f635080c4cfb", + "origin": "Delegated (Power BI Service)", + "value": "GraphQLApi.Read.All" + }, + { + "description": "Allows modifying GraphQLApis on the user’s behalf.", + "displayName": "Read and write GraphQLApis", + "id": "73d01b13-cb5e-466e-8752-a50feccb317e", + "origin": "Delegated (Power BI Service)", + "value": "GraphQLApi.ReadWrite.All" + }, + { + "description": " ", + "displayName": "GraphQLApi.Reshare (retired)", + "id": "88cf9f59-aa53-4386-ae14-3c8263713766", + "origin": "Delegated (Power BI Service)", + "value": "GraphQLApi.Reshare.All" + }, + { + "description": "Allows reading KQL querysets on the user’s behalf.", + "displayName": "Read KQL querysets", + "id": "8826b95a-bc76-4025-97f1-8c89c3d5f210", + "origin": "Delegated (Power BI Service)", + "value": "KQLQueryset.Read.All" + }, + { + "description": "Allows modifying KQL querysets on the user’s behalf.", + "displayName": "Read and write KQL querysets", + "id": "88ba374a-d581-4944-b7c7-1181754eba74", + "origin": "Delegated (Power BI Service)", + "value": "KQLQueryset.ReadWrite.All" + }, + { + "description": " ", + "displayName": "KQLQueryset.Reshare (retired)", + "id": "2d3aa07c-d364-4ce0-acbc-b7e151ee161d", + "origin": "Delegated (Power BI Service)", + "value": "KQLQueryset.Reshare.All" + }, + { + "description": "Allows executing lakehouses on the user’s behalf.", + "displayName": "Execute lakehouses", + "id": "565b3968-767c-4100-8771-a827146f38ce", + "origin": "Delegated (Power BI Service)", + "value": "Lakehouse.Execute.All" + }, + { + "description": "Allows the app to create and manage external data shares for all lakehouses, on behalf of the signed-in user.", + "displayName": "Allows the app to create and manage external data shares for all lakehouses", + "id": "1d1f591a-f469-48d6-8995-a532552ae72c", + "origin": "Delegated (Power BI Service)", + "value": "Lakehouse.ExternalDataShare.All" + }, + { + "description": "Allows reading lakehouses on the user’s behalf.", + "displayName": "Read lakehouses", + "id": "13060bfd-9305-4ec6-8388-8916580f4fa9", + "origin": "Delegated (Power BI Service)", + "value": "Lakehouse.Read.All" + }, + { + "description": "Allows modifying lakehouses on the user’s behalf.", + "displayName": "Read and write lakehouses", + "id": "eee83281-2212-467d-b9e3-2aadfb170f33", + "origin": "Delegated (Power BI Service)", + "value": "Lakehouse.ReadWrite.All" + }, + { + "description": " ", + "displayName": "Lakehouse.Reshare (retired)", + "id": "881e9f00-4e9c-4798-bc50-832fea2cdbe6", + "origin": "Delegated (Power BI Service)", + "value": "Lakehouse.Reshare.All" + }, + { + "description": " ", + "displayName": "MetricSet.Execute (retired)", + "id": "c79ca1a6-cca1-4ef3-98e3-6abf01eb242f", + "origin": "Delegated (Power BI Service)", + "value": "MetricSet.Execute.All" + }, + { + "description": "Allows reading metric sets on the user’s behalf.", + "displayName": "Read metric sets", + "id": "f4611230-1dcf-4466-92d9-59a35c81737a", + "origin": "Delegated (Power BI Service)", + "value": "MetricSet.Read.All" + }, + { + "description": "Allows modifying metric sets on the user’s behalf.", + "displayName": "Read and write metric sets", + "id": "68386d9d-5570-4b99-9211-ab8abd584145", + "origin": "Delegated (Power BI Service)", + "value": "MetricSet.ReadWrite.All" + }, + { + "description": " ", + "displayName": "MetricSet.Reshare (retired)", + "id": "f2fe8057-777e-4536-a069-90f4a2f922bf", + "origin": "Delegated (Power BI Service)", + "value": "MetricSet.Reshare.All" + }, + { + "description": "Allows executing mirrored azure databricks catalogs on the user’s behalf.", + "displayName": "Execute mirrored azure databricks catalogs", + "id": "e618543a-fb4e-4e95-a8f0-7af6549af7a9", + "origin": "Delegated (Power BI Service)", + "value": "MirroredAzureDatabricksCatalog.Execute.All" + }, + { + "description": "Allows reading mirrored azure databricks catalogs on the user’s behalf.", + "displayName": "Read mirrored azure databricks catalogs", + "id": "0107b32c-22a6-4354-ad71-828b6d03598a", + "origin": "Delegated (Power BI Service)", + "value": "MirroredAzureDatabricksCatalog.Read.All" + }, + { + "description": "Allows modifying mirrored azure databricks catalogs on the user’s behalf.", + "displayName": "Read and write mirrored azure databricks catalogs", + "id": "c5431154-27d8-4db7-96d0-8a201ad5d027", + "origin": "Delegated (Power BI Service)", + "value": "MirroredAzureDatabricksCatalog.ReadWrite.All" + }, + { + "description": " ", + "displayName": "MirroredAzureDatabricksCatalog.Reshare (retired)", + "id": "ee9fd26c-e612-44ef-8985-a0c7a1b06ab1", + "origin": "Delegated (Power BI Service)", + "value": "MirroredAzureDatabricksCatalog.Reshare.All" + }, + { + "description": " ", + "displayName": "MirroredDatabase.Execute (retired)", + "id": "67d4aa3f-531f-4db2-a382-7db42788fd35", + "origin": "Delegated (Power BI Service)", + "value": "MirroredDatabase.Execute.All" + }, + { + "description": " ", + "displayName": "KQLQueryset.Execute (retired)", + "id": "b84b0d8d-9870-4b2b-92d2-9bfa7f940db3", + "origin": "Delegated (Power BI Service)", + "value": "KQLQueryset.Execute.All" + }, + { + "description": " ", + "displayName": "KQLDataConnection.Reshare (retired)", + "id": "8191607d-cbe5-49c6-b480-1aae71f3dce6", + "origin": "Delegated (Power BI Service)", + "value": "KQLDataConnection.Reshare.All" + }, + { + "description": "Allows the app to make API calls that require read and write permissions on all KQL data connections, on behalf of the signed-in user.", + "displayName": "Make API calls that require read and write permissions on and write all KQL data connections", + "id": "753e9303-2fd9-496e-aa7c-cf84a133f42a", + "origin": "Delegated (Power BI Service)", + "value": "KQLDataConnection.ReadWrite.All" + }, + { + "description": "Allows the app to make API calls that require read permissions on all KQL data connections, on behalf of the signed-in user.", + "displayName": "Make API calls that require read permissions on all KQL data connections", + "id": "59b5791b-482f-4b95-8498-fe078f6bd6fa", + "origin": "Delegated (Power BI Service)", + "value": "KQLDataConnection.Read.All" + }, + { + "description": "Allows the app to make API calls that require audit permissions on all items, on behalf of the signed-in user.", + "displayName": "Make API calls that require audit permissions on all items", + "id": "6213ab68-aad6-4ec9-836a-306b14f6fee6", + "origin": "Delegated (Power BI Service)", + "value": "Item.Audit.All" + }, + { + "description": "Allows the app to make API calls that require execute permissions on all Fabric items, on behalf of the signed-in user.", + "displayName": "Make API calls that require execute permissions on all Fabric items", + "id": "caf40b1a-f10e-4da1-86e4-5fda17eb2b07", + "origin": "Delegated (Power BI Service)", + "value": "Item.Execute.All" + }, + { + "description": "Allows the app to create and manage external data shares for all Fabric items, on behalf of the signed-in user.", + "displayName": "Allows the app to create and manage external data shares for all Fabric items", + "id": "bae3e5e0-a78a-4b0f-b3a0-0dc52f365b9d", + "origin": "Delegated (Power BI Service)", + "value": "Item.ExternalDataShare.All" + }, + { + "description": "Allows the app to make API calls that require read permissions on all Fabric items, on behalf of the signed-in user.", + "displayName": "Make API calls that require read permissions on all Fabric items", + "id": "d2bc95fc-440e-4b0e-bafd-97182de7aef5", + "origin": "Delegated (Power BI Service)", + "value": "Item.Read.All" + }, + { + "description": "Allows the app to make API calls that require read and write permissions on all Fabric items, on behalf of the signed-in user.", + "displayName": "Make API calls that require read and write permissions on all Fabric items", + "id": "7a27a256-301d-4359-b77b-c2b759d2e362", + "origin": "Delegated (Power BI Service)", + "value": "Item.ReadWrite.All" + }, + { + "description": "Allows the app to make API calls that require reshare permissions on all Fabric items, on behalf of the signed-in user.", + "displayName": "Make API calls that require reshare permissions on all Fabric items", + "id": "02e8d710-956c-4760-b996-2e83935c2cf5", + "origin": "Delegated (Power BI Service)", + "value": "Item.Reshare.All" + }, + { + "description": "Allows the app to make API calls that can read the item metadata of all Fabric items, on behalf of the signed-in user.", + "displayName": "Make API calls that can read item metadata for all Fabric items", + "id": "94a31d2b-0d95-46b5-9dcd-1bf123c80327", + "origin": "Delegated (Power BI Service)", + "value": "ItemMetadata.Read.All" + }, + { + "description": "Allows the app to make API calls that can read and write the item metadata of all Fabric items, on behalf of the signed-in user.", + "displayName": "Make API calls that can read and write item metadata for all Fabric items", + "id": "c289c338-860d-4abf-8312-2455f47f8f33", + "origin": "Delegated (Power BI Service)", + "value": "ItemMetadata.ReadWrite.All" + }, + { + "description": "Allows reading mirrored databases on the user’s behalf.", + "displayName": "Read mirrored databases", + "id": "10051e25-9077-418c-a076-32a2d35132a2", + "origin": "Delegated (Power BI Service)", + "value": "MirroredDatabase.Read.All" + }, + { + "description": " ", + "displayName": "KQLDashboard.Execute (retired)", + "id": "6132db85-22d5-486c-b094-56eb8f746628", + "origin": "Delegated (Power BI Service)", + "value": "KQLDashboard.Execute.All" + }, + { + "description": "Allows modifying KQL dashboards on the user’s behalf.", + "displayName": "Read and write KQL dashboards", + "id": "3a857d04-01aa-421a-aa42-e40b4264b6f7", + "origin": "Delegated (Power BI Service)", + "value": "KQLDashboard.ReadWrite.All" + }, + { + "description": " ", + "displayName": "KQLDashboard.Reshare (retired)", + "id": "ed4d5569-4170-4f10-b174-16f9d1b31cec", + "origin": "Delegated (Power BI Service)", + "value": "KQLDashboard.Reshare.All" + }, + { + "description": "Allows executing KQL databases on the user’s behalf.", + "displayName": "Execute KQL databases", + "id": "21b4da43-510a-43ac-afd4-580e1e2c09c8", + "origin": "Delegated (Power BI Service)", + "value": "KQLDatabase.Execute.All" + }, + { + "description": "Allows the app to create and manage external data shares for all KQL databases, on behalf of the signed-in user.", + "displayName": "Allows the app to create and manage external data shares for all KQL databases", + "id": "3a1af33d-ccfa-4264-998b-348c8c299db1", + "origin": "Delegated (Power BI Service)", + "value": "KQLDatabase.ExternalDataShare.All" + }, + { + "description": "Allows reading KQL databases on the user’s behalf.", + "displayName": "Read KQL databases", + "id": "24367f1a-a6d6-410d-b438-378ed19cb875", + "origin": "Delegated (Power BI Service)", + "value": "KQLDatabase.Read.All" + }, + { + "description": "Allows modifying KQL databases on the user’s behalf.", + "displayName": "Read and write KQL databases", + "id": "726667b1-01a6-4be4-b04c-e95eae4023a8", + "origin": "Delegated (Power BI Service)", + "value": "KQLDatabase.ReadWrite.All" + }, + { + "description": " ", + "displayName": "KQLDatabase.Reshare (retired)", + "id": "83a35b59-6a34-40f4-ac4e-5bf5a6ff9a5d", + "origin": "Delegated (Power BI Service)", + "value": "KQLDatabase.Reshare.All" + }, + { + "description": " ", + "displayName": "KQLDataConnection.Execute (retired)", + "id": "6872ffe8-d8d4-46f9-9a32-5537dad08dd2", + "origin": "Delegated (Power BI Service)", + "value": "KQLDataConnection.Execute.All" + }, + { + "description": "Allows reading KQL dashboards on the user’s behalf.", + "displayName": "Read KQL dashboards", + "id": "f19ea7d7-2f31-4c6d-9845-d5480c5d1798", + "origin": "Delegated (Power BI Service)", + "value": "KQLDashboard.Read.All" + }, + { + "description": "Allows reading Databricks workspaces catalog metadata on the user’s behalf.", + "displayName": "Read Databricks workspaces catalog metadata.", + "id": "6cadaf62-a218-4d72-a641-0f85c813ece3", + "origin": "Delegated (Power BI Service)", + "value": "DatabricksCatalog.Read.All" + }, + { + "description": "Allows reading warehouse snapshots on the user’s behalf.", + "displayName": "Read warehouse snapshots", + "id": "fe27a477-ed49-4762-9157-5a22eec929a7", + "origin": "Delegated (Power BI Service)", + "value": "WarehouseSnapshot.Read.All" + }, + { + "description": " ", + "displayName": "WarehouseSnapshot.Reshare (retired)", + "id": "7e23ffe1-cea7-435d-94ce-4a7b4e8b38a0", + "origin": "Delegated (Power BI Service)", + "value": "WarehouseSnapshot.Reshare.All" + }, + { + "description": "Allows the app to read any alert", + "displayName": "Read all alerts", + "id": "71fe6b80-7034-4028-9ed8-0f316df9c3ff", + "origin": "Application (WindowsDefenderATP)", + "value": "Alert.Read.All" + }, + { + "description": "Allows the app to create or update any alert", + "displayName": "Read and write all alerts", + "id": "0f7000ec-157b-497f-b70e-ef0b0584f140", + "origin": "Application (WindowsDefenderATP)", + "value": "Alert.ReadWrite.All" + }, + { + "description": "Allows the app to create events in the machine timeline", + "displayName": "Write timeline events", + "id": "84ddd701-5fac-4c30-b0ad-aa73a67bea1a", + "origin": "Application (WindowsDefenderATP)", + "value": "Event.Write" + }, + { + "description": "Allows the app to read all file profiles", + "displayName": "Read file profiles", + "id": "8788f1a9-beca-4e26-ba58-10513f3b896f", + "origin": "Application (WindowsDefenderATP)", + "value": "File.Read.All" + }, + { + "description": "Allows the app to read and modify integration settings between itself and the service", + "displayName": "Read and Write Integration settings", + "id": "7c6f6912-60e9-4fcd-bb2a-c25bc35e8c59", + "origin": "Application (WindowsDefenderATP)", + "value": "IntegrationConfiguration.ReadWrite" + }, + { + "description": "Allows the app to read all IP address profiles", + "displayName": "Read IP address profiles", + "id": "47bf842d-354b-49ef-b741-3a6dd815bc13", + "origin": "Application (WindowsDefenderATP)", + "value": "Ip.Read.All" + }, + { + "description": "Allows the app to manage live response library files", + "displayName": "Manage live response library files", + "id": "41d209c7-2511-4fc9-b899-8008a3976f09", + "origin": "Application (WindowsDefenderATP)", + "value": "Library.Manage" + }, + { + "description": "Allows the app to collect forensics from a machine", + "displayName": "Collect forensics", + "id": "15405ab2-2103-4a3c-ad80-e829841cedcc", + "origin": "Application (WindowsDefenderATP)", + "value": "Machine.CollectForensics" + }, + { + "description": "Allows the app to isolate a machine", + "displayName": "Isolate machine", + "id": "7e4e1300-e1b9-4102-88ba-f0cb6e6d5974", + "origin": "Application (WindowsDefenderATP)", + "value": "Machine.Isolate" + }, + { + "description": "Allows the app to run a live response on a specific machine", + "displayName": "Run live response on a specific machine", + "id": "1629b959-c0af-42a1-92f0-f6162060bdf1", + "origin": "Application (WindowsDefenderATP)", + "value": "Machine.LiveResponse" + }, + { + "description": "Allows the app to offboard a machine from the service", + "displayName": "Offboard machine", + "id": "594435bf-36dd-4548-83bd-1bdafe157d7a", + "origin": "Application (WindowsDefenderATP)", + "value": "Machine.Offboard" + }, + { + "description": "Allows the app to read all machine profiles, including the commands that were sent to each machine", + "displayName": "Read all machine profiles", + "id": "ea8291d3-4b9a-44b5-bc3a-6cea3026dc79", + "origin": "Application (WindowsDefenderATP)", + "value": "Machine.Read.All" + }, + { + "description": "Allows the app to create machine records and to read or update any machine record", + "displayName": "Read and write all machine information", + "id": "aa027352-232b-4ed4-b963-a705fc4d6d2c", + "origin": "Application (WindowsDefenderATP)", + "value": "Machine.ReadWrite.All" + }, + { + "description": "Allows the app to restrict code execution on a machine according to policy", + "displayName": "Restrict code execution", + "id": "96b6b35d-074d-4e2d-b167-8d68d9269648", + "origin": "Application (WindowsDefenderATP)", + "value": "Machine.RestrictExecution" + }, + { + "description": "Allows the app to scan a machine", + "displayName": "Scan machine", + "id": "a86d9824-b2b6-45f8-b042-16bc4922ed4e", + "origin": "Application (WindowsDefenderATP)", + "value": "Machine.Scan" + }, + { + "description": "Allows the app to stop a file running on a machine and to quarantine that file", + "displayName": "Stop and quarantine file", + "id": "96e72b5e-7e68-4171-aad1-3937599e4751", + "origin": "Application (WindowsDefenderATP)", + "value": "Machine.StopAndQuarantine" + }, + { + "description": "Users assign to this role will be able to access the Windows Defender ATP portal, view all the data but will not be able to perform any action", + "displayName": "Security Operations - Read Only", + "id": "f820e656-f1d1-4cb8-a566-31d18eeecb40", + "origin": "Application (WindowsDefenderATP)", + "value": "readonly" + }, + { + "description": "Allows the app to run advanced queries", + "displayName": "Run advanced queries", + "id": "93489bf5-0fbc-4f2d-b901-33f2fe08ff05", + "origin": "Application (WindowsDefenderATP)", + "value": "AdvancedQuery.Read.All" + }, + { + "description": "Allows the app to read all remediation tasks", + "displayName": "Read all remediation tasks", + "id": "6a33eedf-ba73-4e5a-821b-f057ef63853a", + "origin": "Application (WindowsDefenderATP)", + "value": "RemediationTasks.Read.All" + }, + { + "description": "Allow the application to access Windows Virtual Desktop on your behalf.", + "displayName": "Access Windows Virtual Desktop", + "id": "1ea0ab9c-b888-476f-aca9-0fc9a53b483a", + "origin": "Delegated (Windows Virtual Desktop)", + "value": "User.Access" + }, + { + "description": "Allow the application to access Windows Store for Business on behalf of the signed-in user.", + "displayName": "Access Windows Store for Business", + "id": "56cee9a4-2b49-4d48-a5c1-b26a2e48aada", + "origin": "Delegated (Windows Store for Business)", + "value": "user_impersonation" + }, + { + "description": "Allows the app to read and write data in your company or school directory, such as users, and groups. Does not allow user or group deletion.", + "displayName": "Read and write directory data", + "id": "78c8a3c8-a07e-4b9e-af1b-b5ccab50a175", + "origin": "Delegated (Windows Azure Active Directory)", + "value": "Directory.ReadWrite.All" + }, + { + "description": "Allows the app to read basic group properties and memberships on behalf of the signed-in user.", + "displayName": "Read all groups", + "id": "6234d376-f627-4f0f-90e0-dff25c5211a3", + "origin": "Delegated (Windows Azure Active Directory)", + "value": "Group.Read.All" + }, + { + "description": "Allows the app to create groups on behalf of the signed-in user and read all group properties and memberships. Additionally, this allows the app to update group properties and memberships for the groups the signed-in user owns.", + "displayName": "Read and write all groups", + "id": "970d6fa6-214a-4a9b-8513-08fad511e2fd", + "origin": "Delegated (Windows Azure Active Directory)", + "value": "Group.ReadWrite.All" + }, + { + "description": "Allows the app to read the memberships of hidden groups and administrative units on behalf of the signed-in user, for those hidden groups and administrative units that the signed-in user has access to.", + "displayName": "Read hidden memberships", + "id": "2d05a661-f651-4d57-a595-489c91eda336", + "origin": "Delegated (Windows Azure Active Directory)", + "value": "Member.Read.Hidden" + }, + { + "description": "Allows the app to read your organization's policies on behalf of the signed-in user.", + "displayName": "Read your organization's policies", + "id": "80e5b1bf-3ad0-4365-943a-0ec983009b67", + "origin": "Delegated (Windows Azure Active Directory)", + "value": "Policy.Read.All" + }, + { + "description": "Allows users to sign in to the app, and allows the app to read the profile of signed-in users. It also allow the app to read basic company information of signed-in users.", + "displayName": "Sign in and read user profile", + "id": "311a71cc-e848-46a1-bdf8-97ff7156d8e6", + "origin": "Delegated (Windows Azure Active Directory)", + "value": "User.Read" + }, + { + "description": "Allows the app to read the full set of profile properties of all users in your company or school, on behalf of the signed-in user. Additionally, this allows the app to read the profiles of the signed-in user's reports and manager.", + "displayName": "Read all users' full profiles", + "id": "c582532d-9d9e-43bd-a97c-2667a28ce295", + "origin": "Delegated (Windows Azure Active Directory)", + "value": "User.Read.All" + }, + { + "description": "Allows the app to read a basic set of profile properties of all users in your company or school on behalf of the signed-in user. Includes display name, first and last name, photo, and email address. Additionally, this allows the app to read basic info about the signed-in user's reports and manager.", + "displayName": "Read all users' basic profiles", + "id": "cba73afc-7f69-4d86-8450-4978e04ecd1a", + "origin": "Delegated (Windows Azure Active Directory)", + "value": "User.ReadBasic.All" + }, + { + "description": "Allows the application to access Azure Resource Manager acting as users in the organization.", + "displayName": "Access Azure Resource Manager as organization users", + "id": "41094075-9dad-400e-a0bd-54e686782033", + "origin": "Delegated (Windows Azure Service Management API)", + "value": "user_impersonation" + }, + { + "description": "Allow the application to sign in to Windows on behalf of the signed-in user", + "displayName": "Sign in to Windows", + "id": "d0ee7be9-09ed-4def-83f2-6e72005521f7", + "origin": "Delegated (Windows Cloud Login)", + "value": "user_impersonation" + }, + { + "description": "Can buy products for inventory, assign and reclaim licenses.", + "displayName": "Basic purchaser", + "id": "cf498509-9a5c-4d83-aa26-2b70fcbd0e1c", + "origin": "Application (Windows Store for Business)", + "value": "basicpurchaser" + }, + { + "description": "Can only access the things made available to them by their organization.", + "displayName": "Basic user", + "id": "16beb6e1-c277-4b64-97fa-5c90295a5c84", + "origin": "Application (Windows Store for Business)", + "value": "basicuser" + }, + { + "description": "Has all the permissions of Purchaser. Can also grant access, set permission levels, and change all settings and configurations.", + "displayName": "Administrator", + "id": "f75b6470-490d-4b7b-a084-15d2f79fcd26", + "origin": "Application (Windows Store for Business)", + "value": "bspadmin" + }, + { + "description": "A configured management tool can distribute app licenses that have been acquired in the Windows Store for Business Portal to users and devices within an organization.", + "displayName": "Management Tool", + "id": "e4c00fbe-aea4-4c3b-b803-335d512be9d6", + "origin": "Application (Windows Store for Business)", + "value": "bspmdm" + }, + { + "description": "Can buy products for inventory, assign and reclaim licenses.", + "displayName": "Purchaser", + "id": "20220577-b1fd-4061-bfed-05a068857ffc", + "origin": "Application (Windows Store for Business)", + "value": "bsppurchaser" + }, + { + "description": "Has access to the device guard signing page, can sign policies and catalogs.", + "displayName": "Device Guard signer", + "id": "4c7bcbe9-70eb-42d2-a8b1-a66d985811c3", + "origin": "Application (Windows Store for Business)", + "value": "deviceguardsigner" + }, + { + "description": "Allows user to view purchased products and subscriptions", + "displayName": "Assets.Read", + "id": "30000000-aaaa-bbbb-cccc-100000000002", + "origin": "Delegated (Windows Store for Business)", + "value": "Assets.Read" + }, + { + "description": "Creators can create Windows Virtual Desktop Tenants", + "displayName": "Tenant.Create", + "id": "299dad25-58e3-473d-9733-171fb3034713", + "origin": "Application (Windows Virtual Desktop)", + "value": "Tenant.Create" + }, + { + "description": "Allows the app to read any Threat and Vulnerability Management score", + "displayName": "Read Threat and Vulnerability Management score", + "id": "02b005dd-f804-43b4-8fc7-078460413f74", + "origin": "Application (WindowsDefenderATP)", + "value": "Score.Read.All" + }, + { + "description": "Users assign to this role will be able to access the Windows Defender ATP portal, view all the data and be able to perform actions such as change alerts status, apply suppression rules etc.", + "displayName": "Security Operations - Read & Write", + "id": "2261fd4a-5f23-4b74-9e4d-f4ac92dc86a2", + "origin": "Application (WindowsDefenderATP)", + "value": "secop" + }, + { + "description": "Allows the app to read all security baselines assessment information", + "displayName": "Read all security baselines assessment information", + "id": "e870c0c1-c1a2-41ca-948e-a33912d2d3f0", + "origin": "Application (WindowsDefenderATP)", + "value": "SecurityBaselinesAssessment.Read.All" + }, + { + "description": "Allows the app to create machine records and to read or update any machine record that the signed-in user can create, read or update.", + "displayName": "Read and write machine information", + "id": "f6846c57-9e3c-4a65-81aa-2f5e09ff4f0b", + "origin": "Delegated (WindowsDefenderATP)", + "value": "Machine.ReadWrite" + }, + { + "description": "Allows the app to restrict code execution on a machine according to policy on behalf of the signed-in user", + "displayName": "Restrict code execution", + "id": "abddfa88-80bb-4aef-81ff-18cbf29363a9", + "origin": "Delegated (WindowsDefenderATP)", + "value": "Machine.RestrictExecution" + }, + { + "description": "Allows the app to scan a machine on behalf of the signed-in user", + "displayName": "Scan machine", + "id": "b4618115-647e-42a5-bd0d-0ea9878fb376", + "origin": "Delegated (WindowsDefenderATP)", + "value": "Machine.Scan" + }, + { + "description": "Allows the app to restrict code execution on a machine according to policy on behalf of the signed-in user", + "displayName": "Stop and quarantine file", + "id": "69e036b7-3f10-4d4c-a85e-82295629eca8", + "origin": "Delegated (WindowsDefenderATP)", + "value": "Machine.StopAndQuarantine" + }, + { + "description": "Allows the app to read remediation tasks that the signed in user can", + "displayName": "Read remediation tasks", + "id": "19956c04-168f-4f44-b471-48c8f50dc0c8", + "origin": "Delegated (WindowsDefenderATP)", + "value": "RemediationTasks.Read" + }, + { + "description": "Allows the app to read Threat and Vulnerability Management score on behalf of the signed-in user", + "displayName": "Read Threat and Vulnerability Management score", + "id": "df4ed126-3a4c-460a-b0fc-67aea84fc332", + "origin": "Delegated (WindowsDefenderATP)", + "value": "Score.Read" + }, + { + "description": "Allows the app to read security baselines assessment information", + "displayName": "Read security baselines assessment information", + "id": "d42e2aa1-a664-43a9-b7c6-2766d44a6687", + "origin": "Delegated (WindowsDefenderATP)", + "value": "SecurityBaselinesAssessment.Read" + }, + { + "description": "Allows the app to read security configurations that the signed in user can access", + "displayName": "Read security configurations", + "id": "4ac83e46-552f-4948-91c2-f7eaff971018", + "origin": "Delegated (WindowsDefenderATP)", + "value": "SecurityConfiguration.Read" + }, + { + "description": "Allows the app to read and write security configurations that the signed in user can manage", + "displayName": "Read and write security configurations", + "id": "bfc81a3a-4f6d-4bfe-b945-d7fe6747d2a0", + "origin": "Delegated (WindowsDefenderATP)", + "value": "SecurityConfiguration.ReadWrite" + }, + { + "description": "Allows the app to read Threat and Vulnerability Management security recommendations on behalf of the signed-in user", + "displayName": "Read Threat and Vulnerability Management security recommendations", + "id": "1ab96238-1253-4059-a32f-4087f20ed65d", + "origin": "Delegated (WindowsDefenderATP)", + "value": "SecurityRecommendation.Read" + }, + { + "description": "Allows the app to read Threat and Vulnerability Management software information on behalf of the signed-in user", + "displayName": "Read Threat and Vulnerability Management software information", + "id": "5f216ada-3f51-4a22-ace5-06b198328476", + "origin": "Delegated (WindowsDefenderATP)", + "value": "Software.Read" + }, + { + "description": "Allows the app to create IOCs and to read or update IOCs it created, on behalf of the signed-in user", + "displayName": "Read and write IOCs", + "id": "650ff1f9-dd5f-48ee-8c58-7beef332c818", + "origin": "Delegated (WindowsDefenderATP)", + "value": "Ti.ReadWrite" + }, + { + "description": "Allows the app to read all URL profiles on behalf of the signed-in user", + "displayName": "Read URL profiles", + "id": "42b4777c-6196-49ad-9cfc-207e73f2eb61", + "origin": "Delegated (WindowsDefenderATP)", + "value": "Url.Read.All" + }, + { + "description": "Allows the app to read all user profiles on behalf of the signed-in user", + "displayName": "Read user profiles", + "id": "ffd6563e-842b-4cfc-b349-06006e0473a3", + "origin": "Delegated (WindowsDefenderATP)", + "value": "User.Read.All" + }, + { + "description": "Allows the app to read Threat and Vulnerability Management vulnerability information on behalf of the signed-in user", + "displayName": "Read Threat and Vulnerability Management vulnerability information", + "id": "63a677ce-818c-4409-9d12-5c6d2e2a6bfe", + "origin": "Delegated (WindowsDefenderATP)", + "value": "Vulnerability.Read" + }, + { + "description": "Allows the app to impersonate the signed-in user to access the Partner Center API.", + "displayName": "Partner Center as User", + "id": "1cebfa2a-fb4d-419e-b5f9-839b4383e05a", + "origin": "Delegated (Microsoft Partner Center)", + "value": "user_impersonation" + }, + { + "description": "Allows the app to manage restricted resources based on the other permissions granted to the app, without a signed-in user.", + "displayName": "Manage restricted resources in the directory", + "id": "f20584af-9290-4153-9280-ff8bb2c0ea7f", + "origin": "Application", + "value": "Directory.Write.Restricted" + }, + { + "description": "Allows the app to read machine profiles (including the commands that were sent to each machine), that the signed in user can access", + "displayName": "Read machine information", + "id": "fbd3d33a-b1f5-4573-906c-51b39682fbcf", + "origin": "Delegated (WindowsDefenderATP)", + "value": "Machine.Read" + }, + { + "description": "Allows the app to offboard a machine from the service on behalf of the signed-in user", + "displayName": "Offboard machine", + "id": "29d1c73a-07f6-495f-b62b-1554a954d0a3", + "origin": "Delegated (WindowsDefenderATP)", + "value": "Machine.Offboard" + }, + { + "description": "Allows the app to run live response on a specific machine according to policy on behalf of the signed-in user", + "displayName": "Run live response on a specific machine", + "id": "25fb0c21-5877-492a-8d0c-e7893a9585cc", + "origin": "Delegated (WindowsDefenderATP)", + "value": "Machine.LiveResponse" + }, + { + "description": "Allows the app to isolate a machine on behalf of the signed-in user", + "displayName": "Isolate machine", + "id": "479231ef-3b86-4933-ae6b-1fa84bba9e31", + "origin": "Delegated (WindowsDefenderATP)", + "value": "Machine.Isolate" + }, + { + "description": "Allows the app to read all security configurations", + "displayName": "Read all security configurations", + "id": "227f2ea0-c2c2-4428-b7af-9ff40f1a720e", + "origin": "Application (WindowsDefenderATP)", + "value": "SecurityConfiguration.Read.All" + }, + { + "description": "Allows the app to read and write all security configurations", + "displayName": "Read and write all security configurations", + "id": "e5e05709-32a3-4c85-89c8-67596eb94f24", + "origin": "Application (WindowsDefenderATP)", + "value": "SecurityConfiguration.ReadWrite.All" + }, + { + "description": "Allows the app to read any Threat and Vulnerability Management security recommendation", + "displayName": "Read Threat and Vulnerability Management security recommendations", + "id": "6443965c-7dd2-4cfd-b38f-bb7772bee163", + "origin": "Application (WindowsDefenderATP)", + "value": "SecurityRecommendation.Read.All" + }, + { + "description": "Allows the app to read any Threat and Vulnerability Management software information", + "displayName": "Read Threat and Vulnerability Management software information", + "id": "37f71c98-d198-41ae-964d-2c49aab74926", + "origin": "Application (WindowsDefenderATP)", + "value": "Software.Read.All" + }, + { + "description": "Allows the app to read all IOCs", + "displayName": "Read all IOCs", + "id": "528ca142-c849-4a5b-935e-10b8b9c38a84", + "origin": "Application (WindowsDefenderATP)", + "value": "Ti.Read.All" + }, + { + "description": "Allows the app to create IOCs and to read or update IOCs it created", + "displayName": "Read and write IOCs belonging to the app", + "id": "a8bc2240-f96a-46a1-bad5-6a960b7327a1", + "origin": "Application (WindowsDefenderATP)", + "value": "Ti.ReadWrite" + }, + { + "description": "Allows the app to manage all IOCs of the tenant", + "displayName": "Read and write all IOCs", + "id": "fc511a58-3adf-4d71-af24-00f13e35e479", + "origin": "Application (WindowsDefenderATP)", + "value": "Ti.ReadWrite.All" + }, + { + "description": "Allows the app to read all URL profiles", + "displayName": "Read URL profiles", + "id": "721af526-ffa8-42d7-9b84-1a56244dd99d", + "origin": "Application (WindowsDefenderATP)", + "value": "Url.Read.All" + }, + { + "description": "Allows the app to read data in your company or school directory, such as users, groups, and apps.", + "displayName": "Read directory data", + "id": "5778995a-e1bf-45b8-affa-663a9f3f4d04", + "origin": "Delegated (Windows Azure Active Directory)", + "value": "Directory.Read.All" + }, + { + "description": "Allows the app to read all user profiles", + "displayName": "Read user profiles", + "id": "a833834a-4cf1-4732-8acf-bbcfa13fb610", + "origin": "Application (WindowsDefenderATP)", + "value": "User.Read.All" + }, + { + "description": "Allows the app to run advanced queries, that the signed-in user can execute.", + "displayName": "Run advanced queries", + "id": "1fb6e712-1bd9-4184-b1c0-5e71e759196b", + "origin": "Delegated (WindowsDefenderATP)", + "value": "AdvancedQuery.Read" + }, + { + "description": "Allows the app to read any alert that the signed in user can access", + "displayName": "Read alerts", + "id": "b2069dc0-9fe9-4e6d-9aca-ccf3dd503819", + "origin": "Delegated (WindowsDefenderATP)", + "value": "Alert.Read" + }, + { + "description": "Allows the app to create or update any alert that the signed in user can create or update", + "displayName": "Read and write alerts", + "id": "cbc3b413-21e6-416d-95a4-af87687efbd0", + "origin": "Delegated (WindowsDefenderATP)", + "value": "Alert.ReadWrite" + }, + { + "description": "Allows the app to read all file profiles on", + "displayName": "Read file profiles", + "id": "8fce64a0-67c8-4e39-8f47-cac9ff7e13bb", + "origin": "Delegated (WindowsDefenderATP)", + "value": "File.Read.All" + }, + { + "description": "Allows the app to read all IP address profiles on behalf of the signed-in user", + "displayName": "Read IP address profiles", + "id": "b65a97e8-c8e8-4908-b19a-f654615de1a9", + "origin": "Delegated (WindowsDefenderATP)", + "value": "Ip.Read.All" + }, + { + "description": "Allows the app to manage live response library files on behalf of the signed-in user", + "displayName": "Manage live response library files", + "id": "5998a3da-2c9b-4bf3-99bd-44c9fe337ad2", + "origin": "Delegated (WindowsDefenderATP)", + "value": "Library.Manage" + }, + { + "description": "Allows the app to collect forensics data from a machine on behalf of the signed-in user", + "displayName": "Collect forensics", + "id": "5eb7b9dc-cbce-4c7e-9d73-5be248260ae6", + "origin": "Delegated (WindowsDefenderATP)", + "value": "Machine.CollectForensics" + }, + { + "description": "Allows the app to read any Threat and Vulnerability Management vulnerability information", + "displayName": "Read Threat and Vulnerability Management vulnerability information", + "id": "41269fc5-d04d-4bfd-bce7-43a51cea049a", + "origin": "Application (WindowsDefenderATP)", + "value": "Vulnerability.Read.All" + }, + { + "description": "Allows modifying warehouse snapshots on the user’s behalf.", + "displayName": "Read and write warehouse snapshots", + "id": "00a826b4-8fc8-4273-b68f-5947f7d13795", + "origin": "Delegated (Power BI Service)", + "value": "WarehouseSnapshot.ReadWrite.All" + }, + { + "description": "Allows the app to have the same access to information in the directory as the signed-in user.", + "displayName": "Access the directory as the signed-in user", + "id": "a42657d6-7f20-40e3-b6f0-cee03008a62a", + "origin": "Delegated (Windows Azure Active Directory)", + "value": "Directory.AccessAsUser.All" + }, + { + "description": "Allows the app to read the memberships of hidden groups and administrative units without a signed-in user.", + "displayName": "Read all hidden memberships", + "id": "9728c0c4-a06b-4e0e-8d1b-3d694e8ec207", + "origin": "Application (Windows Azure Active Directory)", + "value": "Member.Read.Hidden" + }, + { + "description": "Allows the app to read M365 assets insights.", + "displayName": "Read M365 insights for Purview", + "id": "5a55b1b6-8996-4250-abc2-74ec0107ab20", + "origin": "Application (Purview Ecosystem)", + "value": "PurviewData.Read.All" + }, + { + "description": "Allows the user to call UploadActivity API.", + "displayName": "Purview UploadActivity API", + "id": "9d4a6836-0ce9-4bcd-9559-0399e6da09b9", + "origin": "Delegated (Purview Ecosystem)", + "value": "Purview.Activities.Upload.All" + }, + { + "description": "Allows content processing on behalf of user", + "displayName": "Purview ProcessContent API", + "id": "13f23ab3-b1fa-41f6-af62-b1afc79de846", + "origin": "Delegated (Purview Ecosystem)", + "value": "Purview.Content.Process.All" + }, + { + "description": "Allows an application to call ProcessContent API on behalf of a user", + "displayName": "Purview ProcessContent API", + "id": "5087908c-b26e-4cd3-afe9-12489ad60deb", + "origin": "Delegated (Purview Ecosystem)", + "value": "Purview.ProcessContent.All" + }, + { + "description": "Allows access to ProcessConversationMessages API.", + "displayName": "Purview ProcessConversationMessages API", + "id": "d4ed36df-1979-4939-aeec-4db91905c84d", + "origin": "Delegated (Purview Ecosystem)", + "value": "Purview.ProcessConversationMessages.All" + }, + { + "description": "Allows access to protection scopes on behalf of user", + "displayName": "Purview ProtectionScopes API", + "id": "2ecf07b4-8c46-4b2b-acd0-6635c0171fc0", + "origin": "Delegated (Purview Ecosystem)", + "value": "Purview.ProtectionScopes.Read.All" + }, + { + "description": "Allows access to Purview SensitivityLabels APIs", + "displayName": "Sensitivity Labels for Purview", + "id": "06e3dab0-0fcd-4186-a17c-8d7b7c66258e", + "origin": "Delegated (Purview Ecosystem)", + "value": "Purview.SensitivityLabels.All" + }, + { + "description": "Allows access to Purview File SensitivityLabels APIs", + "displayName": "File Sensitivity Labels for Purview", + "id": "6718b42b-dbde-438d-a9c2-c598144664ed", + "origin": "Delegated (Purview Ecosystem)", + "value": "Purview.SensitivityLabels.File" + }, + { + "description": "Allows access to Purview Read SensitivityLabels APIs", + "displayName": "Read Sensitivity Labels for Purview", + "id": "f6b3cb3b-702a-4280-bf39-558b85cbfca3", + "origin": "Delegated (Purview Ecosystem)", + "value": "Purview.SensitivityLabels.Read" + }, + { + "description": "This allows applications to call Quota Service APIs", + "displayName": "Call all Quota Service APIs", + "id": "b77e1667-c4c2-4be1-ab53-cc94767800ad", + "origin": "Application (Quota Core Service PROD)", + "value": "Qs.Api.All" + }, + { + "description": "Allows the application to access Sherlock Conversations API acting as users in the organization.", + "displayName": "Access Sherlock Conversations API as organization users", + "id": "1a8d204a-10fb-4da1-a303-17497f914fbc", + "origin": "Delegated (Sherlock)", + "value": "Conversations.Access" + }, + { + "description": "Allows apps to call ModernPostPurchaseProvisioning API", + "displayName": "ModernPostPurchaseProvisioning.Execute", + "id": "8339ddc4-fc04-4ad0-b7d5-b7d83667da93", + "origin": "Application (Signup)", + "value": "ModernPostPurchaseProvisioning.Execute" + }, + { + "description": "Allows the apps to create tenant, save tenant profile and add company tags", + "displayName": "Tenant.Create", + "id": "fca3dabc-fd9a-4d63-bc1f-196bfa2df787", + "origin": "Application (Signup)", + "value": "Tenant.Create" + }, + { + "description": "Allows the app to assign license for a product", + "displayName": "License.Assign", + "id": "8a82ad6b-9201-4f8e-a324-b97e0fcd3e46", + "origin": "Delegated (Signup)", + "value": "License.Assign" + }, + { + "description": "Allows the caller to perform low friction trial for the given user.", + "displayName": "LowFriction.ReadWrite", + "id": "0ec94f6b-7dfb-43d3-a076-648593587760", + "origin": "Delegated (Signup)", + "value": "LowFriction.ReadWrite" + }, + { + "description": "Allows the app to send distributed emails to impacted users post system outage", + "displayName": "Soar.SendEmail", + "id": "83e1ccc9-47d7-4632-8fbd-1a4392c87254", + "origin": "Delegated (Signup)", + "value": "Soar.SendEmail" + }, + { + "description": "Allows the app to add managed subscriptions to a tenant", + "displayName": "Add subscriptions", + "id": "dc13fe4e-f936-494b-8b88-09d4e9a5cde0", + "origin": "Delegated (Signup)", + "value": "Subscription.Add" + }, + { + "description": "Allows full access to UploadActivity API", + "displayName": "Upload Activity for Purview", + "id": "5c672fc5-9428-4b19-96d7-4fd10240c2c6", + "origin": "Application (Purview Ecosystem)", + "value": "Purview.UploadActivity.All" + }, + { + "description": "Allows the caller to perform product signup eligibility check for the given user", + "displayName": "Users.Signup.Product", + "id": "62c3283a-e0b2-4608-85d5-013e99604063", + "origin": "Delegated (Signup)", + "value": "Users.Signup.Product" + }, + { + "description": "Allows the app access to Purview Read SensitivityLabels APIs", + "displayName": "Read Sensitivity Labels for Purview", + "id": "cba40051-8d05-45da-aa85-f6321b023c16", + "origin": "Application (Purview Ecosystem)", + "value": "Purview.SensitivityLabels.Read" + }, + { + "description": "Allows the app access to Purview SensitivityLabels APIs", + "displayName": "Sensitivity Labels for Purview", + "id": "c79a7e98-4663-4e08-aaf4-cc6dfc36a524", + "origin": "Application (Purview Ecosystem)", + "value": "Purview.SensitivityLabels.All" + }, + { + "description": "Allows the app to make API calls that commit workspace content and setting to remote git repository, on behalf of the signed-in user.", + "displayName": "Make API calls that commit workspace content and setting to remote git repository.", + "id": "a55d4405-a37a-4d41-ad6e-745665a3bbcb", + "origin": "Delegated (Power BI Service)", + "value": "Workspace.GitCommit.All" + }, + { + "description": "Allows the app to make API calls that update workspace content and setting from remote git repository, on behalf of the signed-in user.", + "displayName": "Make API calls that update workspace content and setting from remote git repository.", + "id": "5809ab1d-9154-49e7-a105-d82760eac8cf", + "origin": "Delegated (Power BI Service)", + "value": "Workspace.GitUpdate.All" + }, + { + "description": "The app can view all workspaces that the signed in user has access to.", + "displayName": "View all workspaces", + "id": "b2f1b2fa-f35c-407c-979c-a858a808ba85", + "origin": "Delegated (Power BI Service)", + "value": "Workspace.Read.All" + }, + { + "description": "The app can view and edit all workspaces that the signed in user has access to.", + "displayName": "Read and write all workspaces", + "id": "445002fb-a6f2-4dc1-a81e-4254a111cd29", + "origin": "Delegated (Power BI Service)", + "value": "Workspace.ReadWrite.All" + }, + { + "description": "Allow the app to access resources on behalf of the signed-in user.", + "displayName": "Access system data", + "id": "b9fa208e-7a07-49d9-9fe6-9ed353eec6c1", + "origin": "Delegated (Prod Messaging Catalog First Party App)", + "value": "access_as_user" + }, + { + "description": "Allows the app to call UploadActivity API.", + "displayName": "Call the UploadActivity API", + "id": "db90b932-4ee2-4549-8970-1932ef440310", + "origin": "Application (Purview Ecosystem)", + "value": "Purview.Activities.Upload.All" + }, + { + "description": "Allows the app to request batch activities upload", + "displayName": "Call the BatchUploadActivity API", + "id": "13a61426-7aef-4d3e-aaaf-0c95062048ba", + "origin": "Application (Purview Ecosystem)", + "value": "Purview.Activities.UploadBatch.All" + }, + { + "description": "Allows the app create access to data assets and metadata.", + "displayName": "Create Assets for Purview", + "id": "faadf052-bcc5-4045-9df6-11a3e217fb8e", + "origin": "Application (Purview Ecosystem)", + "value": "Purview.Assets.Create" + }, + { + "description": "Allows the app delete access to data assets and metadata.", + "displayName": "Delete Assets for Purview", + "id": "f6893e92-b5aa-4634-870a-e2bc4d2d7fad", + "origin": "Application (Purview Ecosystem)", + "value": "Purview.Assets.Delete" + }, + { + "description": "Allows the app read access to data assets and metadata.", + "displayName": "Read Assets for Purview", + "id": "04cd5d64-65c5-4dc5-9582-89bac29ed189", + "origin": "Application (Purview Ecosystem)", + "value": "Purview.Assets.Read" + }, + { + "description": "Allows the app update access to data assets and metadata.", + "displayName": "Update Assets for Purview", + "id": "db52b997-83d6-4090-aea1-60858744d271", + "origin": "Application (Purview Ecosystem)", + "value": "Purview.Assets.Update" + }, + { + "description": "Allows the app upsert access to data assets and metadata.", + "displayName": "Upsert Assets for Purview", + "id": "45b1a781-b80c-4a21-895c-5a6afb57efbf", + "origin": "Application (Purview Ecosystem)", + "value": "Purview.Assets.Upsert" + }, + { + "description": "Allows the app to call the ProcessContent API", + "displayName": "Call the ProcessContent API", + "id": "fd2b47b4-2d6e-4abe-bd96-57cd37b899a5", + "origin": "Application (Purview Ecosystem)", + "value": "Purview.Content.Process.All" + }, + { + "description": "Allows the app to request batch content processing", + "displayName": "Call the ProcessContentBatch API", + "id": "d0ccca8f-3f3b-44b4-914d-ce2e6a0d7edc", + "origin": "Application (Purview Ecosystem)", + "value": "Purview.Content.ProcessBatch.All" + }, + { + "description": "Allows full access to ProcessContent API", + "displayName": "Process Content for Purview", + "id": "c4d09b20-ef57-4e63-9fd1-0392d5ce853d", + "origin": "Application (Purview Ecosystem)", + "value": "Purview.ProcessContent.All" + }, + { + "description": "Allows full access to ProcessConversationMessages API", + "displayName": "Process Conversation Messages for Purview", + "id": "a4543e1f-6e5d-4ec9-a54a-f3b8c156163f", + "origin": "Application (Purview Ecosystem)", + "value": "Purview.ProcessConversationMessages.All" + }, + { + "description": "Allows the app to read protection scopes", + "displayName": "Read Purview protection scopes", + "id": "7e2fc5f2-d647-4926-89f6-f13ad2950560", + "origin": "Application (Purview Ecosystem)", + "value": "Purview.ProtectionScopes.Read.All" + }, + { + "description": "Allows the app access to Purview File SensitivityLabels APIs", + "displayName": "File Sensitivity Labels for Purview", + "id": "a817e1ed-b6e2-4214-a252-ab12ec7dad09", + "origin": "Application (Purview Ecosystem)", + "value": "Purview.SensitivityLabels.File" + }, + { + "description": "Allows the app create an on-demand Skype meeting and join guest users into Skype for Business services", + "displayName": "Guest user join services (preview)", + "id": "b783bde8-ffc2-4f0c-96ce-6e2900b7aa4e", + "origin": "Application (Skype for Business Online)", + "value": "Anonymous" + }, + { + "description": "Allows the app to send and receive audio and video; and manage audio/video service scenarios", + "displayName": "Send/Receive Audio and Video (preview)", + "id": "05a4e3e8-cfa6-4934-bb91-b6fc4ce6f340", + "origin": "Application (Skype for Business Online)", + "value": "Conversations.AudioVideo" + }, + { + "description": "Allows the app to send and receive instant messages; and manage instant messaging service scenarios", + "displayName": "Send/Receive Instant Messages (preview)", + "id": "f7a521bb-ae17-42df-8096-97ebcc8b4edb", + "origin": "Application (Skype for Business Online)", + "value": "Conversations.Chat" + }, + { + "description": "Gives full access to the API", + "displayName": "full_access", + "id": "f4922361-5b56-4b3b-808f-a25115425e16", + "origin": "Delegated (Verifiable Credentials Service Admin)", + "value": "full_access" + }, + { + "description": "This allows the application to create Verifiable Credential issuance and presentation requests", + "displayName": "VerifiableCredential.Create.All", + "id": "949ebb93-18f8-41b4-b677-c2bfea940027", + "origin": "Application (Verifiable Credentials Service Request)", + "value": "VerifiableCredential.Create.All" + }, + { + "description": "This allows the application to create Verifiable Credential issuance requests", + "displayName": "VerifiableCredential.Create.IssueRequest", + "id": "0165bd66-5f36-41ef-abde-4e8fc0c91294", + "origin": "Application (Verifiable Credentials Service Request)", + "value": "VerifiableCredential.Create.IssueRequest" + }, + { + "description": "This allows the application to create Verifiable Credential presentation requests", + "displayName": "VerifiableCredential.Create.PresentRequest", + "id": "410607a4-22de-48a8-b35d-ad33c0c2e1bf", + "origin": "Application (Verifiable Credentials Service Request)", + "value": "VerifiableCredential.Create.PresentRequest" + }, + { + "description": "Allows user to create Verifiable Credential issuance requests.", + "displayName": "User Issuance", + "id": "b5aaa6fb-8c09-4929-877c-9147695b78b8", + "origin": "Delegated (Verifiable Credentials Service Request)", + "value": "VerifiableCredential.Create.IssueRequest.User" + }, + { + "description": "Allows the application to read and check MyAccount Verified ID eligibility on behalf of the signed-in user.", + "displayName": "Read MyAccount Verified ID eligibility", + "id": "438013ce-a6ed-4686-9a80-778b7d82e8ce", + "origin": "Delegated (Verifiable Credentials Service Request)", + "value": "VerifiedId.MyAccountEligibility.Read" + }, + { + "description": "Allows the partner app to read and write the properties of Cloud PCs, without a signed-in user.", + "displayName": "Partner read and write cloud pc", + "id": "c107831b-9c28-4609-b219-a7b3fc5cc190", + "origin": "Application (Windows 365)", + "value": "CloudPC.PartnerReadWrite.All" + }, + { + "description": "For IW service test", + "displayName": "EndUser.Access", + "id": "5184a2ce-115e-4318-9526-df3e39c2e839", + "origin": "Application (Windows 365)", + "value": "EndUser.Access" + }, + { + "description": "Allows the app to read applications and service principals without a signed-in user", + "displayName": "Read all applications", + "id": "3afa6a7d-9b1a-42eb-948e-1650a849e176", + "origin": "Application (Windows Azure Active Directory)", + "value": "Application.Read.All" + }, + { + "description": "Allows the app to create, read, update and delete applications and service principals without a signed-in user. Does not allow management of consent grants.", + "displayName": "Read and write all applications", + "id": "1cda74f2-2616-4834-b122-5cb1b07f8a59", + "origin": "Application (Windows Azure Active Directory)", + "value": "Application.ReadWrite.All" + }, + { + "description": "Allows the app to create other applications, and fully manage those applications (read, update, update application secrets and delete), without a signed-in user. It cannot update any apps that it is not an owner of.", + "displayName": "Manage apps that this app creates or owns", + "id": "824c81eb-e3f8-4ee6-8f6d-de7f50d565b7", + "origin": "Application (Windows Azure Active Directory)", + "value": "Application.ReadWrite.OwnedBy" + }, + { + "description": "Allows the app to read and write all device properties without a signed in user. Does not allow device creation, device deletion or update of device alternative security identifiers.", + "displayName": "Read and write devices", + "id": "1138cb37-bd11-4084-a2b7-9f71582aeddb", + "origin": "Application (Windows Azure Active Directory)", + "value": "Device.ReadWrite.All" + }, + { + "description": "Allows the app to read and write all domain properties without a signed in user. Also allows the app to add, verify and remove domains.", + "displayName": "Read and write domains", + "id": "abefe9df-d5a9-41c6-a60b-27b38eac3efb", + "origin": "Application (Windows Azure Active Directory)", + "value": "Domain.ReadWrite.All" + }, + { + "description": "This allows  the application to search authorities and contracts in the VerifiedID network", + "displayName": "VerifiableCredential.Network.Read", + "id": "63f52f43-0b98-429c-b291-b2dba4a64504", + "origin": "Application (Verifiable Credentials Service Admin)", + "value": "VerifiableCredential.Network.Read" + }, + { + "description": "This allows the application to search credentials via Admin API", + "displayName": "VerifiableCredential.Credential.Search", + "id": "933a4159-27ca-4486-b1be-dce09da38475", + "origin": "Application (Verifiable Credentials Service Admin)", + "value": "VerifiableCredential.Credential.Search" + }, + { + "description": "This allows  the application to revoke credentials via Admin API", + "displayName": "VerifiableCredential.Credential.Revoke", + "id": "c7656015-9c77-47f7-ae83-110ad70f1edc", + "origin": "Application (Verifiable Credentials Service Admin)", + "value": "VerifiableCredential.Credential.Revoke" + }, + { + "description": "This allows the application to perform operations on contracts via Admin API", + "displayName": "VerifiableCredential.Contract.ReadWrite", + "id": "077813bc-e516-4576-bafd-07bead19c0dc", + "origin": "Application (Verifiable Credentials Service Admin)", + "value": "VerifiableCredential.Contract.ReadWrite" + }, + { + "description": "Allows the app to send and receive voice calls; and manage PSTN service scenarios", + "displayName": "Send/Receive PSTN (preview)", + "id": "30a91c70-863f-4a08-b01c-6c1d78685414", + "origin": "Application (Skype for Business Online)", + "value": "Conversations.PSTN" + }, + { + "description": "Allows the app to join and manage Skype meetings", + "displayName": "Join and Manage Skype Meetings (preview)", + "id": "e821ef97-a9f6-4c9e-bb6a-29fa0d8f6101", + "origin": "Application (Skype for Business Online)", + "value": "Meetings.JoinManage" + }, + { + "description": "Allows the app to create on-demand Skype meetings (short term expiry)", + "displayName": "Create on-demand Skype meetings (preview)", + "id": "189125ec-ea35-4135-b00a-e51472464beb", + "origin": "Application (Skype for Business Online)", + "value": "Meetings.ScheduleOnDemand" + }, + { + "description": "Allows the app to read and write Skype user contacts and groups\r\n", + "displayName": "Read/write Skype user contacts and groups", + "id": "5bdeff8b-73d9-4b8a-9e9b-d44c6105f9b4", + "origin": "Delegated (Skype for Business Online)", + "value": "Contacts.ReadWrite" + }, + { + "description": "Allows the app to initiate instant messages, audio, video, and desktop sharing conversations; and join meetings on-behalf of the signed-in user\r\n", + "displayName": "Initiate conversations and join meetings", + "id": "44e84b5a-52a3-4b41-975c-6c960414004a", + "origin": "Delegated (Skype for Business Online)", + "value": "Conversations.Initiate" + }, + { + "description": "Allows the app to receive instant messages, audio, video, and desktop sharing invitations on-behalf of the signed-in user\r\n", + "displayName": "Receive conversation invites (preview)", + "id": "4d48dea7-b534-4bca-9d76-5f8a7a8edae8", + "origin": "Delegated (Skype for Business Online)", + "value": "Conversations.Receive" + }, + { + "description": "Allows the app to create Skype meetings on-behalf of the signed-in user\r\n", + "displayName": "Create Skype Meetings", + "id": "d0c8f2ea-8f80-4289-8e78-4bc821cde1bc", + "origin": "Delegated (Skype for Business Online)", + "value": "Meetings.ReadWrite" + }, + { + "description": "Allows the app to read and update presence, photo, location, note, call forwarding settings of the signed-in user\r\n", + "displayName": "Read/write Skype user information (preview)", + "id": "208afe8f-9dfa-4f72-a755-6b810d61f42f", + "origin": "Delegated (Skype for Business Online)", + "value": "User.ReadWrite" + }, + { + "description": "Allows the app to read all your organization's policies without a signed-in user. ", + "displayName": "Read your organization's policies", + "id": "6c2d1b1d-a490-4178-ba6b-7efceda9129b", + "origin": "Application (Windows Azure Active Directory)", + "value": "Policy.Read.All" + }, + { + "description": "Allows the app to access the SQL Adx Proxy on behalf of the signed-in user.", + "displayName": "Access Sql Adx Proxy as user", + "id": "78ec75b3-3d15-4adb-bdc7-a8ab1f81e402", + "origin": "Delegated (SQL ADX Proxy)", + "value": "access_as_user" + }, + { + "description": "Allows reading basic user details", + "displayName": "user.read", + "id": "42ac4dc0-0862-4f71-89ed-85c8dca034a4", + "origin": "Delegated (Targeted Messaging Service)", + "value": "user.read" + }, + { + "description": "Allows access to the Azure API Center Data API service on behalf of the signed-in user", + "displayName": "Access Azure API Center Data API", + "id": "48f5e34f-bdfb-42e4-8da9-6519222a42ba", + "origin": "Delegated (TEST-Azure API Center)", + "value": "user_impersonation" + }, + { + "description": "Allows the app to call the Sentinel Platform service APIs on behalf of a user.", + "displayName": "Sentinel Platform Delegated API Access", + "id": "991a963a-4203-4dbc-acf2-254a258f76f2", + "origin": "Delegated (TEST-SecurityPlatform)", + "value": "SentinelPlatform.DelegatedAccess" + }, + { + "description": "Enables the application to perform trusted issuance of verifiable credentials.", + "displayName": "VerifiedCredentials.TrustedIssuance", + "id": "b4ebfa8b-750f-4993-8cf7-6a48fb98f13e", + "origin": "Application (Verifiable Credentials Service)", + "value": "VerifiedCredentials.TrustedIssuance" + }, + { + "description": "Allows the user to present access token during issuance", + "displayName": "Issuance.default", + "id": "1498e9cd-61d0-4068-b918-627d7ead8386", + "origin": "Delegated (Verifiable Credentials Service)", + "value": "issuance.default" + }, + { + "description": "This allows the application to perform read operations on authorities via Admin API", + "displayName": "VerifiableCredential.Authority.Read", + "id": "65499f04-0d5f-42ef-927e-6b4673fae3df", + "origin": "Application (Verifiable Credentials Service Admin)", + "value": "VerifiableCredential.Authority.Read" + }, + { + "description": "This allows the application to perform operations on authorities via Admin API", + "displayName": "VerifiableCredential.Authority.ReadWrite", + "id": "4ceb7a90-1485-40b1-accf-83a647694c0f", + "origin": "Application (Verifiable Credentials Service Admin)", + "value": "VerifiableCredential.Authority.ReadWrite" + }, + { + "description": "This allows the application to perform read operations on contracts via Admin API", + "displayName": "VerifiableCredential.Contract.Read", + "id": "24811a58-8ce0-4f09-a081-9ed0441ad3f2", + "origin": "Application (Verifiable Credentials Service Admin)", + "value": "VerifiableCredential.Contract.Read" + }, + { + "description": "PhysicalRP.ReadWrite", + "displayName": "PhysicalRP.ReadWrite", + "id": "da98d496-63c4-4eb4-b55c-19b2c584fca3", + "origin": "Delegated (StoreWebServices)", + "value": "PhysicalRP.ReadWrite" + }, + { + "description": "Allows the application to list and query any shared user profile information associated with the current tenant on behalf of the signed-in user. It also permits the application to export external user data (e.g. customer content or system-generated logs), for any user associated with the current tenant on behalf of the signed-in user.", + "displayName": "Read all shared cross-tenant user profiles and export their data", + "id": "759dcd16-3c90-463c-937e-abf89f991c18", + "origin": "Delegated (Microsoft Graph)", + "value": "CrossTenantUserProfileSharing.Read.All" + }, + { + "description": " ", + "displayName": "DataAgent.Reshare (retired)", + "id": "82b1d51c-42df-4ba9-9aee-bbba1e3f2ab7", + "origin": "Delegated (Power BI Service)", + "value": "DataAgent.Reshare.All" + }, + { + "description": "Allows reading data agents on the user’s behalf.", + "displayName": "Read data agents", + "id": "40fa91d5-73ef-412c-a8c8-c8658670d0eb", + "origin": "Delegated (Power BI Service)", + "value": "DataAgent.Read.All" + }, + { + "description": "Allows the app to have send access to all mailboxes", + "displayName": "Application access for sending emails via SMTP AUTH", + "id": "7146a1f0-8703-45b3-9eae-527a64c00995", + "origin": "Application (Office 365 Exchange Online)", + "value": "SMTP.SendAsApp" + }, + { + "description": "Allows the app to read user tasks in all mailboxes without a signed-in user.", + "displayName": "Read user tasks in all mailboxes", + "id": "c1b0de0a-1de9-455d-919f-eca451053141", + "origin": "Application (Office 365 Exchange Online)", + "value": "Tasks.Read" + }, + { + "description": "Allows the app to create, read, update, and delete tasks in all mailboxes without a signed-in user.", + "displayName": "Read and write tasks in all mailboxes", + "id": "2c6a42ca-0d4d-49ad-bc0e-21222c449a65", + "origin": "Application (Office 365 Exchange Online)", + "value": "Tasks.ReadWrite" + }, + { + "description": "Allows the app to read the full set of profile properties, reports, and managers of other users in your organization, on behalf of the signed-in user.", + "displayName": "Read all users' full profiles", + "id": "bf24470f-10c1-436d-8d53-7b997eb473be", + "origin": "Application (Office 365 Exchange Online)", + "value": "User.Read.All" + }, + { + "description": "Allows the app to read a basic set of profile properties of other users in your organization on behalf of the signed-in user. This includes display name, first and last name, email address and photo.", + "displayName": "Read all users' basic profiles", + "id": "77e65b5a-ceae-48b3-9490-50a86a038a48", + "origin": "Application (Office 365 Exchange Online)", + "value": "User.ReadBasic.All" + }, + { + "description": "Allows the app to read events in user calendars.", + "displayName": "Read user calendars", + "id": "5b9be81f-2977-4d27-8faf-bb43af8fc705", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "Calendars.Read" + }, + { + "description": "Allows the app to read events in all calendars that the user can access, including delegate and shared calendars.", + "displayName": "Read user and shared calendars ", + "id": "da710fc9-1e83-407b-8c5c-09d225031769", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "Calendars.Read.All" + }, + { + "description": "Allows the app to read events in all calendars that the user can access, including delegate and shared calendars.", + "displayName": "Read user and shared calendars ", + "id": "c21d8660-9de1-4404-85b6-59695921bd8d", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "Calendars.Read.Shared" + }, + { + "description": "Allows the app to create, read, update, and delete events in user calendars.", + "displayName": "Read and write user calendars", + "id": "765f423e-b55d-412e-97e3-13a800c3a537", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "Calendars.ReadWrite" + }, + { + "description": "Allows the app to create, read, update and delete events in all calendars in the organization user has permissions to access. This includes delegate and shared calendars.", + "displayName": "Read and write user and shared calendars ", + "id": "bbd1ca91-75e0-4814-ad94-9c5dbbae3415", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "Calendars.ReadWrite.All" + }, + { + "description": "Allows the app to create, read, update and delete events in all calendars in the organization user has permissions to access. This includes delegate and shared calendars.", + "displayName": "Read and write user and shared calendars", + "id": "4585ecca-5b47-432f-ac70-e1391e4951ed", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "Calendars.ReadWrite.Shared" + }, + { + "description": "Allows the app to read user contacts.", + "displayName": "Read user contacts", + "id": "181aac24-028a-486e-a649-b3742c74ec71", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "Contacts.Read" + }, + { + "description": "Allows the app to read contacts a user has permissions to access, including their own and shared contacts.", + "displayName": "Read user and shared contacts ", + "id": "d660a04c-7b62-4b4c-bea3-89226df00142", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "Contacts.Read.All" + }, + { + "description": "Allows the app to read contacts a user has permissions to access, including their own and shared contacts.", + "displayName": "Read user and shared contacts ", + "id": "d6aa6fa9-3360-416a-b8db-021249d58e86", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "Contacts.Read.Shared" + }, + { + "description": "Allows the app to create, read, update, and delete user contacts.", + "displayName": "Read and write user contacts", + "id": "32253599-e142-4cf0-810d-4827eedd1cfa", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "Contacts.ReadWrite" + }, + { + "description": "Allows the app to create, read, update, and delete contacts a user has permissions to, including their own and shared contacts.", + "displayName": "Read and write user and shared contacts ", + "id": "44882612-f346-430a-b938-4f00ee1c77a7", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "Contacts.ReadWrite.All" + }, + { + "description": "Allows the app to create, read, update, and delete contacts a user has permissions to, including their own and shared contacts.", + "displayName": "Read and write user and shared contacts ", + "id": "c54cba4f-60fe-4332-b0de-b5990fd1999e", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "Contacts.ReadWrite.Shared" + }, + { + "description": "Download all reports via the Office 365 reporting web service", + "displayName": "ReportingWebService.Read.All", + "id": "b4d5a5c7-c085-487f-b922-ef0d6ebde6b1", + "origin": "Application (Office 365 Exchange Online)", + "value": "ReportingWebService.Read.All" + }, + { + "description": "Allows the app to have the same access to mailboxes as the signed-in user via Exchange ActiveSync.", + "displayName": "Access mailboxes via Exchange ActiveSync", + "id": "266d2589-20b5-4f91-9a03-89247d1be8da", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "EAS.AccessAsUser.All" + }, + { + "description": "Allow application to access user’s mailbox via POP protocol", + "displayName": "POP.AccessAsApp", + "id": "cb842b43-da6e-4506-86fe-bb12199c656d", + "origin": "Application (Office 365 Exchange Online)", + "value": "POP.AccessAsApp" + }, + { + "description": "Allows the application to read and write tenant-wide people settings without a signed-in user.", + "displayName": "Read and write all tenant-wide people settings", + "id": "98ed40ef-611a-4479-bd35-eaa7863e946a", + "origin": "Application (Office 365 Exchange Online)", + "value": "PeopleSettings.ReadWrite.All" + }, + { + "description": "Allows the app to create, read, update, and delete events of all calendars without a signed-in user.", + "displayName": "Read and write calendars in all mailboxes", + "id": "ef54d2bf-783f-4e0f-bca1-3210c0444d99", + "origin": "Application (Office 365 Exchange Online)", + "value": "Calendars.ReadWrite.All" + }, + { + "description": "Allows the app to read all contacts in all mailboxes without a signed-in user.", + "displayName": "Read contacts in all mailboxes", + "id": "089fe4d0-434a-44c5-8827-41ba8a0b17f5", + "origin": "Application (Office 365 Exchange Online)", + "value": "Contacts.Read" + }, + { + "description": "Allows the app to create, read, update, and delete all contacts in all mailboxes without a signed-in user.", + "displayName": "Read and write contacts in all mailboxes", + "id": "6918b873-d17a-4dc1-b314-35f528134491", + "origin": "Application (Office 365 Exchange Online)", + "value": "Contacts.ReadWrite" + }, + { + "description": "Allows the app to manage a limited set of Exchange Online configuration objects without user interaction, for specific scenarios supported by the adminapi/v2.0 endpoint. To enable management actions an admin must also assign the appropriate Exchange RBAC roles directly to the app. This permission replaces the previous Exchange.ManageAsAppV2 permission.", + "displayName": "Manage Exchange Online Admin API as App", + "id": "95930782-af91-4e2e-89b7-e34ce33a0450", + "origin": "Application (Office 365 Exchange Online)", + "value": "Exchange.AdminAPI.ManageAsApp" + }, + { + "description": "Allows the app to manage the organization's Exchange environment without any user interaction. This includes mailboxes, groups, and other configuration objects. To enable management actions, an admin must assign the appropriate roles directly to the app.", + "displayName": "Manage Exchange As Application", + "id": "dc50a0fb-09a3-484d-be87-e023b12c6440", + "origin": "Application (Office 365 Exchange Online)", + "value": "Exchange.ManageAsApp" + }, + { + "description": "Allows the app to manage a limited set of Exchange Online configuration objects without user interaction, for specific scenarios supported by the adminapi/v2.0 endpoint. To enable management actions an admin must also assign the appropriate Exchange RBAC roles directly to the app.", + "displayName": "Manage Exchange as application v2", + "id": "ea3d980d-ebc7-4be4-8298-a167e5b8797c", + "origin": "Application (Office 365 Exchange Online)", + "value": "Exchange.ManageAsAppV2" + }, + { + "description": "Allows the app to have full access via Exchange Web Services to all mailboxes without a signed-in user.", + "displayName": "Use Exchange Web Services with full access to all mailboxes", + "id": "dc890d15-9560-4a4c-9b7f-a736ec74ec40", + "origin": "Application (Office 365 Exchange Online)", + "value": "full_access_as_app" + }, + { + "description": "Allow application to access user’s mailbox via IMAP protocol", + "displayName": "IMAP.AccessAsApp", + "id": "5e5addcd-3e8d-4e90-baf5-964efab2b20a", + "origin": "Application (Office 365 Exchange Online)", + "value": "IMAP.AccessAsApp" + }, + { + "description": "Allows the app to read mail in all mailboxes without a signed-in user.", + "displayName": "Read mail in all mailboxes", + "id": "810c84a8-4a9e-49e6-bf7d-12d183f40d01", + "origin": "Application (Office 365 Exchange Online)", + "value": "Mail.Read" + }, + { + "description": "Allows the app to create, read, update, and delete mail in all mailboxes without a signed-in user. Does not include permission to send mail.", + "displayName": "Read and write mail in all mailboxes", + "id": "e2a3a72e-5f79-4c64-b1b1-878b674786c9", + "origin": "Application (Office 365 Exchange Online)", + "value": "Mail.ReadWrite" + }, + { + "description": "Allows the app to send mail as any user without a signed-in user.", + "displayName": "Send mail as any user", + "id": "b633e1c5-b582-4048-a93e-9f11b44c7e96", + "origin": "Application (Office 365 Exchange Online)", + "value": "Mail.Send" + }, + { + "description": "Application permission grants permission to move mailboxes between Office365 organizations", + "displayName": "Move mailboxes between organizations", + "id": "f7264778-fba9-422d-8e9e-2675a2c4b513", + "origin": "Application (Office 365 Exchange Online)", + "value": "Mailbox.Migration" + }, + { + "description": "Allows the app to read user's mailbox settings without a signed-in user. Does not include permission to send mail.", + "displayName": "Read all user mailbox settings", + "id": "d45fa9f8-36e5-4cd2-b601-b063c7cf9ac2", + "origin": "Application (Office 365 Exchange Online)", + "value": "MailboxSettings.Read" + }, + { + "description": "Allows the app to create, read, update, and delete user's mailbox settings without a signed-in user. Does not include permission to send mail.", + "displayName": "Read and write all user mailbox settings", + "id": "f9156939-25cd-4ba8-abfe-7fabcf003749", + "origin": "Application (Office 365 Exchange Online)", + "value": "MailboxSettings.ReadWrite" + }, + { + "description": "Allows the app to read the organization and related resources, on behalf of the signed-in user. Related resources include things like subscribed SKUs and tenant branding information.", + "displayName": "Organization.Read.All", + "id": "15f260d6-f874-4366-8672-6b3658c5a09b", + "origin": "Application (Office 365 Exchange Online)", + "value": "Organization.Read.All" + }, + { + "description": "Allows the app to read and write the organization and related resources, on behalf of the signed-in user. Related resources include things like subscribed SKUs and tenant branding information.", + "displayName": "Organization.ReadWrite.All", + "id": "c976971c-a54d-4835-a240-2479e3dac74a", + "origin": "Application (Office 365 Exchange Online)", + "value": "Organization.ReadWrite.All" + }, + { + "description": "Allows the application to read tenant-wide people settings without a signed-in user.", + "displayName": "Read all tenant-wide people settings", + "id": "789ef6b5-4ecc-4f61-b6b3-66ef3109173c", + "origin": "Application (Office 365 Exchange Online)", + "value": "PeopleSettings.Read.All" + }, + { + "description": "Allows the app to read company places (conference rooms and room lists) for calendar events and other applications, without a signed-in user.", + "displayName": "Read all company places", + "id": "4830e04b-48ac-4de5-bbd9-8aceb58e506b", + "origin": "Application (Office 365 Exchange Online)", + "value": "Place.Read.All" + }, + { + "description": "Allows the app to have the same access to mailboxes as the signed-in user via Exchange Web Services.", + "displayName": "Access mailboxes as the signed-in user via Exchange Web Services", + "id": "3b5f3d61-589b-4a3c-a359-5dd4b5ee5bd5", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "EWS.AccessAsUser.All" + }, + { + "description": "Allows the app to manage a limited set of Exchange Online configuration objects via the adminapi/v2.0 endpoint. This permission is intended for specific scenarios and requires appropriate Exchange RBAC role assignments. This permission replaces the previous Exchange.ManageV2 permission.", + "displayName": "Manage Exchange Online Admin API", + "id": "9f021f4d-e924-4317-bf46-8db5e8340c6c", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "Exchange.AdminAPI.Manage" + }, + { + "description": "Allows the app to manage the organization's Exchange environment, such as mailboxes, groups, and other configuration objects. To enable management actions, an admin must assign the appropriate roles to the app user.", + "displayName": "Manage Exchange configuration", + "id": "ab4f2b77-0b06-4fc1-a9de-02113fc2ab7c", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "Exchange.Manage" + }, + { + "description": "Allows the app to read and write the organization and related resources, on behalf of the signed-in user. Related resources include things like subscribed SKUs and tenant branding information.", + "displayName": "Organization.ReadWrite.All", + "id": "17f07f5d-fb80-4278-ba37-70ae04d476a3", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "Organization.ReadWrite.All" + }, + { + "description": "Allows the app to read a ranked list of relevant people of the signed-in user. The list includes local contacts, contacts from social networking, your organization's directory, and people from recent communications (such as email and Skype).", + "displayName": "Read users' relevant people lists (preview)", + "id": "9478ac54-3753-4543-b95a-4fad24978902", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "People.Read" + }, + { + "description": "Allows the app to create, read and write to the ranked list of relevant people of the signed-in user. The list includes local contacts, contacts from social networking, your organization's directory, and people from recent communications (such as email and Skype).", + "displayName": "Read and write users' relevant people lists (preview)", + "id": "a88daf86-d44d-4077-8258-54131dd44e5d", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "People.ReadWrite" + }, + { + "description": "Allows the application to read tenant-wide people settings on behalf of the signed-in user.", + "displayName": "Read tenant-wide people settings", + "id": "e1eeeffa-b5a5-4b80-ae8f-ccb93d4b75eb", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "PeopleSettings.Read.All" + }, + { + "description": "Allows the application to read and write tenant-wide people settings on behalf of the signed-in user.", + "displayName": "Read and write tenant-wide people settings", + "id": "5430838e-68e7-4b12-b353-06478ace39c3", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "PeopleSettings.ReadWrite.All" + }, + { + "description": "Allows the app to read your company's places (conference rooms and room lists) for calendar events and other applications, on behalf of the signed-in user.", + "displayName": "Read all company places", + "id": "43ed0a33-2264-4716-b3bd-c5d8e248eebf", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "Place.Read.All" + }, + { + "description": "Allows the app to have the same access to mailboxes as the signed-in user via POP protocol.", + "displayName": "Read and write access to your mail via POP", + "id": "fb698133-92fa-453e-a9ed-688e10f2e5ac", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "POP.AccessAsUser.All" + }, + { + "description": "Download reports via the Office 365 reporting web service", + "displayName": "ReportingWebService.Read", + "id": "bbbcc29c-7bd7-48f0-8c8b-ef5f9865b626", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "ReportingWebService.Read" + }, + { + "description": "Allows the app to be able to send emails from the user’s mailbox using the SMTP AUTH client submission protocol.", + "displayName": "Access to sending emails from your mailbox using SMTP AUTH", + "id": "76faac2a-0f20-42f1-928a-50de5b9dbe52", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "SMTP.Send" + }, + { + "description": "Allows the app to read user tasks", + "displayName": "Read user tasks", + "id": "8af8046f-5694-470f-91e4-d47ad05eda18", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "Tasks.Read" + }, + { + "description": "Allows the app to read all tasks a user has access to", + "displayName": "Read all tasks a user has access to", + "id": "3d5e9942-27d3-4e96-80b1-696c7a3369c1", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "Tasks.Read.Shared" + }, + { + "description": "Allows the app to create, read, update and delete user tasks ", + "displayName": "Create, read, update and delete user tasks ", + "id": "6b49b74d-642f-4417-a6b4-820576845707", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "Tasks.ReadWrite" + }, + { + "description": "Allows the app to create, read, update and delete all tasks a user has access to", + "displayName": "Create, read, update and delete all tasks a user has access to", + "id": "2915e980-bca5-4194-9a3f-71c4ccdbd77b", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "Tasks.ReadWrite.Shared" + }, + { + "description": "Allows the app to read a set of the current user's profile properties in your company or school. Includes display name, photo, and email address.", + "displayName": "Read user profiles", + "id": "6223a6d3-53ef-4f8f-982a-895b39483c61", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "User.Read" + }, + { + "description": "Allows the app to read the full set of profile properties, reports, and managers of other users in your organization, on behalf of the signed-in user.", + "displayName": "Read all users' full profiles", + "id": "eb665d05-7f76-4d1b-b176-1cfc814e668d", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "User.Read.All" + }, + { + "description": "Allows the app to read a basic set of profile properties of other users in your organization on behalf of the signed-in user. This includes display name, first and last name, email address and photo.", + "displayName": "Read all users' basic profiles", + "id": "9b005f11-86f0-45f7-8c27-4fff5d849916", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "User.ReadBasic.All" + }, + { + "description": "Allows the app to read a basic set of profile properties of users in your company or school on behalf of the signed-in user. Includes display name, photo, and email address.", + "displayName": "Read all users' basic profiles", + "id": "6222dbab-a24c-4210-9d91-2f47cf565614", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "User.ReadBasic.All" + }, + { + "description": "Allows the app to read the organization and related resources, on behalf of the signed-in user. Related resources include things like subscribed SKUs and tenant branding information.", + "displayName": "Organization.Read.All", + "id": "1d490c92-d2ca-4a30-b52e-6edf5f279f4d", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "Organization.Read.All" + }, + { + "description": "This allows application to host MyDay Owa powered experience for shared mailbox and calendar", + "displayName": "OPX.MyDay.Shared", + "id": "405782ba-4062-4ea3-bd33-f7c731841e3b", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "OPX.MyDay.Shared" + }, + { + "description": "This allows application to host MyDay Owa powered experience for both user and shared mailbox and calendar", + "displayName": "OPX.MyDay.All", + "id": "d056cee4-aed2-4aa4-b2a9-292fe18b06d2", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "OPX.MyDay.All" + }, + { + "description": "This allows the application to host MyDay Owa powered experience", + "displayName": "OPX.MyDay", + "id": "8cac6046-ce43-4348-855c-efd9d956b7bf", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "OPX.MyDay" + }, + { + "description": "Allows the app to manage a limited set of Exchange Online configuration objects via the adminapi/v2.0 endpoint. This permission is intended for specific scenarios and requires appropriate Exchange RBAC role assignments.", + "displayName": "Manage Exchange configuration v2", + "id": "44d6b5f2-d42b-4fa7-b999-1c5fcab98e4d", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "Exchange.ManageV2" + }, + { + "description": "Allows the app to read group properties on behalf of the signed-in user, and read group calendar and conversations on public groups and groups the signed in user is a member of.", + "displayName": "Read all groups (preview)", + "id": "b5c79e22-9bf2-42d7-b60d-1b95c11ebc66", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "Group.Read.All" + }, + { + "description": "Allows the app to read group properties. Additionally allows the app to update group properties for groups the signed-in user owns. Also allows the app to read and write group calendar and conversations on public groups and groups the signed-in user is a member of.", + "displayName": "Read and write all groups (preview)", + "id": "27235839-268c-4d68-a668-351401ff623a", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "Group.ReadWrite.All" + }, + { + "description": "Allows the app to have the same access to mailboxes as the signed-in user via IMAP protocol.", + "displayName": "Read and write access to your mail via IMAP", + "id": "195adc35-e27b-454b-a7ed-1ecdffa1c09f", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "IMAP.AccessAsUser.All" + }, + { + "description": "Allows the app to read email in user mailboxes.", + "displayName": "Read user mail", + "id": "185758ba-798d-4b72-9e54-429a413a2510", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "Mail.Read" + }, + { + "description": "Allows the app to read mail a user can access, including their own and shared mail.", + "displayName": "Read user and shared mail ", + "id": "ad13ac2e-ad46-4dc0-b7da-249c94395a6d", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "Mail.Read.All" + }, + { + "description": "Allows the app to read mail a user can access, including their own and shared mail.", + "displayName": "Read user and shared mail ", + "id": "1d894596-c906-42b1-8422-9360440c1c0c", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "Mail.Read.Shared" + }, + { + "description": "Allows the app to read the signed-in user's mailbox except body, previewBody, attachments and any extended properties.", + "displayName": "Read user basic mail", + "id": "dab085de-3e14-432f-a47f-84b6457059c4", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "Mail.ReadBasic" + }, + { + "description": "Allows the app to read events of all R without a signed-in user", + "displayName": "Read calendars in all mailboxes", + "id": "2dfdc6dc-2fa7-4a2c-a922-dbd4f85d17be", + "origin": "Application (Office 365 Exchange Online)", + "value": "Calendars.Read.All" + }, + { + "description": "Allows the app to create, read, update, and delete email in user mailboxes. Does not include permission to send mail.", + "displayName": "Read and write user mail", + "id": "75767999-c7a8-481e-a6b4-19458e0b30a5", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "Mail.ReadWrite" + }, + { + "description": "Allows the app to create, read, update, and delete mail a user has permission to access, including their own and shared mail. Does not include permission to send mail.", + "displayName": "Read and write user and shared mail ", + "id": "b09ec548-3f99-4d0a-859c-c9b7ff53b7a9", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "Mail.ReadWrite.Shared" + }, + { + "description": "Allows the app to send mail as users in the organization.", + "displayName": "Send mail as a user", + "id": "5eb43c10-865a-4259-960a-83946678f8dd", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "Mail.Send" + }, + { + "description": "Allows the app to send mail as the signed-in user, including sending on-behalf of others.", + "displayName": "Send mail on behalf of others", + "id": "e843bc88-e493-446d-a73c-0ded7ff1913f", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "Mail.Send.All" + }, + { + "description": "Allows the app to send mail as the signed-in user, including sending on-behalf of others.", + "displayName": "Send mail on behalf of others", + "id": "16572339-6149-452b-b084-280b01354687", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "Mail.Send.Shared" + }, + { + "description": "Allows the app to read user's mailbox settings. Does not include permission to send mail.", + "displayName": "Read user mailbox settings ", + "id": "d36ad51d-15a2-458d-9b3a-16dbe4c51c30", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "MailboxSettings.Read" + }, + { + "description": "Allows the app to create, read, update, and delete user's mailbox settings. Does not include permission to send mail.", + "displayName": "Read and write user mailbox settings", + "id": "2e83d72d-8895-4b66-9eea-abb43449ab8b", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "MailboxSettings.ReadWrite" + }, + { + "description": "Allows the app to read user notes", + "displayName": "Read user notes", + "id": "505d82a7-24f3-4632-bffc-4d21625b31de", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "Notes.Read" + }, + { + "description": "Allows the app to create, read, update and delete user notes", + "displayName": "Create, read, update and delete user notes", + "id": "1b69a6c3-108d-42d0-a3ec-fafcd610e80b", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "Notes.ReadWrite" + }, + { + "description": "Allows the app to create, read, update, and delete mail a user has permission to access, including their own and shared mail. Does not include permission to send mail.", + "displayName": "Read and write user and shared mail ", + "id": "140e747e-90d3-4de0-8618-85a0cc7a1129", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "Mail.ReadWrite.All" + }, + { + "description": "Allows the app to create, read, and write a set of the current user's profile properties in your company or school. Includes display name, photo, and email address.", + "displayName": "Read and write user profiles", + "id": "f9408c03-bd3d-48c4-8bee-17a72d20bd9c", + "origin": "Delegated (Office 365 Exchange Online)", + "value": "User.ReadWrite" + }, + { + "description": "Allows the app to read events of all calendars without a signed-in user.", + "displayName": "Read calendars in all mailboxes", + "id": "798ee544-9d2d-430c-a058-570e29e34338", + "origin": "Application (Office 365 Exchange Online)", + "value": "Calendars.Read" + }, + { + "description": "A placeholder scope for preauth", + "displayName": "PreAuthPlaceholder", + "id": "25b4ea33-257e-47f5-b778-8df9c7e10548", + "origin": "Delegated (o365.servicecommunications.microsoft.com)", + "value": "PreAuthPlaceholder" + }, + { + "description": "Allows the application to call Purview APIs without a signed-in user", + "displayName": "Purview Application API Access", + "id": "8d48872e-7710-4001-bfd0-7dac15c28f69", + "origin": "Application (Microsoft Project Babylon)", + "value": "Purview.ApplicationAccess" + }, + { + "description": "Allows the application to call Purview APIs on behalf of a user", + "displayName": "Purview Delegated API Access", + "id": "817468d0-81dd-4cb5-94ac-07ca133fbbf6", + "origin": "Delegated (Microsoft Project Babylon)", + "value": "Purview.DelegatedAccess" + }, + { + "description": "Allow the application to sign in to Windows on behalf of the signed-in user", + "displayName": "Sign in to Windows", + "id": "dc5f2fe2-469e-48e9-87e5-6e48938b8789", + "origin": "Delegated (Microsoft Remote Desktop)", + "value": "user_impersonation" + }, + { + "description": "Allows the application to get or read all service configuration and log data", + "displayName": "Read all service configuration and log data for the Azure Information Protection service.", + "id": "e23bd57d-bfd5-4906-867f-89fb5ed8cd43", + "origin": "Application (Microsoft Rights Management Services)", + "value": "Application.Read.All" + }, + { + "description": "c", + "displayName": "Read protected content on behalf of a user", + "id": "7f740376-647b-4ad7-9ff7-292af252707a", + "origin": "Application (Microsoft Rights Management Services)", + "value": "Content.DelegatedReader" + }, + { + "description": "c", + "displayName": "Create protected content on behalf of a user", + "id": "d13f921c-7f21-4c08-bade-db9d048bd0da", + "origin": "Application (Microsoft Rights Management Services)", + "value": "Content.DelegatedWriter" + }, + { + "description": "c", + "displayName": "Read all protected content for this tenant", + "id": "7347eb49-7a1a-43c5-8eac-a5cd1d1c7cf0", + "origin": "Application (Microsoft Rights Management Services)", + "value": "Content.SuperUser" + }, + { + "description": "c", + "displayName": "Create protected content", + "id": "006e763d-a822-41fc-8df5-8d3d7fe20022", + "origin": "Application (Microsoft Rights Management Services)", + "value": "Content.Writer" + }, + { + "description": "Allow the application to use Azure Rights Management", + "displayName": "Create and access protected content for users", + "id": "c9c9a04d-3b66-4ca8-a00c-fca953e2afd3", + "origin": "Delegated (Microsoft Rights Management Services)", + "value": "user_impersonation" + }, + { + "description": "Allow the app to access Log Analytics on behalf of the signed-in user.", + "displayName": "Situational Awareness User Impersonation", + "id": "be810c4b-8598-4528-a3e3-1736473d6bf8", + "origin": "Delegated (Microsoft Sentinel Situational Awareness Workspace Access)", + "value": "UserImpersonation" + }, + { + "description": "Allows applications to read and write CPMC vocabularies for various tenants", + "displayName": "CustomerManagement.ReadWrite.CPMC", + "id": "6dc96d9f-75cd-4c03-a450-6bf81a89ad9e", + "origin": "Application (Microsoft Service Trust)", + "value": "CustomerManagement.ReadWrite.CPMC" + }, + { + "description": "Allows applications to read and write MMD vocabularies for various tenants", + "displayName": "CustomerManagement.ReadWrite.MMD", + "id": "1994c32e-87b9-46f9-a8b8-7daf25cd5a95", + "origin": "Application (Microsoft Service Trust)", + "value": "CustomerManagement.ReadWrite.MMD" + }, + { + "description": "Our app will be a one stop shop for current and prospective customers who need Security, Privacy, and Compliance information around Microsoft Cloud (Azure, Dynamics CRM Online and Office 365). It should be open any tenant who has AAD record – trial tenants as well as paid tenant across Microsoft Cloud. ", + "displayName": "Microsoft Service Trust", + "id": "50bf3dfa-9431-427d-823a-f146d9350034", + "origin": "Application (Microsoft Service Trust)", + "value": "TBD" + }, + { + "description": "Allows users to read and write Compliance Manager data.", + "displayName": "Read and write all Compliance Manager data", + "id": "05d808b8-d17a-47c9-ae7f-e20768cbad5d", + "origin": "Delegated (Microsoft Service Trust)", + "value": "ComplianceManager.ReadWrite.All" + }, + { + "description": "Allows users to read documents.", + "displayName": "Read all documents", + "id": "e808ab43-7555-447f-8b87-1d6a5c5038ef", + "origin": "Delegated (Microsoft Service Trust)", + "value": "Documents.Read.All" + }, + { + "description": "Our app will be a one stop shop for current and prospective customers who need Security, Privacy, and Compliance information around Microsoft Cloud (Azure, Dynamics CRM Online and Office 365). It should be open any tenant who has AAD record – trial tenants as well as paid tenant across Microsoft Cloud.", + "displayName": "Microsoft Service Trust", + "id": "b55dae21-0932-4324-a1cd-45a046d7a6e1", + "origin": "Delegated (Microsoft Service Trust)", + "value": "Trust.Content.All" + }, + { + "description": "Allows a client to access Premonition API on users behalf", + "displayName": "Access Premonition API", + "id": "33ee90e9-11ee-4949-81e2-b93e26b390d6", + "origin": "Delegated (Microsoft Premonition)", + "value": "All" + }, + { + "description": "Allows the app to run advanced hunting queries", + "displayName": "Run advanced hunting queries", + "id": "7734e8e5-8dde-42fc-b5ae-6eafea078693", + "origin": "Application (Microsoft Threat Protection)", + "value": "AdvancedHunting.Read.All" + }, + { + "description": "Allows for all operations of library resources", + "displayName": "Librarian", + "id": "59699edb-24d7-488d-a355-2b0e39dce24c", + "origin": "Application (Microsoft Premonition)", + "value": "Library.ReadWrite" + }, + { + "description": "Allows control plane operations of VNETs owned by the user", + "displayName": "VNET.ReadWrite", + "id": "9d4d9b26-9ecf-4796-ab43-702f556bc88c", + "origin": "Delegated (Microsoft Power Platform Service - PROD)", + "value": "VNET.ReadWrite" + }, + { + "description": "Directory.AccessAsUser.All", + "displayName": "Directory.AccessAsUser.All", + "id": "7757dd34-1b17-4123-afba-9bdbeeb48d1a", + "origin": "Delegated (Microsoft password reset service)", + "value": "Directory.AccessAsUser.All" + }, + { + "description": "Allow access the Office People API", + "displayName": "Access the Office People API", + "id": "de374d49-137d-465b-b4bb-ef2cec26583f", + "origin": "Delegated (Microsoft People Cards Service)", + "value": "Calendar.Read" + }, + { + "description": "Allow access to chat messages via this API.", + "displayName": "Read chat messages (delegated)", + "id": "594a414c-f70a-4504-b1f6-f1b5a4c95b2f", + "origin": "Delegated (Microsoft People Cards Service)", + "value": "Chat.Read" + }, + { + "description": "Allow access to read and send chat messages via this API.", + "displayName": "Read and write chat messages (delegated)", + "id": "20abcb3c-0462-42ce-9fa0-d1c97a22f4e5", + "origin": "Delegated (Microsoft People Cards Service)", + "value": "Chat.ReadWrite" + }, + { + "description": "Allow access the Office People API", + "displayName": "Access the Office People API", + "id": "1c95d935-5ae8-4181-944c-746c8b105528", + "origin": "Delegated (Microsoft People Cards Service)", + "value": "Files.Read" + }, + { + "description": "Allow access the Office People API", + "displayName": "Access the Office People API", + "id": "9beac4c9-e7ab-4507-9600-1f78e7d6097e", + "origin": "Delegated (Microsoft People Cards Service)", + "value": "Files.ReadWrite" + }, + { + "description": "Allow access the Office People API", + "displayName": "Access the Office People API", + "id": "36e9a6a1-4901-4ea6-bf22-12fa030a7dda", + "origin": "Delegated (Microsoft People Cards Service)", + "value": "Group.ReadWrite" + }, + { + "description": "Allow access the Office People API", + "displayName": "Access the Office People API", + "id": "de67d5fd-90b8-49f3-9ed1-d8010f5455a4", + "origin": "Delegated (Microsoft People Cards Service)", + "value": "Insight.Read" + }, + { + "description": "Allow access the Office People API", + "displayName": "Access the Office People API", + "id": "b4ed7afe-ffaf-4a1c-ade1-91d29981e3bb", + "origin": "Delegated (Microsoft People Cards Service)", + "value": "LLM.Read" + }, + { + "description": "Allow access the Office People API", + "displayName": "Access the Office People API", + "id": "75763f24-4303-4c58-a8b1-8e03ead9d770", + "origin": "Delegated (Microsoft People Cards Service)", + "value": "Mail.Read" + }, + { + "description": "Allow access the Office People API", + "displayName": "Access the Office People API", + "id": "2e87e453-2fc7-45ee-947f-bf81dc7f0926", + "origin": "Delegated (Microsoft People Cards Service)", + "value": "OnlineMeetings.Read" + }, + { + "description": "Allow access the Office People API", + "displayName": "Access the Office People API", + "id": "a248f402-c66c-43c5-87b7-f1bcb63d5541", + "origin": "Delegated (Microsoft People Cards Service)", + "value": "Organization.Read" + }, + { + "description": "Allow client apps to read policy data via Loki.", + "displayName": "Loki Policy.Read", + "id": "1d28bd1f-7c1b-4e6b-bc5b-abc07323e7f2", + "origin": "Delegated (Microsoft People Cards Service)", + "value": "Policy.Read" + }, + { + "description": "Allow access the Office People API", + "displayName": "Access the Office People API", + "id": "62e3da56-9c9b-4e31-a9bb-967b9a2b361f", + "origin": "Delegated (Microsoft People Cards Service)", + "value": "User.Read" + }, + { + "description": "Access the Office People API", + "displayName": "Access to log on", + "id": "07c496ee-38d1-46df-b73d-45e1ff46d11e", + "origin": "Delegated (Microsoft People Cards Service)", + "value": "User.Read.All" + }, + { + "description": "Allow access the Office People API", + "displayName": "Access the Office People API", + "id": "9eb61caf-4504-44c6-9d98-48e6a1ab8639", + "origin": "Delegated (Microsoft People Cards Service)", + "value": "User.ReadWrite" + }, + { + "description": "Allows data plane access to all VNETs on the system", + "displayName": "VNET.Read.All", + "id": "e2159836-d709-4343-a518-8bb0e5744afa", + "origin": "Application (Microsoft Power Platform Service - PROD)", + "value": "VNET.Read.All" + }, + { + "description": "Allows reading library resources", + "displayName": "Visitor", + "id": "91fb9a45-6b1c-4d38-b915-4f1987a64c1c", + "origin": "Application (Microsoft Premonition)", + "value": "Library.Read" + }, + { + "description": "Allows the app to create or update any custom detection rule", + "displayName": "Read and write all custom detection rules", + "id": "a7deff90-e2f5-4e4e-83a3-2c74e7002e28", + "origin": "Application (Microsoft Threat Protection)", + "value": "CustomDetections.ReadWrite.All" + }, + { + "description": "Allows the app to read any incident", + "displayName": "Read all incidents", + "id": "a9790345-4595-42e4-971a-ccdc79f19b7c", + "origin": "Application (Microsoft Threat Protection)", + "value": "Incident.Read.All" + }, + { + "description": "Allows the app to create or update any incident", + "displayName": "Read and write all incidents", + "id": "8d90f441-09cf-4fdc-ab45-e874fa3a28e8", + "origin": "Application (Microsoft Threat Protection)", + "value": "Incident.ReadWrite.All" + }, + { + "description": "Allows the app to read and write MCP tools on behalf of the user.", + "displayName": "Read and write MCP tools", + "id": "e6b51422-0b88-40ba-8639-b7b0451665cb", + "origin": "Delegated (Microsoft_Azure_Support)", + "value": "Mcp.Tools.ReadWrite" + }, + { + "description": "Allows the app to perform support operations on behalf of the user", + "displayName": "Perform support operations for the user", + "id": "5f4c8442-eba3-4814-adca-42c6bb62590c", + "origin": "Delegated (Microsoft_Azure_Support)", + "value": "SupportAndTroubleshootOperations.All" + }, + { + "description": "Azure Storage Express service for customers to transfer large amount of data to their storage accounts using storage devices", + "displayName": "Azure Storage Express", + "id": "d6f965c1-8fd3-4bfd-b912-705922a79272", + "origin": "Application (MicrosoftStorageExpressPodAADApp)", + "value": "user_impersonation" + }, + { + "description": "Business Admins who can use application", + "displayName": "MDLAdminCenterRole.Admin", + "id": "ea358ccf-c4a8-48ac-8b94-2558ae2f7a5c", + "origin": "Application (MileIQ Admin Center)", + "value": "mdladmincenterrole.admin" + }, + { + "description": "This allows users to sign in to Spatial Maps", + "displayName": "mixedreality.signin", + "id": "326ee642-50a8-4c21-ba85-68887a9e84f3", + "origin": "Delegated (MRMaps PPE)", + "value": "mixedreality.signin" + }, + { + "description": "This allows users to sign in to Spatial Maps", + "displayName": "mixedreality.signin", + "id": "b0c501aa-3640-456a-961e-7c302c78bc91", + "origin": "Delegated (MRMapsProd)", + "value": "mixedreality.signin" + }, + { + "description": "Allows the app to read Defender RBAC and URBAC roles for a given identity.", + "displayName": "Read all Defender RBAC and URBAC roles and permissions", + "id": "1e95888a-4dde-464e-85df-8e8e7895a6f0", + "origin": "Application (MTP Unified RBAC)", + "value": "Defender.RBAC.Read.All" + }, + { + "description": "Role to use instead of user impersonation pre auth.", + "displayName": "user impersonation pre auth", + "id": "c40e2389-78db-4e82-8eca-d0b898970f73", + "origin": "Application (MTP Unified RBAC)", + "value": "UserImpersonation" + }, + { + "description": "This allows users to read workspaces", + "displayName": "workspace.read", + "id": "f05e1490-846d-4ed2-abcf-869977a8a09f", + "origin": "Delegated (My Apps)", + "value": "workspace.read" + }, + { + "description": "This allows users to modify workspaces", + "displayName": "workspace.write", + "id": "c1b318ae-a684-4c27-ab24-81350fb6a401", + "origin": "Delegated (My Apps)", + "value": "workspace.write" + }, + { + "description": "c", + "displayName": "AzureEventGridSecureWebhook", + "id": "c141600b-52f9-46b0-a3ad-5f3eb4890cc1", + "origin": "Application (Networking-MNC)", + "value": "AzureEventGridSecureWebhook" + }, + { + "description": "Allows apps to start and get status of transitions.", + "displayName": "Transitions.ReadWrite", + "id": "226ed26c-f1f6-46f4-8892-54c1d9569817", + "origin": "Application (O365 Demeter)", + "value": "Transitions.ReadWrite" + }, + { + "description": "Allows the pidl test application to access commerce api", + "displayName": "pidlsdktest.read", + "id": "ed05fd6e-dbd5-4815-b368-18c07b9acc1e", + "origin": "Delegated (O365 Demeter)", + "value": "pidlsdktest.read" + }, + { + "description": "This allows users to read proposals", + "displayName": "proposal.read", + "id": "59c7e161-b483-4c17-91db-1c8b632c90b1", + "origin": "Delegated (O365 Demeter)", + "value": "proposal.read" + }, + { + "description": "Allows callers to read subscriptions", + "displayName": "subscriptions.read", + "id": "b24afad3-a979-4f67-8e97-6da6301b3c2e", + "origin": "Delegated (O365 Demeter)", + "value": "subscriptions.read" + }, + { + "description": "This allows teams users to access HAPI", + "displayName": "teams", + "id": "e734da10-5e7c-48a0-a906-1a0d8b751264", + "origin": "Delegated (O365 Demeter)", + "value": "teams" + }, + { + "description": "Allow the application full access to the Azure Key Vault service on behalf of the signed-in user", + "displayName": "Have full access to Azure Service Bus service", + "id": "40e16207-c5fd-4916-8ca4-64565f2367ca", + "origin": "Delegated (Microsoft.ServiceBus)", + "value": "user_impersonation" + }, + { + "description": "Read Write Access To MDLRest APIs", + "displayName": "MDLRest.ReadWrite", + "id": "bd79113c-40b8-4608-8c47-994bf0c2853d", + "origin": "Delegated (Microsoft.MileIQ.RESTService)", + "value": "MDLRest.ReadWrite" + }, + { + "description": "Read Access To MDLRest APIs", + "displayName": "MDLRest.Read", + "id": "9b5904c8-49e7-4d21-81c0-118a2a9c7d81", + "origin": "Delegated (Microsoft.MileIQ.RESTService)", + "value": "MDLRest.Read" + }, + { + "description": "Allows the app to run advanced hunting queries, that the signed-in user can execute.", + "displayName": "Run advanced hunting queries", + "id": "15f11de4-5113-4268-a2d1-0bad43d781f9", + "origin": "Delegated (Microsoft Threat Protection)", + "value": "AdvancedHunting.Read" + }, + { + "description": "Allows the app to create or update any custom detection rule that the signed in user can", + "displayName": "Read and write custom detection rules", + "id": "ddbb8d0c-c86b-4ece-bcb7-0f031a9cf103", + "origin": "Delegated (Microsoft Threat Protection)", + "value": "CustomDetections.ReadWrite" + }, + { + "description": "Allows the app to read any incident that the signed in user can access", + "displayName": "Read incidents", + "id": "12c153a4-4204-4224-aa3c-4626e2f47c2b", + "origin": "Delegated (Microsoft Threat Protection)", + "value": "Incident.Read" + }, + { + "description": "Allows the app to create, read or update any incident that the signed in user can create, read or update", + "displayName": "Read and write incidents", + "id": "2af415cc-88cf-4146-a0c8-444c3a80d5db", + "origin": "Delegated (Microsoft Threat Protection)", + "value": "Incident.ReadWrite" + }, + { + "description": "Allow access to all serivce capabilities", + "displayName": "All", + "id": "9bd5ab7f-4031-4045-ace9-6bebbad202f6", + "origin": "Delegated (Microsoft Visual Studio Services API)", + "value": "all" + }, + { + "description": "Allow the application to delete feature flags on behalf of the signed-in user.", + "displayName": "Delete Feature Flags", + "id": "954b4156-78fb-4d19-94b0-41dc7b763e68", + "origin": "Delegated (Microsoft.Azconfig)", + "value": "FeatureFlag.Delete" + }, + { + "description": "Allow the application to read feature flags on behalf of the signed-in user.", + "displayName": "Read Feature Flags", + "id": "dd4449fe-4788-4656-b3f2-4f066e14478a", + "origin": "Delegated (Microsoft.Azconfig)", + "value": "FeatureFlag.Read" + }, + { + "description": "Allow the application to write feature flags on behalf of the signed-in user.", + "displayName": "Write Feature Flags", + "id": "f3cb665c-6320-4ae2-996d-b58707ade4c3", + "origin": "Delegated (Microsoft.Azconfig)", + "value": "FeatureFlag.Write" + }, + { + "description": "Allow the application full access to the OCM service on behalf of the signed-in user", + "displayName": "Have full access to the OCM Service ", + "id": "9454efbe-3f0a-4074-9ec5-a25adefb6f87", + "origin": "Delegated (O365SBRM Service)", + "value": "user_impersonation" + }, + { + "description": "Allow the application to delete key-values on behalf of the signed-in user.", + "displayName": "Delete Key-Values", + "id": "08eeff12-9b4a-4273-b3d9-ff8a13c32645", + "origin": "Delegated (Microsoft.Azconfig)", + "value": "KeyValue.Delete" + }, + { + "description": "Allow the application to write key-values on behalf of the signed-in user.", + "displayName": "Write Key-Values", + "id": "77967a14-4f88-4960-84da-e8f71f761ac2", + "origin": "Delegated (Microsoft.Azconfig)", + "value": "KeyValue.Write" + }, + { + "description": "Allow the application to perform snapshot actions, such as archive, on behalf of the signed-in user.", + "displayName": "Perform Snapshot Actions", + "id": "28bb462a-d940-4cbe-afeb-281756df9af8", + "origin": "Delegated (Microsoft.Azconfig)", + "value": "Snapshot.Action" + }, + { + "description": "Allow the application to read snapshots on behalf of the signed-in user.", + "displayName": "Read Snapshots", + "id": "5970d132-a862-421f-9352-8ed18f833d78", + "origin": "Delegated (Microsoft.Azconfig)", + "value": "Snapshot.Read" + }, + { + "description": "Allow the application to write snapshots on behalf of the signed-in user.", + "displayName": "Write Snapshots", + "id": "ea601552-5fd3-4792-9dfc-e85be5a6827c", + "origin": "Delegated (Microsoft.Azconfig)", + "value": "Snapshot.Write" + }, + { + "description": "To allow application to send notifications to Azure AD provisioning service", + "displayName": "Notifications.Write", + "id": "0d7005c0-855a-485a-9d5b-5676f80a4a04", + "origin": "Delegated (Microsoft.Azure.SyncFabric)", + "value": "Notifications.Write" + }, + { + "description": "Allow the application full access to the Azure Event Hubs service on behalf of the signed-in user", + "displayName": "Have full access to the Azure Event Hub service", + "id": "7d388411-3845-4cfc-aa69-33192f4b9735", + "origin": "Delegated (Microsoft.EventHubs)", + "value": "user_impersonation" + }, + { + "description": "MileIQ.All", + "displayName": "MileIQ.All", + "id": "d26f02bb-ae28-4375-9184-101879252b0f", + "origin": "Delegated (Microsoft.MileIQ.Dashboard)", + "value": "MileIQ.All" + }, + { + "description": "Allow the application to read key-values on behalf of the signed-in user.", + "displayName": "Read Key-Values", + "id": "8d17f7f7-030c-4b57-8129-cfb5a16433cd", + "origin": "Delegated (Microsoft.Azconfig)", + "value": "KeyValue.Read" + }, + { + "description": "Allows modifying data agents on the user’s behalf.", + "displayName": "Read and write data agents", + "id": "c23fda5c-561f-4890-ad72-5f57bb7496fd", + "origin": "Delegated (Power BI Service)", + "value": "DataAgent.ReadWrite.All" + }, + { + "description": "c", + "displayName": "activitydata.tenant.read", + "id": "ba4ca83c-e834-4e66-bfe1-df738f63b557", + "origin": "Application (Office 365 Information Protection)", + "value": "activitydata.tenant.read" + }, + { + "description": "This allows the app to read AirAdminAction alerts", + "displayName": "AirAdminAction.tenant.read", + "id": "cc02f7ae-3d9b-42c9-bc91-3424d92c5547", + "origin": "Application (Office 365 Information Protection)", + "value": "AirAdminAction.tenant.read" + }, + { + "description": "Allows the app to read the current user's enterprise resources.", + "displayName": "Read user project enterprise resources", + "id": "b8341dab-4143-49da-8eb9-3d8c073f9e77", + "origin": "Delegated (Office 365 SharePoint Online)", + "value": "EnterpriseResource.Read" + }, + { + "description": "Allows the app to read, create, update, and delete the current user’s enterprise resources.", + "displayName": "Read and write user project enterprise resources", + "id": "2511a087-5795-4cae-9123-d5b7d6ec4844", + "origin": "Delegated (Office 365 SharePoint Online)", + "value": "EnterpriseResource.Write" + }, + { + "description": "Allows the app to read migration data using the SharePoint admin site, on behalf of the signed-in user.", + "displayName": "Can view migration data from the admin site", + "id": "f569098d-1005-4cc6-a812-3bdccdcfbb98", + "origin": "Delegated (Office 365 SharePoint Online)", + "value": "Migration.Read.All" + }, + { + "description": "Allows the app to read and write migration data using the SharePoint admin site, on behalf of the signed-in user.", + "displayName": "Can view and edit migration data from the admin site", + "id": "1a50df37-8278-4139-9af3-3b60d57dd007", + "origin": "Delegated (Office 365 SharePoint Online)", + "value": "Migration.ReadWrite.All" + }, + { + "description": "Allows the app to read the current user's files.", + "displayName": "Read user files", + "id": "dd2c8d78-58e1-46d7-82dd-34d411282686", + "origin": "Delegated (Office 365 SharePoint Online)", + "value": "MyFiles.Read" + }, + { + "description": "Allows the app to read, create, update, and delete the current user's files.", + "displayName": "Read and write user files", + "id": "2cfdc887-d7b4-4798-9b33-3d98d6b95dd2", + "origin": "Delegated (Office 365 SharePoint Online)", + "value": "MyFiles.Write" + }, + { + "description": "Allows the app to read the current user's projects.", + "displayName": "Read user projects", + "id": "2beb830c-70d1-4f5b-a983-79cbdb0c6c6a", + "origin": "Delegated (Office 365 SharePoint Online)", + "value": "Project.Read" + }, + { + "description": "Allows the app to read, create, update, and delete the current users’ projects.", + "displayName": "Read and write user projects", + "id": "d75a7b17-f04e-40d9-8e35-79b949bdb891", + "origin": "Delegated (Office 365 SharePoint Online)", + "value": "Project.Write" + }, + { + "description": "Allows the app to have full control of all ProjectWebApp site collections the signed-in user.", + "displayName": "Have full control of all ProjectWebApp site collections", + "id": "e7e732bd-932b-45c4-8ce5-40d60a7daad9", + "origin": "Delegated (Office 365 SharePoint Online)", + "value": "ProjectWebApp.FullControl" + }, + { + "description": "Allows the app to read all OData reporting data from all ProjectWebApp site collections for the signed-in user.", + "displayName": "Read ProjectWebApp OData reporting data", + "id": "a4c14cd7-8bd6-4337-8e87-78623dfc023b", + "origin": "Delegated (Office 365 SharePoint Online)", + "value": "ProjectWebAppReporting.Read" + }, + { + "description": "Read, write and manage Cross-Tenant migration settings and tasks, on behalf of the signed-in user", + "displayName": "Read, write and manage Cross-Tenant migration settings and tasks", + "id": "5b625b3d-65b6-4fb0-85d9-8a5aa26bdf36", + "origin": "Delegated (Office 365 SharePoint Online)", + "value": "SharePointCrossTenantMigration.Manage.All" + }, + { + "description": "Read Cross-Tenant migration settings and tasks, on behalf of the signed-in user", + "displayName": "Read Cross-Tenant migration settings and tasks", + "id": "8d11884e-6820-4673-a5d6-64d2a68b311e", + "origin": "Delegated (Office 365 SharePoint Online)", + "value": "SharePointCrossTenantMigration.Read.All" + }, + { + "description": "Allows the application to read the tenant-level settings in SharePoint and OneDrive on behalf of the signed-in user.", + "displayName": "Read SharePoint and OneDrive tenant settings", + "id": "a9b72c67-36e4-4bef-b91f-dff5801f2270", + "origin": "Delegated (Office 365 SharePoint Online)", + "value": "SharePointTenantSettings.Read.All" + }, + { + "description": "Allows the application to read and change the tenant-level settings of SharePoint and OneDrive on behalf of the signed-in user.", + "displayName": "Read and change SharePoint and OneDrive tenant settings", + "id": "aef0f52b-5892-4590-ae2c-d3f7928bd577", + "origin": "Delegated (Office 365 SharePoint Online)", + "value": "SharePointTenantSettings.ReadWrite.All" + }, + { + "description": "Allows the app to run search queries and to read basic site info on behalf of the current signed-in user. Search results are based on the user's permissions instead of the app's permissions.", + "displayName": "Run search queries as a user", + "id": "1002502a-9a71-4426-8551-69ab83452fab", + "origin": "Delegated (Office 365 SharePoint Online)", + "value": "Sites.Search.All" + }, + { + "description": "Allows the app to access a subset of site collections with a signed-in user. The specific site collections and the permissions granted will be configured in SharePoint Online.", + "displayName": "Access selected site collections", + "id": "9ac4404a-0323-446d-b334-b4ae4d18b38a", + "origin": "Delegated (Office 365 SharePoint Online)", + "value": "Sites.Selected" + }, + { + "description": "Allows the app to read site collection metadata using the SharePoint admin site, on behalf of the signed-in user.", + "displayName": "Can view site collection metadata from the admin site", + "id": "ee5c91f0-be0b-463e-85c9-57f0514c3d29", + "origin": "Delegated (Office 365 SharePoint Online)", + "value": "SitesMetadataAdmin.Read.All" + }, + { + "description": "Allows the app to create, read, update, and delete documents and list items in all site collections on behalf of the signed-in user.", + "displayName": "Read and write items in all site collections", + "id": "640ddd16-e5b7-4d71-9690-3f4022699ee7", + "origin": "Delegated (Office 365 SharePoint Online)", + "value": "AllSites.Write" + }, + { + "description": "Allows the app to read and write site collection metadata using the SharePoint admin site, on behalf of the signed-in user.", + "displayName": "Can view and edit site collection metadata from the admin site", + "id": "9aaa3660-6678-4cb8-b4b5-be92b6f4fbf0", + "origin": "Delegated (Office 365 SharePoint Online)", + "value": "SitesMetadataAdmin.ReadWrite.All" + }, + { + "description": "Allows the app to read documents and list items in all site collections on behalf of the signed-in user.", + "displayName": "Read items in all site collections", + "id": "4e0d77b0-96ba-4398-af14-3baa780278f4", + "origin": "Delegated (Office 365 SharePoint Online)", + "value": "AllSites.Read" + }, + { + "description": "Allows the app to have full control of all site collections on behalf of the signed-in user.", + "displayName": "Have full control of all site collections", + "id": "56680e0d-d2a3-4ae1-80d8-3c4f2100e3d0", + "origin": "Delegated (Office 365 SharePoint Online)", + "value": "AllSites.FullControl" + }, + { + "description": "Read, write and manage Cross-Tenant migration settings and tasks, without a signed-in user", + "displayName": "Read, write and manage Cross-Tenant migration settings and tasks", + "id": "806d1b36-e37b-4f02-9c9e-a1269982b2aa", + "origin": "Application (Office 365 SharePoint Online)", + "value": "SharePointCrossTenantMigration.Manage.All" + }, + { + "description": "Read Cross-Tenant migration settings and tasks, without a signed-in user", + "displayName": "Read Cross-Tenant migration settings and tasks", + "id": "8cfb5122-1118-41e2-b9f2-8ab21a06c030", + "origin": "Application (Office 365 SharePoint Online)", + "value": "SharePointCrossTenantMigration.Read.All" + }, + { + "description": "Allows the application to read the tenant-level settings of SharePoint and OneDrive, without a signed-in user.", + "displayName": "Read SharePoint and OneDrive tenant settings", + "id": "e370eb8e-f9cc-499d-90cc-7da62103e4f3", + "origin": "Application (Office 365 SharePoint Online)", + "value": "SharePointTenantSettings.Read.All" + }, + { + "description": "Allows the application to read and change the tenant-level settings of SharePoint and OneDrive, without a signed-in user.", + "displayName": "Read and change SharePoint and OneDrive tenant settings", + "id": "85bd96b3-dd7e-44d4-94f4-47fbb58e5718", + "origin": "Application (Office 365 SharePoint Online)", + "value": "SharePointTenantSettings.ReadWrite.All" + }, + { + "description": "Allows the app to have full control of all site collections without a signed in user.", + "displayName": "Have full control of all site collections", + "id": "678536fe-1083-478a-9c59-b99265e6b0d3", + "origin": "Application (Office 365 SharePoint Online)", + "value": "Sites.FullControl.All" + }, + { + "description": "Allows the app to read, create, update, and delete document libraries and lists in all site collections without a signed in user.", + "displayName": "Read and write items and lists in all site collections", + "id": "9bff6588-13f2-4c48-bbf2-ddab62256b36", + "origin": "Application (Office 365 SharePoint Online)", + "value": "Sites.Manage.All" + }, + { + "description": "Allows the app to read documents and list items in all site collections without a signed in user.", + "displayName": "Read items in all site collections", + "id": "d13f72ca-a275-4b96-b789-48ebcc4da984", + "origin": "Application (Office 365 SharePoint Online)", + "value": "Sites.Read.All" + }, + { + "description": "Allows the app to create, read, update, and delete documents and list items in all site collections without a signed in user.", + "displayName": "Read and write items in all site collections", + "id": "fbcd29d2-fcca-4405-aded-518d457caae4", + "origin": "Application (Office 365 SharePoint Online)", + "value": "Sites.ReadWrite.All" + }, + { + "description": "Allow the application to access a subset of site collections without a signed in user. The specific site collections and the permissions granted will be configured in SharePoint Online.", + "displayName": "Access selected site collections", + "id": "20d37865-089c-4dee-8c41-6967602d4ac8", + "origin": "Application (Office 365 SharePoint Online)", + "value": "Sites.Selected" + }, + { + "description": "Allows the app to read site collection metadata without a signed-in user.", + "displayName": "Read access to site collection metadata on the SharePoint admin site", + "id": "1ee80186-f376-4677-b0be-fbad73a4a9ea", + "origin": "Application (Office 365 SharePoint Online)", + "value": "SitesMetadataAdmin.Read.All" + }, + { + "description": "Allows the app to read and write site collection metadata without a signed-in user.", + "displayName": "Read and write access to site collection metadata on the SharePoint admin site", + "id": "6d004c19-cc42-41ea-8888-cce2fc5bd2c5", + "origin": "Application (Office 365 SharePoint Online)", + "value": "SitesMetadataAdmin.ReadWrite.All" + }, + { + "description": "Allows the app to read tenant reports via the SharePoint admin site without a signed-in user.", + "displayName": "Read access to tenant report data on the SharePoint admin site", + "id": "3cc23ca2-bb9d-42cd-9802-f792b98bac3b", + "origin": "Application (Office 365 SharePoint Online)", + "value": "TenantReports.Read.All" + }, + { + "description": "Allows the app to read and write tenant reports via the SharePoint admin site without a signed-in user.", + "displayName": "Read and write access to tenant report data on the SharePoint admin site", + "id": "e512dd2e-d13a-4816-9202-d3ef0357c7b1", + "origin": "Application (Office 365 SharePoint Online)", + "value": "TenantReports.ReadWrite.All" + }, + { + "description": "Allows the app to read enterprise managed metadata and to read basic site info without a signed in user.", + "displayName": "Read managed metadata", + "id": "2a8d57a5-4090-4a41-bf1c-3c621d2ccad3", + "origin": "Application (Office 365 SharePoint Online)", + "value": "TermStore.Read.All" + }, + { + "description": "Allows the app to write enterprise managed metadata and to read basic site info without a signed in user.", + "displayName": "Read and write managed metadata", + "id": "c8e3537c-ec53-43b9-bed3-b2bd3617ae97", + "origin": "Application (Office 365 SharePoint Online)", + "value": "TermStore.ReadWrite.All" + }, + { + "description": "Allows the app to read user profiles without a signed in user.", + "displayName": "Read user profiles", + "id": "df021288-bdef-4463-88db-98f22de89214", + "origin": "Application (Office 365 SharePoint Online)", + "value": "User.Read.All" + }, + { + "description": "Allows the app to read and update user profiles and to read basic site info without a signed in user.", + "displayName": "Read and write user profiles", + "id": "741f803b-c850-494e-b5df-cde7c675a1ca", + "origin": "Application (Office 365 SharePoint Online)", + "value": "User.ReadWrite.All" + }, + { + "description": "Allows the app to read, create, update, and delete document libraries and lists in all site collections on behalf of the signed-in user.", + "displayName": "Read and write items and lists in all site collections", + "id": "b3f70a70-8a4b-4f95-9573-d71c496a53f4", + "origin": "Delegated (Office 365 SharePoint Online)", + "value": "AllSites.Manage" + }, + { + "description": "Allows the app to submit project task status updates the signed-in user.", + "displayName": "Submit project task status updates", + "id": "c4258712-0efb-41f1-b6bc-be58e4e32f3f", + "origin": "Delegated (Office 365 SharePoint Online)", + "value": "TaskStatus.Submit" + }, + { + "description": "Allows the app to read tenant reports via the SharePoint admin site, on behalf of the signed-in user.", + "displayName": "Can view tenant reports from the admin site", + "id": "97533022-c395-42ce-bcf7-7d554ac912fc", + "origin": "Delegated (Office 365 SharePoint Online)", + "value": "TenantReports.Read.All" + }, + { + "description": "Allows the app to read and write tenant reports via the SharePoint admin site, on behalf of the signed-in user.", + "displayName": "Can view and edit tenant reports from the admin site", + "id": "fb471c34-3a48-412f-969b-e2b9bc071042", + "origin": "Delegated (Office 365 SharePoint Online)", + "value": "TenantReports.ReadWrite.All" + }, + { + "description": "Allow the application full access to the Azure Key Vault service on behalf of the signed-in user.", + "displayName": "Have full access to the Azure Key Vault service", + "id": "2049d009-d5d1-4947-85ab-8b727def2427", + "origin": "Delegated (Power BI Service)", + "value": "Code.AccessAzureKeyvault.All" + }, + { + "description": "Allows the app to make API calls to access all Fabric resources, on behalf of signed-in user.", + "displayName": "Allow API calls to access all Fabric resources on behalf of signed-in user", + "id": "a66db2ff-fea7-40f3-8b63-2a35d6c5f753", + "origin": "Delegated (Power BI Service)", + "value": "Code.AccessFabric.All" + }, + { + "description": "This is for Azure SQL database and data warehouse access", + "displayName": "Access Azure SQL database and data warehouse", + "id": "a1999a26-9603-41b3-abf3-45698c57f620", + "origin": "Delegated (Power BI Service)", + "value": "Code.AccessSQL.All" + }, + { + "description": "Allow the application to access Azure Storage on behalf of the signed-in user.", + "displayName": "Access Azure Storage", + "id": "3f981b02-4cb2-4226-b4d2-c378f291573c", + "origin": "Delegated (Power BI Service)", + "value": "Code.AccessStorage.All" + }, + { + "description": "Allows the app to make API calls that require read permissions on all Fabric connections, on behalf of the signed-in user.", + "displayName": "Make API calls that require read permissions on all Fabric connections", + "id": "9ff18859-b8d5-4a89-9912-448b84dfb097", + "origin": "Delegated (Power BI Service)", + "value": "Connection.Read.All" + }, + { + "description": "Allows the app to make API calls that require read and write permissions on all Fabric connections, on behalf of the signed-in user.", + "displayName": "Make API calls that require read and write permissions on all Fabric connections", + "id": "3be8fe94-2189-4d8b-89c6-dd35c5cea6ef", + "origin": "Delegated (Power BI Service)", + "value": "Connection.ReadWrite.All" + }, + { + "description": " ", + "displayName": "Connection.Reshare (retired)", + "id": "346e63ff-7cb8-4d86-9f6b-c9212f86a719", + "origin": "Delegated (Power BI Service)", + "value": "Connection.Reshare.All" + }, + { + "description": "App can automatically create content and datasets for a user.", + "displayName": "Create content", + "id": "f3076109-ca66-412a-be10-d4ee1be95d47", + "origin": "Delegated (Power BI Service)", + "value": "Content.Create" + }, + { + "description": "Allows executing copy jobs on the user’s behalf.", + "displayName": "Execute copy jobs", + "id": "5de94f0e-625f-423b-a7e3-aa181fa938c7", + "origin": "Delegated (Power BI Service)", + "value": "CopyJob.Execute.All" + }, + { + "description": "Allows reading copy jobs on the user’s behalf.", + "displayName": "Read copy jobs", + "id": "fa6c6162-62d9-4f38-b83e-52a4a0382d00", + "origin": "Delegated (Power BI Service)", + "value": "CopyJob.Read.All" + }, + { + "description": "Allows modifying copy jobs on the user’s behalf.", + "displayName": "Read and write copy jobs", + "id": "615e36fa-c072-424a-a1f6-c098849fade9", + "origin": "Delegated (Power BI Service)", + "value": "CopyJob.ReadWrite.All" + }, + { + "description": " ", + "displayName": "CopyJob.Reshare (retired)", + "id": "840d81d1-e227-45e2-982c-e76ce6908b43", + "origin": "Delegated (Power BI Service)", + "value": "CopyJob.Reshare.All" + }, + { + "description": " ", + "displayName": "Dashboard.Execute (retired)", + "id": "bcd38192-b0a2-4c40-bc0e-5728ec6ee69d", + "origin": "Delegated (Power BI Service)", + "value": "Dashboard.Execute.All" + }, + { + "description": "Allows reading dashboards on the user’s behalf.", + "displayName": "Read dashboards", + "id": "2448370f-f988-42cd-909c-6528efd67c1a", + "origin": "Delegated (Power BI Service)", + "value": "Dashboard.Read.All" + }, + { + "description": "Allows modifying dashboards on the user’s behalf.", + "displayName": "Read and write dashboards", + "id": "b271f05e-8329-4b97-baa4-91cf15b99cf1", + "origin": "Delegated (Power BI Service)", + "value": "Dashboard.ReadWrite.All" + }, + { + "description": " ", + "displayName": "Dashboard.Reshare (retired)", + "id": "c67b16d3-b5b8-4c87-8ca0-a8e00c6d6ff3", + "origin": "Delegated (Power BI Service)", + "value": "Dashboard.Reshare.All" + }, + { + "description": "Allows executing data agents on the user’s behalf.", + "displayName": "Execute data agents", + "id": "c6756612-6853-4145-a661-90c1d045b2dc", + "origin": "Delegated (Power BI Service)", + "value": "DataAgent.Execute.All" + }, + { + "description": "Allow the application full access to the Azure Data Lake service on behalf of the signed-in user.", + "displayName": "Have full access to the Azure Data Lake service", + "id": "c655ab60-056e-4dd8-8cd0-6b5398bf6002", + "origin": "Delegated (Power BI Service)", + "value": "Code.AccessAzureDataLake.All" + }, + { + "description": "Allow the application to access Azure Data Explorer on behalf of the signed-in user.", + "displayName": "Access Azure Data Explorer", + "id": "eaf7943f-ddfe-4442-96af-9419cf9522f3", + "origin": "Delegated (Power BI Service)", + "value": "Code.AccessAzureDataExplorer.All" + }, + { + "description": "Allows reading Iceberg and Delta table catalog metadata from the external provider on the users behalf.", + "displayName": "Read Iceberg and Delta table catalog metadata from the provider", + "id": "debb9ba3-fb14-491a-884c-b4406ac079f5", + "origin": "Delegated (Power BI Service)", + "value": "CatalogMirroring.Read.All" + }, + { + "description": "Allows reading catalog on the user’s behalf.", + "displayName": "Read catalog", + "id": "c595891f-110c-4524-abce-fa7b97bc7c65", + "origin": "Delegated (Power BI Service)", + "value": "Catalog.Read.All" + }, + { + "description": "Allows the app to read managed metadata and to read basic site info on behalf of the signed-in user.", + "displayName": "Read managed metadata", + "id": "a468ea40-458c-4cc2-80c4-51781af71e41", + "origin": "Delegated (Office 365 SharePoint Online)", + "value": "TermStore.Read.All" + }, + { + "description": "Allows the app to read, create, update, and delete managed metadata and to read basic site info on behalf of the signed-in user.", + "displayName": "Read and write managed metadata", + "id": "59a198b5-0420-45a8-ae59-6da1cb640505", + "origin": "Delegated (Office 365 SharePoint Online)", + "value": "TermStore.ReadWrite.All" + }, + { + "description": "Allows the app to read user profiles and to read basic site info on behalf of the signed-in user.", + "displayName": "Read user profiles", + "id": "0cea5a30-f6f8-42b5-87a0-84cc26822e02", + "origin": "Delegated (Office 365 SharePoint Online)", + "value": "User.Read.All" + }, + { + "description": "Allows the app to read and update user profiles and to read basic site info on behalf of the signed-in user.", + "displayName": "Read and write user profiles", + "id": "82866913-39a9-4be7-8091-f4fa781088ae", + "origin": "Delegated (Office 365 SharePoint Online)", + "value": "User.ReadWrite.All" + }, + { + "description": "Allow app to read properties of jobs submitted to eDiscovery.", + "displayName": "eDiscovery.Jobs.Read", + "id": "0f062bc7-6f95-469a-810f-d846e6cbba44", + "origin": "Application (Office365 Zoom)", + "value": "eDiscovery.Jobs.Read" + }, + { + "description": "Allow app to submit jobs to eDiscovery.", + "displayName": "eDiscovery.Jobs.Write", + "id": "5faac7a2-ab08-4049-aece-ab2b4de5f59e", + "origin": "Application (Office365 Zoom)", + "value": "eDiscovery.Jobs.Write" + }, + { + "description": "Allow app to access compliance connector", + "displayName": "Connector.Read", + "id": "06d98eed-6e1d-47d1-af81-f69ca60a0e97", + "origin": "Delegated (Office365 Zoom)", + "value": "Connector.Read" + }, + { + "description": "Allow app to download the ediscovery exported data", + "displayName": "eDiscovery.Export.Download", + "id": "df0d2e21-1705-4006-b158-1114609fdfbd", + "origin": "Delegated (Office365 Zoom)", + "value": "eDiscovery.Export.Download" + }, + { + "description": "Allow the application to provision OneDrive for Business drives for users in the tenant, without a signed-in user.", + "displayName": "Provision OneDrive for Business drives without a signed-in user.", + "id": "7689db6e-2939-41b4-98b7-13c05a52bcd6", + "origin": "Application (Office 365 SharePoint Online)", + "value": "OneDrive.Provision.All" + }, + { + "description": "Legacy scope used by Office Client", + "displayName": "user_impersonation", + "id": "4003e653-ac3b-43c3-af2c-a49553838842", + "origin": "Delegated (OfficeClientService)", + "value": "user_impersonation" + }, + { + "description": "Allows the app to read a set of the current user’s profile properties in your company or school. Includes display name, photo, and email address.", + "displayName": "Read user profiles", + "id": "6b0a3177-0946-453c-95bf-1d7b1886f0e4", + "origin": "Delegated (OneProfile Service)", + "value": "User.Read" + }, + { + "description": "Allow full access to the Microsoft Authorization Service on behalf of the signed-in user", + "displayName": "Have full access to the Microsoft Authorization Service", + "id": "e1e4ebc7-1bb4-4ccc-8394-895d471ba1a7", + "origin": "Delegated (Policy Administration Service)", + "value": "user_impersonation" + }, + { + "description": "The app can view all content in the tenant without a signed in user.", + "displayName": "View all content in tenant", + "id": "654b31ae-d941-4e22-8798-7add8fdf049f", + "origin": "Application (Power BI Service)", + "value": "Tenant.Read.All" + }, + { + "description": "The app can create, edit, view, and delete all content in the tenant without a signed in user.", + "displayName": "Read and write all content in tenant", + "id": "28379fa9-8596-4fd9-869e-cb60a93b5d84", + "origin": "Application (Power BI Service)", + "value": "Tenant.ReadWrite.All" + }, + { + "description": "The app can view all Power BI apps the signed in user has access to.", + "displayName": "View all Power BI apps", + "id": "8b01a991-5a5a-47f8-91a2-84d6bfd72c02", + "origin": "Delegated (Power BI Service)", + "value": "App.Read.All" + }, + { + "description": "The app can view all Power BI Premium and Power BI Embedded capacities that the signed in user has access to.", + "displayName": "View all capacities", + "id": "76e2ebd5-0dfb-4a5b-93c7-ed89e0362834", + "origin": "Delegated (Power BI Service)", + "value": "Capacity.Read.All" + }, + { + "description": "The app can view and edit all Power BI Premium and Power BI Embedded capacities that the signed in user has access to.", + "displayName": "Read and write all capacities", + "id": "4eabc3d1-b762-40ff-9da5-0e18fdf11230", + "origin": "Delegated (Power BI Service)", + "value": "Capacity.ReadWrite.All" + }, + { + "description": "c", + "displayName": "AggConsumptionBillingReport.Read.All", + "id": "f9d2daf6-8028-48d1-b4c1-b963f7ae7a73", + "origin": "Application (Office 365 Information Protection)", + "value": "AggConsumptionBillingReport.Read.All" + }, + { + "description": "Allows the app to read and write migration data via the SharePoint admin site without a signed-in user.", + "displayName": "Read and write access to migration data on the SharePoint admin site", + "id": "dfe5a59c-77fe-436b-9a47-375a284cf302", + "origin": "Application (Office 365 SharePoint Online)", + "value": "Migration.ReadWrite.All" + }, + { + "description": "Allows the app to search across sharepoint content. This is used for 3S unfurl route.", + "displayName": "Search across the users office content", + "id": "2aec0168-f9e2-4ce1-bb0f-1145f35f5a60", + "origin": "Delegated (Office 365 Search Service)", + "value": "SubstrateSearchServiceFiles.ReadAll" + }, + { + "description": "This allows apps to read the MtpAction", + "displayName": "MtpAction.tenant.read", + "id": "86e9643d-f798-40d2-98bb-293a7daaa2d7", + "origin": "Application (Office 365 Information Protection)", + "value": "MtpAction.tenant.read" + }, + { + "description": "This allows apps to write the MtpAction", + "displayName": "MtpAction.tenant.write", + "id": "e6e79fef-f4ee-4f93-aec3-3f0001087066", + "origin": "Application (Office 365 Information Protection)", + "value": "MtpAction.tenant.write" + }, + { + "description": "c", + "displayName": "MtpMailboxRuleAction.tenant.read", + "id": "ca69fdfa-5ba6-4c33-9eac-013653c2c3af", + "origin": "Application (Office 365 Information Protection)", + "value": "MtpMailboxRuleAction.tenant.read" + }, + { + "description": "c", + "displayName": "MtpMailboxRuleAction.tenant.write", + "id": "5f124282-d2c3-47dd-87ff-106ba9aa079b", + "origin": "Application (Office 365 Information Protection)", + "value": "MtpMailboxRuleAction.tenant.write" + }, + { + "description": "Allow o365 applications to read MTP Status", + "displayName": "mtpstatus.tenant.read", + "id": "74450f94-a5b1-4243-a991-9a688375883f", + "origin": "Application (Office 365 Information Protection)", + "value": "mtpstatus.tenant.read" + }, + { + "description": "c", + "displayName": "OcrBillingConfiguration-Internal.Write.All", + "id": "a585dd3a-70b6-4ff4-a1b9-093b4214e7e4", + "origin": "Application (Office 365 Information Protection)", + "value": "OcrBillingConfiguration-Internal.Write.All" + }, + { + "description": "This allows apps to read the OneCyberRelocationData", + "displayName": "OneCyberRelocationData.tenant.read", + "id": "9b31c028-bb6a-4d1a-8295-4514d184c061", + "origin": "Application (Office 365 Information Protection)", + "value": "OneCyberRelocationData.tenant.read" + }, + { + "description": "This allows apps to write the OneCyberRelocationData", + "displayName": "OneCyberRelocationData.tenant.write", + "id": "03f3ead7-60e6-4522-847b-8a67bb9c50df", + "origin": "Application (Office 365 Information Protection)", + "value": "OneCyberRelocationData.tenant.write" + }, + { + "description": "c", + "displayName": "PolicyStatusSummary-Internal.Write.All", + "id": "6c68db0a-4608-48d3-b0bf-4d0917b88bef", + "origin": "Application (Office 365 Information Protection)", + "value": "PolicyStatusSummary-Internal.Write.All" + }, + { + "description": "c", + "displayName": "Purview.DataAccess.All", + "id": "67a4e76f-5125-4b64-bcd6-b42a60d47dbe", + "origin": "Application (Office 365 Information Protection)", + "value": "Purview.DataAccess.All" + }, + { + "description": "c", + "displayName": "QuarantinedMessage.Read.All", + "id": "3e4e080e-55db-4fa9-8d68-0d9199d4c792", + "origin": "Application (Office 365 Information Protection)", + "value": "QuarantinedMessage.Read.All" + }, + { + "description": "c", + "displayName": "Recipient.tenant.read", + "id": "81eac90b-5b5b-422f-8d2a-9bc2055c5453", + "origin": "Application (Office 365 Information Protection)", + "value": "Recipient.tenant.read" + }, + { + "description": "c", + "displayName": "Recipient.tenant.write", + "id": "7b67f132-5c43-45cb-8cc3-e78fd80d0776", + "origin": "Application (Office 365 Information Protection)", + "value": "Recipient.tenant.write" + }, + { + "description": "c", + "displayName": "RecipientBatch.tenant.write", + "id": "39de55cd-e81d-4d2a-880d-3872c4fb992d", + "origin": "Application (Office 365 Information Protection)", + "value": "RecipientBatch.tenant.write" + }, + { + "description": "this allows to read reducedrecipient", + "displayName": "reducedrecipient.read.all", + "id": "24c052b7-e297-4369-b344-e4b62baa3fca", + "origin": "Application (Office 365 Information Protection)", + "value": "reducedrecipient.read.all" + }, + { + "description": "c", + "displayName": "Relocation.ReadWrite.All", + "id": "092afc53-fa1b-44c3-9fdf-46fcd25a5d99", + "origin": "Application (Office 365 Information Protection)", + "value": "Relocation.ReadWrite.All" + }, + { + "description": "c", + "displayName": "RemediationEmailResult.Read.All", + "id": "cae0e51f-af85-4f35-870d-9f024147648d", + "origin": "Application (Office 365 Information Protection)", + "value": "RemediationEmailResult.Read.All" + }, + { + "description": "c", + "displayName": "MessageTraceDetail.tenant.read", + "id": "85e837d7-9e4b-4bb2-9535-08bb51aa974a", + "origin": "Application (Office 365 Information Protection)", + "value": "MessageTraceDetail.tenant.read" + }, + { + "description": "c", + "displayName": "RemediationEmailResult.ReadWrite.All", + "id": "dac43cb8-9b13-43b1-bc17-e7eb8fe26717", + "origin": "Application (Office 365 Information Protection)", + "value": "RemediationEmailResult.ReadWrite.All" + }, + { + "description": "c", + "displayName": "MessageTrace.Read.All", + "id": "06ab0d31-7112-476e-a479-66394bec63d6", + "origin": "Application (Office 365 Information Protection)", + "value": "MessageTrace.Read.All" + }, + { + "description": "c", + "displayName": "M365ContentExplorer.Read.All", + "id": "26872368-3756-4995-a1d0-73cfa9d8f83a", + "origin": "Application (Office 365 Information Protection)", + "value": "M365ContentExplorer.Read.All" + }, + { + "description": "This allows apps to write the AirAdminAction alerts", + "displayName": "AirAdminAction.tenant.write", + "id": "43f2aa58-36d7-421e-8628-fbe9b129bf76", + "origin": "Application (Office 365 Information Protection)", + "value": "AirAdminAction.tenant.write" + }, + { + "description": "c", + "displayName": "Alert.Read.All", + "id": "43e3dfa5-222e-4a48-8253-d36086c5558c", + "origin": "Application (Office 365 Information Protection)", + "value": "Alert.Read.All" + }, + { + "description": "This allows to read tenant Office 365 alerts.", + "displayName": "alert.tenant.read", + "id": "d91202fb-0f5f-4245-8148-dfe12af913e6", + "origin": "Application (Office 365 Information Protection)", + "value": "alert.tenant.read" + }, + { + "description": "This allows to change any Office 365 alerts belong to the tenant.", + "displayName": "alert.tenant.write", + "id": "723c28f9-60b9-4cd4-8b04-6d344a3c4d84", + "origin": "Application (Office 365 Information Protection)", + "value": "alert.tenant.write" + }, + { + "description": "c", + "displayName": "AttackSimulationData.tenant.read", + "id": "06c43929-37aa-4707-ae05-68d6d967953e", + "origin": "Application (Office 365 Information Protection)", + "value": "AttackSimulationData.tenant.read" + }, + { + "description": "c", + "displayName": "AuditProvisioningData.Tenant.Read", + "id": "9760b448-d4d4-478b-bebc-0ebe62c935c9", + "origin": "Application (Office 365 Information Protection)", + "value": "AuditProvisioningData.Tenant.Read" + }, + { + "description": "c", + "displayName": "AzureActivityData.Read.All", + "id": "926c05c5-5941-491b-973a-509c2a4a2542", + "origin": "Application (Office 365 Information Protection)", + "value": "AzureActivityData.Read.All" + }, + { + "description": "c", + "displayName": "compliancestatus.tenant.read", + "id": "59c90462-e42e-4698-8a51-196ebd407166", + "origin": "Application (Office 365 Information Protection)", + "value": "compliancestatus.tenant.read" + }, + { + "description": "c", + "displayName": "CustomTag.Tenant.Read", + "id": "e0ba9b2a-a247-4d95-bca6-43211b61057f", + "origin": "Application (Office 365 Information Protection)", + "value": "CustomTag.Tenant.Read" + }, + { + "description": "c", + "displayName": "CustomTag.Tenant.Write", + "id": "92b5621e-0e43-46c9-b830-bc26b325a150", + "origin": "Application (Office 365 Information Protection)", + "value": "CustomTag.Tenant.Write" + }, + { + "description": "c", + "displayName": "DataInsightsSubscription.tenant.read", + "id": "bfeb98e9-5067-42b0-a7df-9022b927a10e", + "origin": "Application (Office 365 Information Protection)", + "value": "DataInsightsSubscription.tenant.read" + }, + { + "description": "c", + "displayName": "DataInsightsSubscription.tenant.write", + "id": "182f95e9-8c6f-4c32-8de2-e1abc1fe6ea4", + "origin": "Application (Office 365 Information Protection)", + "value": "DataInsightsSubscription.tenant.write" + }, + { + "description": "c", + "displayName": "DataInsightsUsersData.tenant.read", + "id": "0eabd45c-e771-460d-a601-2d534e78a1be", + "origin": "Application (Office 365 Information Protection)", + "value": "DataInsightsUsersData.tenant.read" + }, + { + "description": "c", + "displayName": "DynamicRiskPreventionTag.Tenant.Read", + "id": "f63db487-96bf-49af-8a79-faa86287aee6", + "origin": "Application (Office 365 Information Protection)", + "value": "DynamicRiskPreventionTag.Tenant.Read" + }, + { + "description": "c", + "displayName": "DynamicRiskPreventionTag.Tenant.Write", + "id": "15188c9e-7879-4e83-9d63-c728da8feb0d", + "origin": "Application (Office 365 Information Protection)", + "value": "DynamicRiskPreventionTag.Tenant.Write" + }, + { + "description": "c", + "displayName": "EopDataInsights.AccessAsApp", + "id": "cfbd1345-3cf0-408c-8c99-1491bde7ce52", + "origin": "Application (Office 365 Information Protection)", + "value": "EopDataInsights.AccessAsApp" + }, + { + "description": "c", + "displayName": "InsiderRiskData.Read.All", + "id": "57fee0bb-e97d-4e5c-a663-2b0c7ce0db37", + "origin": "Application (Office 365 Information Protection)", + "value": "InsiderRiskData.Read.All" + }, + { + "description": "This scope allows Apps to read tenant's MessageEventSummary data", + "displayName": "messageeventsummary.tenant.read", + "id": "51aa070e-cc8b-45a9-8530-3fc96b0aa701", + "origin": "Application (Office 365 Information Protection)", + "value": "messageeventsummary.tenant.read" + }, + { + "description": "This allows to change RoleGroupMember to the tenant", + "displayName": "RoleGroupMember.tenant.write", + "id": "abe60d99-0a67-4250-afc0-290614d84b41", + "origin": "Application (Office 365 Information Protection)", + "value": "RoleGroupMember.tenant.write" + }, + { + "description": "c", + "displayName": "TenantLicenseStatus.Read.All", + "id": "27787a44-0423-4f0d-a417-1276c93397fb", + "origin": "Application (Office 365 Information Protection)", + "value": "TenantLicenseStatus.Read.All" + }, + { + "description": "c", + "displayName": "ThreatSubmission.ReadWrite.All", + "id": "944c8d5a-fdcd-4aac-af4d-3366942700d5", + "origin": "Application (Office 365 Information Protection)", + "value": "ThreatSubmission.ReadWrite.All" + }, + { + "description": "Allows the application to read service health information for your organization.", + "displayName": "Read activity reports for your organization", + "id": "825c9d21-ba03-4e97-8007-83f020ff8c0f", + "origin": "Application (Office 365 Management APIs)", + "value": "Deprecated_ActivityReports.Read" + }, + { + "description": "Allows the application to read threat intelligence data for your organization", + "displayName": "Read threat intelligence data for your organization", + "id": "69784729-33e3-471d-b130-744ce05343e5", + "origin": "Application (Office 365 Management APIs)", + "value": "Deprecated_ThreatIntelligence.Read" + }, + { + "description": "Allows the application to read service health information for your organization.", + "displayName": "Read service health information for your organization", + "id": "e2cea78f-e743-4d8f-a16a-75b629a038ae", + "origin": "Application (Office 365 Management APIs)", + "value": "ServiceHealth.Read" + }, + { + "description": "Allows the application to read threat intelligence data for your organization", + "displayName": "Read threat intelligence data for your organization", + "id": "17f1c501-83cd-414c-9064-cd10f7aef836", + "origin": "Application (Office 365 Management APIs)", + "value": "ThreatIntelligence.Read" + }, + { + "description": "Allows the application to read activity data for your organization.", + "displayName": "Read activity data for your organization", + "id": "594c1fb6-4f81-4475-ae41-0c394909246c", + "origin": "Delegated (Office 365 Management APIs)", + "value": "ActivityFeed.Read" + }, + { + "description": "Allows the application to read DLP policy events, including detected sensitive data, for your organization.", + "displayName": "Read DLP policy events including detected sensitive data", + "id": "4807a72c-ad38-4250-94c9-4eabfe26cd55", + "origin": "Delegated (Office 365 Management APIs)", + "value": "ActivityFeed.ReadDlp" + }, + { + "description": "Allows the application to read service health information for your organization.", + "displayName": "Read activity reports for your organization", + "id": "b3b78c39-cb1d-4d17-820a-25d9196a800e", + "origin": "Delegated (Office 365 Management APIs)", + "value": "ActivityReports.Read" + }, + { + "description": "Allows the application to read all the AppCatalog", + "displayName": "Read App Catalog", + "id": "bb050071-0147-4956-8cf1-9168939c0a79", + "origin": "Application (Office 365 Search Service)", + "value": "AppCatalog.Read.All" + }, + { + "description": "Allows the app to read the signed-in files of the user and files shared with the user.", + "displayName": "Read user files and files shared with user", + "id": "749d9cae-ceda-4718-bd22-1ae6830cbee6", + "origin": "Delegated (Office 365 Search Service)", + "value": "Files.Read" + }, + { + "description": "Allows the app to read email in user mailboxes.", + "displayName": "Read user mail", + "id": "fdf2c210-c550-4378-b72d-0d94197f7bd3", + "origin": "Delegated (Office 365 Search Service)", + "value": "Mail.Read" + }, + { + "description": "Allows the app to read a ranked list of relevant people of the signed-in user. The list includes local contacts, contacts from social networking, your organization's directory, and people from recent communications (such as email and Skype)", + "displayName": "Read users' relevant people lists", + "id": "36073ebf-e0ad-4838-b99a-8f63f2b60db1", + "origin": "Delegated (Office 365 Search Service)", + "value": "People.Read" + }, + { + "description": "Allows the app to list QnA and to read their properties on behalf of the signed-in user. ", + "displayName": "Read all QnA", + "id": "537ceb4f-32cd-4b8e-bab3-8303e950ccf0", + "origin": "Delegated (Office 365 Search Service)", + "value": "QnA.Read.All" + }, + { + "description": "Allows the app to search across the users office content. This content includes relevant people, documents, emails and skype messages.", + "displayName": "Search across the office content of the user", + "id": "2aec0168-f9e2-4ce1-bb0f-1145f35f5a64", + "origin": "Delegated (Office 365 Search Service)", + "value": "SubstrateSearch-Internal.ReadWrite" + }, + { + "description": "c", + "displayName": "TiRemediation.Read.All", + "id": "5c6799ba-41c0-49cc-9f2d-7486a52d52a0", + "origin": "Application (Office 365 Information Protection)", + "value": "TiRemediation.Read.All" + }, + { + "description": "c", + "displayName": "TiRemediation.ReadWrite.All", + "id": "38b568f3-92e4-4c17-92b7-a49c28904247", + "origin": "Application (Office 365 Information Protection)", + "value": "TiRemediation.ReadWrite.All" + }, + { + "description": "c", + "displayName": "UsersAggregateByAttackSimulation.tenant.read", + "id": "e0ff780c-d4f4-4ef4-b0ad-b19863ca72a2", + "origin": "Application (Office 365 Information Protection)", + "value": "UsersAggregateByAttackSimulation.tenant.read" + }, + { + "description": "This allows user to read Office 365 alerts.", + "displayName": "alert.read", + "id": "6b300195-82d7-4a39-a7bc-0510371998cc", + "origin": "Delegated (Office 365 Information Protection)", + "value": "alert.read" + }, + { + "description": "This allows user to change Office 365 alerts.", + "displayName": "alert.write", + "id": "bcc2bc0d-d08c-412a-b24d-f6f78d714bdc", + "origin": "Delegated (Office 365 Information Protection)", + "value": "alert.write" + }, + { + "description": "AtpStandardPolicy.Tenant.Read", + "displayName": "AtpStandardPolicy.Tenant.Read", + "id": "b87cb2cc-0570-4e76-9606-3528d5fb44e7", + "origin": "Delegated (Office 365 Information Protection)", + "value": "AtpStandardPolicy.Tenant.Read" + }, + { + "description": "Allows the app to read migration data via the SharePoint admin site without a signed-in user.", + "displayName": "Read access to migration data on the SharePoint admin site", + "id": "b7155856-e8b7-4ba1-bf43-8c9912353676", + "origin": "Application (Office 365 SharePoint Online)", + "value": "Migration.Read.All" + }, + { + "description": "AtpStandardPolicy.Tenant.Write", + "displayName": "AtpStandardPolicy.Tenant.Write", + "id": "9945d5be-d9cb-45d0-b347-3f827c0d374d", + "origin": "Delegated (Office 365 Information Protection)", + "value": "AtpStandardPolicy.Tenant.Write" + }, + { + "description": "This allows user to read M365ContentExplorer", + "displayName": "M365ContentExplorer.Read.All", + "id": "b80e6cec-e423-411f-8c98-7fe5a25ca7cf", + "origin": "Delegated (Office 365 Information Protection)", + "value": "M365ContentExplorer.Read.All" + }, + { + "description": "This allows user to read MtpRoleInfo", + "displayName": "mtproleinfo.read", + "id": "6f44fc23-ea08-4666-8329-85597e11bdcd", + "origin": "Delegated (Office 365 Information Protection)", + "value": "mtproleinfo.read" + }, + { + "description": "Purview.DataAccess.All", + "displayName": "Purview.DataAccess.All", + "id": "739f66f6-655e-48e4-b5bd-2bbeb6077954", + "origin": "Delegated (Office 365 Information Protection)", + "value": "Purview.DataAccess.All" + }, + { + "description": "This Allows User to read the Rbac Roles", + "displayName": "RbacAccessCheck.read", + "id": "384a8502-84c5-41d9-a875-7834b77c8005", + "origin": "Delegated (Office 365 Information Protection)", + "value": "RbacAccessCheck.read" + }, + { + "description": "RbacTenantStatus.Read", + "displayName": "RbacTenantStatus.Read", + "id": "edfd2d1c-b4b5-4b83-b5e8-c38594e49c26", + "origin": "Delegated (Office 365 Information Protection)", + "value": "RbacTenantStatus.Read" + }, + { + "description": "RbacTenantStatus.Write", + "displayName": "RbacTenantStatus.Write", + "id": "4e26c42d-fab0-4daa-9ea6-d860a28aa7d0", + "origin": "Delegated (Office 365 Information Protection)", + "value": "RbacTenantStatus.Write" + }, + { + "description": "LabelAnalyticsActivityData.Read.All", + "displayName": "LabelAnalyticsActivityData.Read.All", + "id": "2da9421b-01d5-43ec-9c8e-b1bfa4a8b2bb", + "origin": "Delegated (Office 365 Information Protection)", + "value": "LabelAnalyticsActivityData.Read.All" + }, + { + "description": "Allows the application to list and query user profile information associated with the current tenant on behalf of the signed-in user. It also permits the application to export external user data (e.g. customer content or system-generated logs), associated with the current tenant on behalf of the signed-in user.", + "displayName": "Read shared cross-tenant user profile and export data", + "id": "cb1ba48f-d22b-4325-a07f-74135a62ee41", + "origin": "Delegated (Microsoft Graph)", + "value": "CrossTenantUserProfileSharing.Read" + }, + { + "description": "Allows the application to obtain basic tenant information about another target tenant within the Azure AD ecosystem on behalf of the signed-in user.", + "displayName": "Read cross-tenant basic information", + "id": "81594d25-e88e-49cf-ac8c-fecbff49f994", + "origin": "Delegated (Microsoft Graph)", + "value": "CrossTenantInformation.ReadBasic.All" + }, + { + "description": "Allows the app to read and write organization-wide copilot limited mode setting on behalf of the signed-in user.", + "displayName": "Read and write organization-wide copilot limited mode setting", + "id": "4704e5b2-0ada-4aa0-b18c-00ad7525bc06", + "origin": "Delegated (Microsoft Graph)", + "value": "CopilotSettings-LimitedMode.ReadWrite" + }, + { + "description": "Manage all settings", + "displayName": "settings.manage", + "id": "cb792285-1541-416c-a581-d8ede4ebc219", + "origin": "Application (Microsoft Cloud App Security)", + "value": "settings.manage" + }, + { + "description": "View all settings", + "displayName": "settings.read", + "id": "8e41f311-31d5-43aa-bb79-8fd4e14a8745", + "origin": "Application (Microsoft Cloud App Security)", + "value": "settings.read" + }, + { + "description": "Manage discovery alerts, reports, apps, and other related information", + "displayName": "discovery.manage", + "id": "f6e78c1a-b9c7-42d3-b067-220689a7a2e9", + "origin": "Delegated (Microsoft Cloud App Security)", + "value": "discovery.manage" + }, + { + "description": "View discovery alerts, reports, apps, and other related information", + "displayName": "discovery.read", + "id": "e9aa7b67-ea0d-435b-ab36-592cd9b23d61", + "origin": "Delegated (Microsoft Cloud App Security)", + "value": "discovery.read" + }, + { + "description": "Manage alerts, activities, policies, and other investigation-related information", + "displayName": "investigation.manage", + "id": "a832eaa3-0cfc-4a2b-9af1-27c5b092dd40", + "origin": "Delegated (Microsoft Cloud App Security)", + "value": "investigation.manage" + }, + { + "description": "View alerts, activities and policies", + "displayName": "investigation.read", + "id": "83bc8d83-2679-44ef-b813-d5f556fc4474", + "origin": "Delegated (Microsoft Cloud App Security)", + "value": "investigation.read" + }, + { + "description": "Allows the application to access the Cognitive Services API acting as users in the organization.", + "displayName": "Access Cognitive Services API as organization users.", + "id": "5f1e8914-a52b-429f-9324-91b92b81adaf", + "origin": "Delegated (Microsoft Cognitive Services)", + "value": "user_impersonation" + }, + { + "description": "Allow third-party application access to the Microsoft Customer Insights Service API.", + "displayName": "Have third-party access to Microsoft Customer Insights Service API", + "id": "056209dc-5c35-434b-9569-0c77d4aa6047", + "origin": "Application (Microsoft Customer Insights)", + "value": "CustomerInsights.Api.All" + }, + { + "description": "Allows App to Read from Azure Data Lake", + "displayName": "Read from Azure Data Lake", + "id": "ede937ec-309f-443a-bc4d-34c78296e4fd", + "origin": "Delegated (Microsoft Customer Insights)", + "value": "ADLS.Read" + }, + { + "description": "Allow the application access to the Microsoft Customer Insights Service API on behalf of the signed-in user.", + "displayName": "Have access to Microsoft Customer Insights Service API", + "id": "2e3c0709-0f8e-46b4-a196-ee6a15d858cd", + "origin": "Delegated (Microsoft Customer Insights)", + "value": "user_impersonation" + }, + { + "description": "Role to use instead of user impersonation pre auth.", + "displayName": "user impersonation pre auth", + "id": "6e61bc2a-6212-4824-b2fc-17261f45f642", + "origin": "Application (Microsoft Defender Hunting)", + "value": "UserImpersonation" + }, + { + "description": "Allows the app to see your list of devices as well as devices shared in your family.", + "displayName": "See your list of devices", + "id": "79a8f059-5727-4e7d-986a-d509f1799603", + "origin": "Delegated (Microsoft Device Directory Service)", + "value": "dds.read" + }, + { + "description": "Allows the app to be added to your list of devices and apps.", + "displayName": "Be added to your list of devices and apps", + "id": "b2c5a8a4-d75c-4c8d-ab0e-325d6d89c9e1", + "origin": "Delegated (Microsoft Device Directory Service)", + "value": "dds.register" + }, + { + "description": "Scope to allow FirstParty APPS to make PolicySync calls", + "displayName": "EopPolicySync.AccessAsApp", + "id": "c79b0778-99a8-4d45-9063-3f160ec2776d", + "origin": "Application (Microsoft Exchange Online Protection)", + "value": "EopPolicySync.AccessAsApp" + }, + { + "description": "c", + "displayName": "Exchange.ManageAsApp", + "id": "455e5cd2-84e8-4751-8344-5672145dfa17", + "origin": "Application (Microsoft Exchange Online Protection)", + "value": "Exchange.ManageAsApp" + }, + { + "description": "c", + "displayName": "ThreatSubmission.ReadWrite.All", + "id": "8f819283-077c-4c68-aa24-0ad706da26e0", + "origin": "Application (Microsoft Exchange Online Protection)", + "value": "ThreatSubmission.ReadWrite.All" + }, + { + "description": "Used to get token for agent to get network scan tasks", + "displayName": "NetworkScanAgent.Operate", + "id": "2adb0da9-d999-4186-85a4-0b66bbd9a535", + "origin": "Application (MDATPNetworkScanAgent)", + "value": "NetworkScanAgent.Operate" + }, + { + "description": "This allows users to install a new MDATP network scan agent", + "displayName": "NetworkScanAgent.Manage", + "id": "c65f274d-9690-45ee-a2ec-09e1e1f81bcd", + "origin": "Delegated (MDATPNetworkScanAgent)", + "value": "NetworkScanAgent.Manage" + }, + { + "description": "c", + "displayName": "Medeina.App", + "id": "85e2c024-8528-488c-a14e-42b99dfb2635", + "origin": "Application (Medeina Service)", + "value": "Medeina.App" + }, + { + "description": "Allows users to access the Medeina APIs", + "displayName": "Medeina.Access", + "id": "91918404-f9e2-41da-9065-5b776af95942", + "origin": "Delegated (Medeina Service)", + "value": "Medeina.Access" + }, + { + "description": "Medeina.Temp", + "displayName": "Medeina.Temp", + "id": "d8ea63e6-7c3c-4788-82e8-ba4ed13b95a3", + "origin": "Delegated (Medeina Service)", + "value": "Medeina.Temp" + }, + { + "description": "Allows users to access the Medeina APIs", + "displayName": "Mediena.Access", + "id": "fa228bfa-2a9b-48e9-9145-9ac39ac9b6f8", + "origin": "Delegated (Medeina Service Dev)", + "value": "Medeina.Access" + }, + { + "description": "Allow the application to access all the APIs registered with App Service", + "displayName": "Access APIs registered with App Service", + "id": "e0ea806b-d128-49dc-ac08-2bf18f7874d8", + "origin": "Delegated (Microsoft Azure App Service)", + "value": "user_impersonation" + }, + { + "description": "Allow the application to access the Azure Batch Service API on behalf of the signed-in user.", + "displayName": "Access Azure Batch Service", + "id": "635ced16-958c-4230-8508-ac2c509d94c6", + "origin": "Delegated (Microsoft Azure Batch)", + "value": "user_impersonation" + }, + { + "description": "Allows the application to access Azure Growth Signup APIs acting as signed-in users", + "displayName": "Azure Growth Signup", + "id": "df5e5f31-cc73-4a31-9291-89348976337e", + "origin": "Delegated (Microsoft Azure Signup Portal)", + "value": "azuregrowth.api.signup" + }, + { + "description": "Allows synchronizing billing information, such as the number of physical processor cores, between the Azure Stack HCI cluster and the cloud.", + "displayName": "Sync billing information", + "id": "7c2ddece-a157-4a29-a61a-1f8116e7cc57", + "origin": "Application (Microsoft Azure Stack HCI Service)", + "value": "AzureStackHCI.Billing.Sync" + }, + { + "description": "Allows synchronizing census metadata, such as hardware vendor and software version, between the Azure Stack HCI cluster and the cloud.", + "displayName": "Sync census metadata", + "id": "b582234c-0282-4247-9d0a-730c45218605", + "origin": "Application (Microsoft Azure Stack HCI Service)", + "value": "AzureStackHCI.Census.Sync" + }, + { + "description": "Allows read only access to cluster level properties, between the Azure Stack HCI cluster and the cloud", + "displayName": "Read only access to cluster level properties", + "id": "2344a320-6a09-4530-bed7-c90485b5e5e2", + "origin": "Application (Microsoft Azure Stack HCI Service)", + "value": "AzureStackHCI.Cluster.Read" + }, + { + "description": "Allows read and write access to cluster level actions, between the Azure Stack HCI cluster and the cloud", + "displayName": "Read and write access to cluster level actions", + "id": "493bd689-9082-40db-a506-11f40b68128f", + "origin": "Application (Microsoft Azure Stack HCI Service)", + "value": "AzureStackHCI.Cluster.ReadWrite" + }, + { + "description": "Allows read only access to cluster node level properties, between the Azure Stack HCI cluster node and the cloud", + "displayName": "Read only access to cluster node level properties", + "id": "8fa5445e-80fb-4c71-a3b1-9a16a81a1966", + "origin": "Application (Microsoft Azure Stack HCI Service)", + "value": "AzureStackHCI.ClusterNode.Read" + }, + { + "description": "Allows read and write access to cluster node level actions, between the Azure Stack HCI cluster node and the cloud", + "displayName": "Read and write access to cluster node level actions", + "id": "bbe8afc9-f3ba-4955-bb5f-1cfb6960b242", + "origin": "Application (Microsoft Azure Stack HCI Service)", + "value": "AzureStackHCI.ClusterNode.ReadWrite" + }, + { + "description": "Stream Analytics access to Power BI", + "displayName": "Stream Analytics access to Power BI", + "id": "27fa5b85-cf15-41f1-a29d-2e44dde75208", + "origin": "Delegated (Microsoft Azure Stream Analytics)", + "value": "user_impersonation" + }, + { + "description": "This scope allows working with Microsoft Project Arcadia Workspaces' Artifacts API.", + "displayName": "workspaceartifacts.management", + "id": "f99087ab-db8f-46be-8ff0-613ef11c6ed8", + "origin": "Delegated (Microsoft Azure Synapse Gateway)", + "value": "workspaceartifacts.management" + }, + { + "description": "Allows the app to read access reviews, reviewers, decisions and settings in the organization, without a signed-in user.", + "displayName": "Read all access reviews", + "id": "d07a8cc0-3d51-4b77-b3b0-32704d1f69fa", + "origin": "Application (Microsoft Graph)", + "value": "AccessReview.Read.All" + }, + { + "description": "Allows the app to read, update, delete and perform actions on access reviews, reviewers, decisions and settings in the organization, without a signed-in user.", + "displayName": "Manage all access reviews", + "id": "ef5f7d5c-338f-44b0-86c3-351f46c8bb5f", + "origin": "Application (Microsoft Graph)", + "value": "AccessReview.ReadWrite.All" + }, + { + "description": "Allows the app to read, update, delete and perform actions on access reviews, reviewers, decisions and settings in the organization for group and app memberships, without a signed-in user.", + "displayName": "Manage access reviews for group and app memberships", + "id": "18228521-a591-40f1-b215-5fad4488c117", + "origin": "Application (Microsoft Graph)", + "value": "AccessReview.ReadWrite.Membership" + }, + { + "description": "Allows deleting or restoring agent identity blueprints without a signed-in user.", + "displayName": "Delete and restore agent identity blueprints.", + "id": "3f80b699-6405-4e36-a4df-4f19950ff91e", + "origin": "Application (Microsoft Graph)", + "value": "AgentIdentityBlueprint.DeleteRestore.All" + }, + { + "description": "Allows the client to read all agent identity blueprints without a signed-in user.", + "displayName": "Read all agent identity blueprints", + "id": "7547a7d1-36fa-4479-9c31-559a600eaa4f", + "origin": "Application (Microsoft Graph)", + "value": "AgentIdentityBlueprint.Read.All" + }, + { + "description": "Allows the client to read, update, create, and delete agent identity blueprints without a signed-in user.", + "displayName": "Read and write all agent identity blueprints.", + "id": "7fddd33b-d884-4ec0-8696-72cff90ff825", + "origin": "Application (Microsoft Graph)", + "value": "AgentIdentityBlueprint.ReadWrite.All" + }, + { + "description": "Allows updating agent identity blueprint authorization and authentication properties without a signed-in user.", + "displayName": "Update agent identity blueprint authorization and authentication properties", + "id": "19202363-278e-49c2-bf00-391e2ba00881", + "origin": "Application (Microsoft Graph)", + "value": "AgentIdentityBlueprint.UpdateAuthProperties.All" + }, + { + "description": "Allows updating agent identity blueprint branding without a signed-in user.", + "displayName": "Update agent identity blueprint branding", + "id": "76232daa-a1e4-4544-b664-495a006513bf", + "origin": "Application (Microsoft Graph)", + "value": "AgentIdentityBlueprint.UpdateBranding.All" + }, + { + "description": "Allows creating new agent identity blueprint principals without a signed-in user.", + "displayName": "Create agent identity blueprint principals.", + "id": "8959696d-d07e-4916-9b1e-3ba9ce459161", + "origin": "Application (Microsoft Graph)", + "value": "AgentIdentityBlueprintPrincipal.Create" + }, + { + "description": "Allows deleting or restoring agent identity blueprint principals without a signed-in user.", + "displayName": "Delete and restore agent identity blueprint principals.", + "id": "f86a2dd8-9298-4675-bd78-f5a3572da2d7", + "origin": "Application (Microsoft Graph)", + "value": "AgentIdentityBlueprintPrincipal.DeleteRestore.All" + }, + { + "description": "Allows enabling or disabling agent identity blueprint principals without a signed-in user.", + "displayName": "Enable or disable agent identity blueprint principals.", + "id": "a0bdd23d-8b19-4682-b428-574d96527c6f", + "origin": "Application (Microsoft Graph)", + "value": "AgentIdentityBlueprintPrincipal.EnableDisable.All" + }, + { + "description": "Allows reading agent identity blueprint principals without a signed-in user.", + "displayName": "Read agent identity blueprint principals.", + "id": "9361dea9-4524-493d-941d-f1b65aaf6c7c", + "origin": "Application (Microsoft Graph)", + "value": "AgentIdentityBlueprintPrincipal.Read.All" + }, + { + "description": "Allows the app to read, update, create, and delete agent identity blueprint principals without a signed-in user.", + "displayName": "Read and write all agent identity blueprint principals.", + "id": "3bc933bc-8b4d-4cb6-ac49-b73774299250", + "origin": "Application (Microsoft Graph)", + "value": "AgentIdentityBlueprintPrincipal.ReadWrite.All" + }, + { + "description": "Allows the app to create agent users, read and update agent ID user profiles and read basic company properties, delete and restore agent users without a signed in user.", + "displayName": "Read and write all agent ID users' full profiles", + "id": "b782c9ad-6f2b-4894-a21b-72bf22417f0a", + "origin": "Application (Microsoft Graph)", + "value": "AgentIdUser.ReadWrite.All" + }, + { + "description": "Allows the app to create agent users, read and update agent ID user profiles, delete and restore agent users under an agent blueprint, and read basic company properties without a signed-in user.", + "displayName": "Read and write full profiles of agent ID users under an agent blueprint", + "id": "4aa6e624-eee0-40ab-bdd8-f9639038a614", + "origin": "Application (Microsoft Graph)", + "value": "AgentIdUser.ReadWrite.IdentityParentedBy" + }, + { + "description": "Allows the app to read all agent instances and their related collections in your organization's Agent Registry without a signed-in user.", + "displayName": "Read all agent instances in Agent Registry", + "id": "799a4732-85b8-4c67-b048-75f0e88a232b", + "origin": "Application (Microsoft Graph)", + "value": "AgentInstance.Read.All" + }, + { + "description": "Allows the app to create, read, update, and delete all agent instances in your organization's Agent Registry without a signed-in user.", + "displayName": "Read and write all agent instances in Agent Registry", + "id": "07abdd95-78dc-4353-bd32-09f880ea43d0", + "origin": "Application (Microsoft Graph)", + "value": "AgentInstance.ReadWrite.All" + }, + { + "description": "Allows the app to create, read, update, and delete agent instances that designate the calling app as their manager in your organization's Agent Registry without a signed-in user.", + "displayName": "Read and write managed-by agent instances in Agent Registry", + "id": "782ab1bf-24f1-4c27-8bbc-2006d42792a6", + "origin": "Application (Microsoft Graph)", + "value": "AgentInstance.ReadWrite.ManagedBy" + }, + { + "description": "Allows the app to read agent registration information without a signed-in user.", + "displayName": "Read all agent registrations", + "id": "d3acceb6-4673-47c0-aeac-582f2c7cf72c", + "origin": "Application (Microsoft Graph)", + "value": "AgentRegistration.Read.All" + }, + { + "description": "Allows the app to read and write agent registration information without a signed-in user.", + "displayName": "Read and write all agent registrations", + "id": "39fb8c64-7bd3-4107-8515-14d6e55ddda4", + "origin": "Application (Microsoft Graph)", + "value": "AgentRegistration.ReadWrite.All" + }, + { + "description": "Allows creating new agent identity blueprints without a signed-in user.", + "displayName": "Create agent identity blueprints.", + "id": "ea4b2453-ad2d-4d94-9155-10d5d9493ce9", + "origin": "Application (Microsoft Graph)", + "value": "AgentIdentityBlueprint.Create" + }, + { + "description": "Allows updating agent identity blueprint credentials without a signed-in user.", + "displayName": "Update agent identity blueprint credentials", + "id": "0510736e-bdfb-4b37-9a1f-89b4a074763a", + "origin": "Application (Microsoft Graph)", + "value": "AgentIdentityBlueprint.AddRemoveCreds.All" + }, + { + "description": "Allows the client to read, update, create, and delete agent identities without a signed-in user.", + "displayName": "Read and write all agent identities", + "id": "dcf7150a-88d4-4fe6-9be1-c2744c455397", + "origin": "Application (Microsoft Graph)", + "value": "AgentIdentity.ReadWrite.All" + }, + { + "description": "Allows the app to read all agent identities without a signed-in user.", + "displayName": "Read all agent identities", + "id": "b2b8f011-2898-4234-9092-5059f6c1ebfa", + "origin": "Application (Microsoft Graph)", + "value": "AgentIdentity.Read.All" + }, + { + "description": "Allows an app to read all acronyms without a signed-in user.", + "displayName": "Read all acronyms", + "id": "8c0aed2c-0c61-433d-b63c-6370ddc73248", + "origin": "Application (Microsoft Graph)", + "value": "Acronym.Read.All" + }, + { + "description": "Allows the app to read administrative units and administrative unit membership without a signed-in user.", + "displayName": "Read all administrative units", + "id": "134fd756-38ce-4afd-ba33-e9623dbe66c2", + "origin": "Application (Microsoft Graph)", + "value": "AdministrativeUnit.Read.All" + }, + { + "description": "Allows the app to create, read, update, and delete administrative units and manage administrative unit membership without a signed-in user.", + "displayName": "Read and write all administrative units", + "id": "5eb59dd3-1da2-4329-8733-9dabdc435916", + "origin": "Application (Microsoft Graph)", + "value": "AdministrativeUnit.ReadWrite.All" + }, + { + "description": "Allows the app to read all agent cards and their skills in your organization's Agent Registry without a signed-in user.", + "displayName": "Read all agent cards in Agent Registry", + "id": "aec9e0a0-6f46-4150-a9f7-05e9e3e87399", + "origin": "Application (Microsoft Graph)", + "value": "AgentCard.Read.All" + }, + { + "description": "Allows the app to create, read, update, and delete all agent cards and manage their skills in your organization's Agent Registry without a signed-in user.", + "displayName": "Read and write all agent cards in Agent Registry", + "id": "ef566853-42d6-45a5-bed9-5ccb82c98b4f", + "origin": "Application (Microsoft Graph)", + "value": "AgentCard.ReadWrite.All" + }, + { + "description": "Allows the app to read and update agent cards that designate the calling app as their manager and manage their skills in your organization's Agent Registry without a signed-in user.", + "displayName": "Read and write managed-by agent cards in Agent Registry", + "id": "9c4a07db-e0c1-4fb0-8e85-dfd8ae3b8201", + "origin": "Application (Microsoft Graph)", + "value": "AgentCard.ReadWrite.ManagedBy" + }, + { + "description": "Allows the app to read all agent card manifests in your organization's Agent Registry without a signed-in user.", + "displayName": "Read all agent card manifests in Agent Registry", + "id": "3ee18438-e6e5-4858-8f1c-d7b723b45213", + "origin": "Application (Microsoft Graph)", + "value": "AgentCardManifest.Read.All" + }, + { + "description": "Allows the app to read and write to all agent card manifests in your organization's Agent Registry without a signed-in user.", + "displayName": "Read and write all agent card manifests in Agent Registry", + "id": "228b1a03-f7ca-4348-b50d-e8a547ab61af", + "origin": "Application (Microsoft Graph)", + "value": "AgentCardManifest.ReadWrite.All" + }, + { + "description": "Allows an application to read or write Customer Key Encryption Tenant Data", + "displayName": "Read or Write Customer Key Encryption Tenant Data", + "id": "e85fa438-368f-4c1d-909a-5760a4e045ae", + "origin": "Delegated (M365DataAtRestEncryption)", + "value": "CustomerKeyTenant-Internal.ReadWrite.All" + }, + { + "description": "Allows the app to read and write agent card manifests that name it as manager in your organization's Agent Registry without a signed-in user.", + "displayName": "Read and write managed-by agent card manifests in Agent Registry", + "id": "77f6034c-52f5-4526-9fa1-d55a67e72cc4", + "origin": "Application (Microsoft Graph)", + "value": "AgentCardManifest.ReadWrite.ManagedBy" + }, + { + "description": "Allows the app to create, read, update, and delete all collections and manage their membership in your organization's Agent Registry without a signed-in user.", + "displayName": "Read and write all collections in Agent Registry, except quarantined and global", + "id": "feb31d7d-a227-4487-898c-e014840d07b3", + "origin": "Application (Microsoft Graph)", + "value": "AgentCollection.ReadWrite.All" + }, + { + "description": "Allows the app to create, read, update, and delete collections that designate the calling app as their manager and manage their membership in your organization's Agent Registry without a signed-in user.", + "displayName": "Read and write managed-by collections in Agent Registry", + "id": "2e0fb698-9996-479f-926b-ce63f4397829", + "origin": "Application (Microsoft Graph)", + "value": "AgentCollection.ReadWrite.ManagedBy" + }, + { + "description": "Allows the app to read the communication configuration of agent blueprints without a signed-in user.", + "displayName": "Read all agent communication configurations", + "id": "eccf3f2d-f81a-4718-95d7-ef4a0c42ac43", + "origin": "Application (Microsoft Graph)", + "value": "AgentCommunicationConfiguration.Read.All" + }, + { + "description": "Allows the app to read and update the communication configuration of agent blueprints without a signed-in user.", + "displayName": "Read and write all agent communication configurations", + "id": "9c72696d-c77b-4d8d-b59e-dfca4792c9ec", + "origin": "Application (Microsoft Graph)", + "value": "AgentCommunicationConfiguration.ReadWrite.All" + }, + { + "description": "Allows the client to create agent identities without a signed-in user, even if the client is not the parent agent identity blueprint.", + "displayName": "Create agent identities without an agent blueprint parent", + "id": "ad25cc1d-84d8-47df-a08e-b34c2e800819", + "origin": "Application (Microsoft Graph)", + "value": "AgentIdentity.Create.All" + }, + { + "description": "Allows the app to create agent identities as the parent agent identity blueprint and fully manage them, including reading, updating, and deleting, without a signed-in user.", + "displayName": "Create and manage agent identities as the parent agent identity blueprint", + "id": "4c390976-b2b7-42e0-9187-c6be3bead001", + "origin": "Application (Microsoft Graph)", + "value": "AgentIdentity.CreateAsManager" + }, + { + "description": "Allows the client to delete and restore agent identities without a signed-in user.", + "displayName": "Delete and restore agent identities", + "id": "5b016f9b-18eb-41d4-869a-66931914d1c8", + "origin": "Application (Microsoft Graph)", + "value": "AgentIdentity.DeleteRestore.All" + }, + { + "description": "Allows the client to enable or disable agent identities without a signed-in user.", + "displayName": "Enable or disable agent identities", + "id": "69ee0943-4fa4-4ec8-8e52-d12e4ea661a3", + "origin": "Application (Microsoft Graph)", + "value": "AgentIdentity.EnableDisable.All" + }, + { + "description": "Allows the app to read all collections and their membership in your organization's Agent Registry without a signed-in user.", + "displayName": "Read all collections in Agent Registry, except quarantined and global", + "id": "e65ee1da-d1d5-467b-bdd0-3e9bb94e6e0c", + "origin": "Application (Microsoft Graph)", + "value": "AgentCollection.Read.All" + }, + { + "description": "Allows the app to read terms of use agreements, without a signed in user.", + "displayName": "Read all terms of use agreements", + "id": "2f3e6f8c-093b-4c57-a58b-ba5ce494a169", + "origin": "Application (Microsoft Graph)", + "value": "Agreement.Read.All" + }, + { + "description": "Allows an application to read or write Microsoft Managed Key Data", + "displayName": "Read or Write Microsoft Managed Key Data", + "id": "ac23b270-1dc3-4ee4-bd56-d7eb945c2332", + "origin": "Application (M365DataAtRestEncryption)", + "value": "MicrosoftManagedKey-Internal.ReadWrite.All" + }, + { + "description": "Allows an application to delete Customer Key Encryption Tenant data", + "displayName": "Delete Customer Key Encryption Tenant Data", + "id": "ef16fcfc-1309-42bc-9c0c-7eb48a9d5f02", + "origin": "Application (M365DataAtRestEncryption)", + "value": "CustomerKeyTenant-Internal.Delete.All" + }, + { + "description": "Allows the uesr to Read and Write Asset Resource, on behalf of the signed-in user.", + "displayName": "Read and Write Asset Resource", + "id": "3e2a4aea-4efd-4851-9af9-de64ccbb354f", + "origin": "Delegated (EASM API)", + "value": "AssetResource.ReadWrite.All" + }, + { + "description": "Allows the user to read DiscoveryGroup, on behalf of the signed-in user.", + "displayName": "Read DiscoveryGroup information", + "id": "034f2362-c46e-4e6b-9905-c23d1b928bce", + "origin": "Delegated (EASM API)", + "value": "DiscoveryGroup.Read.All" + }, + { + "description": "Allows the uesr to Read and Write DiscoveryGroup, on behalf of the signed-in user.", + "displayName": "Read and Write DiscoveryGroup", + "id": "ff3b7cad-a709-4b6f-9304-862191f23ff6", + "origin": "Delegated (EASM API)", + "value": "DiscoveryGroup.ReadWrite.All" + }, + { + "description": "Allows the user to read Discovery Run, on behalf of the signed-in user.", + "displayName": "Read Discovery Run information", + "id": "255d9bfb-b946-4910-a246-17382a9f5759", + "origin": "Delegated (EASM API)", + "value": "DiscoveryRun.Read.All" + }, + { + "description": "Allows the uesr to Read and Write Discovery Run, on behalf of the signed-in user.", + "displayName": "Read and Write Discovery Run", + "id": "1bd45aed-5a43-435e-a2ab-719d6d88f94f", + "origin": "Delegated (EASM API)", + "value": "DiscoveryRun.ReadWrite.All" + }, + { + "description": "Allows the user to read Discovery Templates information, such as workspace, on behalf of the signed-in user.", + "displayName": "Read all Discovery Templates information", + "id": "e63fca22-b70f-468d-8ac6-22be260f12c4", + "origin": "Delegated (EASM API)", + "value": "DiscoveryTemplate.Read.All" + }, + { + "description": "Allows the user to read Filter, on behalf of the signed-in user.", + "displayName": "Read Filter information", + "id": "a24df28a-cd42-4d3c-b5f2-ed5be3dea64c", + "origin": "Delegated (EASM API)", + "value": "Filter.Read.All" + }, + { + "description": "Allows the uesr to Read and Write Filter, on behalf of the signed-in user.", + "displayName": "Read and Write Filter", + "id": "21813d7d-e1db-4a36-827d-5416f07e39ca", + "origin": "Delegated (EASM API)", + "value": "Filter.ReadWrite.All" + }, + { + "description": "Allows the user to read Tag, on behalf of the signed-in user.", + "displayName": "Read Tag information", + "id": "5fa521bf-ccf9-4e9e-9155-63c00c6ace83", + "origin": "Delegated (EASM API)", + "value": "Tag.Read.All" + }, + { + "description": "Allows the uesr to Read and Write Tag, on behalf of the signed-in user.", + "displayName": "Read and Write Tag", + "id": "18688279-8144-4ff4-953a-5174888c57a5", + "origin": "Delegated (EASM API)", + "value": "Tag.ReadWrite.All" + }, + { + "description": "Allows the user to read Task, on behalf of the signed-in user.", + "displayName": "Read Task information", + "id": "1d6a2ebe-7d16-455f-b698-e91b02f66a3b", + "origin": "Delegated (EASM API)", + "value": "Task.Read.All" + }, + { + "description": "Allows the uesr to Read and Write Task, on behalf of the signed-in user.", + "displayName": "Read and Write Task", + "id": "8d25b6b1-b5ca-416d-be6a-900b51f31e18", + "origin": "Delegated (EASM API)", + "value": "Task.ReadWrite.All" + }, + { + "description": "Allows the user to read Workspace, on behalf of the signed-in user.", + "displayName": "Read Workspace information", + "id": "42f3ea82-12d0-4de1-9d0f-463211dcaae6", + "origin": "Delegated (EASM API)", + "value": "Workspace.Read.All" + }, + { + "description": "Allows the uesr to Read and Write Workspace, on behalf of the signed-in user.", + "displayName": "Read and Write Workspace", + "id": "0081ccf6-a13e-4372-ad55-df45ffb7dea5", + "origin": "Delegated (EASM API)", + "value": "Workspace.ReadWrite.All" + }, + { + "description": "Contributors have the ability to make all requests supported by the API, for any subscription", + "displayName": "EdgeZoneRpApi.Contributor", + "id": "453ddc3f-d897-4299-9ca1-6401b3313412", + "origin": "Application (Edge Zone RP API - Prod)", + "value": "EdgeZoneRpApi.Contributor" + }, + { + "description": "Allow the application to access Azure Event Grid on behalf of the signed-in user.", + "displayName": "Access Azure Event Grid", + "id": "d5f985b8-5529-49be-926a-58f4a026c488", + "origin": "Delegated (EventGrid Data API)", + "value": "user_impersonation" + }, + { + "description": "Allows the app to create resources on behalf of eligible users when policy and RBAC permit.", + "displayName": "Act On Behalf Of - Create All", + "id": "b1a7c8e2-1234-4cde-9876-abcdef123456", + "origin": "Application (Fidalgo Dataplane Public)", + "value": "actonbehalfof.create.all" + }, + { + "description": "Allows the user to read Asset Resource, including asset resource, asset audit trails, asset summary and asset snapshot, on behalf of the signed-in user.", + "displayName": "Read Asset Resource information", + "id": "2288f070-b471-48c3-b16b-113c05a3cfbb", + "origin": "Delegated (EASM API)", + "value": "AssetResource.Read.All" + }, + { + "description": "Allows users to access Fidalgo resources.", + "displayName": "access_as_user", + "id": "983c9dc3-3bcf-4538-9937-bab8b1a31d86", + "origin": "Delegated (Fidalgo Dataplane Public)", + "value": "access_as_user" + }, + { + "description": "Dynamics 365 is a business management solution that’s connecting people and processes like never before. From day one, it makes ordering, selling, invoicing, and reporting easier and faster.", + "displayName": "Access as the signed-in user", + "id": "bce0976a-cb0b-473b-8800-84eda9f8e447", + "origin": "Delegated (Dynamics 365 Business Central)", + "value": "user_impersonation" + }, + { + "description": "Grants full access to the Business Central automation APIs. These APIs provide the capability to automate company setup.", + "displayName": "Full access to automation", + "id": "d365bc00-a990-0000-00bc-160000000001", + "origin": "Application (Dynamics 365 Business Central)", + "value": "Automation.ReadWrite.All" + }, + { + "description": "Allows calling debugging APIs", + "displayName": "UserScope-PPE.Debug.All", + "id": "f6c5fb21-2e2e-42f4-a961-ffb661669441", + "origin": "Application (DirectoryLookupService)", + "value": "UserScope-PPE.Debug.All" + }, + { + "description": "This allows app to run ppe tenant userscope in DLS", + "displayName": "UserScope-PPE.ReadWrite.All", + "id": "60f89623-e4c0-4fbd-84c6-a7f1e4108959", + "origin": "Application (DirectoryLookupService)", + "value": "UserScope-PPE.ReadWrite.All" + }, + { + "description": "This allows app to access test tenant data", + "displayName": "Users-Dev.Read.All", + "id": "0f998d84-9f24-404e-ac95-4de6e66be0c3", + "origin": "Application (DirectoryLookupService)", + "value": "Users-Dev.Read.All" + }, + { + "description": "This allows app to access PPE tenant data", + "displayName": "Users-PPE.Read.All", + "id": "15139b67-d076-43b0-bcae-d959c69a8458", + "origin": "Application (DirectoryLookupService)", + "value": "Users-PPE.Read.All" + }, + { + "description": "Allows the application to access the Microsoft.Discovery data-plane services as a user in the organization", + "displayName": "Access Microsoft.Discovery service as a user", + "id": "89262da0-2b68-4c38-a6eb-79d067511de8", + "origin": "Delegated (Discovery data-plane service App)", + "value": "access_as_user" + }, + { + "description": "access dnc as application", + "displayName": "Dnc.Application.All", + "id": "d220ea05-1e7a-47bd-a414-7215922c7cab", + "origin": "Application (DNC)", + "value": "Dnc.Application.All" + }, + { + "description": "access dnc as user", + "displayName": "Dnc.User.All", + "id": "f474b40e-b24b-4cbb-b2bb-aedcae68541d", + "origin": "Delegated (DNC)", + "value": "Dnc.User.All" + }, + { + "description": "Allow the application to access Domain Controller Services on behalf of the signed-in user.", + "displayName": "Access Domain Controller Services", + "id": "dcf6ff68-86c0-44e6-83f2-502f9fdd4b26", + "origin": "Delegated (Domain Controller Services)", + "value": "user_impersonation" + }, + { + "description": "Get orchestration and activity work-items, and post results", + "displayName": "Execute", + "id": "2fd36249-0769-40b9-bf88-00cb556e2594", + "origin": "Delegated (DTS-Authentication)", + "value": "Execute" + }, + { + "description": "Manage orchestrations", + "displayName": "Manage", + "id": "5b8637fc-0900-41a9-94f7-a06f7b393c54", + "origin": "Delegated (DTS-Authentication)", + "value": "Manage" + }, + { + "description": "Allow raising events to orchestrations", + "displayName": "Raise Events", + "id": "c24049f6-d976-4a21-9cae-3b1dbeaf7548", + "origin": "Delegated (DTS-Authentication)", + "value": "RaiseEvents" + }, + { + "description": "Get or list orchestrations, including data payloads", + "displayName": "Read All", + "id": "770cc82b-17ad-4de5-b79d-6ea1a7d2b7f2", + "origin": "Delegated (DTS-Authentication)", + "value": "Read.All" + }, + { + "description": "Get or lists orchestration metadata, but does not allow returning data payloads", + "displayName": "Read Metadata", + "id": "bc864a93-0398-42d0-824a-e48e25b6340d", + "origin": "Delegated (DTS-Authentication)", + "value": "Read.Metadata" + }, + { + "description": "Allow clients to read topic suggestions and update their status", + "displayName": "TopicSuggestion-Internal.ReadWrite", + "id": "e6d57537-d278-4cb8-9f1d-19e173bcf4eb", + "origin": "Delegated (DWEngineV2)", + "value": "TopicSuggestion-Internal.ReadWrite" + }, + { + "description": "Grants full access to the Business Central Admin Center API. This API provides capability to execute administrative tasks for a Business Central tenant.", + "displayName": "Full access to Admin Center API", + "id": "a20fe46f-4f63-4666-8e0e-9b882d90b837", + "origin": "Application (Dynamics 365 Business Central)", + "value": "AdminCenter.ReadWrite.All" + }, + { + "description": "Grants full access to the Business Central web services APIs. These APIs provide the capability to call web services APIs and modify Business Central data.\t", + "displayName": "Full access to web services API", + "id": "a42b0b75-311e-488d-b67e-8fe84f924341", + "origin": "Application (Dynamics 365 Business Central)", + "value": "API.ReadWrite.All" + }, + { + "description": "Dynamics 365 is a business management solution that’s connecting people and processes like never before. From day one, it makes ordering, selling, invoicing, and reporting easier and faster.", + "displayName": "Access according to the application's permissions in Dynamics 365 Business Central", + "id": "3983b928-ed40-4111-bbad-e7910cf234bd", + "origin": "Application (Dynamics 365 Business Central)", + "value": "app_access" + }, + { + "description": "Dynamics 365 is a business management solution that’s connecting people and processes like never before. From day one, it makes ordering, selling, invoicing, and reporting easier and faster.", + "displayName": "Access Dynamics 365 Business Central as the signed-in user", + "id": "2fb13c28-9d89-417f-9af2-ec3065bc16e6", + "origin": "Delegated (Dynamics 365 Business Central)", + "value": "Financials.ReadWrite.All" + }, + { + "description": "Allow the application to access Fiji Storage on behalf of the signed-in user.", + "displayName": "Access Fiji Storage", + "id": "4e5661b3-5a0d-47fc-b7a6-e5b659cfea8b", + "origin": "Delegated (Fiji Storage)", + "value": "user_impersonation" + }, + { + "description": "Allow applications to access Genome RP API on behalf of the signed-in user", + "displayName": "Access Genome RP API as the signed-in user", + "id": "6fc8a23e-a3f1-4b36-9b21-8df0b525bd83", + "origin": "Delegated (Genome RP API)", + "value": "access_as_user" + }, + { + "description": "Allows the app to read all external connections without a signed-in user.", + "displayName": "ExternalConnection.Read.All", + "id": "1ef94f6e-ade0-4b79-9f7f-a72563e3ad60", + "origin": "Application (Graph Connector Service)", + "value": "ExternalConnection.Read.All" + }, + { + "description": "Allow Healthcare Agent Service APIs to process the data sent to it.", + "displayName": "Healthcare Agent Service APIs Process", + "id": "28e41776-5350-4c1e-9ee4-689de5cb6d85", + "origin": "Delegated (Health Safeguards REST API)", + "value": "HealthcareAgentServiceApis.Process" + }, + { + "description": "Invoke Diagnostics", + "displayName": "Invoke Diagnostics", + "id": "8040cfef-0635-4aa6-b099-9f40d6866bbb", + "origin": "Delegated (IAM Supportability)", + "value": "invoke" + }, + { + "description": "Allow the application to access Dynamics 365 Recommendations on behalf of the signed-in user.", + "displayName": "Dynamics 365 Recommendations", + "id": "47e2b85b-5704-487f-be47-74aa52bbe838", + "origin": "Delegated (Intelligent Recommendations Service)", + "value": "user_impersonation" + }, + { + "description": "Allow the application to access Azure Data Explorer on behalf of the signed-in user.", + "displayName": "Access Azure Data Explorer", + "id": "00d678f0-da44-4b12-a6d6-c98bcfd1c5fe", + "origin": "Delegated (KustoService)", + "value": "user_impersonation" + }, + { + "description": "Allow this application to access Log Analytics data on", + "displayName": "Read Log Analytics data", + "id": "e8f6e161-84d0-4cd7-9441-2d46ec9ec3d5", + "origin": "Application (Log Analytics API)", + "value": "Data.Read" + }, + { + "description": "Allow this application to access Log Analytics data on behalf of the user", + "displayName": "Read Log Analytics data as user", + "id": "e8dac03d-d467-4a7e-9293-9cca7df08b31", + "origin": "Delegated (Log Analytics API)", + "value": "Data.Read" + }, + { + "description": "Grants access to execute diagnostics", + "displayName": "Diagnostic.Execute.All", + "id": "2417f470-7490-419f-820a-144a3ba39e79", + "origin": "Delegated (M365 Pillar Diagnostics Service)", + "value": "Diagnostic.Execute.All" + }, + { + "description": "Scope used by TAC", + "displayName": "M365AdminPortal.Centro.Read", + "id": "46d318a0-1196-4991-a1cc-5de005ce9c0a", + "origin": "Delegated (M365 Pillar Diagnostics Service)", + "value": "M365AdminPortal.Centro.Read" + }, + { + "description": "Scope used by EAC", + "displayName": "M365AdminPortal.Settings.Read", + "id": "89100101-60c5-46a6-8c41-85bde6f3a2f0", + "origin": "Delegated (M365 Pillar Diagnostics Service)", + "value": "M365AdminPortal.Settings.Read" + }, + { + "description": "Scope used by SAC", + "displayName": "User.Read", + "id": "f4dd0a95-1d33-479d-90f7-7ef86537471e", + "origin": "Delegated (M365 Pillar Diagnostics Service)", + "value": "User.Read" + }, + { + "description": "Scope used by MAC and Security center", + "displayName": "user_impersonation", + "id": "75a4e338-37d0-450b-928f-b9e546c8a03a", + "origin": "Delegated (M365 Pillar Diagnostics Service)", + "value": "user_impersonation" + }, + { + "description": "This scope allows the holder to invoke customer onboarding scenarios", + "displayName": "CustomerKeyOnboarding-Internal.ReadWrite.All", + "id": "837e1541-7f8b-4fcf-a215-12fe252cd3e2", + "origin": "Application (M365DataAtRestEncryption)", + "value": "CustomerKeyOnboarding-Internal.ReadWrite.All" + }, + { + "description": "Allows application to Wrap or Unwrap data using CustomerKey Encryption Policy", + "displayName": "Wrap or Unwrap data using CustomerKey Encryption Policy", + "id": "42e29572-777c-48b4-bdaf-c63df6da65d3", + "origin": "Application (M365DataAtRestEncryption)", + "value": "CustomerKeyPolicy.WrapUnwrap" + }, + { + "description": "Allows application to Wrap or Unwrap data using CustomerKey Encryption Policy.", + "displayName": "Wrap or Unwrap data using CustomerKey Encryption Policy", + "id": "f8a2c9d1-31d8-4a2e-900a-4105aaf50280", + "origin": "Application (M365DataAtRestEncryption)", + "value": "CustomerKeyPolicy-Internal.WrapUnwrap.All" + }, + { + "description": "Allows an application to Read or Write resource application registration data for their application", + "displayName": "Read or Write resource application registration data for their application", + "id": "6bbe32a7-dfd3-4ab5-877a-62b4caa98d4d", + "origin": "Application (M365DataAtRestEncryption)", + "value": "CustomerKeyRegistration.ReadWrite.All" + }, + { + "description": "Allows an application to Read or Write resource application registration data for any application", + "displayName": "Read or Write resource application registration data for any application", + "id": "d747c012-3ada-46fa-9807-9c2534d06a14", + "origin": "Application (M365DataAtRestEncryption)", + "value": "CustomerKeyRegistration-Internal.ReadWrite.All" + }, + { + "description": "Allows an application to read Customer Key Encryption Tenant Data", + "displayName": "Read Customer Key Encryption Tenant Data", + "id": "54f050d4-7d90-4abe-97bd-15325467480e", + "origin": "Application (M365DataAtRestEncryption)", + "value": "CustomerKeyTenant.Read.All" + }, + { + "description": "Access the Azure Health Bot", + "displayName": "AzureHealthBot.PortalAccess", + "id": "4d148aae-170b-417a-9d06-6978383e329a", + "origin": "Delegated (Health Bot Portal V4)", + "value": "AzureHealthBot.PortalAccess" + }, + { + "description": "Allow the app to read webhook connection details, on behalf of the signed-in user.", + "displayName": "Read webhook connection details", + "id": "a3028c9a-803b-47f3-be20-0f9a6c25a813", + "origin": "Delegated (Graph Connector Service)", + "value": "WebhookData.Read.All" + }, + { + "description": "Allows the app to read and write external items on behalf of a signed-in user. The signed-in user must be an administrator. The app can only read external items of the connection that it is authorized to.", + "displayName": "ExternalItem.ReadWrite.OwnedBy", + "id": "13d477ed-f4cf-4cc0-9678-80517234742e", + "origin": "Delegated (Graph Connector Service)", + "value": "ExternalItem.ReadWrite.OwnedBy" + }, + { + "description": "Allows the app to read and write all external items on behalf of a signed-in user. The signed-in user must be an administrator.", + "displayName": "ExternalItem.ReadWrite.All", + "id": "565c16dd-b86f-4528-9d73-af8687391f02", + "origin": "Delegated (Graph Connector Service)", + "value": "ExternalItem.ReadWrite.All" + }, + { + "description": "Allows the app to read external connections without a signed-in user. The app can only read external connections that it is authorized to. ", + "displayName": "ExternalConnection.Read.OwnedBy", + "id": "6ed7b42a-d211-4a23-9d86-4ad9bb3cd8c9", + "origin": "Application (Graph Connector Service)", + "value": "ExternalConnection.Read.OwnedBy" + }, + { + "description": "Allows the app to read and write all external connections without a signed-in user.", + "displayName": "ExternalConnection.ReadWrite.All", + "id": "296c3066-18b3-4977-9e2b-9d2ca1fda62c", + "origin": "Application (Graph Connector Service)", + "value": "ExternalConnection.ReadWrite.All" + }, + { + "description": "Allows the app to read and write external connections without a signed-in user. The app can only read and write external connections that it is authorized to, or it can create new external connections. ", + "displayName": "ExternalConnection.ReadWrite.OwnedBy", + "id": "f4601885-7fd6-4ade-9175-09e03e5bc85c", + "origin": "Application (Graph Connector Service)", + "value": "ExternalConnection.ReadWrite.OwnedBy" + }, + { + "description": "Allows the app to read settings of external connections without a signed-in user. The app can only read settings of connections that it is authorized to. ", + "displayName": "ExternalConnectionSetting.Read.OwnedBy", + "id": "e5f41e81-b3e3-4345-8fc2-33254784c76b", + "origin": "Application (Graph Connector Service)", + "value": "ExternalConnectionSetting.Read.OwnedBy" + }, + { + "description": "Allows the app to read and write settings of external connections without a signed-in user. The app can only read settings of connections that it is authorized to. ", + "displayName": "ExternalConnectionSetting.ReadWrite.OwnedBy", + "id": "a82b69a1-5441-4adf-b26d-0fe741adab90", + "origin": "Application (Graph Connector Service)", + "value": "ExternalConnectionSetting.ReadWrite.OwnedBy" + }, + { + "description": "Allows the app to read all external items without a signed-in user.", + "displayName": "ExternalItem.Read.All", + "id": "89e9f68a-2eb2-49a3-94fe-0fd4b162663b", + "origin": "Application (Graph Connector Service)", + "value": "ExternalItem.Read.All" + }, + { + "description": "Allows the app to read external items without a signed-in user. The app can only read items of the connection that it is authorized to.", + "displayName": "ExternalItem.Read.OwnedBy", + "id": "2d39c17b-ba50-4d0b-9b85-6bc10574bcdb", + "origin": "Application (Graph Connector Service)", + "value": "ExternalItem.Read.OwnedBy" + }, + { + "description": "Allows the app to read and write all external items without a signed-in user.", + "displayName": "ExternalItem.ReadWrite.All", + "id": "38c3d6ee-69ee-422f-b954-e17819665354", + "origin": "Application (Graph Connector Service)", + "value": "ExternalItem.ReadWrite.All" + }, + { + "description": "Allows the app to read and write external items without a signed-in user. The app can only read external items of the connection that it is authorized to.", + "displayName": "ExternalItem.ReadWrite.OwnedBy", + "id": "c01869db-7dda-4be3-a224-eea18a6e6beb", + "origin": "Application (Graph Connector Service)", + "value": "ExternalItem.ReadWrite.OwnedBy" + }, + { + "description": "Allows the app to read all external connections on behalf of a signed-in user. The signed-in user must be an administrator.", + "displayName": "ExternalConnection.Read.All", + "id": "feac6de7-1991-4608-8905-0bed2fd3f86f", + "origin": "Delegated (Graph Connector Service)", + "value": "ExternalConnection.Read.All" + }, + { + "description": "Allows the app to read external connections on behalf of a signed-in user. The signed-in user must be an administrator. The app can only read external connections that it is authorized to. ", + "displayName": "ExternalConnection.Read.OwnedBy", + "id": "039455a3-0a80-4713-841a-f87a5d43bee9", + "origin": "Delegated (Graph Connector Service)", + "value": "ExternalConnection.Read.OwnedBy" + }, + { + "description": "Allows the app to read and write all external connections on behalf of a signed-in user. The signed-in user must be an administrator.", + "displayName": "ExternalConnection.ReadWrite.All", + "id": "d44774bd-e26c-43b1-996d-51bb90a9078e", + "origin": "Delegated (Graph Connector Service)", + "value": "ExternalConnection.ReadWrite.All" + }, + { + "description": "Allows the app to read and write external connections on behalf of a signed-in user. The signed-in user must be an administrator. The app can only read and write external connections that it is authorized to, or it can create new external connections. ", + "displayName": "ExternalConnection.ReadWrite.OwnedBy", + "id": "238a47c3-0105-47ae-804f-44a011bcd9d7", + "origin": "Delegated (Graph Connector Service)", + "value": "ExternalConnection.ReadWrite.OwnedBy" + }, + { + "description": "Allows the app to read settings of external connections on behalf of a signed-in user. The signed-in user must be an administrator. The app can only read settings of connections that it is authorized to. ", + "displayName": "ExternalConnectionSetting.Read.OwnedBy", + "id": "874ea7d3-6542-4c86-9dea-1a9165a302e8", + "origin": "Delegated (Graph Connector Service)", + "value": "ExternalConnectionSetting.Read.OwnedBy" + }, + { + "description": "Allows the app to read and write settings of external connections on behalf of a signed-in user. The signed-in user must be an administrator. The app can only read settings of connections that it is authorized to.", + "displayName": "ExternalConnectionSetting.ReadWrite.OwnedBy", + "id": "1d1ee9dd-444c-4646-a36a-db2a1feee3a1", + "origin": "Delegated (Graph Connector Service)", + "value": "ExternalConnectionSetting.ReadWrite.OwnedBy" + }, + { + "description": "Allows the app to read external items on behalf of a signed-in user. The signed-in user must be an administrator. The app can only read items of the connection that it is authorized to.", + "displayName": "ExternalItem.Read.OwnedBy", + "id": "7dd8483d-ffd6-4c0c-a2be-88d3aea446d8", + "origin": "Delegated (Graph Connector Service)", + "value": "ExternalItem.Read.OwnedBy" + }, + { + "description": "Allows the app to read all webhook connection details without a signed-in user.", + "displayName": "WebhookData.Read.All", + "id": "875b7cce-8b8e-4f69-8744-0d0d285c25f3", + "origin": "Application (Graph Connector Service)", + "value": "WebhookData.Read.All" + }, + { + "description": "This allows app to run test tenant userscope in DLS", + "displayName": "UserScope-Dev.ReadWrite.All", + "id": "dc532015-4941-4351-b852-8781cf87c6e5", + "origin": "Application (DirectoryLookupService)", + "value": "UserScope-Dev.ReadWrite.All" + }, + { + "description": "Allows the app to read and write terms of use agreements, without a signed in user.", + "displayName": "Read and write all terms of use agreements", + "id": "c9090d00-6101-42f0-a729-c41074260d47", + "origin": "Application (Microsoft Graph)", + "value": "Agreement.ReadWrite.All" + }, + { + "description": "Allows the app to read all AI enterprise interactions.", + "displayName": "Read all AI enterprise interactions.", + "id": "839c90ab-5771-41ee-aef8-a562e8487c1e", + "origin": "Application (Microsoft Graph)", + "value": "AiEnterpriseInteraction.Read.All" + }, + { + "description": "Allows the app to process and evaluate content for data security, governance and compliance outcomes at tenant scope.", + "displayName": "Process content for data security, governance and compliance", + "id": "5ad511bf-571c-4ef6-8c3c-85b94b85df98", + "origin": "Application (Microsoft Graph)", + "value": "Content.Process.All" + }, + { + "description": "Allows the app to process and evaluate content for data security, governance and compliance outcomes for a user.", + "displayName": "Process content for data security, governance and compliance", + "id": "24ceb246-ad29-4680-90b4-3e91ffad15eb", + "origin": "Application (Microsoft Graph)", + "value": "Content.Process.User" + }, + { + "description": "c", + "displayName": "Read contents activity audit log from the audit store.", + "id": "368425e7-6954-4f5a-9d92-90b75bd580c9", + "origin": "Application (Microsoft Graph)", + "value": "ContentActivity.Read" + }, + { + "description": "Allows the application to upload bulk contents activity audit logs to the audit store.", + "displayName": "Upload content activity audit logs to the audit store.", + "id": "2932e07a-3c29-44e4-bb36-6d0fc176387f", + "origin": "Application (Microsoft Graph)", + "value": "ContentActivity.Write" + }, + { + "description": "Allows the app to read available properties on contracts, without a signed-in user.", + "displayName": "Read contracts", + "id": "f9af4646-98b0-4e9d-a53e-40d4f6452fc4", + "origin": "Application (Microsoft Graph)", + "value": "Contracts.Read.All" + }, + { + "description": "Allows the app to read packages information without a signed-in user.", + "displayName": "Read all packages information", + "id": "72f0655d-6228-4ddc-8e1b-164973b9213b", + "origin": "Application (Microsoft Graph)", + "value": "CopilotPackages.Read.All" + }, + { + "description": "Allows the app to read and update packages information without a signed-in user.", + "displayName": "Read and update all packages information", + "id": "ed31732f-9495-47ed-ba3b-4ed0948c1c64", + "origin": "Application (Microsoft Graph)", + "value": "CopilotPackages.ReadWrite.All" + }, + { + "description": "Allows the app to read Copilot policy settings for the organization, without a signed-in user.", + "displayName": "Read Copilot policy settings", + "id": "556d5e2e-1081-4452-8147-26c3a1b06f58", + "origin": "Application (Microsoft Graph)", + "value": "CopilotPolicySettings.Read" + }, + { + "description": "Allows the app to read and write Copilot policy settings for the organization, without a signed-in user.", + "displayName": "Read and write Copilot policy settings", + "id": "cc147c17-b8e8-4d3f-9f94-aa9e279a079a", + "origin": "Application (Microsoft Graph)", + "value": "CopilotPolicySettings.ReadWrite" + }, + { + "description": "Allows the application to obtain basic tenant information about another target tenant within the Azure AD ecosystem without a signed-in user.", + "displayName": "Read cross-tenant basic information", + "id": "cac88765-0581-4025-9725-5ebc13f729ee", + "origin": "Application (Microsoft Graph)", + "value": "CrossTenantInformation.ReadBasic.All" + }, + { + "description": "Allows the application to list and query any shared user profile information associated with the current tenant without a signed-in user. It also permits the application to export external user data (e.g. customer content or system-generated logs), for any user associated with the current tenant without a signed-in user.", + "displayName": "Read all shared cross-tenant user profiles and export their data", + "id": "8b919d44-6192-4f3d-8a3b-f86f8069ae3c", + "origin": "Application (Microsoft Graph)", + "value": "CrossTenantUserProfileSharing.Read.All" + }, + { + "description": "Allows the application to list and query any shared user profile information associated with the current tenant without a signed-in user. It also permits the application to export and remove external user data (e.g. customer content or system-generated logs), for any user associated with the current tenant without a signed-in user.", + "displayName": "Read all shared cross-tenant user profiles and export or delete their data", + "id": "306785c5-c09b-4ba0-a4ee-023f3da165cb", + "origin": "Application (Microsoft Graph)", + "value": "CrossTenantUserProfileSharing.ReadWrite.All" + }, + { + "description": "Allows the app to read your organization's custom authentication extensions without a signed-in user.", + "displayName": "Read all custom authentication extensions", + "id": "88bb2658-5d9e-454f-aacd-a3933e079526", + "origin": "Application (Microsoft Graph)", + "value": "CustomAuthenticationExtension.Read.All" + }, + { + "description": "Allows the app to read or write your organization's custom authentication extensions without a signed-in user.", + "displayName": "Read and write all custom authentication extensions", + "id": "c2667967-7050-4e7e-b059-4cbbb3811d03", + "origin": "Application (Microsoft Graph)", + "value": "CustomAuthenticationExtension.ReadWrite.All" + }, + { + "description": "Allows custom authentication extensions associated with the app to receive HTTP requests triggered by an authentication event. The request can include information about a user, client and resource service principals, and other information about the authentication.", + "displayName": "Receive custom authentication extension HTTP requests", + "id": "214e810f-fda8-4fd7-a475-29461495eb00", + "origin": "Application (Microsoft Graph)", + "value": "CustomAuthenticationExtension.Receive.Payload" + }, + { + "description": "Allows the app to read custom detection rules without a signed-in user.", + "displayName": "Read all custom detection rules", + "id": "673a007a-9e0f-4c97-b066-3c0164486909", + "origin": "Application (Microsoft Graph)", + "value": "CustomDetection.Read.All" + }, + { + "description": "Allows the app to read and write custom detection rules without a signed-in user.", + "displayName": "Read and write all custom detection rules", + "id": "e0fd9c8d-a12e-4cc9-9827-20c8c3cd6fb8", + "origin": "Application (Microsoft Graph)", + "value": "CustomDetection.ReadWrite.All" + }, + { + "description": "Allows the app to update the on-premises sync behavior of all contacts in all mailboxes without a signed-in user.", + "displayName": "Read and update the on-premises sync behavior of contacts", + "id": "c8948c23-e66b-42db-83fd-770b71ab78d2", + "origin": "Application (Microsoft Graph)", + "value": "Contacts-OnPremisesSyncBehavior.ReadWrite.All" + }, + { + "description": "Allows the app to read custom security attribute assignments for all principals in the tenant without a signed in user.", + "displayName": "Read custom security attribute assignments", + "id": "3b37c5a4-1226-493d-bec3-5d6c6b866f3f", + "origin": "Application (Microsoft Graph)", + "value": "CustomSecAttributeAssignment.Read.All" + }, + { + "description": "Allows the app to read app consent requests and approvals, and deny or approve those requests without a signed-in user.", + "displayName": "Read and write all consent requests", + "id": "9f1b81a7-0223-4428-bfa4-0bcb5535f27d", + "origin": "Application (Microsoft Graph)", + "value": "ConsentRequest.ReadWrite.All" + }, + { + "description": "Allows the app to read names and members of all one-to-one and group chats in Microsoft Teams where the associated Teams application is installed, without a signed-in user.", + "displayName": "Read names and members of all chat threads where the associated Teams application is installed.", + "id": "818ba5bd-5b3e-4fe0-bbe6-aa4686669073", + "origin": "Application (Microsoft Graph)", + "value": "Chat.ReadBasic.WhereInstalled" + }, + { + "description": "Allows an app to read and write all chat messages in Microsoft Teams, without a signed-in user.", + "displayName": "Read and write all chat messages", + "id": "294ce7c9-31ba-490a-ad7d-97a7d075e4ed", + "origin": "Application (Microsoft Graph)", + "value": "Chat.ReadWrite.All" + }, + { + "description": "Allows the app to read and write all chat messages in Microsoft Teams for chats where the associated Teams application is installed, without a signed-in user.", + "displayName": "Read and write all chat messages for chats where the associated Teams application is installed.", + "id": "ad73ce80-f3cd-40ce-b325-df12c33df713", + "origin": "Application (Microsoft Graph)", + "value": "Chat.ReadWrite.WhereInstalled" + }, + { + "description": "Allows the app to update Microsoft Teams 1-to-1 or group chat messages by patching a set of Data Loss Prevention (DLP) policy violation properties to handle the output of DLP processing.", + "displayName": "Flag chat messages for violating policy", + "id": "7e847308-e030-4183-9899-5235d7270f58", + "origin": "Application (Microsoft Graph)", + "value": "Chat.UpdatePolicyViolation.All" + }, + { + "description": "Read the members of all chats, without a signed-in user.", + "displayName": "Read the members of all chats", + "id": "a3410be2-8e48-4f32-8454-c29a7465209d", + "origin": "Application (Microsoft Graph)", + "value": "ChatMember.Read.All" + }, + { + "description": "Allows the app to read the members of all chats where the associated Teams application is installed, without a signed-in user.", + "displayName": "Read the members of all chats where the associated Teams application is installed.", + "id": "93e7c9e4-54c5-4a41-b796-f2a5adaacda7", + "origin": "Application (Microsoft Graph)", + "value": "ChatMember.Read.WhereInstalled" + }, + { + "description": "Add and remove members from all chats, without a signed-in user.", + "displayName": "Add and remove members from all chats", + "id": "57257249-34ce-4810-a8a2-a03adf0c5693", + "origin": "Application (Microsoft Graph)", + "value": "ChatMember.ReadWrite.All" + }, + { + "description": "Allows the app to add and remove members from all chats where the associated Teams application is installed, without a signed-in user.", + "displayName": "Add and remove members from all chats where the associated Teams application is installed.", + "id": "e32c2cd9-0124-4e44-88fc-772cd98afbdb", + "origin": "Application (Microsoft Graph)", + "value": "ChatMember.ReadWrite.WhereInstalled" + }, + { + "description": "Allows the app to read all one-to-one and group chats messages in Microsoft Teams, without a signed-in user.", + "displayName": "Read all chat messages", + "id": "b9bb2381-47a4-46cd-aafb-00cb12f68504", + "origin": "Application (Microsoft Graph)", + "value": "ChatMessage.Read.All" + }, + { + "description": "Allows the app to read all details of discovered cloud apps in the organization, without a signed-in user.", + "displayName": "Read all discovered cloud applications data", + "id": "64a59178-dad3-4673-89db-84fdcd622fec", + "origin": "Application (Microsoft Graph)", + "value": "CloudApp-Discovery.Read.All" + }, + { + "description": "Allows the app to read the properties of Cloud PCs, without a signed-in user.", + "displayName": "Read Cloud PCs", + "id": "a9e09520-8ed4-4cde-838e-4fdea192c227", + "origin": "Application (Microsoft Graph)", + "value": "CloudPC.Read.All" + }, + { + "description": "Allows the app to read and write the properties of Cloud PCs, without a signed-in user.", + "displayName": "Read and write Cloud PCs", + "id": "3b4349e1-8cf5-45a3-95b7-69d1751d3e6a", + "origin": "Application (Microsoft Graph)", + "value": "CloudPC.ReadWrite.All" + }, + { + "description": "Allows the app to list Viva Engage communities, and to read their properties without a signed-in user.", + "displayName": "Read all Viva Engage communities", + "id": "407f0cce-3212-441f-9f55-3bc91342cf86", + "origin": "Application (Microsoft Graph)", + "value": "Community.Read.All" + }, + { + "description": "Allows the app to create Viva Engage communities, read all community properties, update community properties, and delete communities without a signed-in user.", + "displayName": "Read and write all Viva Engage communities", + "id": "35d59e32-eab5-4553-9345-abb62b4c703c", + "origin": "Application (Microsoft Graph)", + "value": "Community.ReadWrite.All" + }, + { + "description": "Allows the app to read all Configuration Monitoring entities, without a signed-in user.", + "displayName": "Read all Configuration Monitoring entities", + "id": "aca929ec-9830-44dc-bda1-85cf938aaa95", + "origin": "Application (Microsoft Graph)", + "value": "ConfigurationMonitoring.Read.All" + }, + { + "description": "Allows the app to read and write all Configuration Monitoring entities, without a signed-in user.", + "displayName": "Read and write all Configuration Monitoring entities", + "id": "cfa85bfb-2ee8-4e13-8e7f-489e57a015a1", + "origin": "Application (Microsoft Graph)", + "value": "ConfigurationMonitoring.ReadWrite.All" + }, + { + "description": "Allows the app to read consent requests and approvals without a signed-in user.", + "displayName": "Read all consent requests", + "id": "1260ad83-98fb-4785-abbb-d6cc1806fd41", + "origin": "Application (Microsoft Graph)", + "value": "ConsentRequest.Read.All" + }, + { + "description": "Allows the app to read and write custom security attribute assignments for all principals in the tenant without a signed in user.", + "displayName": "Read and write custom security attribute assignments", + "id": "de89b5e4-5b8f-48eb-8925-29c2b33bd8bd", + "origin": "Application (Microsoft Graph)", + "value": "CustomSecAttributeAssignment.ReadWrite.All" + }, + { + "description": "Allows the app to read all audit logs for events that contain information about custom security attributes, without a signed-in user.", + "displayName": "Read all custom security attribute audit logs", + "id": "2a4f026d-e829-4e84-bdbf-d981a2703059", + "origin": "Application (Microsoft Graph)", + "value": "CustomSecAttributeAuditLogs.Read.All" + }, + { + "description": "Allows the app to read custom security attribute definitions for the tenant without a signed in user.", + "displayName": "Read custom security attribute definitions", + "id": "b185aa14-d8d2-42c1-a685-0f5596613624", + "origin": "Application (Microsoft Graph)", + "value": "CustomSecAttributeDefinition.Read.All" + }, + { + "description": "Allows the app to read the properties of devices managed by Microsoft Intune, without a signed-in user.", + "displayName": "Read Microsoft Intune devices", + "id": "2f51be20-0bb4-4fed-bf7b-db946066c75e", + "origin": "Application (Microsoft Graph)", + "value": "DeviceManagementManagedDevices.Read.All" + }, + { + "description": "Allows the app to read and write the properties of devices managed by Microsoft Intune, without a signed-in user. Does not allow high impact operations such as remote wipe and password reset on the device’s owner", + "displayName": "Read and write Microsoft Intune devices", + "id": "243333ab-4d21-40cb-a475-36241daa0842", + "origin": "Application (Microsoft Graph)", + "value": "DeviceManagementManagedDevices.ReadWrite.All" + }, + { + "description": "Allows the app to read the properties relating to the Microsoft Intune Role-Based Access Control (RBAC) settings, without a signed-in user.", + "displayName": "Read Microsoft Intune RBAC settings", + "id": "58ca0d9a-1575-47e1-a3cb-007ef2e4583b", + "origin": "Application (Microsoft Graph)", + "value": "DeviceManagementRBAC.Read.All" + }, + { + "description": "Allows the app to read and write the properties relating to the Microsoft Intune Role-Based Access Control (RBAC) settings, without a signed-in user.", + "displayName": "Read and write Microsoft Intune RBAC settings", + "id": "e330c4f0-4170-414e-a55a-2f022ec2b57b", + "origin": "Application (Microsoft Graph)", + "value": "DeviceManagementRBAC.ReadWrite.All" + }, + { + "description": "Allows the app to read Microsoft Intune device compliance scripts, device management scripts, device shell scripts, device custom attribute shell scripts and device health scripts, without a signed-in user.", + "displayName": "Read Microsoft Intune Scripts", + "id": "c7a5be92-2b3d-4540-8a67-c96dcaae8b43", + "origin": "Application (Microsoft Graph)", + "value": "DeviceManagementScripts.Read.All" + }, + { + "description": "Allows the app to read and write Microsoft Intune device compliance scripts, device management scripts, device shell scripts, device custom attribute shell scripts and device health scripts, without a signed-in user.", + "displayName": "Read and write Microsoft Intune Scripts", + "id": "9255e99d-faf5-445e-bbf7-cb71482737c4", + "origin": "Application (Microsoft Graph)", + "value": "DeviceManagementScripts.ReadWrite.All" + }, + { + "description": "Allows the app to read Microsoft Intune service properties including device enrollment and third party service connection configuration, without a signed-in user.", + "displayName": "Read Microsoft Intune configuration", + "id": "06a5fe6d-c49d-46a7-b082-56b1b14103c7", + "origin": "Application (Microsoft Graph)", + "value": "DeviceManagementServiceConfig.Read.All" + }, + { + "description": "Allows the app to read and write Microsoft Intune service properties including device enrollment and third party service connection configuration, without a signed-in user.", + "displayName": "Read and write Microsoft Intune configuration", + "id": "5ac13192-7ace-4fcf-b828-1a26f28068ee", + "origin": "Application (Microsoft Graph)", + "value": "DeviceManagementServiceConfig.ReadWrite.All" + }, + { + "description": "Allows the app to create device templates. The app is marked as owner of the created device template. As a member of owners, the app will be allowed to manage devices created from the template.", + "displayName": "Create device template", + "id": "abf6441f-0772-4932-96e7-0191478dd73a", + "origin": "Application (Microsoft Graph)", + "value": "DeviceTemplate.Create" + }, + { + "description": "Allows the app to read all device templates, without a signed-in user.", + "displayName": "Read all device templates", + "id": "dd9febb5-0c6d-419f-b256-3afe12c6adeb", + "origin": "Application (Microsoft Graph)", + "value": "DeviceTemplate.Read.All" + }, + { + "description": "Allows the app to create, read, update and delete any device template, without a signed-in user. It also allows the app to add or remove owners on any device template.", + "displayName": "Read and write all device templates", + "id": "9fadb66e-6421-4744-aede-4ab6fb98a884", + "origin": "Application (Microsoft Graph)", + "value": "DeviceTemplate.ReadWrite.All" + }, + { + "description": "Allows the app to read data in your organization's directory, such as users, groups and apps, without a signed-in user.", + "displayName": "Read directory data", + "id": "7ab1d382-f21e-4acd-a863-ba3e13f7da61", + "origin": "Application (Microsoft Graph)", + "value": "Directory.Read.All" + }, + { + "description": "Allows the app to read and write data in your organization's directory, such as users, and groups, without a signed-in user. Does not allow user or group deletion.", + "displayName": "Read and write directory data", + "id": "19dbc75e-c2e2-444c-a770-ec69d8559fc7", + "origin": "Application (Microsoft Graph)", + "value": "Directory.ReadWrite.All" + }, + { + "description": "Allows the app to read all Azure AD recommendations, without a signed-in user.", + "displayName": "Read all Azure AD recommendations", + "id": "ae73097b-cb2a-4447-b064-5d80f6093921", + "origin": "Application (Microsoft Graph)", + "value": "DirectoryRecommendations.Read.All" + }, + { + "description": "Allows the app to read and update all Azure AD recommendations, without a signed-in user.", + "displayName": "Read and update all Azure AD recommendations", + "id": "0e9eea12-4f01-45f6-9b8d-3ea4c8144158", + "origin": "Application (Microsoft Graph)", + "value": "DirectoryRecommendations.ReadWrite.All" + }, + { + "description": "Allows the app to read all domain properties without a signed-in user.", + "displayName": "Read domains", + "id": "dbb9058a-0e50-45d7-ae91-66909b5d4664", + "origin": "Application (Microsoft Graph)", + "value": "Domain.Read.All" + }, + { + "description": "Allows the app to read and write all domain properties without a signed in user. Also allows the app to add, verify and remove domains.", + "displayName": "Read and write domains", + "id": "7e05723c-0bb0-42da-be95-ae9f08a6e53c", + "origin": "Application (Microsoft Graph)", + "value": "Domain.ReadWrite.All" + }, + { + "description": "Allows the app to perform remote high impact actions such as wiping the device or resetting the passcode on devices managed by Microsoft Intune, without a signed-in user.", + "displayName": "Perform user-impacting remote actions on Microsoft Intune devices", + "id": "5b07b0dd-2377-4e44-a38d-703f09a0dc3c", + "origin": "Application (Microsoft Graph)", + "value": "DeviceManagementManagedDevices.PrivilegedOperations.All" + }, + { + "description": "Allows the app to read and write properties of Microsoft Intune-managed deployment plans and their ring configurations, without a signed-in user.", + "displayName": "Read and write Microsoft Intune Deployment Plans", + "id": "68356fd1-028d-4ce3-b724-241dec11127a", + "origin": "Application (Microsoft Graph)", + "value": "DeviceManagementDeploymentPlans.ReadWrite.All" + }, + { + "description": "Allows the app to read properties of Microsoft Intune-managed deployment plans and their ring configurations, without a signed-in user.", + "displayName": "Read Microsoft Intune Deployment Plans", + "id": "c5825671-0390-4bf2-b99b-80496fd4b673", + "origin": "Application (Microsoft Graph)", + "value": "DeviceManagementDeploymentPlans.Read.All" + }, + { + "description": "Allows the app to read and write properties of Microsoft Intune-managed device configuration and device compliance policies and their assignment to groups, without a signed-in user.", + "displayName": "Read and write Microsoft Intune device configuration and policies", + "id": "9241abd9-d0e6-425a-bd4f-47ba86e767a4", + "origin": "Application (Microsoft Graph)", + "value": "DeviceManagementConfiguration.ReadWrite.All" + }, + { + "description": "Allows the app to read and write custom security attribute definitions for the tenant without a signed in user.", + "displayName": "Read and write custom security attribute definitions", + "id": "12338004-21f4-4896-bf5e-b75dfaf1016d", + "origin": "Application (Microsoft Graph)", + "value": "CustomSecAttributeDefinition.ReadWrite.All" + }, + { + "description": "Allows the app to read the provisioning configuration of all active custom security attributes without a signed-in user.", + "displayName": "Read the provisioning configuration of all active custom security attributes", + "id": "9fd1f8bf-a443-4df6-bc2a-5d00c5ec7828", + "origin": "Application (Microsoft Graph)", + "value": "CustomSecAttributeProvisioning.Read.All" + }, + { + "description": "Allows the app to read and edit the provisioning configuration of all active custom security attributes without a signed-in user.", + "displayName": "Read and edit the provisioning configuration of all active custom security attributes", + "id": "1db69e9c-8d0a-498d-a5df-11fd0b68ceab", + "origin": "Application (Microsoft Graph)", + "value": "CustomSecAttributeProvisioning.ReadWrite.All" + }, + { + "description": "Read custom tags data, without a signed-in user", + "displayName": "Read all custom tags data", + "id": "ab8a5872-7c88-47a6-8141-7becce939190", + "origin": "Application (Microsoft Graph)", + "value": "CustomTags.Read.All" + }, + { + "description": "Read and write custom tags data, without a signed-in user", + "displayName": "Read and write custom tags data", + "id": "2f503208-e509-4e39-974c-8cc16e5785c9", + "origin": "Application (Microsoft Graph)", + "value": "CustomTags.ReadWrite.All" + }, + { + "description": "Allows the app to read details of delegated admin relationships with customers like access details (that includes roles) and the duration as well as specific role assignments to security groups without a signed-in user.", + "displayName": "Read Delegated Admin relationships with customers", + "id": "f6e9e124-4586-492f-adc0-c6f96e4823fd", + "origin": "Application (Microsoft Graph)", + "value": "DelegatedAdminRelationship.Read.All" + }, + { + "description": "Allows the app to manage (create-update-terminate) Delegated Admin relationships with customers and role assignments to security groups for active Delegated Admin relationships without a signed-in user.", + "displayName": "Manage Delegated Admin relationships with customers", + "id": "cc13eba4-8cd8-44c6-b4d4-f93237adce58", + "origin": "Application (Microsoft Graph)", + "value": "DelegatedAdminRelationship.ReadWrite.All" + }, + { + "description": "Allows the app to read all delegated permission grants, without a signed-in user.", + "displayName": "Read all delegated permission grants", + "id": "81b4724a-58aa-41c1-8a55-84ef97466587", + "origin": "Application (Microsoft Graph)", + "value": "DelegatedPermissionGrant.Read.All" + }, + { + "description": "Read names and members of all one-to-one and group chats in Microsoft Teams, without a signed-in user.", + "displayName": "Read names and members of all chat threads", + "id": "b2e060da-3baf-4687-9611-f4ebc0f0cbde", + "origin": "Application (Microsoft Graph)", + "value": "Chat.ReadBasic.All" + }, + { + "description": "Allows the app to manage permission grants for delegated permissions exposed by any API (including Microsoft Graph), without a signed-in user.", + "displayName": "Manage all delegated permission grants", + "id": "8e8e4742-1d95-4f68-9d56-6ee75648c72a", + "origin": "Application (Microsoft Graph)", + "value": "DelegatedPermissionGrant.ReadWrite.All" + }, + { + "description": "Allows the app to read device local credential properties including passwords, without a signed-in user.", + "displayName": "Read device local credential passwords", + "id": "884b599e-4d48-43a5-ba94-15c414d00588", + "origin": "Application (Microsoft Graph)", + "value": "DeviceLocalCredential.Read.All" + }, + { + "description": "Allows the app to read device local credential properties excluding passwords, without a signed-in user.", + "displayName": "Read device local credential properties", + "id": "db51be59-e728-414b-b800-e0f010df1a79", + "origin": "Application (Microsoft Graph)", + "value": "DeviceLocalCredential.ReadBasic.All" + }, + { + "description": "Allows the app to read the properties, group assignments and status of apps, app configurations and app protection policies managed by Microsoft Intune, without a signed-in user.", + "displayName": "Read Microsoft Intune apps", + "id": "7a6ee1e7-141e-4cec-ae74-d9db155731ff", + "origin": "Application (Microsoft Graph)", + "value": "DeviceManagementApps.Read.All" + }, + { + "description": "Allows the app to read and write the properties, group assignments and status of apps, app configurations and app protection policies managed by Microsoft Intune, without a signed-in user.", + "displayName": "Read and write Microsoft Intune apps", + "id": "78145de6-330d-4800-a6ce-494ff2d33d07", + "origin": "Application (Microsoft Graph)", + "value": "DeviceManagementApps.ReadWrite.All" + }, + { + "description": "Allows the app to read certification authority information without a signed-in user.", + "displayName": "Read Microsoft Cloud PKI objects", + "id": "315b6e8c-d92a-4691-919d-00ce76d1344a", + "origin": "Application (Microsoft Graph)", + "value": "DeviceManagementCloudCA.Read.All" + }, + { + "description": "Allows the app to read and write certification authority information without a signed-in user.", + "displayName": "Read and write Microsoft Cloud PKI objects", + "id": "f15eb2ba-ef8a-4f70-991d-da5d045154e2", + "origin": "Application (Microsoft Graph)", + "value": "DeviceManagementCloudCA.ReadWrite.All" + }, + { + "description": "Allows the app to read properties of Microsoft Intune-managed device configuration and device compliance policies and their assignment to groups, without a signed-in user.", + "displayName": "Read Microsoft Intune device configuration and policies", + "id": "dc377aa6-52d8-4e23-b271-2a7ae04cedf3", + "origin": "Application (Microsoft Graph)", + "value": "DeviceManagementConfiguration.Read.All" + }, + { + "description": "Allows the app to read your organization's devices' configuration information without a signed-in user.", + "displayName": "Read all devices", + "id": "7438b122-aefc-4978-80ed-43db9fcc7715", + "origin": "Application (Microsoft Graph)", + "value": "Device.Read.All" + }, + { + "description": "Allows the app to read terms of use acceptance statuses, without a signed in user.", + "displayName": "Read all terms of use acceptance statuses", + "id": "d8e4ec18-f6c0-4620-8122-c8b1f2bf400e", + "origin": "Application (Microsoft Graph)", + "value": "AgreementAcceptance.Read.All" + }, + { + "description": "Allows the app to read all one-to-one or group chat messages in Microsoft Teams for chats where the associated Teams application is installed, without a signed-in user.", + "displayName": "Read all chat messages for chats where the associated Teams application is installed.", + "id": "1c1b4c8e-3cc7-4c58-8470-9b92c9d5848b", + "origin": "Application (Microsoft Graph)", + "value": "Chat.Read.WhereInstalled" + }, + { + "description": "Allows the app to delete and recover deleted chats, without a signed-in user.", + "displayName": "Delete and recover deleted chats", + "id": "9c7abde0-eacd-4319-bf9e-35994b1a1717", + "origin": "Application (Microsoft Graph)", + "value": "Chat.ManageDeletion.All" + }, + { + "description": "Allows the app to read and query audit logs from Exchange workload, without a signed-in user", + "displayName": "Read audit logs data from Exchange workload", + "id": "6b0d2622-d34e-4470-935b-b96550e5ca8d", + "origin": "Application (Microsoft Graph)", + "value": "AuditLogsQuery-Exchange.Read.All" + }, + { + "description": "Allows the app to read and query audit logs from OneDrive workload, without a signed-in user", + "displayName": "Read audit logs data from OneDrive workload", + "id": "8a169a81-841c-45fd-ad43-96aede8801a0", + "origin": "Application (Microsoft Graph)", + "value": "AuditLogsQuery-OneDrive.Read.All" + }, + { + "description": "Allows the app to read and query audit logs from SharePoint workload, without a signed-in user", + "displayName": "Read audit logs data from SharePoint workload", + "id": "91c64a47-a524-4fce-9bf3-3d569a344ecf", + "origin": "Application (Microsoft Graph)", + "value": "AuditLogsQuery-SharePoint.Read.All" + }, + { + "description": "Allows the app to read the authentication context information in your organization without a signed-in user.", + "displayName": "Read all authentication context information", + "id": "381f742f-e1f8-4309-b4ab-e3d91ae4c5c1", + "origin": "Application (Microsoft Graph)", + "value": "AuthenticationContext.Read.All" + }, + { + "description": "Allows the app to read and update the authentication context information in your organization without a signed-in user.", + "displayName": "Read and write all authentication context information", + "id": "a88eef72-fed0-4bf7-a2a9-f19df33f8b83", + "origin": "Application (Microsoft Graph)", + "value": "AuthenticationContext.ReadWrite.All" + }, + { + "description": "Allows the app to read all backup configurations, and lists of Microsoft 365 service resources to be backed-up, without a signed-in user.", + "displayName": "Read all backup configuration policies", + "id": "5fbb5982-3230-4882-93c0-2167523ce0c2", + "origin": "Application (Microsoft Graph)", + "value": "BackupRestore-Configuration.Read.All" + }, + { + "description": "Allows the app to read and update the backup configuration, and list of Microsoft 365 service resources to be backed-up, without a signed-in user.", + "displayName": "Read and edit all backup configuration policies", + "id": "18133149-5489-40ac-80f0-4b6fa85f6cdc", + "origin": "Application (Microsoft Graph)", + "value": "BackupRestore-Configuration.ReadWrite.All" + }, + { + "description": "Allows the app to read the status of M365 backup service (enable/disable), without signed in user", + "displayName": "Read the status of the M365 backup service", + "id": "6fe20a79-0e15-45a1-b019-834c125993a0", + "origin": "Application (Microsoft Graph)", + "value": "BackupRestore-Control.Read.All" + }, + { + "description": "Allows the app to update or read the status of M365 backup service (enable/disable), without signed in user", + "displayName": "Update or read the status of the M365 backup service", + "id": "fb240865-88f8-4a1d-923f-98dbc7920860", + "origin": "Application (Microsoft Graph)", + "value": "BackupRestore-Control.ReadWrite.All" + }, + { + "description": "Allows the app to monitor all backup and restore jobs, view quota usage and billing details, without a signed-in user.", + "displayName": "Read all monitoring, quota and billing information for the tenant", + "id": "ecae8511-f2d7-4be4-bdbf-91f244d45986", + "origin": "Application (Microsoft Graph)", + "value": "BackupRestore-Monitor.Read.All" + }, + { + "description": "Allows the app to read all restore sessions, without a signed-in user.", + "displayName": "Read all restore sessions", + "id": "87853aa5-0372-4710-b34b-cef27bb7156e", + "origin": "Application (Microsoft Graph)", + "value": "BackupRestore-Restore.Read.All" + }, + { + "description": "Allows the app to search all backup snapshots for Microsoft 365 resources, and restore Microsoft 365 resources from a backed-up snapshot, without a signed-in user.", + "displayName": "Read restore all sessions and start restore sessions from backups", + "id": "bebd0841-a3d8-4313-a51d-731112c8ee41", + "origin": "Application (Microsoft Graph)", + "value": "BackupRestore-Restore.ReadWrite.All" + }, + { + "description": "Allows the app to search all backup snapshots for Microsoft 365 resources, without a signed-in user.", + "displayName": "Search for metadata properties in all backup snapshots", + "id": "f6135c51-c766-4be1-9638-ed90c2ed2443", + "origin": "Application (Microsoft Graph)", + "value": "BackupRestore-Search.Read.All" + }, + { + "description": "Allows the app to read and write the billing configuration on all applications without a signed-in user.", + "displayName": "Read and write application billing configuration", + "id": "9e8be751-7eee-4c09-bcfd-d64f6b087fd8", + "origin": "Application (Microsoft Graph)", + "value": "BillingConfiguration.ReadWrite.All" + }, + { + "description": "Allows an app to read BitLocker keys for all devices, without a signed-in user. Allows read of the recovery key.", + "displayName": "Read all BitLocker keys", + "id": "57f1cf28-c0c4-4ec3-9a30-19a2eaaf2f6e", + "origin": "Application (Microsoft Graph)", + "value": "BitlockerKey.Read.All" + }, + { + "description": "Allows an app to read basic BitLocker key properties for all devices, without a signed-in user. Does not allow read of the recovery key.", + "displayName": "Read all BitLocker keys basic information", + "id": "f690d423-6b29-4d04-98c6-694c42282419", + "origin": "Application (Microsoft Graph)", + "value": "BitlockerKey.ReadBasic.All" + }, + { + "description": "Allows an app to read, write and manage bookings appointments, businesses, customers, services, and staff on behalf of the signed-in user.", + "displayName": "Manage bookings information", + "id": "6b22000a-1228-42ec-88db-b8c00399aecb", + "origin": "Application (Microsoft Graph)", + "value": "Bookings.Manage.All" + }, + { + "description": "Allows the app to read and query audit logs from Entra (Azure AD) workload, without a signed-in user", + "displayName": "Read audit logs data from Entra (Azure AD) workload", + "id": "7276d950-48fc-4269-8348-f22f2bb296d0", + "origin": "Application (Microsoft Graph)", + "value": "AuditLogsQuery-Entra.Read.All" + }, + { + "description": "Allows an app to read Bookings appointments, businesses, customers, services, and staff without a signed-in user. ", + "displayName": "Read all Bookings related resources.", + "id": "6e98f277-b046-4193-a4f2-6bf6a78cd491", + "origin": "Application (Microsoft Graph)", + "value": "Bookings.Read.All" + }, + { + "description": "Allows the app to read and query audit logs from Endpoint Data Loss Prevention workload, without a signed-in user", + "displayName": "Read audit logs data from Endpoint Data Loss Prevention workload", + "id": "0bc85aed-7b0b-437a-bac8-3b29a1b84c99", + "origin": "Application (Microsoft Graph)", + "value": "AuditLogsQuery-Endpoint.Read.All" + }, + { + "description": "Allows the app to read and query audit logs from all services.", + "displayName": "Read audit logs data from all services", + "id": "5e1e9171-754d-478c-812c-f1755a9a4c2d", + "origin": "Application (Microsoft Graph)", + "value": "AuditLogsQuery.Read.All" + }, + { + "description": "Allows the app to read the API connectors used in user authentication flows, without a signed-in user.", + "displayName": "Read API connectors for authentication flows", + "id": "b86848a7-d5b1-41eb-a9b4-54a4e6306e97", + "origin": "Application (Microsoft Graph)", + "value": "APIConnectors.Read.All" + }, + { + "description": "Allows the app to read, create and manage the API connectors used in user authentication flows, without a signed-in user.", + "displayName": "Read and write API connectors for authentication flows", + "id": "1dfe531a-24a6-4f1b-80f4-7a0dc5a0a171", + "origin": "Application (Microsoft Graph)", + "value": "APIConnectors.ReadWrite.All" + }, + { + "description": "Allows the app to read apps in the app catalogs without a signed-in user.", + "displayName": "Read all app catalogs", + "id": "e12dae10-5a57-4817-b79d-dfbec5348930", + "origin": "Application (Microsoft Graph)", + "value": "AppCatalog.Read.All" + }, + { + "description": "Allows the app to create, read, update, and delete apps in the app catalogs without a signed-in user.", + "displayName": "Read and write to all app catalogs", + "id": "dc149144-f292-421e-b185-5953f2e98d7f", + "origin": "Application (Microsoft Graph)", + "value": "AppCatalog.ReadWrite.All" + }, + { + "description": "Allows the app to read all applications and service principals without a signed-in user.", + "displayName": "Read all applications", + "id": "9a5d68dd-52b0-4cc2-bd40-abcf44ac3a30", + "origin": "Application (Microsoft Graph)", + "value": "Application.Read.All" + }, + { + "description": "Allows the app to read and update all apps in your organization, without a signed-in user.", + "displayName": "Read and update all apps", + "id": "fc023787-fd04-4e44-9bc7-d454f00c0f0a", + "origin": "Application (Microsoft Graph)", + "value": "Application.ReadUpdate.All" + }, + { + "description": "Allows the app to create, read, update and delete applications and service principals without a signed-in user. Allows management of app role assignments, except those exposed by Microsoft Graph. Does not allow management of delegated permission grants.", + "displayName": "Read and write all applications", + "id": "1bfefb4e-e0b5-418b-a88f-73c46d2cc8e9", + "origin": "Application (Microsoft Graph)", + "value": "Application.ReadWrite.All" + }, + { + "description": "Allows the app to create other applications, and fully manage those applications (read, update, update application secrets and delete), without a signed-in user. It cannot update any apps that it is not an owner of.", + "displayName": "Manage apps that this app creates or owns", + "id": "18a4783c-866b-4cc7-a460-3d5e5662c884", + "origin": "Application (Microsoft Graph)", + "value": "Application.ReadWrite.OwnedBy" + }, + { + "description": "Allows the app to read and write the remote desktop security configuration for all apps in your organization, without a signed-in user.", + "displayName": "Read and write the remote desktop security configuration for all apps", + "id": "3be0012a-cc4e-426b-895b-f9c836bf6381", + "origin": "Application (Microsoft Graph)", + "value": "Application-RemoteDesktopConfig.ReadWrite.All" + }, + { + "description": "Allows the app to manage permission grants for application permissions to any API (including Microsoft Graph) and application assignments for any app, without a signed-in user.", + "displayName": "Manage app permission grants and app role assignments", + "id": "06b708a9-e830-4db3-a914-8e69da51d44f", + "origin": "Application (Microsoft Graph)", + "value": "AppRoleAssignment.ReadWrite.All" + }, + { + "description": "Allows the app to read all approvals and approval item subscriptions, without a signed-in user.", + "displayName": "Read all approvals", + "id": "9f265de7-8d5e-4e9a-a805-5e8bbc49656f", + "origin": "Application (Microsoft Graph)", + "value": "ApprovalSolution.Read.All" + }, + { + "description": "Allows the app to read all approvals and create, update, or remove approval item subscriptions, without a signed-in user.", + "displayName": "Read all approvals and manage approval subscriptions", + "id": "45583558-1113-4d06-8969-e79a28edc9ad", + "origin": "Application (Microsoft Graph)", + "value": "ApprovalSolution.ReadWrite.All" + }, + { + "description": "Allows the app to read attack simulation and training data for an organization without a signed-in user.", + "displayName": "Read attack simulation data of an organization", + "id": "93283d0a-6322-4fa8-966b-8c121624760d", + "origin": "Application (Microsoft Graph)", + "value": "AttackSimulation.Read.All" + }, + { + "description": "Allows the app to read, create, and update attack simulation and training data for an organization without a signed-in user.", + "displayName": "Read, create, and update all attack simulation data of an organization", + "id": "e125258e-8c8a-42a8-8f55-ab502afa52f3", + "origin": "Application (Microsoft Graph)", + "value": "AttackSimulation.ReadWrite.All" + }, + { + "description": "c", + "displayName": "Read activity audit log from the audit store.", + "id": "99bc85fb-e857-4220-9f8c-3a1c83148d2e", + "origin": "Application (Microsoft Graph)", + "value": "AuditActivity.Read" + }, + { + "description": "Allows the application to upload bulk activity audit logs to the audit store.", + "displayName": "Upload activity audit logs to the audit store.", + "id": "f6318678-2713-4bb6-b123-233e7336c1bd", + "origin": "Application (Microsoft Graph)", + "value": "AuditActivity.Write" + }, + { + "description": "Allows the app to read and query your audit log activities, without a signed-in user.", + "displayName": "Read all audit log data", + "id": "b0afded3-3588-46d8-8b3d-9842eff778da", + "origin": "Application (Microsoft Graph)", + "value": "AuditLog.Read.All" + }, + { + "description": "Allows the app to read and query audit logs from Dynamics CRM workload, without a signed-in user", + "displayName": "Read audit logs data from Dynamics CRM workload", + "id": "20e6f8e4-ffac-4cf7-82f7-70ddb7564318", + "origin": "Application (Microsoft Graph)", + "value": "AuditLogsQuery-CRM.Read.All" + }, + { + "description": "Allows an app to read and write bookings appointments, businesses, customers, services, and staff on behalf of the signed-in user. Does not allow create, delete and publish of booking businesses.", + "displayName": "Read and write bookings information", + "id": "0c4b2d20-7919-468d-8668-c54b09d4dee8", + "origin": "Application (Microsoft Graph)", + "value": "Bookings.ReadWrite.All" + }, + { + "description": "Allows an app to read and write Bookings appointments and customers, and additionally allows reading businesses, services, and staff without a signed-in user.", + "displayName": "Read and write all Bookings related resources.", + "id": "9769393e-5a9f-4302-9e3d-7e018ecb64a7", + "origin": "Application (Microsoft Graph)", + "value": "BookingsAppointment.ReadWrite.All" + }, + { + "description": "Allows an app to read all bookmarks without a signed-in user.", + "displayName": "Read all bookmarks", + "id": "be95e614-8ef3-49eb-8464-1c9503433b86", + "origin": "Application (Microsoft Graph)", + "value": "Bookmark.Read.All" + }, + { + "description": "Allows the app to join group calls and scheduled meetings in your organization, without a signed-in user. The app will be joined with the privileges of a directory user to meetings in your organization.", + "displayName": "Join group calls and meetings as an app", + "id": "f6b49018-60ab-4f81-83bd-22caeabfed2d", + "origin": "Application (Microsoft Graph)", + "value": "Calls.JoinGroupCall.All" + }, + { + "description": "Allows the app to anonymously join group calls and scheduled meetings in your organization, without a signed-in user. The app will be joined as a guest to meetings in your organization.", + "displayName": "Join group calls and meetings as a guest", + "id": "fd7ccf6b-3d28-418b-9701-cd10f5cd2fd4", + "origin": "Application (Microsoft Graph)", + "value": "Calls.JoinGroupCallAsGuest.All" + }, + { + "description": "Allows the app to report synthetic media detections for participants in calls, without a signed-in user.", + "displayName": "Report synthetic media detections in calls", + "id": "050b0d28-840a-4ff6-bdf9-cc6221acbc1f", + "origin": "Application (Microsoft Graph)", + "value": "Calls.ReportSyntheticMedia.All" + }, + { + "description": "Allows the app to read call transcripts for all calls without a signed-in user.", + "displayName": "Read all call transcripts", + "id": "4cd61b6d-8692-40bf-9d90-7f38db5e5fce", + "origin": "Application (Microsoft Graph)", + "value": "CallTranscripts.Read.All" + }, + { + "description": "Allows the app to read all cases, relations, tasks, attachments and activities, without a signed-in user.", + "displayName": "Read all cases, relations, tasks, attachments and activities", + "id": "b328f35f-712c-40d6-b6b4-95449d77b352", + "origin": "Application (Microsoft Graph)", + "value": "CaseManagement.Read.All" + }, + { + "description": "Allows the app to read and write to all cases, relations, tasks, attachments and activities, without a signed-in user.", + "displayName": "Read and write to all cases, relations, tasks, attachments and activities", + "id": "57ac77a0-bb98-4ba8-bf9a-7af06dcdcc1f", + "origin": "Application (Microsoft Graph)", + "value": "CaseManagement.ReadWrite.All" + }, + { + "description": "Allows to read all Change Management items.", + "displayName": "Read Change Management items", + "id": "418dae40-2b65-4819-900c-519a04e4d278", + "origin": "Application (Microsoft Graph)", + "value": "ChangeManagement.Read.All" + }, + { + "description": "Create channels in any team, without a signed-in user.", + "displayName": "Create channels", + "id": "f3a65bd4-b703-46df-8f7e-0174fea562aa", + "origin": "Application (Microsoft Graph)", + "value": "Channel.Create" + }, + { + "description": "Delete channels in any team, without a signed-in user.", + "displayName": "Delete channels", + "id": "6a118a39-1227-45d4-af0c-ea7b40d210bc", + "origin": "Application (Microsoft Graph)", + "value": "Channel.Delete.All" + }, + { + "description": "Read all channel names and channel descriptions, without a signed-in user.", + "displayName": "Read the names and descriptions of all channels", + "id": "59a6b24b-4225-4393-8165-ebaec5f55d7a", + "origin": "Application (Microsoft Graph)", + "value": "Channel.ReadBasic.All" + }, + { + "description": "Read the members of all channels, without a signed-in user.", + "displayName": "Read the members of all channels", + "id": "3b55498e-47ec-484f-8136-9013221c06a9", + "origin": "Application (Microsoft Graph)", + "value": "ChannelMember.Read.All" + }, + { + "description": "Add and remove members from all channels, without a signed-in user. Also allows changing a member's role, for example from owner to non-owner.", + "displayName": "Add and remove members from all channels", + "id": "35930dcf-aceb-4bd1-b99a-8ffed403c974", + "origin": "Application (Microsoft Graph)", + "value": "ChannelMember.ReadWrite.All" + }, + { + "description": "Allows the app to read all channel messages in Microsoft Teams", + "displayName": "Read all channel messages", + "id": "7b2449af-6ccd-4f4d-9f78-e550c193f0d1", + "origin": "Application (Microsoft Graph)", + "value": "ChannelMessage.Read.All" + }, + { + "description": "Allows the app to update Microsoft Teams channel messages by patching a set of Data Loss Prevention (DLP) policy violation properties to handle the output of DLP processing.", + "displayName": "Flag channel messages for violating policy", + "id": "4d02b0cc-d90b-441f-8d82-4fb55c34d6bb", + "origin": "Application (Microsoft Graph)", + "value": "ChannelMessage.UpdatePolicyViolation.All" + }, + { + "description": "Read all channel names, channel descriptions, and channel settings, without a signed-in user.", + "displayName": "Read the names, descriptions, and settings of all channels", + "id": "c97b873f-f59f-49aa-8a0e-52b32d762124", + "origin": "Application (Microsoft Graph)", + "value": "ChannelSettings.Read.All" + }, + { + "description": "Read and write the names, descriptions, and settings of all channels, without a signed-in user.", + "displayName": "Read and write the names, descriptions, and settings of all channels", + "id": "243cded2-bd16-4fd6-a953-ff8177894c3d", + "origin": "Application (Microsoft Graph)", + "value": "ChannelSettings.ReadWrite.All" + }, + { + "description": "Allows the app to create chats without a signed-in user. ", + "displayName": "Create chats", + "id": "d9c48af6-9ad9-47ad-82c3-63757137b9af", + "origin": "Application (Microsoft Graph)", + "value": "Chat.Create" + }, + { + "description": "Allows the app to place outbound calls to multiple users and add participants to meetings in your organization, without a signed-in user.", + "displayName": "Initiate outgoing group calls from the app", + "id": "4c277553-8a09-487b-8023-29ee378d8324", + "origin": "Application (Microsoft Graph)", + "value": "Calls.InitiateGroupCall.All" + }, + { + "description": "Allows the app to place outbound calls to a single user and transfer calls to users in your organization’s directory, without a signed-in user.", + "displayName": "Initiate outgoing 1 to 1 calls from the app", + "id": "284383ee-7f6e-4e40-a2a8-e85dcb029101", + "origin": "Application (Microsoft Graph)", + "value": "Calls.Initiate.All" + }, + { + "description": "Allows the app to get direct access to media streams in a call, without a signed-in user.", + "displayName": "Access media streams in a call as an app", + "id": "a7a681dc-756e-4909-b988-f160edc6655f", + "origin": "Application (Microsoft Graph)", + "value": "Calls.AccessMedia.All" + }, + { + "description": "Allows the app to read call records for all calls and online meetings without a signed-in user.", + "displayName": "Read all call records", + "id": "45bbb07e-7321-4fd7-a8f6-3ff27e6a81c8", + "origin": "Application (Microsoft Graph)", + "value": "CallRecords.Read.All" + }, + { + "description": "Allows an app to read all browser site lists configured for your organization, without a signed-in user.", + "displayName": "Read all browser site lists for your organization", + "id": "c5ee1f21-fc7f-4937-9af0-c91648ff9597", + "origin": "Application (Microsoft Graph)", + "value": "BrowserSiteLists.Read.All" + }, + { + "description": "Allows an app to read and write all browser site lists configured for your organization, without a signed-in user.", + "displayName": "Read and write all browser site lists for your organization", + "id": "8349ca94-3061-44d5-9bfb-33774ea5e4f9", + "origin": "Application (Microsoft Graph)", + "value": "BrowserSiteLists.ReadWrite.All" + }, + { + "description": "Allows the app to read the configurations of business scenarios it owns, without a signed-in user.", + "displayName": "Read all business scenario configurations this app creates or owns", + "id": "acc0fc4d-2cd6-4194-8700-1768d8423d86", + "origin": "Application (Microsoft Graph)", + "value": "BusinessScenarioConfig.Read.OwnedBy" + }, + { + "description": "Allows the app to create new business scenarios and fully manage the configurations of scenarios it owns, without a signed-in user.", + "displayName": "Read and write all business scenario configurations this app creates or owns", + "id": "bbea195a-4c47-4a4f-bff2-cba399e11698", + "origin": "Application (Microsoft Graph)", + "value": "BusinessScenarioConfig.ReadWrite.OwnedBy" + }, + { + "description": "Allows the app to read the data associated with the business scenarios it owns, without a signed-in user.", + "displayName": "Read data for all business scenarios this app creates or owns", + "id": "6c0257fd-cffe-415b-8239-2d0d70fdaa9c", + "origin": "Application (Microsoft Graph)", + "value": "BusinessScenarioData.Read.OwnedBy" + }, + { + "description": "Allows the app to fully manage the data associated with the business scenarios it owns, without a signed-in user.", + "displayName": "Read and write data for all business scenarios this app creates or owns", + "id": "f2d21f22-5d80-499e-91cc-0a8a4ce16f54", + "origin": "Application (Microsoft Graph)", + "value": "BusinessScenarioData.ReadWrite.OwnedBy" + }, + { + "description": "Allows the app to read work hours and locations settings, recurrences, and occurrences for all users in the organization, without a signed-in user.", + "displayName": "Read all users' work hours and locations", + "id": "470229df-a15a-4b08-9d95-8c534862b362", + "origin": "Application (Microsoft Graph)", + "value": "Calendars.Read.All" + }, + { + "description": "Allows the app to read all 1-to-1 or group chat messages in Microsoft Teams.", + "displayName": "Read all chat messages", + "id": "6b7d71aa-70aa-4810-a8d9-5d9fb2830017", + "origin": "Application (Microsoft Graph)", + "value": "Chat.Read.All" + }, + { + "description": "Allows the app to read events of all calendars, except for properties such as body, attachments, and extensions, without a signed-in user.", + "displayName": "Read basic details of calendars in all mailboxes ", + "id": "8ba4a692-bc31-4128-9094-475872af8a53", + "origin": "Application (Microsoft Graph)", + "value": "Calendars.ReadBasic.All" + }, + { + "description": "Allows the app to read, create, update, and delete work hours and locations settings, recurrences, and occurrences for all users in the organization, without a signed-in user.", + "displayName": "Read and write all users' work hours and locations", + "id": "d91697b0-a708-4c11-a712-8fb2dc081aa2", + "origin": "Application (Microsoft Graph)", + "value": "Calendars.ReadWrite.All" + }, + { + "description": "Allows the app to read all AI Insights for all calls, without a signed-in user.", + "displayName": "Read all AI Insights for calls.", + "id": "792b782b-7822-4b92-8103-77e44f2f706c", + "origin": "Application (Microsoft Graph)", + "value": "CallAiInsights.Read.All" + }, + { + "description": "Allows the app to read delegation settings of you", + "displayName": "Read delegation settings", + "id": "5aa33e77-b893-495e-bdc5-4bf6f27d42a0", + "origin": "Application (Microsoft Graph)", + "value": "CallDelegation.Read.All" + }, + { + "description": "Allows the app to read and write delegation settings of you", + "displayName": "Read and write delegation settings", + "id": "8d06abce-e69b-4122-ba60-4f901bb1db2f", + "origin": "Application (Microsoft Graph)", + "value": "CallDelegation.ReadWrite.All" + }, + { + "description": "Allows the app to read call event information for all users in your organization, without a signed-in user.", + "displayName": "Read all call events", + "id": "1abb026f-7572-49f6-9ddd-ad61cbba181e", + "origin": "Application (Microsoft Graph)", + "value": "CallEvents.Read.All" + }, + { + "description": "Allows the app to read emergency call event information for all users in your organization without a signed-in user.", + "displayName": "Read all emergency call events", + "id": "f0a35f91-2aa6-4a99-9d5a-5b6bcb66204e", + "origin": "Application (Microsoft Graph)", + "value": "CallEvents-Emergency.Read.All" + }, + { + "description": "Allows the app to read call recordings for all calls without a signed-in user.", + "displayName": "Read all call recordings", + "id": "ce8fb1f1-5e1f-44a0-b102-4ec28454d0dc", + "origin": "Application (Microsoft Graph)", + "value": "CallRecordings.Read.All" + }, + { + "description": "Allows the app to read all PSTN and direct routing call log data without a signed-in user.", + "displayName": "Read PSTN and direct routing call log data", + "id": "a2611786-80b3-417e-adaa-707d4261a5f0", + "origin": "Application (Microsoft Graph)", + "value": "CallRecord-PstnCalls.Read.All" + }, + { + "description": "Allows the app to read internal federation configuration for a domain.", + "displayName": "Read internal federation configuration for a domain.", + "id": "c0e5a7b0-e8b7-40a7-b8e0-8249e6ea81d5", + "origin": "Application (Microsoft Graph)", + "value": "Domain-InternalFederation.Read.All" + }, + { + "description": "Allows calling debugging APIs", + "displayName": "UserScope-Dev.Debug.All", + "id": "d3aaaaff-f3e8-4b2f-8285-12478a43eb7d", + "origin": "Application (DirectoryLookupService)", + "value": "UserScope-Dev.Debug.All" + }, + { + "description": "Allows calling debugging APIs", + "displayName": "UserScope.Debug.All", + "id": "9c87ec21-0463-42cf-a35b-1b1e81ac135f", + "origin": "Application (DirectoryLookupService)", + "value": "UserScope.Debug.All" + }, + { + "description": "Grants the ability to read, write, and manage symbols.", + "displayName": "Symbols (read, write and manage)", + "id": "6314624e-fd22-4945-a279-2bab145fe26e", + "origin": "Delegated (Azure DevOps)", + "value": "vso.symbols_manage" + }, + { + "description": "Grants the ability to read and write symbols.", + "displayName": "Symbols (read and write)", + "id": "61b345ff-217b-4c81-9648-88991cf1c1ee", + "origin": "Delegated (Azure DevOps)", + "value": "vso.symbols_write" + }, + { + "description": "Grants the ability to read, create and manage taskgroups.", + "displayName": "Task Groups (read, create and manage)", + "id": "7a350bc8-d2d9-4842-9c59-a815a1923097", + "origin": "Delegated (Azure DevOps)", + "value": "vso.taskgroups_manage" + }, + { + "description": "Grants the ability to read task groups.", + "displayName": "Task Groups (read)", + "id": "8f5046df-6ee0-497b-b5b2-5e125f882eae", + "origin": "Delegated (Azure DevOps)", + "value": "vso.taskgroups_read" + }, + { + "description": "Grants the ability to read and create task groups.", + "displayName": "Task Groups (read, create)", + "id": "8be3739c-dabb-4cf4-a05d-207a5220e4f0", + "origin": "Delegated (Azure DevOps)", + "value": "vso.taskgroups_write" + }, + { + "description": "Grants the ability to read test plans, cases, results and other test management related artifacts.", + "displayName": "Test management (read)", + "id": "c0efe20b-0db1-4aec-9cb5-cdc097b2e773", + "origin": "Delegated (Azure DevOps)", + "value": "vso.test" + }, + { + "description": "Grants the ability to read, create, and update test plans, cases, results and other test management related artifacts.", + "displayName": "Test management (read and write)", + "id": "0a731f7b-93ec-4267-aa35-47b4b9fcdf08", + "origin": "Delegated (Azure DevOps)", + "value": "vso.test_write" + }, + { + "description": "Grants the ability to manage (view and revoke) existing tokens to organization administrators", + "displayName": "Token Administration", + "id": "859fb1f9-e5ab-4e67-88f8-a971d3e4707a", + "origin": "Delegated (Azure DevOps)", + "value": "vso.tokenadministration" + }, + { + "description": "Grants the ability to manage delegated authorization tokens to users", + "displayName": "Delegated Authorization Tokens", + "id": "ea83b09f-09d2-4ee5-bb93-d346c57debdb", + "origin": "Delegated (Azure DevOps)", + "value": "vso.tokens" + }, + { + "description": "Grants the ability to read, create and manage variable groups.", + "displayName": "Variable Groups (read, create and manage)", + "id": "e20dcb7e-deff-4025-805a-853e74ff44c1", + "origin": "Delegated (Azure DevOps)", + "value": "vso.variablegroups_manage" + }, + { + "description": "Grants the ability to read variable groups.", + "displayName": "Variable Groups (read)", + "id": "469808c3-0aad-4ce3-854e-0080dfc973d9", + "origin": "Delegated (Azure DevOps)", + "value": "vso.variablegroups_read" + }, + { + "description": "Grants the ability to read and create variable groups.", + "displayName": "Variable Groups (read, create)", + "id": "c4679fff-04f1-4e29-88b4-4ae78bf4ee27", + "origin": "Delegated (Azure DevOps)", + "value": "vso.variablegroups_write" + }, + { + "description": "\tGrants the ability to read wikis, wiki pages and wiki attachments. Also grants the ability to search wiki pages.", + "displayName": "Wiki (read)", + "id": "7ad94a7f-9169-422b-a66b-1c74dea4c016", + "origin": "Delegated (Azure DevOps)", + "value": "vso.wiki" + }, + { + "description": "Grants the ability to read, create and updates wikis, wiki pages and wiki attachments.", + "displayName": "Wiki (read and write)", + "id": "b5ffdb18-5c2f-420d-a35a-8ffe20092235", + "origin": "Delegated (Azure DevOps)", + "value": "vso.wiki_write" + }, + { + "description": "Grants the ability to read work items, queries, boards, area and iterations paths, and other work item tracking related metadata. Also grants the ability to execute queries, search work items and to receive notifications about work item events via service hooks.", + "displayName": "Work items (read)", + "id": "3214d9aa-5551-4ef3-a866-22914177e2a4", + "origin": "Delegated (Azure DevOps)", + "value": "vso.work" + }, + { + "description": "Grants full access to work items, queries, backlogs, plans, and work item tracking metadata. Also provides the ability to receive notifications about work item events via service hooks.", + "displayName": "Work items (full)", + "id": "3e49c96c-b24e-493b-a225-497a7b3805ab", + "origin": "Delegated (Azure DevOps)", + "value": "vso.work_full" + }, + { + "description": "Grants the ability to read, create, and update work items and queries, update board metadata, read area and iterations paths other work item tracking related metadata, execute queries, and to receive notifications about work item events via service hooks.", + "displayName": "Work items (read and write)", + "id": "dfc8977a-1f87-4e99-95cb-4bd25e4f546d", + "origin": "Delegated (Azure DevOps)", + "value": "vso.work_write" + }, + { + "description": "Grants the ability to read symbols.", + "displayName": "Symbols (read)", + "id": "c424c3d9-15df-4837-9fa3-b9ed83b3687a", + "origin": "Delegated (Azure DevOps)", + "value": "vso.symbols" + }, + { + "description": "Read/Write access for all objects in the space topology", + "displayName": "Read/Write Access", + "id": "4589bd03-58cb-4e6c-b17f-b580e39652f8", + "origin": "Delegated (Azure Digital Twins)", + "value": "Read.Write" + }, + { + "description": "Grants the ability to read and query service endpoints.", + "displayName": "Service Endpoints (read and query)", + "id": "81928d24-d278-4dc9-baf9-6756e5ea62e2", + "origin": "Delegated (Azure DevOps)", + "value": "vso.serviceendpoint_query" + }, + { + "description": "Grants the ability to read service endpoints.", + "displayName": "Service Endpoints (read)", + "id": "503568bd-aea0-4478-a536-a8325f5f0830", + "origin": "Delegated (Azure DevOps)", + "value": "vso.serviceendpoint" + }, + { + "description": "Grants the ability to create and read feeds and packages.", + "displayName": "Packaging (read and write)", + "id": "fb6a8425-8933-4b7f-9c4a-154568e06e5c", + "origin": "Delegated (Azure DevOps)", + "value": "vso.packaging_write" + }, + { + "description": "Grants the ability to read Pats for a user", + "displayName": "Pats (read)", + "id": "de2740de-3092-4b70-afcd-df4a8a4ecacf", + "origin": "Delegated (Azure DevOps)", + "value": "vso.pats" + }, + { + "description": "Grants the ability to read and manage Pats for a user", + "displayName": "Pats (read and manage)", + "id": "15b69eb5-89f0-4f4d-8d0d-219397dab9c4", + "origin": "Delegated (Azure DevOps)", + "value": "vso.pats_manage" + }, + { + "description": "Grants the ability to manage a protected resource or a pipeline's request to use a protected resource, agent pool, environment, queue, repository, secure files, service connection, and variable group", + "displayName": "Pipeline Resources (use and manage)", + "id": "8deb8858-ff9b-4c4e-b702-5a6abbb28db0", + "origin": "Delegated (Azure DevOps)", + "value": "vso.pipelineresources_manage" + }, + { + "description": "Grants the ability to approve a pipeline's request to use a protected resource, agent pool, environment, queue, repository, secure files, service connection, and variable group", + "displayName": "Pipeline Resources (use)", + "id": "7c6f675c-fff5-4f8a-adf1-1a3d6f3fafdc", + "origin": "Delegated (Azure DevOps)", + "value": "vso.pipelineresources_use" + }, + { + "description": "Grants the ability to read your profile, accounts, collections, projects, teams, and other top-level organizational artifacts.", + "displayName": "User profile (read)", + "id": "4ee63f9b-9e65-476c-a487-9fea1e00c7ef", + "origin": "Delegated (Azure DevOps)", + "value": "vso.profile" + }, + { + "description": "Grants the ability to write to your profile.", + "displayName": "User profile (write)", + "id": "cf053792-b7ad-46ba-aecb-a8e9b706587e", + "origin": "Delegated (Azure DevOps)", + "value": "vso.profile_write" + }, + { + "description": "Grants the ability to read projects and teams.", + "displayName": "Project and team (read)", + "id": "7b1a2725-1134-40f5-a891-d20bbb122919", + "origin": "Delegated (Azure DevOps)", + "value": "vso.project" + }, + { + "description": "Grants the ability to create, read, update, and delete projects and teams.", + "displayName": "Project and team (read, write and manage)", + "id": "0bf9fd64-9272-43aa-8459-00e29f78e146", + "origin": "Delegated (Azure DevOps)", + "value": "vso.project_manage" + }, + { + "description": "Grants the ability to read and update projects and teams.", + "displayName": "Project and team (read and write)", + "id": "e8a8f033-da2f-4059-ba3e-63a8f69b8842", + "origin": "Delegated (Azure DevOps)", + "value": "vso.project_write" + }, + { + "description": "Grants the ability to read release artifacts, including releases, release definitions and release environment.", + "displayName": "Release (read)", + "id": "a6abae6c-fe64-4795-aea0-ccf174ec25cf", + "origin": "Delegated (Azure DevOps)", + "value": "vso.release" + }, + { + "description": "Grants the ability to read and update release artifacts, including releases, release definitions and release environment, and the ability to queue a new release.", + "displayName": "Release (read, write and execute)", + "id": "1decc0a5-a110-4bb7-86e5-0b0ecf40c010", + "origin": "Delegated (Azure DevOps)", + "value": "vso.release_execute" + }, + { + "description": "Grants the ability to read, update, and delete release artifacts, including releases, release definitions and release environment, and the ability to queue and approve a new release.", + "displayName": "Release (read, write, execute and manage)", + "id": "36d3e2c4-2a6b-4dd0-aa72-058aaedf09d4", + "origin": "Delegated (Azure DevOps)", + "value": "vso.release_manage" + }, + { + "description": "Grants the ability to read, create, and manage secure files.", + "displayName": "Secure Files (read, create, and manage)", + "id": "2b7fbe3e-6b64-4f44-a125-fb407930daaf", + "origin": "Delegated (Azure DevOps)", + "value": "vso.securefiles_manage" + }, + { + "description": "Grants the ability to read secure files.", + "displayName": "Secure Files (read)", + "id": "a07b91f9-72ea-4d16-a874-018f0350e3c1", + "origin": "Delegated (Azure DevOps)", + "value": "vso.securefiles_read" + }, + { + "description": "Grants the ability to read and create secure files.", + "displayName": "Secure Files (read, create)", + "id": "6c67f103-736c-44a0-9c09-6e72547c7d99", + "origin": "Delegated (Azure DevOps)", + "value": "vso.securefiles_write" + }, + { + "description": "Grants the ability to read, write, and manage security permissions.", + "displayName": "Security (manage)", + "id": "59ead6af-1488-485a-bd24-059f30ad33f2", + "origin": "Delegated (Azure DevOps)", + "value": "vso.security_manage" + }, + { + "description": "Grants the ability to read, query, and manage service endpoints.", + "displayName": "Service Endpoints (read, query and manage)", + "id": "6f9f984c-a956-40b7-a6ac-4f7e3f091f96", + "origin": "Delegated (Azure DevOps)", + "value": "vso.serviceendpoint_manage" + }, + { + "description": "Read, write access for all objects in the space topology", + "displayName": "Read, write Access", + "id": "6f0a461a-c02f-4f98-994a-b116efecc7b2", + "origin": "Delegated (Azure Digital Twins Authorization PDP)", + "value": "Read.Write" + }, + { + "description": "Allows the user to access consumption APIs on Microsoft Enterprise Graph", + "displayName": "Read Microsoft Enterprise Graph data as an organization user (preview)", + "id": "df43b147-a9af-4e9b-92f9-c366aa5c2c5c", + "origin": "Delegated (Azure Enterprise Knowledge Graph RP)", + "value": "Knowledge.Read" + }, + { + "description": "Role representing Azure ExP backend services.", + "displayName": "Azure ExP Backend Service", + "id": "ddae8266-1a14-470c-92bd-5901e981053f", + "origin": "Application (Azure ExP)", + "value": "az-exp-backend" + }, + { + "description": "Allows user to read DocumentReference resources in a patient's compartment.", + "displayName": "patient.DocumentReference.read", + "id": "e2a5290a-59c6-4847-af7f-c5b16c692f24", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "patient.DocumentReference.read" + }, + { + "description": "Allows user to read Encounter resources in a patient's compartment.", + "displayName": "patient.Encounter.read", + "id": "9e7cdd6e-af8a-4e6d-9170-4274b35864bc", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "patient.Encounter.read" + }, + { + "description": "Allows user to read Goal resources in a patient's compartment.", + "displayName": "patient.Goal.read", + "id": "e6e32ed0-c9a2-4f1b-adb5-e3d59e47bb54", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "patient.Goal.read" + }, + { + "description": "Allows user to read Immunization resources in a patient's compartment.", + "displayName": "patient.Immunization.read", + "id": "f6c2728b-b49d-4d10-bb69-7798f1603807", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "patient.Immunization.read" + }, + { + "description": "Allows a user to read Location resources in a patient's compartment.", + "displayName": "patient.Location.read", + "id": "6fbb6a2c-cd2f-486c-a421-b188f2184df8", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "patient.Location.read" + }, + { + "description": "Allows user to read Medication resources in a patient's compartment.", + "displayName": "patient.Medication.read", + "id": "058fa58e-ac09-4308-970e-2f2da3bf49b4", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "patient.Medication.read" + }, + { + "description": "Allows user to read MedicationRequest resources in a patient's compartment.", + "displayName": "patient.MedicationRequest.read", + "id": "16352d13-6c69-4eee-adbb-f7420b3f252b", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "patient.MedicationRequest.read" + }, + { + "description": "Allows user to read Observation resources in a patient's compartment.", + "displayName": "patient.Observation.read", + "id": "890da85b-b5ad-4981-8966-4b80fd75a5b8", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "patient.Observation.read" + }, + { + "description": "Allows user to read Organization resources in a patient's compartment.", + "displayName": "patient.Organization.read", + "id": "81882045-ad54-4a5f-926c-a2a09d7dd2b5", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "patient.Organization.read" + }, + { + "description": "Allows user to read Patient resources in a patient's compartment.", + "displayName": "patient.Patient.read", + "id": "d23f0b68-a2f5-4647-987b-b58fdd86b49d", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "patient.Patient.read" + }, + { + "description": "Allows user to read Practitioner resources in a patient's compartment.", + "displayName": "patient.Practitioner.read", + "id": "ad27d725-86ff-4bc9-86fb-afdb2f007089", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "patient.Practitioner.read" + }, + { + "description": "Allows user to read PractitionerRole resources in a patient's compartment.", + "displayName": "patient.PractitionerRole.read", + "id": "1d6a9f65-62f4-42b0-93d7-fc3437112f46", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "patient.PractitionerRole.read" + }, + { + "description": "Allows user to read Procedure resources in a patient's compartment.", + "displayName": "patient.Procedure.read", + "id": "be1c7593-9057-4947-a456-424dde696627", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "patient.Procedure.read" + }, + { + "description": "Allows user to read Provenance resources in a patient's compartment.", + "displayName": "patient.Provenance.read", + "id": "d56c68f3-ec5f-4e12-82cc-afa8921337f0", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "patient.Provenance.read" + }, + { + "description": "Allows a user to read all resources in their compartment.", + "displayName": "user.all.read", + "id": "5a03b38d-4081-4265-9558-aab5f2a18b8b", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "user.all.read" + }, + { + "description": "Allows user to read AllergyIntolerance resources in their own compartment.", + "displayName": "user.AllergyIntolerance.read", + "id": "263687ba-ab05-41b1-98ad-f057c7365c91", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "user.AllergyIntolerance.read" + }, + { + "description": "Allows user to read CarePlan resources in their own compartment.", + "displayName": "user.CarePlan.read", + "id": "c2c62692-9cd2-4612-9f15-cf5641284e41", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "user.CarePlan.read" + }, + { + "description": "Allows user to read DiagnosticReport resources in a patient's compartment.", + "displayName": "patient.DiagnosticReport.read", + "id": "ef9475b2-f7da-4a86-b385-9fffe030c310", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "patient.DiagnosticReport.read" + }, + { + "description": "Allows user to read Device resources in a patient's compartment.", + "displayName": "patient.Device.read", + "id": "f7b318b5-9bf5-46d0-8701-845c568925d9", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "patient.Device.read" + }, + { + "description": "Allows user to read Condition resources in a patient's compartment.", + "displayName": "patient.Condition.read", + "id": "60fd617d-0d96-4c44-9cf3-ee91c0c45161", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "patient.Condition.read" + }, + { + "description": "Allows user to read CareTeam resources in a patient's compartment.", + "displayName": "patient.CareTeam.read", + "id": "4860ad86-f40e-4e2b-8661-ba03ee154b19", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "patient.CareTeam.read" + }, + { + "description": "Role representing Azure ExP frontend services.", + "displayName": "Azure ExP Frontend", + "id": "7f9e080a-e4af-4ac9-bc98-8b8cf39c0e40", + "origin": "Application (Azure ExP)", + "value": "az-exp-frontend" + }, + { + "description": "Role representing Azure ExP Reader Security Group.", + "displayName": "Azure ExP Reader", + "id": "6e32d6dd-89e3-4a14-b3c5-578b15e08d70", + "origin": "Application (Azure ExP)", + "value": "Experimentation.Reader" + }, + { + "description": "Allow the application to access Azure Experimentation Platform (ExP) dataplane APIs on behalf of the signed in user.", + "displayName": "Access Azure ExP", + "id": "a8a27510-3bf9-4a9d-a5be-5340c8a026e5", + "origin": "Delegated (Azure ExP)", + "value": "user_impersonation" + }, + { + "description": "c", + "displayName": "Access Kafka rest proxy apis in HDInsight", + "id": "bcc42819-54c2-4d19-a7c5-de8402bf34e3", + "origin": "Application (Azure HDInsight Cluster API)", + "value": "Kafka.ReadWrite" + }, + { + "description": "Allows callers to access all apis", + "displayName": "Cluster API Access", + "id": "8f89faa0-ffef-4007-974d-4989b39ad77d", + "origin": "Delegated (Azure HDInsight Cluster API)", + "value": "Cluster.ReadWrite" + }, + { + "description": "Grants permission for refresh tokens to be used to obtain a new access token even after the user is no longer online.", + "displayName": "offline_access", + "id": "7f08bce5-b21b-4518-b783-c69c9c0cd658", + "origin": "Application (Azure Healthcare APIs)", + "value": "offline_access" + }, + { + "description": "Read and write all resources in the system.", + "displayName": "system.all.all", + "id": "bea2566c-7172-42d0-a06a-0e2e2b58f084", + "origin": "Application (Azure Healthcare APIs)", + "value": "system.all.all" + }, + { + "description": "Grants the ability to create, read, update, and delete feeds and packages.", + "displayName": "Packaging (read, write, and manage)", + "id": "1c2a30a3-4b4c-42b1-bb10-6f24faf344d7", + "origin": "Delegated (Azure DevOps)", + "value": "vso.packaging_manage" + }, + { + "description": "Allows a user to read all resources in the system.", + "displayName": "system.all.read", + "id": "a44c4c64-cdb5-48fc-adbd-070df4852b2c", + "origin": "Application (Azure Healthcare APIs)", + "value": "system.all.read" + }, + { + "description": "Grants permission to obtain launch context.", + "displayName": "launch", + "id": "fa5f9b76-5f20-4466-8b3d-6718de9744e2", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "launch" + }, + { + "description": "Asks for a patient to be selected at launch time.", + "displayName": "launch.patient", + "id": "f6c8eb20-e799-4de7-a7ce-74ca0c7270e1", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "launch.patient" + }, + { + "description": "Grants permission for a refresh token to be used to obtain a new access token as long as the user is still online.", + "displayName": "online_access", + "id": "8f4fe9ac-b7f2-43ca-a0ef-070f4a8ae5dc", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "online_access" + }, + { + "description": "Grants permission to retrieve information about the current logged-in user.", + "displayName": "openid", + "id": "0ea38e69-761b-46c7-ac06-9cd17d518949", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "openid" + }, + { + "description": "Allows user to read all resources in a patient's compartment.", + "displayName": "patient.all.read", + "id": "c71e9482-f052-461b-80e1-7cfeba7b2b12", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "patient.all.read" + }, + { + "description": "Allows user to read AllergyIntolerance resources in a patient's compartment.", + "displayName": "patient.AllergyIntolerance.read", + "id": "fa23e5cb-b06f-4d07-bcf8-f5ce114dd847", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "patient.AllergyIntolerance.read" + }, + { + "description": "Allows user to read CarePlan resources in a patient's compartment.", + "displayName": "patient.CarePlan.read", + "id": "4fcc09b8-fd6b-430f-9b2b-7bbd2c2b7e8f", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "patient.CarePlan.read" + }, + { + "description": "Grants permission to read information about the current logged-in user.", + "displayName": "fhirUser", + "id": "a86144c7-3e19-4b56-9675-15803eb1e617", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "fhirUser" + }, + { + "description": "Allows user to read CareTeam resources in their own compartment.", + "displayName": "user.CareTeam.read", + "id": "3cb5f829-1e8d-4224-9e87-678b02c8f9b1", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "user.CareTeam.read" + }, + { + "description": "Grants the ability to read feeds and packages.", + "displayName": "Packaging (read)", + "id": "fcc79b02-ad6b-4ac7-af05-70cb9e349708", + "origin": "Delegated (Azure DevOps)", + "value": "vso.packaging" + }, + { + "description": "Provides read, write, and management access to subscriptions and read access to event metadata, including filterable field values.", + "displayName": "Notifications (manage)", + "id": "90f74b44-f4ec-4f41-9003-cb9cb64cdd32", + "origin": "Delegated (Azure DevOps)", + "value": "vso.notification_manage" + }, + { + "description": "The app can view and write to all models for the signed in user and models that the user has access to.", + "displayName": "Read and Write all Models", + "id": "59ef67ea-d35f-4c6d-b1ce-95468f134ccb", + "origin": "Delegated (Azure Analysis Services)", + "value": "Model.ReadWrite.All" + }, + { + "description": "Allows access to the Azure API Center Data API service on behalf of the signed-in user.", + "displayName": "Access Azure API Center Data API", + "id": "8351d75c-0972-4fe9-b20b-d91b74614489", + "origin": "Delegated (Azure API Center)", + "value": "Data.Read.All" + }, + { + "description": "Allows access to the Azure API Center Data API service on behalf of the signed-in user", + "displayName": "Access Azure API Center Data API", + "id": "44327351-3395-414e-882e-7aa4a9c3b25d", + "origin": "Delegated (Azure API Center)", + "value": "user_impersonation" + }, + { + "description": "Allows application to read and write DICOM resources.", + "displayName": "Read and write DICOM", + "id": "e69ab058-b8ae-4c0f-b687-5d97888ddccb", + "origin": "Delegated (Azure API for DICOM)", + "value": "Dicom.ReadWrite" + }, + { + "description": "Have full access to the Azure API Connection service.", + "displayName": "Access Azure API Connections runtime service API", + "id": "6c3012bf-22c1-4bb5-959b-dff738314144", + "origin": "Delegated (Azure API Hub)", + "value": "Runtime.All" + }, + { + "description": "Access Azure Cognitive Search", + "displayName": "user_impersonation ", + "id": "a4165a31-5d9e-4120-bd1e-9d88c66fd3b8", + "origin": "Delegated (Azure Cognitive Search)", + "value": "user_impersonation" + }, + { + "description": "c", + "displayName": "CST SP", + "id": "801546d2-55cc-4ff4-b66d-134b1208deb5", + "origin": "Application (Azure Commercial Services Tool - CST)", + "value": "samples.read" + }, + { + "description": "Azure Container Registry Resource Provider", + "displayName": "Azure Container Registry RP", + "id": "a25ca244-bc95-4be7-bd58-ca9325bd24b2", + "origin": "Application (Azure Container Registry)", + "value": "AzureContainerRegistryRP" + }, + { + "description": "Allows the application to access Azure Container Registry acting as users in the organization.", + "displayName": "Access Azure Container Registry as organization users", + "id": "cdcfcdaf-ae2f-408c-9585-bb5b86000ba4", + "origin": "Delegated (Azure Container Registry Application)", + "value": "user_impersonation" + }, + { + "description": "Allows the client application to create and execute sessions that user has access to", + "displayName": "Sessions.ReadWrite.All", + "id": "2843164f-ca31-473a-9198-ceaeb95e59b1", + "origin": "Delegated (Azure ContainerApps Sessions)", + "value": "Sessions.ReadWrite.All" + }, + { + "description": "Allow the application to access Azure Cosmos DB on behalf of the signed-in user.", + "displayName": "Access Azure Cosmos DB", + "id": "8741c20d-e8c0-41ff-8adf-b7b9ba168197", + "origin": "Delegated (Azure Cosmos DB)", + "value": "user_impersonation" + }, + { + "description": "Access CPG Prod services from the application", + "displayName": "Azure CPG Prod", + "id": "45b2dd25-66bb-4e03-8945-c0781f29fc85", + "origin": "Application (Azure CosmosDB for PostgreSQL AAD Authentication)", + "value": "app_impersonation" + }, + { + "description": "Access CPG Prod Service as a user", + "displayName": "Access CPG Prod Services", + "id": "546e6d41-31bd-4cc1-976a-2be4eb91f35e", + "origin": "Delegated (Azure CosmosDB for PostgreSQL AAD Authentication)", + "value": "user_impersonation" + }, + { + "description": "Access CPG Prod services from the application", + "displayName": "Azure CPG Prod", + "id": "bb7fb9ee-c8d2-4c3b-853e-af20f589cb42", + "origin": "Application (Azure CosmosDB for PostgreSQL Microsoft EntraId)", + "value": "app_impersonation" + }, + { + "description": "Access CosmosDB for PostgreSQL as a user", + "displayName": "Access CosmosDB for PostgreSQL", + "id": "51a464c6-5185-43ba-b6fc-cb173be5e291", + "origin": "Delegated (Azure CosmosDB for PostgreSQL Microsoft EntraId)", + "value": "user_impersonation" + }, + { + "description": "Allow the application full access to the Azure Data Lake service on behalf of the signed-in user", + "displayName": "Have full access to the Azure Data Lake service", + "id": "9f15d22d-3cdf-430f-ba48-f75401c0408e", + "origin": "Delegated (Azure Data Lake)", + "value": "user_impersonation" + }, + { + "description": "Allows the app to send which accounts are managed by the PAM solution for all AD and Entra identities for MDI customers.", + "displayName": "Send which accounts are managed by the PAM solution", + "id": "9bba0ae2-fc9b-4c22-b607-b6afdf6601cb", + "origin": "Delegated (Azure Advanced Threat Protection)", + "value": "Identity.PrivilegeAccountTagging" + }, + { + "description": "Allows an application to call ADME apis without a signed in user.", + "displayName": "ADME Application API access", + "id": "c0795231-e282-4abc-8822-576cbaea5bfb", + "origin": "Application (Azure Data Manager for Energy)", + "value": "ADME.ApplicationAccess" + }, + { + "description": "Allows the app to get a list of actions required on AD and Entra identities and update on the status of said actions for MDI customers.", + "displayName": "get a list of actions and update on the status of said actions", + "id": "1f479d02-0183-4852-8264-464546fd8f52", + "origin": "Delegated (Azure Advanced Threat Protection)", + "value": "Identity.PrivilegeAccountActions" + }, + { + "description": "Allows the app to get a list of actions required on AD and Entra identities and update on the status of said actions for MDI customers.", + "displayName": "get a list of actions and update on the status of said actions", + "id": "c613cf81-75fb-4201-a32b-7a58d1fe4dff", + "origin": "Application (Azure Advanced Threat Protection)", + "value": "Identity.PrivilegeAccountActions" + }, + { + "description": "Access to IoT DPS", + "displayName": "Access to IoT DPS", + "id": "02ce5515-6df6-47e3-b3a5-96dd4fc74f64", + "origin": "Delegated (Access IoT Hub Device Provisioning Service)", + "value": "user_impersonation" + }, + { + "description": "Allows user to generate a sas token.", + "displayName": "Generate SAS Token.", + "id": "7a4930f3-f625-4ed4-a257-5cdb1401acb9", + "origin": "Delegated (AIO-Diagnostics-Admin-Service-App)", + "value": "Tokens.Get" + }, + { + "description": "Allows users to sign-in to the app, and allows the app to read the profile of signed-in users. It also allows the app to read basic company information of signed-in users.", + "displayName": "Sign in and read user profile", + "id": "111a3bb6-1bbf-4127-a604-3b1b58acc41b", + "origin": "Delegated (AIO-Diagnostics-Admin-Service-App)", + "value": "user_impersonation" + }, + { + "description": "this allows user to read app usage in the organization. Requires admin consent", + "displayName": "usage.read.all", + "id": "c9742cae-d7c1-4b54-a641-5f676846a768", + "origin": "Delegated (App Protection)", + "value": "usage.read.all" + }, + { + "description": "Allow this application to access Application Insights data", + "displayName": "Read Application Insights Data", + "id": "3c63f9fe-1706-42a7-9f53-25b47753d668", + "origin": "Application (Application Insights API)", + "value": "Data.Read" + }, + { + "description": "Allow this application to access Application Insights data on behalf of the user", + "displayName": "Read Application Insights Data as user", + "id": "c6d30a22-009b-43ce-a9e9-3ca625e7a3d4", + "origin": "Delegated (Application Insights API)", + "value": "Data.Read" + }, + { + "description": "Default permission to access Attestation service", + "displayName": "user_impersonation", + "id": "aabdcc35-2d84-4cae-b8f0-23d8272e3fec", + "origin": "Application (Attestation Service)", + "value": "user_impersonation" + }, + { + "description": "Allow the application to access Azure Autonomous Development Platform on behalf of the signed-in user.", + "displayName": "Access Azure Autonomous Development Platform", + "id": "bd7d1ce5-f0a9-4489-a4df-3e41e7b963e2", + "origin": "Delegated (Autonomous Development Platform)", + "value": "user_impersonation" + }, + { + "description": "Allows a service or application to register Global Secure Access Private Network Connectors.", + "displayName": "Register connectors", + "id": "46314cfe-5021-44c3-a00c-e5e4fdc9b5ac", + "origin": "Application (Azure AD Application Proxy)", + "value": "Connector.Register" + }, + { + "description": "Allows an application to read access recommendation insights", + "displayName": "Read access recommendation insights", + "id": "179ad82c-ddf7-4180-9ecc-af2608f2ae6d", + "origin": "Application (Azure AD Identity Governance Insights)", + "value": "Insights.Read.AccessRecommendation" + }, + { + "description": "Allows an application to read all insights", + "displayName": "Read all insights", + "id": "50974fa0-9c21-4479-a75c-a901ccdb4b5c", + "origin": "Application (Azure AD Identity Governance Insights)", + "value": "Insights.Read.All" + }, + { + "description": "Allows an application to read sign-in insights", + "displayName": "Read sign-in insights", + "id": "c05406e2-24d5-4c73-8c33-dde21e8501e6", + "origin": "Application (Azure AD Identity Governance Insights)", + "value": "Insights.Read.SignIn" + }, + { + "description": "Allows an application to read subscriptions", + "displayName": "Read subscriptions", + "id": "2e03c640-95b1-462d-b0cc-811335e6c60b", + "origin": "Application (Azure AD Identity Governance Insights)", + "value": "Subscriptions.Read" + }, + { + "description": "Allows an application to create other subscriptions and fully manage those subscriptions. It cannot update any subscriptions that it is not an owner of.", + "displayName": "Read and write subscriptions", + "id": "0be7af70-7a46-4866-8e53-d01ebd5c57a1", + "origin": "Application (Azure AD Identity Governance Insights)", + "value": "Subscriptions.ReadWrite" + }, + { + "description": "AD Notification Teams consent", + "displayName": "AD Notification Teams consent", + "id": "0c80a1ae-d4a4-4dfc-acde-ec00fbf86fe8", + "origin": "Delegated (Azure AD Notification)", + "value": "access-as-user" + }, + { + "description": "Allows a user to manage an application's notifications and templates..", + "displayName": "Application.ReadWrite", + "id": "0a8cab7d-5369-41c7-ae0f-dc63a8f0465a", + "origin": "Delegated (Azure AD Notification)", + "value": "Application.ReadWrite" + }, + { + "description": "Allows the app to send which accounts are managed by the PAM solution for all AD and Entra identities for MDI customers.", + "displayName": "Send which accounts are managed by the PAM solution", + "id": "850e8a94-5d16-40ff-9167-cfda8c7f9ea8", + "origin": "Application (Azure Advanced Threat Protection)", + "value": "Identity.PrivilegeAccountTagging" + }, + { + "description": "Allow the app to access resources on behalf of the signed-in user.", + "displayName": "Access ADME", + "id": "b51d4d2d-b434-4627-8f1d-6684a79793e7", + "origin": "Delegated (Azure Data Manager for Energy)", + "value": "user_impersonation" + }, + { + "description": "Allow the application to access Azure Device Update on behalf of the signed-in user.", + "displayName": "Access Azure Device Update", + "id": "884024e2-2000-4ca9-aaad-4436358e330c", + "origin": "Delegated (Azure Device Update)", + "value": "user_impersonation" + }, + { + "description": "Grants the ability to read your load test runs, test results, and APM artifacts.", + "displayName": "Load test (read)", + "id": "e000c422-1bec-45ec-9d30-083f21df9d04", + "origin": "Application (Azure DevOps)", + "value": "vso.loadtest" + }, + { + "description": "Grants the ability to read installed extensions.", + "displayName": "Extensions (read)", + "id": "8fd343dd-9d94-4128-b3a3-f0ba1b869463", + "origin": "Delegated (Azure DevOps)", + "value": "vso.extension" + }, + { + "description": "Grants the ability to read data (settings and documents) stored by installed extensions.", + "displayName": "Extension data (read)", + "id": "9a68ae69-5073-4b4d-bb8a-d9f5acc6f008", + "origin": "Delegated (Azure DevOps)", + "value": "vso.extension.data" + }, + { + "description": "Grants the ability to read and write data (settings and documents) stored by installed extensions.", + "displayName": "Extension data (read and write)", + "id": "c144cb3f-c759-4b9f-991d-37b7b8877072", + "origin": "Delegated (Azure DevOps)", + "value": "vso.extension.data_write" + }, + { + "description": "Grants the ability to install, uninstall, and perform other administrative actions on installed extensions.", + "displayName": "Extensions (read and manage)", + "id": "657d7b2a-c30e-48d1-8041-f563ec1c94fa", + "origin": "Delegated (Azure DevOps)", + "value": "vso.extension_manage" + }, + { + "description": "Grants read access to public and private items and publishers.", + "displayName": "Marketplace", + "id": "c2353c51-3f94-413b-b7b1-083b387258c0", + "origin": "Delegated (Azure DevOps)", + "value": "vso.gallery" + }, + { + "description": "Grants read access and the ability to acquire items.", + "displayName": "Marketplace (acquire)", + "id": "87cbee9a-42a4-4213-963a-189cb029f8fa", + "origin": "Delegated (Azure DevOps)", + "value": "vso.gallery_acquire" + }, + { + "description": "Grants read access and the ability to publish and manage items and publishers.", + "displayName": "Marketplace (manage)", + "id": "05ac28f3-1561-4528-8579-c379a0f02805", + "origin": "Delegated (Azure DevOps)", + "value": "vso.gallery_manage" + }, + { + "description": "Grants read access and the ability to upload, update, and share items.", + "displayName": "Marketplace (publish)", + "id": "ac7ed1fb-75be-4f8c-adf3-4d19a5cead08", + "origin": "Delegated (Azure DevOps)", + "value": "vso.gallery_publish" + }, + { + "description": "Grants the ability to read user, group, scope, and group membership information.", + "displayName": "Graph (read)", + "id": "75a97209-eb46-4571-9d6b-777ae5fcb245", + "origin": "Delegated (Azure DevOps)", + "value": "vso.graph" + }, + { + "description": "Grants the ability to read user, group, scope and group membership information, and to add users, groups, and manage group memberships.", + "displayName": "Graph (manage)", + "id": "e5125ad5-f716-4bc5-8688-14499b80567e", + "origin": "Delegated (Azure DevOps)", + "value": "vso.graph_manage" + }, + { + "description": "Grants the ability to read identities and groups.", + "displayName": "Identity (read)", + "id": "e1bca0e2-994e-4688-b5f6-665b49ee1787", + "origin": "Delegated (Azure DevOps)", + "value": "vso.identity" + }, + { + "description": "Grants the ability to read, write, and manage identities and groups.", + "displayName": "Identity (manage)", + "id": "8b01a8c5-f24c-4740-8104-d74337d52c0f", + "origin": "Delegated (Azure DevOps)", + "value": "vso.identity_manage" + }, + { + "description": "Provides ability to manage deployment group and agent pools.", + "displayName": "Deployment group (read, manage)", + "id": "98b7775c-bc8f-4a14-8ca3-d83bfff24d81", + "origin": "Delegated (Azure DevOps)", + "value": "vso.machinegroup_manage" + }, + { + "description": "Grants the ability to read users, their licenses as well as projects and extensions they can access.", + "displayName": "MemberEntitlement Management (read)", + "id": "eafb48a2-84ed-4179-802a-5d6f1fe452f6", + "origin": "Delegated (Azure DevOps)", + "value": "vso.memberentitlementmanagement" + }, + { + "description": "Grants the ability to manage users, their licenses as well as projects and extensions they can access.", + "displayName": "MemberEntitlement Management (write)", + "id": "7f232b5a-2cf4-410c-833e-7fcb6175eb94", + "origin": "Delegated (Azure DevOps)", + "value": "vso.memberentitlementmanagement_write" + }, + { + "description": "Provides read access to subscriptions and event metadata, including filterable field values.", + "displayName": "Notifications (read)", + "id": "e7fce5bb-fd6c-4f04-9a7d-bc4d67ea64fc", + "origin": "Delegated (Azure DevOps)", + "value": "vso.notification" + }, + { + "description": "Provides access to notification-related diagnostic logs and provides the ability to enable diagnostics for individual subscriptions.", + "displayName": "Notifications (diagnostics)", + "id": "0284cfbf-b7a6-4576-b9f4-cade73cfc16f", + "origin": "Delegated (Azure DevOps)", + "value": "vso.notification_diagnostics" + }, + { + "description": "Provides ability to manage environment", + "displayName": "Environment (read, manage)", + "id": "8f4f9d85-c065-4d6c-8535-99d2998c84bd", + "origin": "Delegated (Azure DevOps)", + "value": "vso.environment_manage" + }, + { + "description": "Provides read only access to licensing entitlements endpoint to get account entitlements.", + "displayName": "Entitlements (Read)", + "id": "c3cbdc26-4b85-4be1-bfb4-af3a552fb28c", + "origin": "Delegated (Azure DevOps)", + "value": "vso.entitlements" + }, + { + "description": "Grants the ability to manage team dashboard information.", + "displayName": "Team dashboards (manage)", + "id": "885358bd-5763-4432-a781-5f2c78eb29e2", + "origin": "Delegated (Azure DevOps)", + "value": "vso.dashboards_manage" + }, + { + "description": "Grants the ability to read team dashboard information.", + "displayName": "Team dashboards (read)", + "id": "e9e366b1-b116-44b7-bd65-575d6bc13fc8", + "origin": "Delegated (Azure DevOps)", + "value": "vso.dashboards" + }, + { + "description": "Grants the ability to create and update load test runs, and read metadata including test results and APM artifacts.", + "displayName": "Load test (read and write)", + "id": "28d646b8-7efa-4ff7-9e39-dfb3a53b7fa6", + "origin": "Application (Azure DevOps)", + "value": "vso.loadtest_write" + }, + { + "description": "Allow the application full access to the REST APIs provided by Visual Studio Team Services on behalf of the signed-in user", + "displayName": "Have full access to Visual Studio Team Services REST APIs", + "id": "ee69721e-6c3a-468f-a9ec-302d16a4c599", + "origin": "Delegated (Azure DevOps)", + "value": "user_impersonation" + }, + { + "description": "Grants the ability to read alerts, result instances, analysis result instances", + "displayName": "AdvancedSecurity (read)", + "id": "78fa0d77-2f93-4844-b309-d46fe87fdc1c", + "origin": "Delegated (Azure DevOps)", + "value": "vso.advsec" + }, + { + "description": "Grants the ability to access sarif upload information, delete analysis, and update alerts", + "displayName": "AdvancedSecurity (read, write, and manage)", + "id": "5a20e9fd-a07c-4c6e-9595-7b78bebaf75d", + "origin": "Delegated (Azure DevOps)", + "value": "vso.advsec_manage" + }, + { + "description": "Grants the ability to upload analyses in sarif", + "displayName": "AdvancedSecurity (read and write)", + "id": "be203134-c456-436a-9be1-c6bd8a5046a4", + "origin": "Delegated (Azure DevOps)", + "value": "vso.advsec_write" + }, + { + "description": "Grants the ability to view tasks, pools, queues, agents, and currently running or recently completed jobs for agents.", + "displayName": "Agent Pools (read)", + "id": "ff83db68-cb4a-4cff-9bfe-285ed2bb9e45", + "origin": "Delegated (Azure DevOps)", + "value": "vso.agentpools" + }, + { + "description": "Grants the ability to manage pools, queues, and agents.", + "displayName": "Agent Pools (read, manage)", + "id": "09370e63-5e5c-4c44-b89a-6368427605d4", + "origin": "Delegated (Azure DevOps)", + "value": "vso.agentpools_manage" + }, + { + "description": "Grants the ability to query analytics.", + "displayName": "Analytics (read)", + "id": "fcd8f1a4-ac62-487a-b198-13632f189646", + "origin": "Delegated (Azure DevOps)", + "value": "vso.analytics" + }, + { + "description": "Provides read and write access to subscriptions and read access to event metadata, including filterable field values.", + "displayName": "Notifications (write)", + "id": "10e32108-6193-4cd9-b405-ab95c87509b0", + "origin": "Delegated (Azure DevOps)", + "value": "vso.notification_write" + }, + { + "description": "Grants the ability to read the auditing log and audit streams to users", + "displayName": "Audit Read Log", + "id": "47446fe8-9e9f-4bb2-bc8b-81a861caeddb", + "origin": "Delegated (Azure DevOps)", + "value": "vso.auditlog" + }, + { + "description": "Grants the ability to access build artifacts, including build results, definitions, and requests, and the ability to receive notifications about build events via service hooks.", + "displayName": "Build (read)", + "id": "0d85fdcb-8267-4af0-857e-7f76b110fbdc", + "origin": "Delegated (Azure DevOps)", + "value": "vso.build" + }, + { + "description": "Grants the ability to access build artifacts, including build results, definitions, and requests, and the ability to queue a build, update build properties, and the ability to receive notifications about build events via service hooks.", + "displayName": "Build (read and execute)", + "id": "b64406bf-2a08-4182-b51e-f51dd0f6d5a3", + "origin": "Delegated (Azure DevOps)", + "value": "vso.build_execute" + }, + { + "description": "Grants the ability to read source code and metadata about commits, changesets, branches, and other version control artifacts. Also grants the ability to search code and get notified about version control events via service hooks.", + "displayName": "Code (read)", + "id": "b325850d-aa53-41ed-b77a-c5036b2f39fa", + "origin": "Delegated (Azure DevOps)", + "value": "vso.code" + }, + { + "description": "Grants full access to source code, metadata about commits, changesets, branches, and other version control artifacts. Also grants the ability to create and manage code repositories, create and manage pull requests and code reviews, and to receive notifications about version control events via service hooks. Also includes limited support for Client OM APIs.", + "displayName": "Code (full)", + "id": "9aae797f-f2fc-47b9-bae7-1db49fdd874b", + "origin": "Delegated (Azure DevOps)", + "value": "vso.code_full" + }, + { + "description": "Grants the ability to read, update, and delete source code, access metadata about commits, changesets, branches, and other version control artifacts. Also grants the ability to create and manage code repositories, create and manage pull requests and code reviews, and to receive notifications about version control events via service hooks.", + "displayName": "Code (read, write, and manage)", + "id": "5f1d8cdf-acb3-47db-b79d-e0c6f18e262d", + "origin": "Delegated (Azure DevOps)", + "value": "vso.code_manage" + }, + { + "description": "Grants the ability to read and write commit and pull request status.", + "displayName": "Code (status)", + "id": "7082e756-8e76-4ebc-a2b0-353809a642c2", + "origin": "Delegated (Azure DevOps)", + "value": "vso.code_status" + }, + { + "description": "Grants the ability to read, update, and delete source code, access metadata about commits, changesets, branches, and other version control artifacts. Also grants the ability to create and manage pull requests and code reviews and to receive notifications about version control events via service hooks.", + "displayName": "Code (read and write)", + "id": "028ffaf1-6f06-490a-979e-38011f92fb7c", + "origin": "Delegated (Azure DevOps)", + "value": "vso.code_write" + }, + { + "description": "Grants the ability to access endpoints needed from an onprem connected server", + "displayName": "Connected Server", + "id": "c994c1ad-fd6d-42ae-9af7-20d4820fe36c", + "origin": "Delegated (Azure DevOps)", + "value": "vso.connected_server" + }, + { + "description": "Grants the ability to manage auditing streams to users", + "displayName": "Audit Streams (manage)", + "id": "ba2781d8-d6df-4b58-ac73-d80e7cdd25cd", + "origin": "Delegated (Azure DevOps)", + "value": "vso.auditstreams_manage" + }, + { + "description": "This allows app to run prod tenant userscope in DLS", + "displayName": "UserScope.ReadWrite.All", + "id": "6f6965e3-3c5a-47e2-81a6-9c40ddacc7f6", + "origin": "Application (DirectoryLookupService)", + "value": "UserScope.ReadWrite.All" + }, + { + "description": "Allows user to read Condition resources in their own compartment.", + "displayName": "user.Condition.read", + "id": "dd554abf-e473-4190-8382-9b096fe49efa", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "user.Condition.read" + }, + { + "description": "Allows user to read DiagnosticReport resources in their own compartment.", + "displayName": "user.DiagnosticReport.read", + "id": "588567a0-59db-4598-8bd7-0cbea9ff1811", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "user.DiagnosticReport.read" + }, + { + "description": "c", + "displayName": "Skype Bot Reviewer", + "id": "ae068e81-caaf-43a2-8081-717c1fb700d0", + "origin": "Application (Bot Framework Dev Portal)", + "value": "SkypeReviewer" + }, + { + "description": "Read-only access to Bcos resources", + "displayName": "Bcos.ReadOnly", + "id": "c6a7f3e3-dea5-4df4-a094-59ceca797083", + "origin": "Application (Branch Connect Web Service)", + "value": "Bcos.ReadOnly" + }, + { + "description": "Read-write access to Bcos resources", + "displayName": "Bcos.ReadWrite", + "id": "aa6b9a9f-c72d-4834-a656-bb689b56844b", + "origin": "Application (Branch Connect Web Service)", + "value": "Bcos.ReadWrite" + }, + { + "description": "Read access to Branch Connect resources", + "displayName": "crosstenant.access", + "id": "231695ae-e8c8-44aa-a5ea-e976ffb46667", + "origin": "Application (Branch Connect Web Service)", + "value": "crosstenant.access" + }, + { + "description": "General access to Branch Connect tenanted resources", + "displayName": "intratenant.access", + "id": "fa7e7dba-1436-4a97-8144-94896b64b1bb", + "origin": "Application (Branch Connect Web Service)", + "value": "intratenant.access" + }, + { + "description": "ISV parter access to opt in or opt out Path Feedback", + "displayName": "Bcos.IsvPartner", + "id": "33cd71af-81fb-4558-b105-eb7a5f4c3191", + "origin": "Delegated (Branch Connect Web Service)", + "value": "Bcos.IsvPartner" + }, + { + "description": "Allows application to invite users to tenant", + "displayName": "Invite users to tenant", + "id": "7431916a-fe67-40b3-b273-602ffe516093", + "origin": "Application (CABProvisioning)", + "value": "user.invite.all" + }, + { + "description": "Allows the calling app to invoke Commerce Pricing APIs as the signed-in user.", + "displayName": "Access Commerce Pricing APIs on behalf of the signed-in user", + "id": "a7558686-77a5-4d0c-8563-d803e81c784c", + "origin": "Delegated (CCM_Pricing_PROD)", + "value": "CommercePricingAPIs.Access" + }, + { + "description": "Allows the app to read and deploy composite solutions, on your behalf", + "displayName": "Read and write composite solutions", + "id": "35bae7a0-c6c0-4835-b671-89f0f1736121", + "origin": "Delegated (CompositeSolutions-Canary)", + "value": "Deployment.ReadWrite" + }, + { + "description": "This allows applications to call CRS APIs.", + "displayName": "Call all CRS APIs", + "id": "f155dcbc-e393-438b-b343-92699349582d", + "origin": "Application (Compute Recommendation Service)", + "value": "Crs.Api.All" + }, + { + "description": "Allows to call service API to query account onboarding data", + "displayName": "Read account onboarding data", + "id": "c8750fbf-30e2-40ed-8519-e2876cbeccb9", + "origin": "Application (Configuration Manager Microservice)", + "value": "Account.Read.All" + }, + { + "description": "Allows to call service API to query or modify account onboarding data", + "displayName": "Read or write account onboarding data", + "id": "76e55082-bbde-4602-b506-bba9c6368668", + "origin": "Application (Configuration Manager Microservice)", + "value": "Account.ReadWrite.All" + }, + { + "description": "Allows user to perform administrative actions", + "displayName": "Admin User", + "id": "c22b119a-bb68-45ab-8a0a-ef26bc7a66e7", + "origin": "Application (Configuration Manager Microservice)", + "value": "AdminUser.ReadWrite" + }, + { + "description": "Allows to call service API to query boundary data", + "displayName": "Read boundary data", + "id": "b026af69-9eae-4bb3-9351-304d4987e170", + "origin": "Application (Configuration Manager Microservice)", + "value": "Boundary.Read.All" + }, + { + "description": "Allows services to call APIs that return SCCM collection membership data", + "displayName": "Read CM Collection Data", + "id": "1ce9e34f-35e8-4af8-b172-09e4dd00453d", + "origin": "Application (Configuration Manager Microservice)", + "value": "CmCollectionData.read" + }, + { + "description": "Allows services to write collection membership data to storage in the Configuration Manager microservice", + "displayName": "Write CM Collection Data", + "id": "a7a0e953-7ed2-423c-849f-9d78b5e44612", + "origin": "Application (Configuration Manager Microservice)", + "value": "CmCollectionData.write" + }, + { + "description": "c", + "displayName": "RBAC Test Role Prod", + "id": "b6c09b98-4044-4869-976f-625da4f561c3", + "origin": "Application (Bot Framework Dev Portal)", + "value": "RBACTestRoleProd" + }, + { + "description": "Allows to call service API to query collection data", + "displayName": "Read collection data", + "id": "66a874a2-b739-43fa-8e1a-176cd8290cf5", + "origin": "Application (Configuration Manager Microservice)", + "value": "Collection.Read.All" + }, + { + "description": "Support engineers for prod devportal", + "displayName": "Prod Devportal Support", + "id": "b1de6b77-7554-4b4d-9db5-dc90af4bbe89", + "origin": "Application (Bot Framework Dev Portal)", + "value": "ProdSupport" + }, + { + "description": "Users of scratch / ppe devportal", + "displayName": "Internal Devportal User", + "id": "0e91f604-29c5-475d-9463-5494ee9b147e", + "origin": "Application (Bot Framework Dev Portal)", + "value": "IntUser" + }, + { + "description": "Allows the app to search all calendars and to read their properties on behalf of the signed-in user. ", + "displayName": "Read all calendars", + "id": "73c5d1d0-1ba7-4978-ad4c-32f0a8a1a9ed", + "origin": "Delegated (Bing)", + "value": "Calendar.Read.All" + }, + { + "description": "Allows the app to search all calendars and to read their properties for default on behalf of the signed-in user. ", + "displayName": "Read all calendars for default", + "id": "46089125-31ba-451a-96a1-278c9490b608", + "origin": "Delegated (Bing)", + "value": "Calendars.Read" + }, + { + "description": "Allows the app to read Copilot product eligibility information, on behalf of the signed-in user.", + "displayName": "Read user Copilot product eligibility information", + "id": "9cc9bf6f-c54f-4db7-801a-a3c0a4f7ea7c", + "origin": "Delegated (Bing)", + "value": "CopilotEligibility.Read" + }, + { + "description": "Allows the app to read Copilot product configuration information, on behalf of the signed-in user.", + "displayName": "Read user Copilot product configuration information", + "id": "cdedc077-0f6e-4cf8-ab81-44ba9d14983c", + "origin": "Delegated (Bing)", + "value": "CopilotSettings.ReadWrite" + }, + { + "description": "Allows the app to read all files the signed-in user can access.", + "displayName": "Read all files that user can access", + "id": "79bb59ea-208c-4cff-881e-098caabe543a", + "origin": "Delegated (Bing)", + "value": "Files.Read.All" + }, + { + "description": "Allows the app to list floor plans and to read their properties on behalf of the signed-in user. ", + "displayName": "Read all floor plans", + "id": "ff60b1a1-5694-4b26-9c81-a5f4fabf51f5", + "origin": "Delegated (Bing)", + "value": "FloorPlan.Read.All" + }, + { + "description": "Allows the app to list groups, and to read their properties and all group memberships on behalf of the signed-in user. Also allows the app to read calendar, conversations, files, and other group content for all groups the signed-in user can access.", + "displayName": "Read all groups", + "id": "fe6d53fc-0936-42ba-8388-d39c6855c3f2", + "origin": "Delegated (Bing)", + "value": "Group.Read.All" + }, + { + "description": "Allows the app to read news feeds and to read their properties on behalf of the signed-in user. ", + "displayName": "Read news feed", + "id": "c11daebe-235e-4429-ab4c-43569661ff2a", + "origin": "Delegated (Bing)", + "value": "NewsFeed.Read" + }, + { + "description": "Allows the app to list QnA and to read their properties on behalf of the signed-in user. ", + "displayName": "Read all QnA", + "id": "bfc6f88b-6314-451d-ac7c-501307ad192a", + "origin": "Delegated (Bing)", + "value": "QnA.Read.All" + }, + { + "description": "Allows the app to read the full set of profile properties, reports, and managers of other users in your organization, on behalf of the signed-in user.", + "displayName": "Read all users' full profiles", + "id": "9ee66b54-9cf0-41b8-87da-d62f8c21222b", + "origin": "Delegated (Bing)", + "value": "User.Read.All" + }, + { + "description": "Allows the app to fetch web grounded data for Copilot", + "displayName": "Access web grounding", + "id": "516d1055-ed6f-4e63-922d-eb17aef59604", + "origin": "Delegated (Bing)", + "value": "Web.Read" + }, + { + "description": "c", + "displayName": "Bing Bot Reviewer", + "id": "c9774c15-ca59-44c2-8934-14892b976eeb", + "origin": "Application (Bot Framework Dev Portal)", + "value": "BingReviewer" + }, + { + "description": "operator who can manage how user access DiretlineSpeech channel", + "displayName": "DirectlineSpeech Channel Operator", + "id": "8445d019-58a2-4852-8169-28bb272b72da", + "origin": "Application (Bot Framework Dev Portal)", + "value": "channelOperator_DirectlineSpeech" + }, + { + "description": "users with this role are automatically placed on the cortana1PSkills flight", + "displayName": "cortana 1P skills", + "id": "7ee5d4d7-5187-475f-bf60-e29825067173", + "origin": "Application (Bot Framework Dev Portal)", + "value": "flight_cortana1PSkills" + }, + { + "description": "users with this role are automatically placed in the cortanaPreview flight", + "displayName": "cortana preview flight role", + "id": "c97c1572-9874-40a8-905a-9e00c9fb7e19", + "origin": "Application (Bot Framework Dev Portal)", + "value": "flight_cortanaPreview" + }, + { + "description": "Admins for scratch / ppe devportal ", + "displayName": "Internal Devportal Admin", + "id": "d5936425-7cdb-467d-990c-de5b9dfecc93", + "origin": "Application (Bot Framework Dev Portal)", + "value": "IntAdmin" + }, + { + "description": "Support engineers for scratch / ppe devportal", + "displayName": "Internal Devportal Support", + "id": "3c23c4fc-15c2-43ff-b26c-ea2fb7f2cac7", + "origin": "Application (Bot Framework Dev Portal)", + "value": "IntSupport" + }, + { + "description": "Admins for prod devportal", + "displayName": "Prod Devportal Admin", + "id": "9160be5e-b0e2-4961-9419-21dc535897ca", + "origin": "Application (Bot Framework Dev Portal)", + "value": "ProdAdmin" + }, + { + "description": "Allows to call service API to query device data", + "displayName": "Read device data", + "id": "15fdfc00-27d5-4f79-8a38-8efe91e3c1cc", + "origin": "Application (Configuration Manager Microservice)", + "value": "Device.Read.All" + }, + { + "description": "Allows to call service API to query or modify device data", + "displayName": "Read or write device data", + "id": "4b03fb80-ef9e-4391-86c9-152c41bf0693", + "origin": "Application (Configuration Manager Microservice)", + "value": "Device.ReadWrite.All" + }, + { + "description": "Allows to call service API to query inventory class", + "displayName": "Read inventory class", + "id": "d430b935-3087-4654-8d58-25faba0dabb5", + "origin": "Application (Configuration Manager Microservice)", + "value": "InventoryClass.Read.All" + }, + { + "description": "Permission to create seeding offer", + "displayName": "CREATE/START Seeding offer for a tenant", + "id": "072da657-3fd6-47c8-914c-384bed197d2a", + "origin": "Application (Consumption Billing)", + "value": "Purview.Offer.Seeding.Create" + }, + { + "description": "Read Permission for seeding offers", + "displayName": "READ Seeding offers for a tenant", + "id": "f3f3f5b3-6e2e-4f6b-8f3c-6e2e2b1e4f4a", + "origin": "Application (Consumption Billing)", + "value": "Purview.Offer.Seeding.Read" + }, + { + "description": "Allows the app to create, read, update and delete applications and service principals on behalf of the signed-in user. Does not allow management of consent grants.", + "displayName": "Read and write all applications (preview)", + "id": "16ad0ec2-1d62-4034-9a8e-88b445cc30ec", + "origin": "Delegated (Consumption Billing)", + "value": "Application.ReadWrite.All" + }, + { + "description": "Permissions for an internal Cortana application to read and write all Cortana user data", + "displayName": "BingCortana-Internal.ReadWrite", + "id": "f78726f6-cf77-45f6-ae8b-87d4b8bd7a2c", + "origin": "Application (Cortana at Work Bing Services)", + "value": "BingCortana-Internal.ReadWrite" + }, + { + "description": "Permissions for an internal Cortana application to read and write all Cortana user data", + "displayName": "BingCortana-Internal.ReadWrite", + "id": "ab725b17-4fd4-46ee-a0bf-102895a209e3", + "origin": "Application (Cortana at Work Service)", + "value": "BingCortana-Internal.ReadWrite" + }, + { + "description": "Permission for internal Cortana applications", + "displayName": "BingCortana-Internal.ReadWrite", + "id": "c9265686-1717-4d25-a640-1f46263a162c", + "origin": "Application (Cortana Runtime Service)", + "value": "BingCortana-Internal.ReadWrite" + }, + { + "description": "Scope for Semantic Machines context access", + "displayName": "SemanticMachineContext.ReadWrite", + "id": "47d1397b-a828-452a-9b8d-2796c4e65f4e", + "origin": "Application (Cortana Runtime Service)", + "value": "SemanticMachineContext.ReadWrite" + }, + { + "description": "allows access to read DeploymentTask and UpdatePolicy", + "displayName": "DeploymentTask.Read", + "id": "a0f3e90e-0c99-4c7d-bfaf-69531a09579c", + "origin": "Application (DeploymentScheduler)", + "value": "deploymenttask.read" + }, + { + "description": "allows access to read or write deployment task and update policies", + "displayName": "DeploymentTask.ReadWrite", + "id": "64b22404-2ac7-4ca5-a15d-607e62d2694b", + "origin": "Application (DeploymentScheduler)", + "value": "deploymenttask.readwrite" + }, + { + "description": "Allow the application permissions to delete any device registered to the signed-in user", + "displayName": "User can delete devices that belong to them", + "id": "086327cd-9afe-4777-8341-b136a1866bb3", + "origin": "Delegated (Device Registration Service)", + "value": "self_service_device_delete" + }, + { + "description": "Allow the application to read profiler traces, insights, and diagnostic data on behalf of the signed-in user", + "displayName": "Read Diagnostic Services data", + "id": "ada81ce7-1959-4df2-81a5-cd710022e0c5", + "origin": "Delegated (Diagnostic Services Data Access)", + "value": "DataAccess.Read" + }, + { + "description": "Allow the application to read and modify profiler settings, trigger profiling sessions, upload symbols, and manage diagnostic data on behalf of the signed-in user", + "displayName": "Read and write Diagnostic Services data", + "id": "ce194c4c-ad00-4826-8328-102bb21ec298", + "origin": "Delegated (Diagnostic Services Data Access)", + "value": "DataAccess.ReadWrite" + }, + { + "description": "Allow the application to access Diagnostic Services on behalf of the signed-in user", + "displayName": "Access Diagnostic Services", + "id": "384a9d29-7ed2-4fc4-b781-1aa48cb2b883", + "origin": "Delegated (Diagnostic Services Data Access)", + "value": "user_impersonation" + }, + { + "description": "This allows app to access prod user profile", + "displayName": "Users.Read.All", + "id": "b0dc367b-7342-44c8-9816-d5f9ade99d5d", + "origin": "Application (DirectoryLookupService)", + "value": "Users.Read.All" + }, + { + "description": "Permission to update seeding offer consumption", + "displayName": "Update consumption against a feature as part of seeding offers", + "id": "b1e2f3d4-5c6b-7a8d-9e0f-1a2b3c4d5e6f", + "origin": "Application (Consumption Billing)", + "value": "Purview.Offer.Seeding.ConsumedUnits.Write" + }, + { + "description": "Read Permission for free trials", + "displayName": "READ Free Trials for a tenant", + "id": "7677749a-98a0-4fb4-ab2b-a0c1f445b393", + "origin": "Application (Consumption Billing)", + "value": "Purview.Offer.FreeTrial.Read" + }, + { + "description": "Permission to create/start free trials", + "displayName": "CREATE/START Free Trials for a tenant", + "id": "6061d6f3-95e7-4aef-b53f-81967905b679", + "origin": "Application (Consumption Billing)", + "value": "Purview.Offer.FreeTrial.Create" + }, + { + "description": "Permission to update free trials consumption", + "displayName": "Update consumption against a feature as part of free trials", + "id": "30d79437-9413-4983-80b2-b9c64df69cde", + "origin": "Application (Consumption Billing)", + "value": "Purview.Offer.FreeTrial.ConsumedUnits.Write" + }, + { + "description": "Allows to call service API to query notification and notification result", + "displayName": "Read notification or notification result", + "id": "20bd8bbf-3063-4a8f-ae4f-f2f5e5bda666", + "origin": "Application (Configuration Manager Microservice)", + "value": "Notification.Read.All" + }, + { + "description": "Allows to call service API to query or modify notification and notification result", + "displayName": "Read or write notification and notification result", + "id": "0db7d603-2368-4c9a-8f47-adc9ac73e5e1", + "origin": "Application (Configuration Manager Microservice)", + "value": "Notification.ReadWrite.All" + }, + { + "description": "Allows the app to list buildings and to read their properties on behalf of the signed-in user. ", + "displayName": "Read all buildings", + "id": "b74d6cc7-732d-424d-9f47-b08e1404f765", + "origin": "Delegated (Bing)", + "value": "Building.Read.All" + }, + { + "description": "Allows the app to create and manage connector configurations. The app would use the connector configuration to send actionable messages to your inbox or a group of your choice.", + "displayName": "Read and write connector configurations", + "id": "ba9c6a98-63fd-487c-b835-c1f895764e25", + "origin": "Delegated (Connectors)", + "value": "webhook.readwrite.all" + }, + { + "description": "Authorized to call the Consumption Billing reporting API for a single tenant id", + "displayName": "GET Consumption Bill Report for a tenant", + "id": "866040a7-8984-4760-8e56-363aefb78d69", + "origin": "Application (Consumption Billing)", + "value": "CBS.Reporting.Read" + }, + { + "description": "Authorized to call the Consumption Billing reporting API for a single tenant id", + "displayName": "GET Consumption Bill Report for any tenant", + "id": "387f12b7-02f9-4763-ae1d-1686945340fc", + "origin": "Application (Consumption Billing)", + "value": "CBS.Reporting.Read.Any" + }, + { + "description": "Authorized to call the GLSaccount for a single tenant id", + "displayName": "GLS Read Permissions", + "id": "4fbf572c-d471-4816-a804-873ab98e6356", + "origin": "Application (Consumption Billing)", + "value": "Gls.Tenant.Forest.Read" + }, + { + "description": "Authorized to call the GLSaccount for any tenant id", + "displayName": "GLS Read Permissions", + "id": "0954fe74-24eb-11f0-b752-325096b39f47", + "origin": "Application (Consumption Billing)", + "value": "Gls.Tenant.Forest.Read.Any" + }, + { + "description": "Authorized to call the Consumption Billing Post API for a single tenant id to enable/disable billing consent at feature level", + "displayName": "POST API to update the billing consent", + "id": "389f13b7-02f9-4763-ae1d-1686945340fc", + "origin": "Application (Consumption Billing)", + "value": "Purview.Account.Feature.State.Write" + }, + { + "description": "Authorized to call the purview account for a single tenant id", + "displayName": "GET Purview Account for a tenant", + "id": "228c9ab9-eb11-4548-a16a-7efb4ed58162", + "origin": "Application (Consumption Billing)", + "value": "Purview.Account.Read" + }, + { + "description": "Authorized to call the purview account for any tenant id", + "displayName": "GET Purview Account for any tenant", + "id": "693c4235-83dd-4f0c-baca-a30c2826c9a8", + "origin": "Application (Consumption Billing)", + "value": "Purview.Account.Read.Any" + }, + { + "description": "Access Connections Service Api", + "displayName": "Access Connections Service Api", + "id": "04d2d44f-432b-4f9b-be28-cb651a028099", + "origin": "Delegated (ConnectionsService)", + "value": "user_impersonation" + }, + { + "description": "Allows user to read Device resources in their own compartment.", + "displayName": "user.Device.read", + "id": "a246e7b9-d55c-43a2-9b5a-b3341bc1a57d", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "user.Device.read" + }, + { + "description": "Allows the app to list bookmarks and to read their properties on behalf of the signed-in user. ", + "displayName": "Read all bookmarks", + "id": "e017a1f7-f5a6-4dc5-a7b6-4342362e299b", + "origin": "Delegated (Bing)", + "value": "Bookmark.Read.All" + }, + { + "description": "Allows members of a special preview group to use experimental features of Bing", + "displayName": "Preview User", + "id": "cfc0dc64-9211-4513-9d32-c387680182cf", + "origin": "Application (Bing)", + "value": "bawuser" + }, + { + "description": "Allow the application full access to the Azure Key Vault service on behalf of the signed-in user", + "displayName": "Have full access to the Azure Key Vault service", + "id": "f53da476-18e3-4152-8e01-aec403e6edc0", + "origin": "Delegated (Azure Key Vault)", + "value": "user_impersonation" + }, + { + "description": "this allows to read user profile", + "displayName": "user.read", + "id": "34a47c2f-cd0d-47b4-a93c-2c41130c671c", + "origin": "Delegated (Azure Kubernetes Service AAD Server)", + "value": "user.read" + }, + { + "description": "Consent for Azure Machine Learning Service", + "displayName": "user_impersonation", + "id": "1a7925b5-f871-417a-9b8b-303f9f29fa10", + "origin": "Delegated (Azure Machine Learning Services)", + "value": "user_impersonation" + }, + { + "description": "Allow the application to access Azure Maps on behalf of the signed-in user.", + "displayName": "Access Azure Maps", + "id": "1c3162b4-de12-4eb3-b521-088e4e2d6915", + "origin": "Delegated (Azure Maps)", + "value": "user_impersonation" + }, + { + "description": "Azure media service API allows creating, modifying, viewing & deleting of media assets", + "displayName": "Access azure media service as signed-in user", + "id": "59f34ebf-a919-4365-9cc3-00193275099c", + "origin": "Delegated (Azure Media Services)", + "value": "access_media_service" + }, + { + "description": "Allows the app to read and write MCP tools on behalf of the user.", + "displayName": "Read and write MCP tools", + "id": "66cf23b6-becd-4117-8b55-28aba2d06b9b", + "origin": "Delegated (Azure Migrate AI Assistant)", + "value": "Mcp.Tools.ReadWrite" + }, + { + "description": "This scope allows Azure Monitor Agents to access Azure Monitor backends on behalf of the Client Device.", + "displayName": "Read Data Collection Rules", + "id": "8a4fc8e7-d346-4d67-a1d3-e83b55cf3659", + "origin": "Delegated (Azure Monitor Control Service)", + "value": "AMA.Ingest" + }, + { + "description": "Allows the app to access GeoCatalogs on behalf of the signed-in user.", + "displayName": "Access GeoCatalogs", + "id": "870e9a7e-1d10-42b2-85dc-2cd2b2cd656b", + "origin": "Delegated (Azure Orbital Planetary Computer)", + "value": "user_impersonation" + }, + { + "description": "Access OSSRDBMS services from the application", + "displayName": "OSSRDBMS Azure", + "id": "017211c5-049f-46b5-a0f0-bcc46299e550", + "origin": "Application (Azure OSSRDBMS Database)", + "value": "app_impersonation" + }, + { + "description": "Access OSSRDBMS services as a user", + "displayName": "Access OSSRDBMS services", + "id": "cef99a3a-4cd3-4408-8143-4375d1e38a17", + "origin": "Delegated (Azure OSSRDBMS Database)", + "value": "user_impersonation" + }, + { + "description": "Allow the application full read and write access to the PKI service", + "displayName": "Have full read and write access to the PKI service", + "id": "65b05492-d252-4876-b9fb-0a848447f31a", + "origin": "Delegated (Azure PKI)", + "value": "Pki.ReadWrite.All" + }, + { + "description": "Azure Event Grid Role", + "displayName": "AzureEventGridSecureWebhookSubscriber", + "id": "83262d98-99cb-496d-8da9-e0ceed85d0ed", + "origin": "Application (Azure Resources Topology)", + "value": "AzureEventGridSecureWebhookSubscriber" + }, + { + "description": "Allows users to deploy Azure resources to one or more regions in a orchestrated manner using an ARM based declarative model.", + "displayName": "Azure Service Deploy", + "id": "bc5c4337-74a0-4a05-864d-576511d9621f", + "origin": "Delegated (Azure Service Deploy)", + "value": "Rollouts.ReadWrite.User" + }, + { + "description": "Allows the application to access Azure SignalR Service on behalf of the signed-in user.", + "displayName": "Access Azure SignalR Service", + "id": "d210251d-4053-4044-ba08-3cb30c4cfcdc", + "origin": "Delegated (Azure SignalR Service Resource Provider)", + "value": "user_impersonation" + }, + { + "description": "Allows the app to create and get a new InvitationId for tenant and billing account creation.", + "displayName": "CreateInvitation20230101", + "id": "b39d576f-c5a3-4820-b851-00060443f895", + "origin": "Application (Azure Signup Api)", + "value": "CreateInvitation20230101" + }, + { + "description": "Allow user to add charity qualification.", + "displayName": "Qualification_Charity", + "id": "0fab173d-4ed1-478b-976a-8c8ee219758b", + "origin": "Application (Azure Signup Api)", + "value": "Qualification_Charity" + }, + { + "description": "Allow user to add commercial qualification.", + "displayName": "Qualification_Commercial", + "id": "f04d1efa-b98b-4a56-ad63-a259c4859fdd", + "origin": "Application (Azure Signup Api)", + "value": "Qualification_Commercial" + }, + { + "description": "Allow the application to access Inference Service on behalf of the signed-in user.", + "displayName": "Access Inference Service", + "id": "a1e1f816-e7ca-4a34-900a-f863a751e400", + "origin": "Delegated (Azure Inference Service)", + "value": "Azure.Inference.User" + }, + { + "description": "Allow user to add dod qualification.", + "displayName": "Qualification_DOD", + "id": "d0175eef-86a5-4f91-ba11-e69a87656565", + "origin": "Application (Azure Signup Api)", + "value": "Qualification_DOD" + }, + { + "description": "Have the permissions to read models, deployments and environments.", + "displayName": "Inference ML reader", + "id": "55d7b9ab-4730-4d3e-9e2c-0130c1e764aa", + "origin": "Application (Azure Inference Service)", + "value": "Azure.Inference.MLReader" + }, + { + "description": "Have the permissions to execute inference calls.", + "displayName": "Inference executor", + "id": "92ad2108-b071-46c2-8ac0-1dcf9a2c4fd6", + "origin": "Application (Azure Inference Service)", + "value": "Azure.Inference.Executor" + }, + { + "description": "Allows user to read DocumentReference resources in their own compartment.", + "displayName": "user.DocumentReference.read", + "id": "23b15307-5f89-4954-b86c-00e2c4279a8f", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "user.DocumentReference.read" + }, + { + "description": "Allows user to read Encounter resources in their own compartment.", + "displayName": "user.Encounter.read", + "id": "0e5be5d5-7b95-4583-aa3c-2de990f139c9", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "user.Encounter.read" + }, + { + "description": "Allows user to read Goal resources in their own compartment.", + "displayName": "user.Goal.read", + "id": "81d580ea-26f6-4822-9d91-31fb29019023", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "user.Goal.read" + }, + { + "description": "Allows user to read Immunization resources in their compartment.", + "displayName": "user.Immunization.read", + "id": "97d73b5a-b052-4953-a6da-3e3f159a2f85", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "user.Immunization.read" + }, + { + "description": "Allows user to read Location resources in their own compartment.", + "displayName": "user.Location.read", + "id": "cf8b4a64-fb79-401f-94a4-28d9d7cd6a8d", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "user.Location.read" + }, + { + "description": "Allows user to read Medication resources in their own compartment.", + "displayName": "user.Medication.read", + "id": "e5a7cdb9-3edb-4b0a-a43d-40a377726680", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "user.Medication.read" + }, + { + "description": "Allows user to read MedicationRequest resources in their own compartment.", + "displayName": "user.MedicationRequest.read", + "id": "c1519c05-9e3c-4656-844a-c83845bbaa9a", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "user.MedicationRequest.read" + }, + { + "description": "Allows user to read Observation resources in their own compartment.", + "displayName": "user.Observation.read", + "id": "556e34b1-56e8-4c27-b6be-679d856c0efa", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "user.Observation.read" + }, + { + "description": "Allows user to read Organization resources in their own compartment.", + "displayName": "user.Organization.read", + "id": "e75606ef-f141-4339-a248-f4c9b07886b2", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "user.Organization.read" + }, + { + "description": "Allows user to read Patient resources in their own compartment.", + "displayName": "user.Patient.read", + "id": "56998e01-1f00-4832-a130-c358e252acf2", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "user.Patient.read" + }, + { + "description": "Allows user to read Practitioner resources in their own compartment.", + "displayName": "user.Practitioner.read", + "id": "079186df-3044-4484-a785-d9750101f8f3", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "user.Practitioner.read" + }, + { + "description": "Allows user to read PractitionerRole resources in their own compartment.", + "displayName": "user.PractitionerRole.read", + "id": "ae77fe95-13db-47f8-968c-5eee9d973273", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "user.PractitionerRole.read" + }, + { + "description": "Allows user to read Procedure resources in their own compartment.", + "displayName": "user.Procedure.read", + "id": "81d46baa-aaff-454b-9a35-b6fdaa0eb2d9", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "user.Procedure.read" + }, + { + "description": "Allows user to read Provenance resources in their own compartment.", + "displayName": "user.Provenance.read", + "id": "8d821765-6bbb-4f52-8b7f-0dfe9ce5cb1e", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "user.Provenance.read" + }, + { + "description": "Allow the application to access Azure Healthcare APIs on behalf of the signed-in user.", + "displayName": "Access Azure Healthcare APIs", + "id": "db75143a-8f20-4238-9450-8b73ef4992f4", + "origin": "Delegated (Azure Healthcare APIs)", + "value": "user_impersonation" + }, + { + "description": "Azure Import Export service for massive data transferring by shipping disks", + "displayName": "Azure Import Export", + "id": "26d59185-6d22-461c-98e2-2cd4bcc6911b", + "origin": "Application (Azure Import Export)", + "value": "user_impersonation" + }, + { + "description": "Have the permissions to run administrative operations in the App Service layer", + "displayName": "Inference administrator", + "id": "ae7e897c-6c70-4824-ba03-bfcc9ad84c64", + "origin": "Application (Azure Inference Service)", + "value": "Azure.Inference.Admin" + }, + { + "description": "Have the permissions to create and modify models, deployments and environments; update traffic of deployments and perform; and scale up deployments.", + "displayName": "Inference ML administrator", + "id": "90d1b19a-1849-4c47-9d91-ed1842c92f52", + "origin": "Application (Azure Inference Service)", + "value": "Azure.Inference.MLAdministrator" + }, + { + "description": "Allow user to add gcc high qualification.", + "displayName": "Qualification_GCCHigh", + "id": "ab0c514c-6a2b-4a77-81bc-74019dff79d3", + "origin": "Application (Azure Signup Api)", + "value": "Qualification_GCCHigh" + }, + { + "description": "Allow user to add government qualification.", + "displayName": "Qualification_Government", + "id": "3e74b245-2d1f-4ffb-a5e8-9a05aceca540", + "origin": "Application (Azure Signup Api)", + "value": "Qualification_Government" + }, + { + "description": "Provides delegated role to the caller.", + "displayName": "SignupPlatformDelegatedRole", + "id": "8de2faed-dae7-4e94-8d5d-a49be218c680", + "origin": "Application (Azure Signup Api)", + "value": "SignupPlatformDelegatedRole" + }, + { + "description": "Allows the app to make API calls that require read and write permissions on ContextualSupport Service, on behalf of the signed-in user.", + "displayName": "Make API calls that require read and write permissions on ContextualSupport Service", + "id": "c21cb1c4-59a0-4474-939f-6e57bf66bfde", + "origin": "Delegated (Azure-WaaS-ContextualSupport-Service)", + "value": "ContextualSupport.ReadWrite.All" + }, + { + "description": "Address Customer Master Reader", + "displayName": "AddressCustomerMasterReader", + "id": "1cbc9fa5-af3f-44ad-9455-35ef4dd212aa", + "origin": "Application (Billing)", + "value": "AddressCustomerMasterReader" + }, + { + "description": "Billing Period Reader", + "displayName": "BillingPeriodReader", + "id": "8e62a787-fdef-400f-86a5-c8ba447b1e3a", + "origin": "Application (Billing)", + "value": "BillingPeriodReader" + }, + { + "description": "Customer Offboarding Manager", + "displayName": "CustomerOffboardingManager", + "id": "3c3a3b03-4791-4e89-8753-fa338df5564d", + "origin": "Application (Billing)", + "value": "CustomerOffboardingManager" + }, + { + "description": "Customer Reader", + "displayName": "CustomerReader", + "id": "a589c920-4000-431e-8a26-3c849e60d9e8", + "origin": "Application (Billing)", + "value": "CustomerReader" + }, + { + "description": "Full Trust Billing Subscription Refund Manager", + "displayName": "FullTrustBillingSubscriptionRefundManager", + "id": "501b1467-43a9-475e-9fbb-5e840ff5b597", + "origin": "Application (Billing)", + "value": "FullTrustBillingSubscriptionRefundManager" + }, + { + "description": "FullTrust EA Partner Organizations Reader", + "displayName": "FullTrustEAPartnerOrganizationsReader", + "id": "1224c7b4-5fd0-40e8-a122-2fb278e6d012", + "origin": "Application (Billing)", + "value": "FullTrustEAPartnerOrganizationsReader" + }, + { + "description": "Full Trust Internal Billing Account Reader", + "displayName": "FullTrustInternalBillingAccountReader", + "id": "42f62651-2fce-49b0-8b8a-b2c36bdb2c28", + "origin": "Application (Billing)", + "value": "FullTrustInternalBillingAccountReader" + }, + { + "description": "Full Trust Project Reader", + "displayName": "FullTrustProjectReader", + "id": "a010eb98-c8e4-40c5-afc7-87df9f063e89", + "origin": "Application (Billing)", + "value": "FullTrustProjectReader" + }, + { + "description": "Indirect Reseller Offboarding Manager", + "displayName": "IndirectResellerOffboardingManager", + "id": "ddbd0f71-1ff8-4335-a4b5-420bab16e11e", + "origin": "Application (Billing)", + "value": "IndirectResellerOffboardingManager" + }, + { + "description": "Manger of legacy APIs", + "displayName": "LegacyManager", + "id": "b0096bea-94b4-42b2-990c-6eedd19f4f4a", + "origin": "Application (Billing)", + "value": "LegacyManager" + }, + { + "description": "Partner Details Manager", + "displayName": "PartnerDetailsManager", + "id": "66b0d3be-66cc-465d-852a-87d76566ce29", + "origin": "Application (Billing)", + "value": "PartnerDetailsManager" + }, + { + "description": "Partner Offboarding Manager", + "displayName": "PartnerOffboardingManager", + "id": "54bff1ce-0f2d-4eb3-a2f0-49d67447a80e", + "origin": "Application (Billing)", + "value": "PartnerOffboardingManager" + }, + { + "description": "Project Reader", + "displayName": "ProjectReader", + "id": "88c2cc2d-c66c-4343-83ae-31fd6fd413b6", + "origin": "Application (Billing)", + "value": "ProjectReader" + }, + { + "description": "Refresh Manager", + "displayName": "RefreshManager", + "id": "73ba0d8a-0f84-44f8-9857-fb364934f4db", + "origin": "Application (Billing)", + "value": "RefreshManager" + }, + { + "description": "Rem Policy Manager", + "displayName": "RemPolicyManager", + "id": "03830c81-7471-499e-a514-e86f71829003", + "origin": "Application (Billing)", + "value": "RemPolicyManager" + }, + { + "description": "Subscription Migrator", + "displayName": "SubscriptionMigrator", + "id": "9edf2859-df6b-4ef5-ab1b-fd2837e2f7f7", + "origin": "Application (Billing)", + "value": "SubscriptionMigrator" + }, + { + "description": "Allow the app to submit and retrieve jobs.", + "displayName": "Jobs.ReadWrite", + "id": "52109446-c0d3-4e4f-9e8a-35bbee6b8b7d", + "origin": "Delegated (AzureQuantum)", + "value": "Jobs.ReadWrite" + }, + { + "description": "allows the user to have full access to Azure Databricks", + "displayName": "user_impersonation", + "id": "739272be-e143-11e8-9f32-f2801f1b9fd1", + "origin": "Delegated (AzureDatabricks)", + "value": "user_impersonation" + }, + { + "description": "Allows a user to invoke any write (POST, PUT) operation across the provisioning API.", + "displayName": "ProvisioningAPI.WriteUser", + "id": "c175fb63-786b-45cc-b884-9372c7f12120", + "origin": "Application (AzureCommunicationsGateway)", + "value": "ProvisioningAPI.WriteUser" + }, + { + "description": "Allows an application to invoke any write (POST, PUT) operation across the provisioning API.", + "displayName": "ProvisioningAPI.Write", + "id": "31e9e2c5-f52d-4a97-9962-5c4fc2d1bcc1", + "origin": "Application (AzureCommunicationsGateway)", + "value": "ProvisioningAPI.Write" + }, + { + "description": "Allows partners to add tags with charity namespace.", + "displayName": "Tag_charity", + "id": "eb6f173a-fd78-41c4-9208-e2df4e4e0475", + "origin": "Application (Azure Signup Api)", + "value": "Tag_charity" + }, + { + "description": "Allows partners to add tags with ea namespace.", + "displayName": "Tag_ea", + "id": "d59f33c1-1406-4028-a0f7-8f00ae1dbf10", + "origin": "Application (Azure Signup Api)", + "value": "Tag_ea" + }, + { + "description": "Allows partners to add tags with edu namespace.", + "displayName": "Tag_edu", + "id": "be01a05d-841f-49e7-9fd9-06ddc2bd6e28", + "origin": "Application (Azure Signup Api)", + "value": "Tag_edu" + }, + { + "description": "Allows partners to add tags with gov namespace.", + "displayName": "Tag_gov", + "id": "537eda31-79dd-4d7a-882a-00651883dcec", + "origin": "Application (Azure Signup Api)", + "value": "Tag_gov" + }, + { + "description": "Allows partners to add tags with servicescope namespace.", + "displayName": "Tag_servicescope", + "id": "b9a6ff0b-f721-41df-915d-a0094656547b", + "origin": "Application (Azure Signup Api)", + "value": "Tag_servicescope" + }, + { + "description": "Allows the app to update the billing stage.", + "displayName": "UpdateBillingStage", + "id": "5653af47-c35e-4df8-b835-bd1b642b6931", + "origin": "Application (Azure Signup Api)", + "value": "UpdateBillingStage" + }, + { + "description": "Allows the app to create a billing account with UsExempt taxid.", + "displayName": "UsExemptTaxId", + "id": "06aac3fb-02c4-49ef-9f4d-c55455eab2e3", + "origin": "Application (Azure Signup Api)", + "value": "UsExemptTaxId" + }, + { + "description": "Access Azure SQL DB and Data Warehouse from the application", + "displayName": "Access Azure SQL DB and Data Warehouse", + "id": "efe4d732-bfbb-4617-8a77-349a9d67c720", + "origin": "Application (Azure SQL Database)", + "value": "app_impersonation" + }, + { + "description": "Allows the app to list Acronym and to read their properties on behalf of the signed-in user. ", + "displayName": "Read all Acronyms", + "id": "92bacdd9-8c69-46f7-a004-387210ecd2eb", + "origin": "Delegated (Bing)", + "value": "Acronym.Read.All" + }, + { + "description": "Access Azure SQL DB and Data Warehouse", + "displayName": "Access Azure SQL DB and Data Warehouse", + "id": "c39ef2d1-04ce-46dc-8b5f-e9a5c60f0fc9", + "origin": "Delegated (Azure SQL Database)", + "value": "user_impersonation" + }, + { + "description": "Allow the application to access Azure Storage on behalf of the signed-in user.", + "displayName": "Access Azure Storage", + "id": "03e0da56-190b-40ad-a80c-ea378c433f7f", + "origin": "Delegated (Azure Storage)", + "value": "user_impersonation" + }, + { + "description": "Allow the application full access to the Azure Time Series Insights service on behalf of the signed-in user.", + "displayName": "Access Azure Time Series Insights service", + "id": "a3a77dfe-67a4-4373-b02a-dfe8485e2248", + "origin": "Delegated (Azure Time Series Insights)", + "value": "user_impersonation" + }, + { + "description": "Allows an application to invoke any operation across the provisioning API.", + "displayName": "ProvisioningAPI.Admin", + "id": "de5aee63-7b89-495b-879a-0c5d6462594d", + "origin": "Application (AzureCommunicationsGateway)", + "value": "ProvisioningAPI.Admin" + }, + { + "description": "Allows a user to invoke any operation across the provisioning API.", + "displayName": "ProvisioningAPI.AdminUser", + "id": "0bafd649-9eef-438f-ad0c-05606fedf8b0", + "origin": "Application (AzureCommunicationsGateway)", + "value": "ProvisioningAPI.AdminUser" + }, + { + "description": "Allows an application to invoke any delete (DELETE) operation across the provisioning API.", + "displayName": "ProvisioningAPI.Delete", + "id": "1a692cf6-98d5-41e5-a04b-9f774d805956", + "origin": "Application (AzureCommunicationsGateway)", + "value": "ProvisioningAPI.Delete" + }, + { + "description": "Allows a user to invoke any delete (DELETE) operation across the provisioning API.", + "displayName": "ProvisioningAPI.DeleteUser", + "id": "6c693d49-fa6e-44ed-a113-c12ce08e2bf3", + "origin": "Application (AzureCommunicationsGateway)", + "value": "ProvisioningAPI.DeleteUser" + }, + { + "description": "Allows an application to invoke any read (GET) operation across the provisioning API.", + "displayName": "ProvisioningAPI.Read", + "id": "ace5c110-8cd1-464a-9058-393fe5cde5ff", + "origin": "Application (AzureCommunicationsGateway)", + "value": "ProvisioningAPI.Read" + }, + { + "description": "Allows a user to invoke any read (GET) operation across the provisioning API.", + "displayName": "ProvisioningAPI.ReadUser", + "id": "a5550f6e-7cd5-4f65-9916-fd0326cde7e9", + "origin": "Application (AzureCommunicationsGateway)", + "value": "ProvisioningAPI.ReadUser" + }, + { + "description": "Allows the application to create conversation threads and reply on existing threads for the SRE agents that user has access to", + "displayName": "Create conversation threads with SRE agents and reply on existing conversation threads", + "id": "c41153e1-cb8a-4a26-ac7e-e6457e0716cf", + "origin": "Delegated (Azure SRE Agent)", + "value": "Threads.ReadWrite.All" + }, + { + "description": "Allows the app to manage restricted resources based on the other permissions granted to the app, on behalf of the signed-in user.", + "displayName": "Manage restricted resources in the directory", + "id": "cba5390f-ed6a-4b7f-b657-0efc2210ed20", + "origin": "Delegated", + "value": "Directory.Write.Restricted" + }, + { + "description": "Allows the app to create, read, update and delete internal federation configuration for a domain.", + "displayName": "Create, read, update and delete internal federation configuration for a domain.", + "id": "64d40371-8d58-4270-bc8a-b4a66de36b9a", + "origin": "Application (Microsoft Graph)", + "value": "Domain-InternalFederation.ReadWrite.All" }, { "description": "Allows the app to read and write eDiscovery objects such as cases, custodians, review sets and other related objects without a signed-in user.", "displayName": "Read and write all eDiscovery objects", "id": "b2620db1-3bf7-4c5b-9cb9-576d29eac736", - "origin": "Application", + "origin": "Application (Microsoft Graph)", "value": "eDiscovery.ReadWrite.All" }, { - "description": "Allows the app to read eDiscovery objects such as cases, custodians, review sets and other related objects without a signed-in user.", - "displayName": "Read all eDiscovery objects", - "id": "50180013-6191-4d1e-a373-e590ff4e66af", - "origin": "Application", - "value": "eDiscovery.Read.All" + "description": "Allows the app to read a basic set of profile properties of other users in your organization without a signed-in user. Includes display name, first and last name, email address, open extensions, and photo.", + "displayName": "Read all users' basic profiles", + "id": "97235f07-e226-4f63-ace3-39588e11d3a1", + "origin": "Application (Microsoft Graph)", + "value": "User.ReadBasic.All" + }, + { + "description": "Allows the app to read and update users, without a signed-in user.", + "displayName": "Read and update users", + "id": "5639c449-cfd9-4088-bc48-3e16da108bf8", + "origin": "Application (Microsoft Graph)", + "value": "User.ReadUpdate.All" + }, + { + "description": "Allows the app to read and update external cloud user profiles without a signed in user.", + "displayName": "Read and write profiles of users that originate from an external cloud.", + "id": "5652f862-b626-407b-a3e6-248aeb95763c", + "origin": "Application (Microsoft Graph)", + "value": "User.ReadWrite.CrossCloud" + }, + { + "description": "Allow the app to revoke all sign in sessions for a user, without a signed-in user.", + "displayName": "Revoke all sign in sessions for a user", + "id": "77f3a031-c388-4f99-b373-dc68676a979e", + "origin": "Application (Microsoft Graph)", + "value": "User.RevokeSessions.All" + }, + { + "description": "Allows the app to delete authentication methods of all users in your organization, without a signed-in user. Authentication methods include things like a user's phone numbers and Authenticator app settings. This does not allow the app to read, create, or modify authentication methods.", + "displayName": "Delete all users' authentication methods", + "id": "712f5e0d-bc8d-4ae5-8242-cfb9a4921ed3", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthenticationMethod.Delete.All" + }, + { + "description": "Allows the app to read authentication methods of all users in your organization, without a signed-in user. Authentication methods include things like a user’s phone numbers and Authenticator app settings. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read all users' authentication methods", + "id": "38d9df27-64da-44fd-b7c5-a6fbac20248f", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthenticationMethod.Read.All" + }, + { + "description": "Allows the application to read and write authentication methods of all users in your organization, without a signed-in user. Authentication methods include things like a user's phone numbers and Authenticator app settings. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods", + "displayName": "Read and write all users' authentication methods ", + "id": "50483e42-d915-4231-9639-7fdb7fd190e5", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthenticationMethod.ReadWrite.All" + }, + { + "description": "Allows the application to delete email methods of all users in your organization, without a signed-in user. This does not allow the app to read, create, or modify email methods.", + "displayName": "Delete all users' email methods", + "id": "f0e9adfd-ed6b-45f5-b969-324a75286a39", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-Email.Delete.All" + }, + { + "description": "Allows the app to read email methods of all users in your organization, without a signed-in user. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read all users' email methods", + "id": "a1e58be0-1095-422b-b067-73434bd7d40f", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-Email.Read.All" + }, + { + "description": "Allows the application to read and write email methods of all users in your organization, without a signed-in user. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read and write all users' email methods", + "id": "e8ecb853-1435-4a49-95ba-ec5b31b11672", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-Email.ReadWrite.All" + }, + { + "description": "Allows the application to delete external authentication methods of all users in your organization, without a signed-in user. This does not allow the app to read, create, or modify external authentication methods.", + "displayName": "Delete all users' external authentication methods", + "id": "7fa6d39e-1e4e-44be-bf9c-e8260b12e1f5", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-External.Delete.All" + }, + { + "description": "Allows the app to read external authentication methods of all users in your organization, without a signed-in user. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read all users' external authentication methods", + "id": "d2c4289f-9f95-40da-ad43-eeb1506f0db7", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-External.Read.All" + }, + { + "description": "Allows the application to read and write external authentication methods of all users in your organization, without a signed-in user. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read and write all users' external authentication methods", + "id": "c7a22c2e-5b01-4129-8159-6c8be2c78f16", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-External.ReadWrite.All" + }, + { + "description": "Allows the application to delete HardwareOATH authentication methods of all users in your organization, without a signed-in user. This does not allow the app to read, create, or modify HardwareOATH authentication methods.", + "displayName": "Delete all users' HardwareOATH authentication methods", + "id": "9d8eb432-7ea3-491a-9ed7-e6361b308f08", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-HardwareOATH.Delete.All" + }, + { + "description": "Allows the app to read HardwareOATH authentication methods of all users in your organization, without a signed-in user. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read all users' HardwareOATH authentication methods", + "id": "7b544555-7811-49ff-8223-a56be870e33a", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-HardwareOATH.Read.All" + }, + { + "description": "Allows the application to read and write HardwareOATH authentication methods of all users in your organization, without a signed-in user. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read and write all users' HardwareOATH authentication methods", + "id": "7e9ebcc1-90aa-4471-8051-e68d6b4e9c89", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-HardwareOATH.ReadWrite.All" + }, + { + "description": "Allows the application to delete Microsoft Authentication methods of all users in your organization, without a signed-in user. This does not allow the app to read, create, or modify Microsoft Authentication methods.", + "displayName": "Delete all users' Microsoft Authentication methods", + "id": "ae494ca6-9612-417a-972a-ef52efaf2de3", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-MicrosoftAuthApp.Delete.All" + }, + { + "description": "Allows the app to read, update and delete identities that are associated with a user's account, without a signed in user. This controls the identities users can sign-in with.", + "displayName": "Manage all users' identities", + "id": "c529cfca-c91b-489c-af2b-d92990b66ce6", + "origin": "Application (Microsoft Graph)", + "value": "User.ManageIdentities.All" + }, + { + "description": "Allows the application to list and read all Tenant Governance requests without a signed-in user.", + "displayName": "Read Tenant Governance requests", + "id": "294294d5-2b81-4cf1-837c-28fc22bc3290", + "origin": "Application (Microsoft Graph)", + "value": "TenantGovernance-Request.Read.All" + }, + { + "description": "Allows the application to read Tenant Governance settings without a signed-in user.", + "displayName": "Read Tenant Governance settings", + "id": "e2d1cac5-0317-4ae3-aca9-59737eb75317", + "origin": "Application (Microsoft Graph)", + "value": "TenantGovernance-Setting.Read.All" + }, + { + "description": "Allows the app to read all term store data, without a signed-in user. This includes all sets, groups and terms in the term store.", + "displayName": "Read all term store data", + "id": "ea047cc2-df29-4f3e-83a3-205de61501ca", + "origin": "Application (Microsoft Graph)", + "value": "TermStore.Read.All" + }, + { + "description": "Allows the app to read, edit or write all term store data, without a signed-in user. This includes all sets, groups and terms in the term store.", + "displayName": "Read and write all term store data", + "id": "f12eb8d6-28e3-46e6-b2c0-b7e4dc69fc95", + "origin": "Application (Microsoft Graph)", + "value": "TermStore.ReadWrite.All" + }, + { + "description": "Allows an app to read your organization's threat assessment requests, without a signed-in user.", + "displayName": "Read threat assessment requests", + "id": "f8f035bb-2cce-47fb-8bf5-7baf3ecbee48", + "origin": "Application (Microsoft Graph)", + "value": "ThreatAssessment.Read.All" }, { "description": "Allows the app to run hunting queries, without a signed-in user.", "displayName": "Run hunting queries", "id": "dd98c7f5-2d42-42d3-a0e4-633161547251", - "origin": "Application", + "origin": "Application (Microsoft Graph)", "value": "ThreatHunting.Read.All" }, { - "description": "Allow the app to read the management data for Teams devices, without a signed-in user.", - "displayName": "Read Teams devices", - "id": "0591bafd-7c1c-4c30-a2a5-2b9aacb1dfe8", - "origin": "Application", - "value": "TeamworkDevice.Read.All" + "description": "Allows the app to read all the indicators for your organization, without a signed-in user.", + "displayName": "Read all threat indicators", + "id": "197ee4e9-b993-4066-898f-d6aecc55125b", + "origin": "Application (Microsoft Graph)", + "value": "ThreatIndicators.Read.All" }, { - "description": "Allow the app to read and write the management data for Teams devices, without a signed-in user.", - "displayName": "Read and write Teams devices", - "id": "79c02f5b-bd4f-4713-bc2c-a8a4a66e127b", - "origin": "Application", - "value": "TeamworkDevice.ReadWrite.All" + "description": "Allows the app to create threat indicators, and fully manage those threat indicators (read, update and delete), without a signed-in user. It cannot update any threat indicators it does not own.", + "displayName": "Manage threat indicators this app creates or owns", + "id": "21792b6c-c986-4ffc-85de-df9da54b52fa", + "origin": "Application (Microsoft Graph)", + "value": "ThreatIndicators.ReadWrite.OwnedBy" }, { - "description": "Allows the app to read and update identity risky service principal for your organization, without a signed-in user.", - "displayName": "Read and write all identity risky service principal information", - "id": "cb8d6980-6bcb-4507-afec-ed6de3a2d798", - "origin": "Application", - "value": "IdentityRiskyServicePrincipal.ReadWrite.All" + "description": "Allows the app to read threat intelligence information, such as indicators, observations, and and articles, without a signed in user.", + "displayName": "Read all Threat Intelligence Information", + "id": "e0b77adb-e790-44a3-b0a0-257d06303687", + "origin": "Application (Microsoft Graph)", + "value": "ThreatIntelligence.Read.All" }, { - "description": "Allows a Teams app to read, install, upgrade, and uninstall its own tabs for any user, without a signed-in user.", - "displayName": "Allow the Teams app to manage only its own tabs for all users", - "id": "3c42dec6-49e8-4a0a-b469-36cff0d9da93", - "origin": "Application", - "value": "TeamsTab.ReadWriteSelfForUser.All" + "description": "Allows the app to read your organization's threat submissions and to view threat submission policies without a signed-in user.", + "displayName": "Read all of the organization's threat submissions", + "id": "86632667-cd15-4845-ad89-48a88e8412e1", + "origin": "Application (Microsoft Graph)", + "value": "ThreatSubmission.Read.All" }, { - "description": "Allows a Teams app to read, install, upgrade, and uninstall its own tabs in any team, without a signed-in user.", - "displayName": "Allow the Teams app to manage only its own tabs for all teams", - "id": "91c32b81-0ef0-453f-a5c7-4ce2e562f449", - "origin": "Application", - "value": "TeamsTab.ReadWriteSelfForTeam.All" + "description": "Allows the app to read your organization's threat submissions and threat submission policies without a signed-in user. Also allows the app to create new threat submissions without a signed-in user.", + "displayName": "Read and write all of the organization's threat submissions", + "id": "d72bdbf4-a59b-405c-8b04-5995895819ac", + "origin": "Application (Microsoft Graph)", + "value": "ThreatSubmission.ReadWrite.All" }, { - "description": "Allows a Teams app to read, install, upgrade, and uninstall its own tabs for any chat, without a signed-in user.", - "displayName": "Allow the Teams app to manage only its own tabs for all chats", - "id": "9f62e4a2-a2d6-4350-b28b-d244728c4f86", - "origin": "Application", - "value": "TeamsTab.ReadWriteSelfForChat.All" + "description": "Allows the app to read your organization's threat submission policies without a signed-in user. Also allows the app to create new threat submission policies without a signed-in user.", + "displayName": "Read and write all of the organization's threat submission policies", + "id": "926a6798-b100-4a20-a22f-a4918f13951d", + "origin": "Application (Microsoft Graph)", + "value": "ThreatSubmissionPolicy.ReadWrite.All" + }, + { + "description": "Allows the app to read trust framework key set properties without a signed-in user.", + "displayName": "Read trust framework key sets", + "id": "fff194f1-7dce-4428-8301-1badb5518201", + "origin": "Application (Microsoft Graph)", + "value": "TrustFrameworkKeySet.Read.All" + }, + { + "description": "Allows the app to read and write trust framework key set properties without a signed-in user.", + "displayName": "Read and write trust framework key sets", + "id": "4a771c9a-1cf2-4609-b88e-3d3e02d539cd", + "origin": "Application (Microsoft Graph)", + "value": "TrustFrameworkKeySet.ReadWrite.All" + }, + { + "description": "Allows the app to create users, without a signed-in user.", + "displayName": "Create users", + "id": "4240f680-4f73-4082-a766-aa916a2dc9b3", + "origin": "Application (Microsoft Graph)", + "value": "User.Create" + }, + { + "description": "Allows the app to delete and restore all users, without a signed-in user.", + "displayName": "Delete and restore all users", + "id": "eccc023d-eccf-4e7b-9683-8813ab36cecc", + "origin": "Application (Microsoft Graph)", + "value": "User.DeleteRestore.All" + }, + { + "description": "Allows the app to enable and disable users' accounts, without a signed-in user.", + "displayName": "Enable and disable user accounts", + "id": "3011c876-62b7-4ada-afa2-506cbbecc68c", + "origin": "Application (Microsoft Graph)", + "value": "User.EnableDisableAccount.All" + }, + { + "description": "Allows the app to invite guest users to the organization, without a signed-in user.", + "displayName": "Invite guest users to the organization", + "id": "09850681-111b-4a89-9bed-3f2cae46d706", + "origin": "Application (Microsoft Graph)", + "value": "User.Invite.All" + }, + { + "description": "Allows the app to read Microsoft authentication methods of all users in your organization, without a signed-in user. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read all users' Microsoft authentication methods", + "id": "a9c5f16e-e5ca-4e33-89ad-903fcfc01c23", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-MicrosoftAuthApp.Read.All" + }, + { + "description": "Allows the application to read and write Microsoft Authentication methods of all users in your organization, without a signed-in user. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read and write all users' Microsoft Authentication methods", + "id": "c833c349-a1ab-4b6d-94a2-fa9a8674420c", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-MicrosoftAuthApp.ReadWrite.All" + }, + { + "description": "Allows the application to delete passkey authentication methods of all users in your organization, without a signed-in user. This does not allow the app to read, create, or modify passkey authentication methods.", + "displayName": "Delete all users' passkey authentication methods", + "id": "9563fbd0-03a7-466e-8042-63d668b7d1a3", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-Passkey.Delete.All" + }, + { + "description": "Allows the application to delete Windows Hello authentication methods of all users in your organization, without a signed-in user. This does not allow the app to read, create, or modify Windows Hello authentication methods.", + "displayName": "Delete all users' Windows Hello authentication methods", + "id": "f3197110-aa7f-4acd-a0fd-71981ad68d42", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-WindowsHello.Delete.All" + }, + { + "description": "Allows the app to read Windows Hello authentication methods of all users in your organization, without a signed-in user. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read all users' Windows Hello methods", + "id": "9b8dd4c7-8cca-4ef5-a34a-9c2c75fcc934", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-WindowsHello.Read.All" }, { - "description": "Allows the app to read all risky service principal information for your organization, without a signed-in user.", - "displayName": "Read all identity risky service principal information", - "id": "607c7344-0eed-41e5-823a-9695ebe1b7b0", - "origin": "Application", - "value": "IdentityRiskyServicePrincipal.Read.All" + "description": "Allows the application to read and write Windows Hello authentication methods of all users in your organization, without a signed-in user. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read and write all users' Windows Hello authentication methods", + "id": "f14eee8a-713e-45aa-8223-2ab74632db1a", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-WindowsHello.ReadWrite.All" }, { - "description": "Allows the app to read and write search configurations, without a signed-in user.", - "displayName": "Read and write your organization's search configuration", - "id": "0e778b85-fefa-466d-9eec-750569d92122", - "origin": "Application", - "value": "SearchConfiguration.ReadWrite.All" + "description": "Allow the app to convert an external user to an internal member user, without a signed-in user.", + "displayName": "Convert an external user to internal member user", + "id": "9d952b72-f741-4b40-9185-8c53076c2339", + "origin": "Application (Microsoft Graph)", + "value": "User-ConvertToInternal.ReadWrite.All" }, { - "description": "Allows the app to read search configurations, without a signed-in user.", - "displayName": "Read your organization's search configuration", - "id": "ada977a5-b8b1-493b-9a91-66c206d76ecf", - "origin": "Application", - "value": "SearchConfiguration.Read.All" + "description": "Allows the app to read the lifecycle information like employeeLeaveDateTime of users in your organization, without a signed-in user.", + "displayName": "Read all users' lifecycle information", + "id": "8556a004-db57-4d7a-8b82-97a13428e96f", + "origin": "Application (Microsoft Graph)", + "value": "User-LifeCycleInfo.Read.All" }, { - "description": "Allows the app to read online meeting artifacts in your organization, without a signed-in user.", - "displayName": "Read online meeting artifacts", - "id": "df01ed3b-eb61-4eca-9965-6b3d789751b2", - "origin": "Application", - "value": "OnlineMeetingArtifact.Read.All" + "description": "Allows the app to read and write the lifecycle information like employeeLeaveDateTime of users in your organization, without a signed-in user.", + "displayName": "Read and write all users' lifecycle information", + "id": "925f1248-0f97-47b9-8ec8-538c54e01325", + "origin": "Application (Microsoft Graph)", + "value": "User-LifeCycleInfo.ReadWrite.All" }, { - "description": "Allows the app to create, read, update, and delete apps in the app catalogs without a signed-in user.", - "displayName": "Read and write to all app catalogs", - "id": "dc149144-f292-421e-b185-5953f2e98d7f", - "origin": "Application", - "value": "AppCatalog.ReadWrite.All" + "description": "Allows the app to read and write secondary mail addresses for all users, without a signed-in user.", + "displayName": "Read and write all secondary mail addresses for users", + "id": "280d0935-0796-47d1-8d26-273470a3f17a", + "origin": "Application (Microsoft Graph)", + "value": "User-Mail.ReadWrite.All" }, { - "description": "Allows the app to read apps in the app catalogs without a signed-in user.", - "displayName": "Read all app catalogs", - "id": "e12dae10-5a57-4817-b79d-dfbec5348930", - "origin": "Application", - "value": "AppCatalog.Read.All" + "description": "Allows the app to send, read, update and delete user’s notifications, without a signed-in user.", + "displayName": "Deliver and manage all user's notifications", + "id": "4e774092-a092-48d1-90bd-baad67c7eb47", + "origin": "Application (Microsoft Graph)", + "value": "UserNotification.ReadWrite.CreatedByApp" }, { - "description": "Allows the app to manage workforce integrations to synchronize data from Microsoft Teams Shifts, without a signed-in user.", - "displayName": "Read and write workforce integrations", - "id": "202bf709-e8e6-478e-bcfd-5d63c50b68e3", - "origin": "Application", - "value": "WorkforceIntegration.ReadWrite.All" + "description": "Allows the app to update the on-premises sync behavior of all users without a signed-in user.", + "displayName": "Read and update the on-premises sync behavior of users", + "id": "a94a502d-0281-4d15-8cd2-682ac9362c4c", + "origin": "Application (Microsoft Graph)", + "value": "User-OnPremisesSyncBehavior.ReadWrite.All" }, { - "description": "Allows the app to read all presence information and write activity and availability of all users in the directory without a signed-in user. Presence information includes activity, availability, status note, calendar out-of-office message, time zone and location.", - "displayName": "Read and write presence information for all users", - "id": "83cded22-8297-4ff6-a7fa-e97e9545a259", - "origin": "Application", - "value": "Presence.ReadWrite.All" + "description": "Allows the app to read and write password profiles and reset passwords for all users, without a signed-in user.", + "displayName": "Read and write all password profiles and reset user passwords", + "id": "cc117bb9-00cf-4eb8-b580-ea2a878fe8f7", + "origin": "Application (Microsoft Graph)", + "value": "User-PasswordProfile.ReadWrite.All" }, { - "description": "Allows the app to read and write tags in Teams without a signed-in user.", - "displayName": "Read and write tags in Teams", - "id": "a3371ca5-911d-46d6-901c-42c8c7a937d8", - "origin": "Application", - "value": "TeamworkTag.ReadWrite.All" + "description": "Allows the app to read and write the mobile phone and business phones for all users, without a signed-in user.", + "displayName": "Read and write all user mobile phone and business phones", + "id": "86ceff06-c822-49ff-989a-d912845ffe69", + "origin": "Application (Microsoft Graph)", + "value": "User-Phone.ReadWrite.All" }, { - "description": "Allows the app to read\u00a0tags in Teams\u00a0without a signed-in user.", - "displayName": "Read tags in Teams", - "id": "b74fd6c4-4bde-488e-9695-eeb100e4907f", - "origin": "Application", - "value": "TeamworkTag.Read.All" + "description": "Allows the app to read all users' shift schedule preferences without a signed-in user.", + "displayName": "Read all user shift preferences", + "id": "de023814-96df-4f53-9376-1e2891ef5a18", + "origin": "Application (Microsoft Graph)", + "value": "UserShiftPreferences.Read.All" }, { - "description": "Allows the app to read and write all Windows update deployment settings for the organization without a signed-in user.", - "displayName": "Read and write all Windows update deployment settings", - "id": "7dd1be58-6e76-4401-bf8d-31d1e8180d5b", - "origin": "Application", - "value": "WindowsUpdates.ReadWrite.All" + "description": "Allows the app to manage all users' shift schedule preferences without a signed-in user.", + "displayName": "Read and write all user shift preferences", + "id": "d1eec298-80f3-49b0-9efb-d90e224798ac", + "origin": "Application (Microsoft Graph)", + "value": "UserShiftPreferences.ReadWrite.All" }, { - "description": "Allows the app to read and write external connections without a signed-in user. The app can only read and write external connections that it is authorized to, or it can create new external connections. ", - "displayName": "Read and write external connections", - "id": "f431331c-49a6-499f-be1c-62af19c34a9d", - "origin": "Application", - "value": "ExternalConnection.ReadWrite.OwnedBy" + "description": "Allows the app to read all user teamwork settings without a signed-in user.", + "displayName": "Read all user teamwork settings", + "id": "fbcd7ef1-df0d-4e05-bb28-93424a89c6df", + "origin": "Application (Microsoft Graph)", + "value": "UserTeamwork.Read.All" }, { - "description": "Allows the app to read and write external items without a signed-in user. The app can only read external items of the connection that it is authorized to.", - "displayName": "Read and write external items", - "id": "8116ae0f-55c2-452d-9944-d18420f5b2c8", - "origin": "Application", - "value": "ExternalItem.ReadWrite.OwnedBy" + "description": "This role can read Verified Id profiles in a tenant.", + "displayName": "Read Verified Id profiles", + "id": "e227c591-dd64-4a8a-a033-816167f7c938", + "origin": "Application (Microsoft Graph)", + "value": "VerifiedId-Profile.Read.All" }, { - "description": "Allow the application to access a subset of site collections without a signed in user.\u00a0\u00a0The specific site collections and the permissions granted will be configured in SharePoint Online.", - "displayName": "Access selected site collections", - "id": "883ea226-0bf2-4a8f-9f9d-92c9162a727d", - "origin": "Application", - "value": "Sites.Selected" + "description": "Allows the application to read virtual appointments for all users, without a signed-in user. The app must also be authorized to access an individual user’s data by the online meetings application access policy.", + "displayName": "Read all virtual appointments for users, as authorized by online meetings application access policy", + "id": "d4f67ec2-59b5-4bdc-b4af-d78f6f9c1954", + "origin": "Application (Microsoft Graph)", + "value": "VirtualAppointment.Read.All" }, { - "description": "Allows the app to read documents and list items in all site collections without a signed in user.", - "displayName": "Read items in all site collections ", - "id": "332a536c-c7ef-4017-ab91-336970924f0d", - "origin": "Application", - "value": "Sites.Read.All" + "description": "Allows the application to read and write virtual appointments for all users, without a signed-in user. The app must also be authorized to access an individual user’s data by the online meetings application access policy.", + "displayName": "Read-write all virtual appointments for users, as authorized by online meetings app access policy", + "id": "bf46a256-f47d-448f-ab78-f226fff08d40", + "origin": "Application (Microsoft Graph)", + "value": "VirtualAppointment.ReadWrite.All" }, { - "description": "Allows the app to create, read, update, and delete documents and list items in all site collections without a signed in user.", - "displayName": "Read and write items in all site collections", - "id": "9492366f-7969-46a4-8d15-ed1a20078fff", - "origin": "Application", - "value": "Sites.ReadWrite.All" + "description": "Allows the application to read and write Temporary Access Pass authentication methods of all users in your organization, without a signed-in user. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read and write all users' Temporary Access Pass methods", + "id": "627169a8-8c15-451c-861a-5b80e383de5c", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-TAP.ReadWrite.All" }, { - "description": "Allows the app to read and write the properties of Cloud PCs, without a signed-in user.", - "displayName": "Read and write Cloud PCs", - "id": "3b4349e1-8cf5-45a3-95b7-69d1751d3e6a", - "origin": "Application", - "value": "CloudPC.ReadWrite.All" + "description": "Allows the app to read Temporary Access Pass authentication methods of all users in your organization, without a signed-in user. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read all users' Temporary Access Pass methods", + "id": "bf82209c-b22b-4747-ac88-a68be99032cf", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-TAP.Read.All" }, { - "description": "Allows the app to read the properties of Cloud PCs, without a signed-in user.", - "displayName": "Read Cloud PCs", - "id": "a9e09520-8ed4-4cde-838e-4fdea192c227", - "origin": "Application", - "value": "CloudPC.Read.All" + "description": "Allows the application to delete Temporary Access Pass authentication methods of all users in your organization, without a signed-in user. This does not allow the app to read, create, or modify Temporary Access Pass authentication methods.", + "displayName": "Delete all users' Temporary Access Pass authentication methods", + "id": "4f872e9d-d232-4ecd-ab9c-337cbdb184e5", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-TAP.Delete.All" }, { - "description": "Allows the app to update service principal endpoints", - "displayName": "Read and update service principal endpoints", - "id": "89c8469c-83ad-45f7-8ff2-6e3d4285709e", - "origin": "Application", - "value": "ServicePrincipalEndpoint.ReadWrite.All" + "description": "Allows the application to read and write SoftwareOATH authentication methods of all users in your organization, without a signed-in user. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read and write all users' SoftwareOATH methods", + "id": "787442d4-3c6e-4e99-aa95-8ccca20a48ff", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-SoftwareOATH.ReadWrite.All" }, { - "description": "Allows the app to read service principal endpoints", - "displayName": "Read service principal endpoints", - "id": "5256681e-b7f6-40c0-8447-2d9db68797a0", - "origin": "Application", - "value": "ServicePrincipalEndpoint.Read.All" + "description": "Allows the app to read passkey authentication methods of all users in your organization, without a signed-in user. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read all users' passkey authentication methods", + "id": "72e00c1d-3e3d-43bb-a0b9-c435611bb1d2", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-Passkey.Read.All" }, { - "description": "Allows the app to create new notifications in users' teamwork activity feeds without a signed in user. These notifications may not be discoverable or be held or governed by compliance policies.", - "displayName": "Send a teamwork activity to any user", - "id": "a267235f-af13-44dc-8385-c1dc93023186", - "origin": "Application", - "value": "TeamsActivity.Send" + "description": "Allows the application to read and write passkey authentication methods of all users in your organization, without a signed-in user. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods", + "displayName": "Read and write all users' passkey authentication methods", + "id": "0400e371-7db1-4338-a269-96069eb65227", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-Passkey.ReadWrite.All" }, { - "description": "Allows the app to read terms of use acceptance statuses, without a signed in user.", - "displayName": "Read all terms of use acceptance statuses", - "id": "d8e4ec18-f6c0-4620-8122-c8b1f2bf400e", - "origin": "Application", - "value": "AgreementAcceptance.Read.All" + "description": "Allows the app to read password authentication methods of all users in your organization, without a signed-in user. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read all users' password authentication methods", + "id": "8d2c17ff-b93d-40d5-9def-d843680509cb", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-Password.Read.All" }, { - "description": "Allows the app to read and write terms of use agreements, without a signed in user.", - "displayName": "Read and write all terms of use agreements", - "id": "c9090d00-6101-42f0-a729-c41074260d47", - "origin": "Application", - "value": "Agreement.ReadWrite.All" + "description": "Allows the application to read and write password authentication methods of all users in your organization, without a signed-in user. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read and write all users' password authentication methods", + "id": "f6d38dfd-ec08-4995-8f07-23e929df0936", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-Password.ReadWrite.All" }, { - "description": "Allows the app to read terms of use agreements, without a signed in user.", - "displayName": "Read all terms of use agreements", - "id": "2f3e6f8c-093b-4c57-a58b-ba5ce494a169", - "origin": "Application", - "value": "Agreement.Read.All" + "description": "Allows the application to delete phone methods of all users in your organization, without a signed-in user. This does not allow the app to read, create, or modify phone methods.", + "displayName": "Delete all users' phone methods", + "id": "59f17651-8b6c-494e-a269-4ac582fbbca0", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-Phone.Delete.All" }, { - "description": "Allows the app to read app consent requests and approvals, and deny or approve those requests without a signed-in user.", - "displayName": "Read and write all consent requests", - "id": "9f1b81a7-0223-4428-bfa4-0bcb5535f27d", - "origin": "Application", - "value": "ConsentRequest.ReadWrite.All" + "description": "Allows the app to read phone authentication methods of all users in your organization, without a signed-in user. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read all users' phone authentication methods", + "id": "f529a223-ea70-43ec-b268-5012de2fbaa2", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-Phone.Read.All" }, { - "description": "Allows the app to read and write your organization's consent requests policy without a signed-in user.", - "displayName": "Read and write your organization's consent request policy", - "id": "999f8c63-0a38-4f1b-91fd-ed1947bdd1a9", - "origin": "Application", - "value": "Policy.ReadWrite.ConsentRequest" + "description": "Allows the application to read and write phone methods of all users in your organization, without a signed-in user. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read and write all users' phone methods", + "id": "6e85d483-7092-4375-babe-0a94a8213a58", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-Phone.ReadWrite.All" }, { - "description": "Allows the app to read consent requests and approvals without a signed-in user.", - "displayName": "Read all consent requests", - "id": "1260ad83-98fb-4785-abbb-d6cc1806fd41", - "origin": "Application", - "value": "ConsentRequest.Read.All" + "description": "Allows the application to delete platform credentials methods of all users in your organization, without a signed-in user. This does not allow the app to read, create, or modify platform credentials methods.", + "displayName": "Delete all users' platform credentials methods", + "id": "bd760918-651f-4e67-b66f-8f614384dec2", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-PlatformCred.Delete.All" }, { - "description": "Allows the app to read basic mail properties in all mailboxes without a signed-in user. Includes all properties except body, previewBody, attachments and any extended properties.", - "displayName": "Read basic mail in all mailboxes", - "id": "693c5e45-0940-467d-9b8a-1022fb9d42ef", - "origin": "Application", - "value": "Mail.ReadBasic.All" + "description": "Allows the application to list and read all Tenant Governance relationships without a signed-in user.", + "displayName": "Read Tenant Governance relationships", + "id": "41c250d0-8793-44e1-a130-5fdbd5bccd0a", + "origin": "Application (Microsoft Graph)", + "value": "TenantGovernance-Relationship.Read.All" }, { - "description": "Allows the app to read basic mail properties in all mailboxes without a signed-in user. Includes all properties except body, previewBody, attachments and any extended properties.", - "displayName": "Read basic mail in all mailboxes", - "id": "6be147d2-ea4f-4b5a-a3fa-3eab6f3c140a", - "origin": "Application", - "value": "Mail.ReadBasic" + "description": "Allows the app to read platform credentials methods of all users in your organization, without a signed-in user. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read all users' platform credentials methods", + "id": "07c0b1e4-15bd-442f-834b-30f8291388d1", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-PlatformCred.Read.All" }, { - "description": "Allows the app to read and write feature rollout policies without a signed-in user. Includes abilities to assign and remove users and groups to rollout of a specific feature.", - "displayName": "Read and write feature rollout policies", - "id": "2044e4f1-e56c-435b-925c-44cd8f6ba89a", - "origin": "Application", - "value": "Policy.ReadWrite.FeatureRollout" + "description": "Allows the application to delete QR authentication methods of all users in your organization, without a signed-in user. This does not allow the app to read, create, or modify QR authentication methods.", + "displayName": "Delete all users' QR authentication methods", + "id": "e1c34213-26ac-400b-9548-a749f1b1a4e0", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-QR.Delete.All" }, { - "description": "Allows the app to read and manage the role-based access control (RBAC) settings for your company's directory, without a signed-in user. This includes instantiating directory roles and managing directory role membership, and reading directory role templates, directory roles and memberships.", - "displayName": "Read and write all directory RBAC settings", - "id": "9e3f62cf-ca93-4989-b6ce-bf83c28f9fe8", - "origin": "Application", - "value": "RoleManagement.ReadWrite.Directory" + "description": "Allows the app to read QR authentication methods of all users in your organization, without a signed-in user. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read all users' QR methods", + "id": "9a45bc50-cddd-4ebe-bd9c-4f2eacf646ae", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-QR.Read.All" }, { - "description": "Allows the app to read the role-based access control (RBAC) settings for your company's directory, without a signed-in user. This includes reading directory role templates, directory roles and memberships.", - "displayName": "Read all directory RBAC settings", - "id": "483bed4a-2ad3-4361-a73b-c83ccdbdc53c", - "origin": "Application", - "value": "RoleManagement.Read.Directory" + "description": "Allows the application to read and write QR authentication methods of all users in your organization, without a signed-in user. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read and write all users' QR methods", + "id": "4869299f-18c3-40c8-98f2-222657e67db1", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-QR.ReadWrite.All" }, { - "description": "Allows the app to read and write the organization and related resources, without a signed-in user.\u00a0Related resources include things like subscribed skus and tenant branding information.", - "displayName": "Read and write organization information", - "id": "292d869f-3427-49a8-9dab-8c70152b74e9", - "origin": "Application", - "value": "Organization.ReadWrite.All" + "description": "Allows the application to delete resource key authentication methods of all users in your organization, without a signed-in user. This does not allow the app to read, create, or modify resource key authentication methods.", + "displayName": "Delete all users' resource key authentication methods", + "id": "a71aecaf-82f1-47c5-ad0a-5e63503b928f", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-ResourceKey.Delete.All" }, { - "description": "Allows the app to read the organization and related resources, without a signed-in user.\u00a0Related resources include things like subscribed skus and tenant branding information.", - "displayName": "Read organization information", - "id": "498476ce-e0fe-48b0-b801-37ba7e2685c6", - "origin": "Application", - "value": "Organization.Read.All" + "description": "Allows the app to read the keys associated with the user representing a resource account.", + "displayName": "Read and write all users' external authentication methods", + "id": "94ed018c-a499-47e0-beef-803b93873ece", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-ResourceKey.Read.All" }, { - "description": "Allows the app to read company places (conference rooms and room lists) for calendar events and other applications, without a signed-in user.", - "displayName": "Read all company places", - "id": "913b9306-0ce1-42b8-9137-6a7df690a760", - "origin": "Application", - "value": "Place.Read.All" + "description": "Allows the app to read and delete the keys associated with the user representing a resource account.", + "displayName": "Read and delete all users' external authentication methods", + "id": "1bf7461f-222a-4525-9760-f7739228d0f4", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-ResourceKey.ReadWrite.All" }, { - "description": "Allows the app to read the memberships of hidden groups and administrative units without a signed-in user.", - "displayName": "Read all hidden memberships", - "id": "658aa5d8-239f-45c4-aa12-864f4fc7e490", - "origin": "Application", - "value": "Member.Read.Hidden" + "description": "Allows the application to delete SoftwareOATH authentication methods of all users in your organization, without a signed-in user. This does not allow the app to read, create, or modify SoftwareOATH authentication methods.", + "displayName": "Delete all users' SoftwareOATH authentication methods", + "id": "e5676e10-1a16-452b-ad10-71f54b755852", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-SoftwareOATH.Delete.All" }, { - "description": "Allow the app to read or write items in all external datasets that the app is authorized to access", - "displayName": "Read and write items in external datasets", - "id": "38c3d6ee-69ee-422f-b954-e17819665354", - "origin": "Application", - "value": "ExternalItem.ReadWrite.All" + "description": "Allows the app to read SoftwareOATH authentication methods of all users in your organization, without a signed-in user. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read all users' SoftwareOATH methods", + "id": "a6b423df-a0c8-411d-a809-a4a5985d2939", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-SoftwareOATH.Read.All" }, { - "description": "Allows the app to read, update, delete and perform actions on access reviews, reviewers, decisions and settings in the organization for group and app memberships, without a signed-in user.", - "displayName": "Manage access reviews for group and app memberships", - "id": "18228521-a591-40f1-b215-5fad4488c117", - "origin": "Application", - "value": "AccessReview.ReadWrite.Membership" + "description": "Allows the application to read and write platform credentials methods of all users in your organization, without a signed-in user. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", + "displayName": "Read and write all users' platform credentials methods", + "id": "1a87acf4-a9ca-4576-a974-452ea265d5f6", + "origin": "Application (Microsoft Graph)", + "value": "UserAuthMethod-PlatformCred.ReadWrite.All" }, { - "description": "Allows the app to read properties of Microsoft Intune-managed device configuration and device compliance policies and their assignment to groups, without a signed-in user.", - "displayName": "Read Microsoft Intune device configuration and policies", - "id": "dc377aa6-52d8-4e23-b271-2a7ae04cedf3", - "origin": "Application", - "value": "DeviceManagementConfiguration.Read.All" + "description": "Allows the application to send notification regarding virtual appointments as any user, without a signed-in user. The app must also be authorized to access an individual user's data by the online meetings application access policy.", + "displayName": "Send notification regarding virtual appointments as any user", + "id": "97e45b36-1250-48e4-bd70-2df6dab7e94a", + "origin": "Application (Microsoft Graph)", + "value": "VirtualAppointmentNotification.Send" }, { - "description": "Allows the app to read the properties, group assignments and status of apps, app configurations and app protection policies managed by Microsoft Intune, without a signed-in user.", - "displayName": "Read Microsoft Intune apps", - "id": "7a6ee1e7-141e-4cec-ae74-d9db155731ff", - "origin": "Application", - "value": "DeviceManagementApps.Read.All" + "description": "Allows the application to list and read related tenants information without a signed-in user.", + "displayName": "Read related tenants", + "id": "7ced9a83-8e7c-46df-b3e0-6b45a6ecedcd", + "origin": "Application (Microsoft Graph)", + "value": "TenantGovernance-RelatedTenant.Read.All" }, { - "description": "Allows the app to read the properties of devices managed by Microsoft Intune, without a signed-in user.", - "displayName": "Read Microsoft Intune devices", - "id": "2f51be20-0bb4-4fed-bf7b-db946066c75e", - "origin": "Application", - "value": "DeviceManagementManagedDevices.Read.All" + "description": "Allows the application to list and read all Tenant Governance invitations without a signed-in user.", + "displayName": "Read Tenant Governance invitations", + "id": "3f4f98e9-6faf-4e5f-814b-ed2ed8a4ec9e", + "origin": "Application (Microsoft Graph)", + "value": "TenantGovernance-Invitation.Read.All" }, { - "description": "Allows the app to read the properties relating to the Microsoft Intune Role-Based Access Control (RBAC) settings, without a signed-in user.", - "displayName": "Read Microsoft Intune RBAC settings", - "id": "58ca0d9a-1575-47e1-a3cb-007ef2e4583b", - "origin": "Application", - "value": "DeviceManagementRBAC.Read.All" + "description": "Read the members of all teams, without a signed-in user.", + "displayName": "Read the members of all teams", + "id": "660b7406-55f1-41ca-a0ed-0b035e182f3e", + "origin": "Application (Microsoft Graph)", + "value": "TeamMember.Read.All" }, { - "description": "Allows the app to read Microsoft Intune service properties including device enrollment and third party service connection configuration, without a signed-in user.", - "displayName": "Read Microsoft Intune configuration", - "id": "06a5fe6d-c49d-46a7-b082-56b1b14103c7", - "origin": "Application", - "value": "DeviceManagementServiceConfig.Read.All" + "description": "Add and remove members from all teams, without a signed-in user. Also allows changing a team member's role, for example from owner to non-owner.", + "displayName": "Add and remove members from all teams", + "id": "0121dc95-1b9f-4aed-8bac-58c5ac466691", + "origin": "Application (Microsoft Graph)", + "value": "TeamMember.ReadWrite.All" }, { - "description": "Allows the app to create, view, update and delete on-premises published resources, on-premises agents and agent groups, as part of a hybrid identity configuration, without a signed in user.", - "displayName": "Manage on-premises published resources", - "id": "0b57845e-aa49-4e6f-8109-ce654fffa618", - "origin": "Application", - "value": "OnPremisesPublishingProfiles.ReadWrite.All" + "description": "Add and remove members from all teams, without a signed-in user. Does not allow adding or removing a member with the owner role. Additionally, does not allow the app to elevate an existing member to the owner role.", + "displayName": "Add and remove members with non-owner role for all teams", + "id": "4437522e-9a86-4a41-a7da-e380edd4a97d", + "origin": "Application (Microsoft Graph)", + "value": "TeamMember.ReadWriteNonOwnerRole.All" + }, + { + "description": "Allows the app to read all users' teamwork activity feed, without a signed-in user.", + "displayName": "Read all users' teamwork activity feed", + "id": "70dec828-f620-4914-aa83-a29117306807", + "origin": "Application (Microsoft Graph)", + "value": "TeamsActivity.Read.All" + }, + { + "description": "Allows the app to create new notifications in users' teamwork activity feeds without a signed in user. These notifications may not be discoverable or be held or governed by compliance policies.", + "displayName": "Send a teamwork activity to any user", + "id": "a267235f-af13-44dc-8385-c1dc93023186", + "origin": "Application (Microsoft Graph)", + "value": "TeamsActivity.Send" }, { - "description": "Allows the app to read and write trust framework key set properties without a signed-in user.", - "displayName": "Read and write trust framework key sets", - "id": "4a771c9a-1cf2-4609-b88e-3d3e02d539cd", - "origin": "Application", - "value": "TrustFrameworkKeySet.ReadWrite.All" + "description": "Allows the app to read, install, upgrade, and uninstall selected Teams apps in any chat, without a signed-in user. Gives the ability to manage permission grants for accessing those specific chats' data.", + "displayName": "Manage installation and permission grants of selected Teams apps in all chats", + "id": "22b74aab-d9e4-46f7-9424-f24b42307227", + "origin": "Application (Microsoft Graph)", + "value": "TeamsAppInstallation.ManageSelectedForChat.All" }, { - "description": "Allows the app to read trust framework key set properties without a signed-in user.", - "displayName": "Read trust framework key sets", - "id": "fff194f1-7dce-4428-8301-1badb5518201", - "origin": "Application", - "value": "TrustFrameworkKeySet.Read.All" + "description": "Allows the app to read, install, upgrade, and uninstall selected Teams apps in any team, without a signed-in user. Gives the ability to manage permission grants for accessing those specific teams' data.", + "displayName": "Manage installation and permission grants of selected Teams apps in all teams", + "id": "b448d252-1f26-4227-b6ff-21ab510975a2", + "origin": "Application (Microsoft Graph)", + "value": "TeamsAppInstallation.ManageSelectedForTeam.All" }, { - "description": "Allows the app to read and write your organization's trust framework policies without a signed in user.", - "displayName": "Read and write your organization's trust framework policies", - "id": "79a677f7-b79d-40d0-a36a-3e6f8688dd7a", - "origin": "Application", - "value": "Policy.ReadWrite.TrustFramework" + "description": "Allows the app to read, install, upgrade, and uninstall selected Teams apps in any user account, without a signed-in user. Gives the ability to manage permission grants for accessing those specific users' data.", + "displayName": "Manage installation and permission grants of selected Teams apps for all user accounts", + "id": "e97a9235-5b3c-43c4-b37d-6786a173fae4", + "origin": "Application (Microsoft Graph)", + "value": "TeamsAppInstallation.ManageSelectedForUser.All" }, { - "description": "Allows the app to read all your organization's policies without a signed in user.", - "displayName": "Read your organization's policies", - "id": "246dd0d5-5bd0-4def-940b-0421030a5b68", - "origin": "Application", - "value": "Policy.Read.All" + "description": "Allows the app to read the Teams apps that are installed in any scope, without a signed-in user. Does not give the ability to read application-specific settings.", + "displayName": "Read installed Teams apps for all installation scopes", + "id": "0fdf35a5-82f8-41ff-9ded-0b761cc73512", + "origin": "Application (Microsoft Graph)", + "value": "TeamsAppInstallation.Read.All" }, { - "description": "Allows the app to read and write your organization\u2019s identity (authentication) providers\u2019 properties without a signed in user.", - "displayName": "Read and write identity providers", - "id": "90db2b9a-d928-4d33-a4dd-8442ae3d41e4", - "origin": "Application", - "value": "IdentityProvider.ReadWrite.All" + "description": "Allows the app to read the Teams apps that are installed in any chat, without a signed-in user. Does not give the ability to read application-specific settings.", + "displayName": "Read installed Teams apps for all chats", + "id": "cc7e7635-2586-41d6-adaa-a8d3bcad5ee5", + "origin": "Application (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadForChat.All" }, { - "description": "Allows the app to read your organization\u2019s identity (authentication) providers\u2019 properties without a signed in user.", - "displayName": "Read identity providers", - "id": "e321f0bb-e7f7-481e-bb28-e3b0b32d4bd0", - "origin": "Application", - "value": "IdentityProvider.Read.All" + "description": "Allows the app to read the Teams apps that are installed in any team, without a signed-in user. Does not give the ability to read application-specific settings.", + "displayName": "Read installed Teams apps for all teams", + "id": "1f615aea-6bf9-4b05-84bd-46388e138537", + "origin": "Application (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadForTeam.All" }, { - "description": "Allows the app to create, read, update, and delete administrative units and manage administrative unit membership without a signed-in user.", - "displayName": "Read and write all administrative units", - "id": "5eb59dd3-1da2-4329-8733-9dabdc435916", - "origin": "Application", - "value": "AdministrativeUnit.ReadWrite.All" + "description": "Allows the app to read the Teams apps that are installed for any user, without a signed-in user. Does not give the ability to read application-specific settings.", + "displayName": "Read installed Teams apps for all users", + "id": "9ce09611-f4f7-4abd-a629-a05450422a97", + "origin": "Application (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadForUser.All" }, { - "description": "Allows the app to read administrative units and administrative unit membership without a signed-in user.", - "displayName": "Read all administrative units", - "id": "134fd756-38ce-4afd-ba33-e9623dbe66c2", - "origin": "Application", - "value": "AdministrativeUnit.Read.All" + "description": "Allows the app to read the selected Teams apps that are installed in any chat, without a signed-in user. Does not give the ability to read application-specific settings.", + "displayName": "Read selected installed Teams apps in all chats", + "id": "53d40ddb-9b27-4c97-b800-985be6041990", + "origin": "Application (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadSelectedForChat.All" }, { - "description": "Allows an app to read published sensitivity labels and label policy settings for the entire organization or a specific user, without a signed in user.", - "displayName": "Read all published labels and label policies for an organization.", - "id": "19da66cb-0fb0-4390-b071-ebc76a349482", - "origin": "Application", - "value": "InformationProtectionPolicy.Read.All" + "description": "Allows the app to read the selected Teams apps that are installed in any team, without a signed-in user. Does not give the ability to read application-specific settings.", + "displayName": "Read selected installed Teams apps in all teams", + "id": "93c6a289-70fd-489e-a053-6cf8f7d772f6", + "origin": "Application (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadSelectedForTeam.All" }, { - "description": "Allows the app to read all the OneNote notebooks in your organization, without a signed-in user.", - "displayName": "Read all OneNote notebooks", - "id": "3aeca27b-ee3a-4c2b-8ded-80376e2134a4", - "origin": "Application", - "value": "Notes.Read.All" + "description": "Allows an app to read, install, upgrade, and uninstall selected apps to any user, without a signed-in user.", + "displayName": "Read selected installed Teams apps for all users", + "id": "44fb0e7c-1f9a-47f1-bb9e-7f92d48ed288", + "origin": "Application (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadSelectedForUser.All" }, { - "description": "Allows the app to invite guest users to the organization, without a signed-in user.", - "displayName": "Invite guest users to the organization", - "id": "09850681-111b-4a89-9bed-3f2cae46d706", - "origin": "Application", - "value": "User.Invite.All" + "description": "Allows the app to read, install, upgrade, and uninstall Teams apps in any chat, without a signed-in user. Gives the ability to manage permission grants for accessing those specific chats' data.", + "displayName": "Manage installation and permission grants of Teams apps for all chats", + "id": "6e74eff9-4a21-45d6-bc03-3a20f61f8281", + "origin": "Application (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadWriteAndConsentForChat.All" }, { - "description": "Allows the app to read, create, update and delete all files in all site collections without a signed in user. ", - "displayName": "Read and write files in all site collections", - "id": "75359482-378d-4052-8f01-80520e7db3cd", - "origin": "Application", - "value": "Files.ReadWrite.All" + "description": "Allows the app to read, install, upgrade, and uninstall Teams apps in any team, without a signed-in user. Gives the ability to manage permission grants for accessing those specific teams' data.", + "displayName": "Manage installation and permission grants of Teams apps for all teams", + "id": "b0c13be0-8e20-4bc5-8c55-963c23a39ce9", + "origin": "Application (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadWriteAndConsentForTeam.All" }, { - "description": "Allows the app to create threat indicators, and fully manage those threat indicators (read, update and delete), without a signed-in user. \u00a0It cannot update any threat indicators it does not own.", - "displayName": "Manage threat indicators this app creates or owns", - "id": "21792b6c-c986-4ffc-85de-df9da54b52fa", - "origin": "Application", - "value": "ThreatIndicators.ReadWrite.OwnedBy" + "description": "Get a list of all teams, without a signed-in user.", + "displayName": "Get a list of all teams", + "id": "2280dda6-0bfd-44ee-a2f4-cb867cfc4c1e", + "origin": "Application (Microsoft Graph)", + "value": "Team.ReadBasic.All" }, { - "description": "Allows the app to read or update security actions, without a signed-in user.", - "displayName": "Read and update your organization's security actions", - "id": "f2bf083f-0179-402a-bedb-b2784de8a49b", - "origin": "Application", - "value": "SecurityActions.ReadWrite.All" + "description": "Allows the app to read, install, upgrade, and uninstall Teams apps in any user account, without a signed-in user. Gives the ability to manage permission grants for accessing those specific users' data.", + "displayName": "Manage installation and permission grants of Teams apps in a user account", + "id": "32ca478f-f89e-41d0-aaf8-101deb7da510", + "origin": "Application (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadWriteAndConsentForUser.All" }, { - "description": "Allows the app to read security actions, without a signed-in user.", - "displayName": "Read your organization's security actions", - "id": "5e0edab9-c148-49d0-b423-ac253e121825", - "origin": "Application", - "value": "SecurityActions.Read.All" + "description": "Allows the app to create teams without a signed-in user. ", + "displayName": "Create teams", + "id": "23fc2474-f741-46ce-8465-674744c5c361", + "origin": "Application (Microsoft Graph)", + "value": "Team.Create" }, { - "description": "Allows the app to read your organization\u2019s security events without a signed-in user. Also allows the app to update editable properties in security events.", - "displayName": "Read and update your organization\u2019s security events", - "id": "d903a879-88e0-4c09-b0c9-82f6a1333f84", - "origin": "Application", - "value": "SecurityEvents.ReadWrite.All" + "description": "Allows the app to read all users’ tasks and task lists in your organization, without a signed-in user.", + "displayName": "Read all users’ tasks and tasklist", + "id": "f10e1f91-74ed-437f-a6fd-d6ae88e26c1f", + "origin": "Application (Microsoft Graph)", + "value": "Tasks.Read.All" }, { - "description": "Allows the app to read your organization\u2019s security events without a signed-in user.", - "displayName": "Read your organization\u2019s security events", - "id": "bf394140-e372-4bf9-a898-299cfc7564e5", - "origin": "Application", - "value": "SecurityEvents.Read.All" + "description": "Allows the app to read and write your organization's sign-in identifiers, without a signed-in user.", + "displayName": "Read and write all sign-in identifiers", + "id": "7fc588a2-ea2d-4d1f-bcf7-33c324b149b8", + "origin": "Application (Microsoft Graph)", + "value": "SignInIdentifier.ReadWrite.All" }, { - "description": "Allows an app to read and write all chat messages in Microsoft Teams, without a signed-in user.", - "displayName": "Read and write all chat messages", - "id": "294ce7c9-31ba-490a-ad7d-97a7d075e4ed", - "origin": "Application", - "value": "Chat.ReadWrite.All" + "description": "Allow the application to archive/reactivate site collections without a signed in user.", + "displayName": "Archive/reactivate Site Collections without a signed in user.", + "id": "e3530185-4080-478c-a4ab-39322704df58", + "origin": "Application (Microsoft Graph)", + "value": "Sites.Archive.All" }, { - "description": "Allows the app to read and update identity risk detection information for your organization without a signed-in user. Update operations include confirming risk event detections.\u00a0", - "displayName": "Read and write all risk detection information", - "id": "db06fb33-1953-4b7b-a2ac-f1e2c854f7ae", - "origin": "Application", - "value": "IdentityRiskEvent.ReadWrite.All" + "description": "Allow the application to create site collections without a signed in user. Upon creation the application will be granted Sites.Selected(application) + FullControl to the newly created site.", + "displayName": "Create Site Collections without a signed in user.", + "id": "80819dd8-2b3b-4551-a1ad-2700fc44f533", + "origin": "Application (Microsoft Graph)", + "value": "Sites.Create.All" }, { - "description": "Allows the app to read and update identity risky user information for your organization without a signed-in user. \u00a0Update operations include dismissing risky users.", - "displayName": "Read and write all risky user information", - "id": "656f6061-f9fe-4807-9708-6a2e0934df76", - "origin": "Application", - "value": "IdentityRiskyUser.ReadWrite.All" + "description": "Allows the app to have full control of all site collections without a signed in user.", + "displayName": "Have full control of all site collections", + "id": "a82116e5-55eb-4c41-a434-62fe8a61c773", + "origin": "Application (Microsoft Graph)", + "value": "Sites.FullControl.All" }, { - "description": "Allows the app to read all files in all site collections without a signed in user.", - "displayName": "Read files in all site collections", - "id": "01d4889c-1287-42c6-ac1f-5d1e02578ef6", - "origin": "Application", - "value": "Files.Read.All" + "description": "Allows the app to create or delete document libraries and lists in all site collections without a signed in user.", + "displayName": "Create, edit, and delete items and lists in all site collections", + "id": "0c0bf378-bf22-4481-8f81-9e89a9b4960a", + "origin": "Application (Microsoft Graph)", + "value": "Sites.Manage.All" }, { - "description": "Allows the app to read the identity risk event information for your organization without a signed in user.", - "displayName": "Read all identity risk event information", - "id": "6e472fd1-ad78-48da-a0f0-97ab2c6b769e", - "origin": "Application", - "value": "IdentityRiskEvent.Read.All" + "description": "Allows the app to read documents and list items in all site collections without a signed in user.", + "displayName": "Read items in all site collections ", + "id": "332a536c-c7ef-4017-ab91-336970924f0d", + "origin": "Application (Microsoft Graph)", + "value": "Sites.Read.All" }, { - "description": "Allows the app to read a limited subset of properties from both the structure of schools and classes in the organization's roster and education-specific information about all users. Includes name, status, role, email address and photo.", - "displayName": "Read a limited subset of the organization's roster", - "id": "0d412a8c-a06c-439f-b3ec-8abcf54d2f96", - "origin": "Application", - "value": "EduRoster.ReadBasic.All" + "description": "Allows the app to create, read, update, and delete documents and list items in all site collections without a signed in user.", + "displayName": "Read and write items in all site collections", + "id": "9492366f-7969-46a4-8d15-ed1a20078fff", + "origin": "Application (Microsoft Graph)", + "value": "Sites.ReadWrite.All" }, { - "description": "Allows the app to read the structure of schools and classes in the organization's roster and education-specific information about all users to be read.", - "displayName": "Read the organization's roster", - "id": "e0ac9e1b-cb65-4fc5-87c5-1a8bc181f648", - "origin": "Application", - "value": "EduRoster.Read.All" + "description": "Allow the application to access a subset of site collections without a signed in user. The specific site collections and the permissions granted will be configured in SharePoint Online.", + "displayName": "Access selected site collections", + "id": "883ea226-0bf2-4a8f-9f9d-92c9162a727d", + "origin": "Application (Microsoft Graph)", + "value": "Sites.Selected" }, { - "description": "Allows the app to read and write the structure of schools and classes in the organization's roster and education-specific information about all users to be read and written.", - "displayName": "Read and write the organization's roster", - "id": "d1808e82-ce13-47af-ae0d-f9b254e6d58a", - "origin": "Application", - "value": "EduRoster.ReadWrite.All" + "description": "Allows the app to read your organization's SPIFFE trust domains and child resources without a signed in user.", + "displayName": "Read SPIFFE trust domains and child resources", + "id": "dcdfc277-41fd-4d68-ad0c-c3057235bd8e", + "origin": "Application (Microsoft Graph)", + "value": "SpiffeTrustDomain.Read.All" }, { - "description": "Read the state and settings of all Microsoft education apps.", - "displayName": "Read Education app settings", - "id": "7c9db06a-ec2d-4e7b-a592-5a1e30992566", - "origin": "Application", - "value": "EduAdministration.Read.All" + "description": "Allows the app to read and write your organization's SPIFFE trust domains and child resources without a signed in user.", + "displayName": "Read and write SPIFFE trust domains and child resources", + "id": "17b78cfd-eeff-447d-8bab-2795af00055a", + "origin": "Application (Microsoft Graph)", + "value": "SpiffeTrustDomain.ReadWrite.All" }, { - "description": "Manage the state and settings of all Microsoft education apps.", - "displayName": "Manage education app settings", - "id": "9bc431c3-b8bc-4a8d-a219-40f10f92eff6", - "origin": "Application", - "value": "EduAdministration.ReadWrite.All" + "description": "Allows the app to modify Viva Engage storylines, read all storylines properties, update storyline properties, and delete storyline properties without a signed-in user.", + "displayName": "Read and write all Viva Engage storylines", + "id": "6eff534b-699e-44d9-af61-a4182f0ec37e", + "origin": "Application (Microsoft Graph)", + "value": "Storyline.ReadWrite.All" }, { - "description": "Allows the app to read the identity risky user information for your organization without a signed in user.", - "displayName": "Read all identity risky user information", - "id": "dc5007c0-2d7d-4c42-879c-2dab87571379", - "origin": "Application", - "value": "IdentityRiskyUser.Read.All" + "description": "Allows the app to read subject rights requests without a signed-in user.", + "displayName": "Read all subject rights requests", + "id": "ee1460f0-368b-4153-870a-4e1ca7e72c42", + "origin": "Application (Microsoft Graph)", + "value": "SubjectRightsRequest.Read.All" }, { - "description": "Allows the app to read and update user profiles without a signed in user.", - "displayName": "Read and write all users' full profiles", - "id": "741f803b-c850-494e-b5df-cde7c675a1ca", - "origin": "Application", - "value": "User.ReadWrite.All" + "description": "Allows the app to read and write subject rights requests without a signed in user.", + "displayName": "Read and write all subject rights requests", + "id": "8387eaa4-1a3c-41f5-b261-f888138e6041", + "origin": "Application (Microsoft Graph)", + "value": "SubjectRightsRequest.ReadWrite.All" }, { - "description": "Allows the app to read user profiles without a signed in user.", - "displayName": "Read all users' full profiles", - "id": "df021288-bdef-4463-88db-98f22de89214", - "origin": "Application", - "value": "User.Read.All" + "description": "Allows the application to read Azure AD synchronization information, without a signed-in user.", + "displayName": "Read all Azure AD synchronization data.", + "id": "5ba43d2f-fa88-4db2-bd1c-a67c5f0fb1ce", + "origin": "Application (Microsoft Graph)", + "value": "Synchronization.Read.All" }, { - "description": "Allows the app to read and query your audit log activities, without a signed-in user.", - "displayName": "Read all audit log data", - "id": "b0afded3-3588-46d8-8b3d-9842eff778da", - "origin": "Application", - "value": "AuditLog.Read.All" + "description": "Allows the application to configure the Azure AD synchronization service, without a signed-in user.", + "displayName": "Read and write all Azure AD synchronization data.", + "id": "9b50c33d-700f-43b1-b2eb-87e89b703581", + "origin": "Application (Microsoft Graph)", + "value": "Synchronization.ReadWrite.All" }, { - "description": "Allows the app to create other applications, and fully manage those applications (read, update, update application secrets and delete), without a signed-in user. \u00a0It cannot update any apps that it is not an owner of.", - "displayName": "Manage apps that this app creates or owns", - "id": "18a4783c-866b-4cc7-a460-3d5e5662c884", - "origin": "Application", - "value": "Application.ReadWrite.OwnedBy" + "description": "Allows the application to upload bulk user data to the identity synchronization service, without a signed-in user.", + "displayName": "Upload user data to the identity synchronization service", + "id": "db31e92a-b9ea-4d87-bf6a-75a37a9ca35a", + "origin": "Application (Microsoft Graph)", + "value": "SynchronizationData-User.Upload" }, { - "description": "Allows the app to export data (e.g. customer content or system-generated logs), associated with any user in your company, when the app is used by a privileged user (e.g. a Company Administrator).", - "displayName": "Export user's data", - "id": "405a51b5-8d8d-430b-9842-8be4b0e9f324", - "origin": "Application", - "value": "User.Export.All" + "description": "Allows the application to upload bulk user data to the identity synchronization service for apps that this application creates or owns, without a signed-in user.", + "displayName": "Upload user data to the identity sync service for apps that this application creates or owns", + "id": "25c32ff3-849a-494b-b94f-20a8ac4e6774", + "origin": "Application (Microsoft Graph)", + "value": "SynchronizationData-User.Upload.OwnedBy" }, { - "description": "Allows the app to read, update, delete and perform actions on programs and program controls in the organization, without a signed-in user.", - "displayName": "Manage all programs", - "id": "60a901ed-09f7-4aa5-a16e-7dd3d6f9de36", - "origin": "Application", - "value": "ProgramControl.ReadWrite.All" + "description": "Allows the app to create, read, update and delete all users’ tasks and task lists in your organization, without a signed-in user", + "displayName": "Read and write all users’ tasks and tasklists", + "id": "44e666d1-d276-445b-a5fc-8815eeb81d55", + "origin": "Application (Microsoft Graph)", + "value": "Tasks.ReadWrite.All" }, { - "description": "Allows the app to read programs and program controls in the organization, without a signed-in user.", - "displayName": "Read all programs", - "id": "eedb7fdd-7539-4345-a38b-4839e4a84cbd", - "origin": "Application", - "value": "ProgramControl.Read.All" + "description": "Allows a Teams app to read, install, upgrade, and uninstall itself for any chat, without a signed-in user, and manage its permission grants for accessing those specific chats' data.", + "displayName": "Allow the Teams app to manage itself and its permission grants for all chats", + "id": "ba1ba90b-2d8f-487e-9f16-80728d85bb5c", + "origin": "Application (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadWriteAndConsentSelfForChat.All" }, { - "description": "Allows the app to read, update, delete and perform actions on access reviews, reviewers, decisions and settings in the organization, without a signed-in user.", - "displayName": "Manage all access reviews", - "id": "ef5f7d5c-338f-44b0-86c3-351f46c8bb5f", - "origin": "Application", - "value": "AccessReview.ReadWrite.All" + "description": "Allows a Teams app to read, install, upgrade, and uninstall itself for any team, without a signed-in user, and manage its permission grants for accessing those specific teams' data.", + "displayName": "Allow the Teams app to manage itself and its permission grants for all teams", + "id": "1e4be56c-312e-42b8-a2c9-009600d732c0", + "origin": "Application (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadWriteAndConsentSelfForTeam.All" }, { - "description": "Allows the app to read access reviews, reviewers, decisions and settings in the organization, without a signed-in user.", - "displayName": "Read all access reviews", - "id": "d07a8cc0-3d51-4b77-b3b0-32704d1f69fa", - "origin": "Application", - "value": "AccessReview.Read.All" + "description": "Allows a Teams app to read, install, upgrade, and uninstall itself for any user account, without a signed-in user, and manage its permission grants for accessing those specific users' data.", + "displayName": "Allow the Teams app to manage itself and its permission grants in all user accounts", + "id": "a87076cf-6abd-4e56-8559-4dbdf41bef96", + "origin": "Application (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadWriteAndConsentSelfForUser.All" }, { - "description": "Allows an app to read all service usage reports without a signed-in user. Services that provide usage reports include Office 365 and Azure Active Directory.", - "displayName": "Read all usage reports", - "id": "230c1aed-a721-4c5d-9cb4-a90514e508ef", - "origin": "Application", - "value": "Reports.Read.All" + "description": "Allows the app to read your tenant's acquired telephone number details, without a signed-in user. Acquired telephone numbers may include attributes related to assigned object, emergency location, network site, etc.", + "displayName": "Read Tenant-Acquired Telephone Number Details", + "id": "39b17d18-680c-41f4-b9c2-5f30629e7cb6", + "origin": "Application (Microsoft Graph)", + "value": "TeamsTelephoneNumber.Read.All" }, { - "description": "Allows the app to read any user's scored list of relevant people, without a signed-in user. The list can include local contacts, contacts from social networking, your organization's directory, and people from recent communications (such as email and Skype).", - "displayName": "Read all users' relevant people lists", - "id": "b528084d-ad10-4598-8b93-929746b4d7d6", - "origin": "Application", - "value": "People.Read.All" + "description": "Allows the app to read your tenant's acquired telephone number details, without a signed-in user. Acquired telephone numbers may include attributes related to assigned object, emergency location, network site, etc.", + "displayName": "Read and Modify Tenant-Acquired Telephone Number Details", + "id": "0a42382f-155c-4eb1-9bdc-21548ccaa387", + "origin": "Application (Microsoft Graph)", + "value": "TeamsTelephoneNumber.ReadWrite.All" }, { - "description": "Allows the app to update Microsoft Teams 1-to-1 or group chat messages by patching a set of Data Loss Prevention (DLP) policy violation properties to handle the output of DLP processing.", - "displayName": "Flag chat messages for violating policy", - "id": "7e847308-e030-4183-9899-5235d7270f58", - "origin": "Application", - "value": "Chat.UpdatePolicyViolation.All" + "description": "Allows the app to read your tenant's user configurations, without a signed-in user. User configuration may include attributes related to user, such as telephone number, assigned policies, etc.", + "displayName": "Read Teams user configurations", + "id": "a91eadaf-2c3c-4362-908b-fb172d208fc6", + "origin": "Application (Microsoft Graph)", + "value": "TeamsUserConfiguration.Read.All" }, { - "description": "Allows the app to read all 1-to-1 or group chat messages in Microsoft Teams.", - "displayName": "Read all chat messages", - "id": "6b7d71aa-70aa-4810-a8d9-5d9fb2830017", - "origin": "Application", - "value": "Chat.Read.All" + "description": "Allows the app to read all available Teams Templates, without a signed-user.", + "displayName": "Read all available Teams Templates", + "id": "6323133e-1f6e-46d4-9372-ac33a0870636", + "origin": "Application (Microsoft Graph)", + "value": "TeamTemplates.Read.All" }, { - "description": "Allows the app to read all channel messages in Microsoft Teams", - "displayName": "Read all channel messages", - "id": "7b2449af-6ccd-4f4d-9f78-e550c193f0d1", - "origin": "Application", - "value": "ChannelMessage.Read.All" + "description": "Allows the app to create chat and channel messages, without a signed in user. The app specifies which user appears as the sender, and can backdate the message to appear as if it was sent long ago. The messages can be sent to any chat or channel in the organization.", + "displayName": "Create chat and channel messages with anyone's identity and with any timestamp", + "id": "dfb0dd15-61de-45b2-be36-d6a69fba3c79", + "origin": "Application (Microsoft Graph)", + "value": "Teamwork.Migrate.All" + }, + { + "description": "Allows the app to read all teamwork settings of the organization without a signed-in user.", + "displayName": "Read organizational teamwork settings", + "id": "75bcfbce-a647-4fba-ad51-b63d73b210f4", + "origin": "Application (Microsoft Graph)", + "value": "Teamwork.Read.All" }, { - "description": "Allows the app to update Microsoft Teams channel messages by patching a set of Data Loss Prevention (DLP) policy violation properties to handle the output of DLP processing.", - "displayName": "Flag channel messages for violating policy", - "id": "4d02b0cc-d90b-441f-8d82-4fb55c34d6bb", - "origin": "Application", - "value": "ChannelMessage.UpdatePolicyViolation.All" + "description": "Allows the app to read the Teams app settings without a signed-in user.", + "displayName": "Read Teams app settings", + "id": "475ebe88-f071-4bd7-af2b-642952bd4986", + "origin": "Application (Microsoft Graph)", + "value": "TeamworkAppSettings.Read.All" }, { - "description": "Allows the app to create, read, update and delete applications and service principals without a signed-in user. Does not allow management of consent grants.", - "displayName": "Read and write all applications", - "id": "1bfefb4e-e0b5-418b-a88f-73c46d2cc8e9", - "origin": "Application", - "value": "Application.ReadWrite.All" + "description": "Allows the app to read and write the Teams app settings without a signed-in user.", + "displayName": "Read and write Teams app settings", + "id": "ab5b445e-8f10-45f4-9c79-dd3f8062cc4e", + "origin": "Application (Microsoft Graph)", + "value": "TeamworkAppSettings.ReadWrite.All" }, { - "description": "Allows the app to create, read, update, and delete user's mailbox settings without a signed-in user. Does not include permission to send mail.", - "displayName": "Read and write all user mailbox settings", - "id": "6931bccd-447a-43d1-b442-00a195474933", - "origin": "Application", - "value": "MailboxSettings.ReadWrite" + "description": "Create custom emoji without a signed-in user.", + "displayName": "Create custom emoji", + "id": "85643b08-0e25-4d99-9d68-04ba0fef9740", + "origin": "Application (Microsoft Graph)", + "value": "TeamworkCustomEmoji.Create.All" }, { - "description": "Allows the app to read and write all domain properties without a signed in user. \u00a0Also allows the app to add, \u00a0verify and remove domains.", - "displayName": "Read and write domains", - "id": "7e05723c-0bb0-42da-be95-ae9f08a6e53c", - "origin": "Application", - "value": "Domain.ReadWrite.All" + "description": "Read custom emoji without a signed-in user.", + "displayName": "Read custom emoji", + "id": "1efa3d37-1703-4685-9a59-baf6296fb956", + "origin": "Application (Microsoft Graph)", + "value": "TeamworkCustomEmoji.Read.All" }, { - "description": "Allows the app to read user's mailbox settings without a signed-in user. Does not include permission to send mail.", - "displayName": "Read all user mailbox settings", - "id": "40f97065-369a-49f4-947c-6a255697ae91", - "origin": "Application", - "value": "MailboxSettings.Read" + "description": "Allow the app to read the management data for Teams devices, without a signed-in user.", + "displayName": "Read Teams devices", + "id": "0591bafd-7c1c-4c30-a2a5-2b9aacb1dfe8", + "origin": "Application (Microsoft Graph)", + "value": "TeamworkDevice.Read.All" }, { - "description": "Allows the app to read mail in all mailboxes without a signed-in user.", - "displayName": "Read mail in all mailboxes", - "id": "810c84a8-4a9e-49e6-bf7d-12d183f40d01", - "origin": "Application", - "value": "Mail.Read" + "description": "Allow the app to read and write the management data for Teams devices, without a signed-in user.", + "displayName": "Read and write Teams devices", + "id": "79c02f5b-bd4f-4713-bc2c-a8a4a66e127b", + "origin": "Application (Microsoft Graph)", + "value": "TeamworkDevice.ReadWrite.All" }, { - "description": "Allows the app to create, read, update, and delete mail in all mailboxes without a signed-in user. Does not include permission to send mail.", - "displayName": "Read and write mail in all mailboxes", - "id": "e2a3a72e-5f79-4c64-b1b1-878b674786c9", - "origin": "Application", - "value": "Mail.ReadWrite" + "description": "Allows the app to read all users' sections (folders) for organizing chats and channels in Teams, without a signed-in user.", + "displayName": "Read all users' sections", + "id": "e9e1b87a-726e-4628-8fab-d1fc58d4d9ad", + "origin": "Application (Microsoft Graph)", + "value": "TeamworkSection.Read.All" }, { - "description": "Allows the app to send mail as any user without a signed-in user.", - "displayName": "Send mail as any user", - "id": "b633e1c5-b582-4048-a93e-9f11b44c7e96", - "origin": "Application", - "value": "Mail.Send" + "description": "Allows the app to read and write all users' sections (folders) for organizing chats and channels in Teams, without a signed-in user.", + "displayName": "Read and write all users' sections", + "id": "fd99f9da-42d6-4d00-8a41-4161bea42309", + "origin": "Application (Microsoft Graph)", + "value": "TeamworkSection.ReadWrite.All" }, { - "description": "Allows the app to read all contacts in all mailboxes without a signed-in user.", - "displayName": "Read contacts in all mailboxes", - "id": "089fe4d0-434a-44c5-8827-41ba8a0b17f5", - "origin": "Application", - "value": "Contacts.Read" + "description": "Allows the app to read tags in Teams without a signed-in user.", + "displayName": "Read tags in Teams", + "id": "b74fd6c4-4bde-488e-9695-eeb100e4907f", + "origin": "Application (Microsoft Graph)", + "value": "TeamworkTag.Read.All" }, { - "description": "Allows the app to create, read, update, and delete all contacts in all mailboxes without a signed-in user.", - "displayName": "Read and write contacts in all mailboxes", - "id": "6918b873-d17a-4dc1-b314-35f528134491", - "origin": "Application", - "value": "Contacts.ReadWrite" + "description": "Allows the app to read and write tags in Teams without a signed-in user.", + "displayName": "Read and write tags in Teams", + "id": "a3371ca5-911d-46d6-901c-42c8c7a937d8", + "origin": "Application (Microsoft Graph)", + "value": "TeamworkTag.ReadWrite.All" }, { - "description": "Allows the app to read data in your organization's directory, such as users, groups and apps, without a signed-in user.", - "displayName": "Read directory data", - "id": "7ab1d382-f21e-4acd-a863-ba3e13f7da61", - "origin": "Application", - "value": "Directory.Read.All" + "description": "Allows the app to read all group chat or channel targeted messages in Microsoft Teams.", + "displayName": "Read all targeted messages of group chat or channel", + "id": "b0cfd829-be18-4b31-bb0e-ec1df8197ba3", + "origin": "Application (Microsoft Graph)", + "value": "TeamworkTargetedMessage.Read.All" }, { - "description": "Allows the app to read and write data in your organization's directory, such as users, and groups, without a signed-in user. Does not allow user or group deletion.", - "displayName": "Read and write directory data", - "id": "19dbc75e-c2e2-444c-a770-ec69d8559fc7", - "origin": "Application", - "value": "Directory.ReadWrite.All" + "description": "Allows a Teams app to read, install, upgrade, and uninstall its own tabs for any user, without a signed-in user.", + "displayName": "Allow the Teams app to manage only its own tabs for all users", + "id": "3c42dec6-49e8-4a0a-b469-36cff0d9da93", + "origin": "Application (Microsoft Graph)", + "value": "TeamsTab.ReadWriteSelfForUser.All" }, { - "description": "Allows the app to read and write all device properties without a signed in user. Does not allow device creation, device deletion or update of device alternative security identifiers.", - "displayName": "Read and write devices", - "id": "1138cb37-bd11-4084-a2b7-9f71582aeddb", - "origin": "Application", - "value": "Device.ReadWrite.All" + "description": "Allows a Teams app to read, install, upgrade, and uninstall its own tabs in any team, without a signed-in user.", + "displayName": "Allow the Teams app to manage only its own tabs for all teams", + "id": "91c32b81-0ef0-453f-a5c7-4ce2e562f449", + "origin": "Application (Microsoft Graph)", + "value": "TeamsTab.ReadWriteSelfForTeam.All" }, { - "description": "Allows the app to read events of all calendars without a signed-in user.", - "displayName": "Read calendars in all mailboxes", - "id": "798ee544-9d2d-430c-a058-570e29e34338", - "origin": "Application", - "value": "Calendars.Read" + "description": "Allows a Teams app to read, install, upgrade, and uninstall its own tabs for any chat, without a signed-in user.", + "displayName": "Allow the Teams app to manage only its own tabs for all chats", + "id": "9f62e4a2-a2d6-4350-b28b-d244728c4f86", + "origin": "Application (Microsoft Graph)", + "value": "TeamsTab.ReadWriteSelfForChat.All" }, { - "description": "Allows the app to create, read, update, and delete events of all calendars without a signed-in user.", - "displayName": "Read and write calendars in all mailboxes", - "id": "ef54d2bf-783f-4e0f-bca1-3210c0444d99", - "origin": "Application (Office 365 Exchange Online)", - "value": "Calendars.ReadWrite.All" + "description": "Allows a Teams app to read, install, upgrade, and uninstall all tabs for any user, without a signed-in user.", + "displayName": "Allow the app to manage all tabs for all users", + "id": "425b4b59-d5af-45c8-832f-bb0b7402348a", + "origin": "Application (Microsoft Graph)", + "value": "TeamsTab.ReadWriteForUser.All" }, { - "description": "Allows the app to create, read, update, and delete user's mailbox settings without a signed-in user. Does not include permission to send mail.", - "displayName": "Read and write all user mailbox settings", - "id": "f9156939-25cd-4ba8-abfe-7fabcf003749", - "origin": "Application (Office 365 Exchange Online)", - "value": "MailboxSettings.ReadWrite" + "description": "Allows the app to read, install, upgrade, and uninstall Teams apps in any chat, without a signed-in user. Does not give the ability to read application-specific settings.", + "displayName": "Manage Teams apps for all chats", + "id": "9e19bae1-2623-4c4f-ab6e-2664615ff9a0", + "origin": "Application (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadWriteForChat.All" }, { - "description": "Allows the app to read your organization's user flows, without a signed-in user.", - "displayName": "Read all identity user flows", - "id": "1b0c317f-dd31-4305-9932-259a8b6e8099", - "origin": "Application", - "value": "IdentityUserFlow.Read.All" + "description": "Allows the app to read, install, upgrade, and uninstall Teams apps in any team, without a signed-in user. Does not give the ability to read application-specific settings.", + "displayName": "Manage Teams apps for all teams", + "id": "5dad17ba-f6cc-4954-a5a2-a0dcc95154f0", + "origin": "Application (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadWriteForTeam.All" }, { - "description": "Allows the app to read or write your organization's user flows, without a signed-in user.", - "displayName": "Read and write all identity user flows", - "id": "65319a09-a2be-469d-8782-f6b07debf789", - "origin": "Application", - "value": "IdentityUserFlow.ReadWrite.All" + "description": "Allows the app to read, install, upgrade, and uninstall Teams apps for any user, without a signed-in user. Does not give the ability to read application-specific settings.", + "displayName": "Manage Teams apps for all users", + "id": "74ef0291-ca83-4d02-8c7e-d2391e6a444f", + "origin": "Application (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadWriteForUser.All" }, { - "description": "Allows the app to read and create online meetings as an application in your organization.", - "displayName": "Read and create online meetings", - "id": "b8bb2037-6e08-44ac-a4ea-4674e010e2a4", - "origin": "Application", - "value": "OnlineMeetings.ReadWrite.All" + "description": "Allows the app to read, install, upgrade, and uninstall selected Teams apps in any chat, without a signed-in user. Does not give the ability to read application-specific settings.", + "displayName": "Manage selected installed Teams apps in all chats", + "id": "25bbeaad-04be-4207-83ed-a263aae76ddf", + "origin": "Application (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadWriteSelectedForChat.All" }, { - "description": "Allows the app to read online meeting details in your organization, without a signed-in user.", - "displayName": "Read online meeting details", - "id": "c1684f21-1984-47fa-9d61-2dc8c296bb70", - "origin": "Application", - "value": "OnlineMeetings.Read.All" + "description": "Allows the app to read, install, upgrade, and uninstall selected Teams apps in any team, without a signed-in user. Does not give the ability to read application-specific settings.", + "displayName": "Manage selected installed Teams apps in all teams", + "id": "7b5823ae-d0f2-424d-b90c-d843ffada7d9", + "origin": "Application (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadWriteSelectedForTeam.All" }, { - "description": "Allows the app to get direct access to media streams in a call, without a signed-in user.", - "displayName": "Access media streams in a call as an app", - "id": "a7a681dc-756e-4909-b988-f160edc6655f", - "origin": "Application", - "value": "Calls.AccessMedia.All" + "description": "Allows the app to read, install, upgrade, and uninstall selected Teams apps for any user, without a signed-in user. Does not give the ability to read application-specific settings.", + "displayName": "Manage selected Teams apps installed for all users", + "id": "650a76ec-4118-4b25-9d3a-1f98048a5ee0", + "origin": "Application (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadWriteSelectedForUser.All" }, { - "description": "Allows the app to anonymously join group calls and scheduled meetings in your organization, without a signed-in user. \u00a0The app will be joined as a guest to meetings in your organization.", - "displayName": "Join group calls and meetings as a guest", - "id": "fd7ccf6b-3d28-418b-9701-cd10f5cd2fd4", - "origin": "Application", - "value": "Calls.JoinGroupCallAsGuest.All" + "description": "Allows a Teams app to read, install, upgrade, and uninstall itself for any chat, without a signed-in user.", + "displayName": "Allow the Teams app to manage itself for all chats", + "id": "73a45059-f39c-4baf-9182-4954ac0e55cf", + "origin": "Application (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadWriteSelfForChat.All" }, { - "description": "Allows the app to join group calls and scheduled meetings in your organization, without a signed-in user. \u00a0The app will be joined with the privileges of a directory user to meetings in your organization.", - "displayName": "Join group calls and meetings as an app", - "id": "f6b49018-60ab-4f81-83bd-22caeabfed2d", - "origin": "Application", - "value": "Calls.JoinGroupCall.All" + "description": "Allows a Teams app to read, install, upgrade, and uninstall itself in any team, without a signed-in user.", + "displayName": "Allow the Teams app to manage itself for all teams", + "id": "9f67436c-5415-4e7f-8ac1-3014a7132630", + "origin": "Application (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadWriteSelfForTeam.All" }, { - "description": "Allows the app to place outbound calls to multiple users and add participants to meetings in your organization, without a signed-in user.", - "displayName": "Initiate outgoing group calls from the app", - "id": "4c277553-8a09-487b-8023-29ee378d8324", - "origin": "Application", - "value": "Calls.InitiateGroupCall.All" + "description": "Allows the application to list and read all Tenant Governance policy templates without a signed-in user.", + "displayName": "Read Tenant Governance policy templates", + "id": "eb9465d8-e7c0-4301-8e51-927f34ee3134", + "origin": "Application (Microsoft Graph)", + "value": "TenantGovernance-PolicyTemplate.Read.All" }, { - "description": "Allows the app to place outbound calls to a single user and transfer calls to users in your organization\u2019s directory, without a signed-in user.", - "displayName": "Initiate outgoing 1 to 1 calls from the app", - "id": "284383ee-7f6e-4e40-a2a8-e85dcb029101", - "origin": "Application", - "value": "Calls.Initiate.All" + "description": "Allows a Teams app to read, install, upgrade, and uninstall itself to any user, without a signed-in user.", + "displayName": "Allow the app to manage itself for all users", + "id": "908de74d-f8b2-4d6b-a9ed-2a17b3b78179", + "origin": "Application (Microsoft Graph)", + "value": "TeamsAppInstallation.ReadWriteSelfForUser.All" }, { - "description": "Allows the app to read all organizational contacts without a signed-in user. These contacts are managed by the organization and are different from a user's personal contacts.", - "displayName": "Read organizational contacts", - "id": "e1a88a34-94c4-4418-be12-c87b00e26bea", - "origin": "Application", - "value": "OrgContact.Read.All" + "description": "Read and change all teams' settings, without a signed-in user.", + "displayName": "Read and change all teams' settings", + "id": "bdd80a03-d9bc-451d-b7c4-ce7c63fe3c8f", + "origin": "Application (Microsoft Graph)", + "value": "TeamSettings.ReadWrite.All" }, { - "description": "Allows the app to read and write the properties, group assignments and status of apps, app configurations and app protection policies managed by Microsoft Intune, without a signed-in user.", - "displayName": "Read and write Microsoft Intune apps", - "id": "78145de6-330d-4800-a6ce-494ff2d33d07", - "origin": "Application", - "value": "DeviceManagementApps.ReadWrite.All" + "description": "Allow the app to read or write/update the policy assignment and unassigment for Teams users for all policy type categories.", + "displayName": "Read and Write Teams policy user assignment and unassigment for all policy types.", + "id": "1801e8f4-cf09-4c4e-a1b5-036dfcca6c90", + "origin": "Application (Microsoft Graph)", + "value": "TeamsPolicyUserAssign.ReadWrite.All" }, { - "description": "Allows the app to read and write properties of Microsoft Intune-managed device configuration and device compliance policies and their assignment to groups, without a signed-in user.", - "displayName": "Read and write Microsoft Intune device configuration and policies", - "id": "9241abd9-d0e6-425a-bd4f-47ba86e767a4", - "origin": "Application", - "value": "DeviceManagementConfiguration.ReadWrite.All" + "description": "Allows the app to read your tenant's resource accounts without a signed-in user.", + "displayName": "Read Teams resource accounts", + "id": "b55aa226-33a1-4396-bcf4-edce5e7a31c1", + "origin": "Application (Microsoft Graph)", + "value": "TeamsResourceAccount.Read.All" }, { - "description": "Allows the app to perform remote high impact actions such as wiping the device or resetting the passcode on devices managed by Microsoft Intune, without a signed-in user.", - "displayName": "Perform user-impacting remote actions on Microsoft Intune devices", - "id": "5b07b0dd-2377-4e44-a38d-703f09a0dc3c", - "origin": "Application", - "value": "DeviceManagementManagedDevices.PrivilegedOperations.All" + "description": "Allows the app to create tabs in any team in Microsoft Teams, without a signed-in user. This does not grant the ability to read, modify or delete tabs after they are created, or give access to the content inside the tabs.", + "displayName": "Create tabs in Microsoft Teams.", + "id": "49981c42-fd7b-4530-be03-e77b21aed25e", + "origin": "Application (Microsoft Graph)", + "value": "TeamsTab.Create" }, { - "description": "Allows the app to read and write the properties of devices managed by Microsoft Intune, without a signed-in user. Does not allow high impact operations such as remote wipe and password reset on the device\u2019s owner", - "displayName": "Read and write Microsoft Intune devices", - "id": "243333ab-4d21-40cb-a475-36241daa0842", - "origin": "Application", - "value": "DeviceManagementManagedDevices.ReadWrite.All" + "description": "Read the names and settings of tabs inside any team in Microsoft Teams, without a signed-in user. This does not give access to the content inside the tabs.", + "displayName": "Read tabs in Microsoft Teams.", + "id": "46890524-499a-4bb2-ad64-1476b4f3e1cf", + "origin": "Application (Microsoft Graph)", + "value": "TeamsTab.Read.All" }, { - "description": "Allows the app to read and write the properties relating to the Microsoft Intune Role-Based Access Control (RBAC) settings, without a signed-in user.", - "displayName": "Read and write Microsoft Intune RBAC settings", - "id": "e330c4f0-4170-414e-a55a-2f022ec2b57b", - "origin": "Application", - "value": "DeviceManagementRBAC.ReadWrite.All" + "description": "Read and write tabs in any team in Microsoft Teams, without a signed-in user. This does not give access to the content inside the tabs.", + "displayName": "Read and write tabs in Microsoft Teams.", + "id": "a96d855f-016b-47d7-b51c-1218a98d791c", + "origin": "Application (Microsoft Graph)", + "value": "TeamsTab.ReadWrite.All" }, { - "description": "Allows the app to read and write Microsoft Intune service properties including device enrollment and third party service connection configuration, without a signed-in user.", - "displayName": "Read and write Microsoft Intune configuration", - "id": "5ac13192-7ace-4fcf-b828-1a26f28068ee", - "origin": "Application", - "value": "DeviceManagementServiceConfig.ReadWrite.All" + "description": "Allows a Teams app to read, install, upgrade, and uninstall all tabs for any chat, without a signed-in user.", + "displayName": "Allow the Teams app to manage all tabs for all chats", + "id": "fd9ce730-a250-40dc-bd44-8dc8d20f39ea", + "origin": "Application (Microsoft Graph)", + "value": "TeamsTab.ReadWriteForChat.All" }, { - "description": "Allows the app to manage permission grants for application permissions to any API (including Microsoft Graph) and application assignments for any app, without a signed-in user.", - "displayName": "Manage app permission grants and app role assignments", - "id": "06b708a9-e830-4db3-a914-8e69da51d44f", - "origin": "Application", - "value": "AppRoleAssignment.ReadWrite.All" + "description": "Allows a Teams app to read, install, upgrade, and uninstall all tabs in any team, without a signed-in user.", + "displayName": "Allow the Teams app to manage all tabs for all teams", + "id": "6163d4f4-fbf8-43da-a7b4-060fe85ed148", + "origin": "Application (Microsoft Graph)", + "value": "TeamsTab.ReadWriteForTeam.All" }, { - "description": "Allows the app to manage permission grants for delegated permissions exposed by any API (including Microsoft Graph), without a signed-in user.", - "displayName": "Manage all delegated permission grants", - "id": "8e8e4742-1d95-4f68-9d56-6ee75648c72a", - "origin": "Application", - "value": "DelegatedPermissionGrant.ReadWrite.All" + "description": "Read all team's settings, without a signed-in user.", + "displayName": "Read all teams' settings", + "id": "242607bd-1d2c-432c-82eb-bdb27baa23ab", + "origin": "Application (Microsoft Graph)", + "value": "TeamSettings.Read.All" }, { - "description": "Allows the app to read all users' teamwork activity feed, without a signed-in user.", - "displayName": "Read all users' teamwork activity feed", - "id": "70dec828-f620-4914-aa83-a29117306807", - "origin": "Application", - "value": "TeamsActivity.Read.All" + "description": "Allows the app to read your organization's sign-in identifiers, without a signed-in user.", + "displayName": "Read all sign-in identifiers", + "id": "28e1fe78-598f-4df4-b55e-18bf34218925", + "origin": "Application (Microsoft Graph)", + "value": "SignInIdentifier.Read.All" }, { - "description": "Allows the app to read time-based assignment and just-in-time elevation (including scheduled elevation) of Azure AD built-in and custom administrative roles in your organization, without a signed-in user.", - "displayName": "Read privileged access to Azure AD roles", - "id": "4cdc2547-9148-4295-8d11-be0db1391d6b", - "origin": "Application", - "value": "PrivilegedAccess.Read.AzureAD" + "description": "Allows the app to read all virtual events without a signed-in user.", + "displayName": "Read all users' virtual events", + "id": "1dccb351-c4e4-4e09-a8d1-7a9ecbf027cc", + "origin": "Application (Microsoft Graph)", + "value": "VirtualEvent.Read.All" }, { - "description": "Allows the app to read time-based assignment and just-in-time elevation (including scheduled elevation) of Azure AD groups in your organization, without a signed-in user.", - "displayName": "Read privileged access to Azure AD groups", - "id": "01e37dc9-c035-40bd-b438-b2879c4870a6", - "origin": "Application", - "value": "PrivilegedAccess.Read.AzureADGroup" + "description": "Allows the app to read all Windows update deployment settings for the organization without a signed-in user.", + "displayName": "Read all Windows update deployment settings", + "id": "50a8bf5f-b06a-4ac7-881f-3ca0c4be7550", + "origin": "Application (Microsoft Graph)", + "value": "WindowsUpdates.Read.All" }, { - "description": "Allows the app to read time-based assignment and just-in-time elevation of user privileges to audit Azure resources in your organization, without a signed-in user.", - "displayName": "Read privileged access to Azure resources", - "id": "5df6fe86-1be0-44eb-b916-7bd443a71236", - "origin": "Application", - "value": "PrivilegedAccess.Read.AzureResources" + "description": "Allows the app to create, read, update, and delete events in user calendars.", + "displayName": "Have full access to user calendars ", + "id": "1ec239c2-d7c9-4623-a91a-a9775856bb36", + "origin": "Delegated (Microsoft Graph)", + "value": "Calendars.ReadWrite" }, { - "description": "Allows the app to request and manage time-based assignment and just-in-time elevation (including scheduled elevation) of Azure AD built-in and custom administrative roles in your organization, without a signed-in user.", - "displayName": "Read and write privileged access to Azure AD roles", - "id": "854d9ab1-6657-4ec8-be45-823027bcd009", - "origin": "Application", - "value": "PrivilegedAccess.ReadWrite.AzureAD" + "description": "Allows the app to create, read, update and delete events in all calendars in the organization user has permissions to access. This includes delegate and shared calendars.", + "displayName": "Read and write user and shared calendars", + "id": "12466101-c9b8-439a-8589-dd09ee67e8e9", + "origin": "Delegated (Microsoft Graph)", + "value": "Calendars.ReadWrite.Shared" }, { - "description": "Allows the app to request and manage time-based assignment and just-in-time elevation (including scheduled elevation) of Azure AD groups in your organization, without a signed-in user.", - "displayName": "Read and write privileged access to Azure AD groups", - "id": "2f6817f8-7b12-4f0f-bc18-eeaf60705a9e", - "origin": "Application", - "value": "PrivilegedAccess.ReadWrite.AzureADGroup" + "description": "Allows the app to read all AI Insights for calls, on behalf of the signed-in user.", + "displayName": "Read all AI Insights for calls. ", + "id": "e24bdaf9-83f8-468b-a144-c681ccb6caf4", + "origin": "Delegated (Microsoft Graph)", + "value": "CallAiInsights.Read.All" }, { - "description": "Allows the app to request and manage time-based assignment and just-in-time elevation of Azure resources (like your subscriptions, resource groups, storage, compute) in your organization, without a signed-in user.", - "displayName": "Read and write privileged access to Azure resources", - "id": "6f9d5abc-2db6-400b-a267-7de22a40fb87", - "origin": "Application", - "value": "PrivilegedAccess.ReadWrite.AzureResources" + "description": "Allows the app to read delegation settings of you", + "displayName": "Read delegation settings", + "id": "305b375b-00fe-48bf-81bc-e8d78954c1b6", + "origin": "Delegated (Microsoft Graph)", + "value": "CallDelegation.Read" }, { - "description": "Allows the app to read all the indicators for your organization, without a signed-in user.", - "displayName": "Read all threat indicators", - "id": "197ee4e9-b993-4066-898f-d6aecc55125b", - "origin": "Application", - "value": "ThreatIndicators.Read.All" + "description": "Allows the app to read and write delegation settings of you", + "displayName": "Read and write delegation settings", + "id": "599abf67-f72b-4b5f-98a3-cb38fe646118", + "origin": "Delegated (Microsoft Graph)", + "value": "CallDelegation.ReadWrite" }, { - "description": "Allows the app to send, read, update and delete user\u2019s notifications, without a signed-in user.", - "displayName": "Deliver and manage all user's notifications", - "id": "4e774092-a092-48d1-90bd-baad67c7eb47", - "origin": "Application", - "value": "UserNotification.ReadWrite.CreatedByApp" + "description": "Allows the app to read call event information for an organization for the signed-in user.", + "displayName": "Read call event data", + "id": "43431c03-960e-400f-87c6-8f910321dca3", + "origin": "Delegated (Microsoft Graph)", + "value": "CallEvents.Read" }, { - "description": "Allows the app to read all applications and service principals without a signed-in user.", - "displayName": "Read all applications", - "id": "9a5d68dd-52b0-4cc2-bd40-abcf44ac3a30", - "origin": "Application", - "value": "Application.Read.All" + "description": "Allows the app to read all recordings of calls, on behalf of the signed-in user.", + "displayName": "Read all recordings of calls. ", + "id": "63d31bd6-bcf5-40ca-8283-ba4130a66405", + "origin": "Delegated (Microsoft Graph)", + "value": "CallRecordings.Read.All" }, { - "description": "Allows the app to read memberships and basic group properties for all groups without a signed-in user.", - "displayName": "Read all group memberships", - "id": "98830695-27a2-44f7-8c18-0c3ebc9698f6", - "origin": "Application", - "value": "GroupMember.Read.All" + "description": "Allows the app to read all transcripts of calls, on behalf of the signed-in user.", + "displayName": "Read all transcripts of calls. ", + "id": "fbace248-5d8e-441c-85ca-cc19221a69a2", + "origin": "Delegated (Microsoft Graph)", + "value": "CallTranscripts.Read.All" }, { - "description": "Allows the app to list groups, read basic properties, read and update the membership of the groups this app has access to without a signed-in user. Group properties and owners cannot be updated and groups cannot be deleted.", - "displayName": "Read and write all group memberships", - "id": "dbaae8cf-10b5-4b86-a4a1-f871c94c6695", - "origin": "Application", - "value": "GroupMember.ReadWrite.All" + "description": "Allows the app to read all cases, relations, tasks, attachments and activities, on behalf of the signed-in user.", + "displayName": "Read all cases, relations, tasks, attachments and activities", + "id": "7bdc421c-99cd-4b67-a749-aa8e92775f7e", + "origin": "Delegated (Microsoft Graph)", + "value": "CaseManagement.Read.All" }, { - "description": "Allows the app to create groups without a signed-in user.", - "displayName": "Create groups", - "id": "bf7b1a76-6e77-406b-b258-bf5c7720e98f", - "origin": "Application", - "value": "Group.Create" + "description": "Allows the app to read and write to all cases, relations, tasks, attachments and activities, on behalf of the signed-in user.", + "displayName": "Read and write to all cases, relations, tasks, attachments and activities", + "id": "363a0763-d7eb-40bc-9457-6be55acd81e2", + "origin": "Delegated (Microsoft Graph)", + "value": "CaseManagement.ReadWrite.All" }, { - "description": "Allows an app to read your organization's threat assessment requests, without a signed-in user.", - "displayName": "Read threat assessment requests", - "id": "f8f035bb-2cce-47fb-8bf5-7baf3ecbee48", - "origin": "Application", - "value": "ThreatAssessment.Read.All" + "description": "Allows to read all Change Management items.", + "displayName": "Read Change Management items", + "id": "4628dff5-c33e-4fde-b17a-b64e7acb1bed", + "origin": "Delegated (Microsoft Graph)", + "value": "ChangeManagement.Read.All" }, { - "description": "Allows the app to read all schedules, schedule groups, shifts and associated entities in the Teams or Shifts application without a signed-in user.", - "displayName": "Read all schedule items", - "id": "7b2ebf90-d836-437f-b90d-7b62722c4456", - "origin": "Application", - "value": "Schedule.Read.All" + "description": "Create channels in any team, on behalf of the signed-in user.", + "displayName": "Create channels", + "id": "101147cf-4178-4455-9d58-02b5c164e759", + "origin": "Delegated (Microsoft Graph)", + "value": "Channel.Create" }, { - "description": "Allows the app to manage all schedules, schedule groups, shifts and associated entities in the Teams or Shifts application without a signed-in user.", - "displayName": "Read and write all schedule items", - "id": "b7760610-0545-4e8a-9ec3-cce9e63db01c", - "origin": "Application", - "value": "Schedule.ReadWrite.All" + "description": "Delete channels in any team, on behalf of the signed-in user.", + "displayName": "Delete channels", + "id": "cc83893a-e232-4723-b5af-bd0b01bcfe65", + "origin": "Delegated (Microsoft Graph)", + "value": "Channel.Delete.All" }, { - "description": "Allows the app to read call records for all calls and online meetings without a signed-in user.", - "displayName": "Read all call records", - "id": "45bbb07e-7321-4fd7-a8f6-3ff27e6a81c8", - "origin": "Application", - "value": "CallRecords.Read.All" + "description": "Read channel names and channel descriptions, on behalf of the signed-in user.", + "displayName": "Read the names and descriptions of channels", + "id": "9d8982ae-4365-4f57-95e9-d6032a4c0b87", + "origin": "Delegated (Microsoft Graph)", + "value": "Channel.ReadBasic.All" }, { - "description": "Allows the app to read and write your organization's conditional access policies, without a signed-in user.", - "displayName": "Read and write your organization's conditional access policies", - "id": "01c0a623-fc9b-48e9-b794-0756f8e8f067", - "origin": "Application", - "value": "Policy.ReadWrite.ConditionalAccess" + "description": "Read the members of channels, on behalf of the signed-in user.", + "displayName": "Read the members of channels", + "id": "2eadaff8-0bce-4198-a6b9-2cfc35a30075", + "origin": "Delegated (Microsoft Graph)", + "value": "ChannelMember.Read.All" }, { - "description": "Allows the application to read and write authentication methods of all users in your organization, without a signed-in user. Authentication methods include things like a user\u2019s phone numbers and Authenticator app settings. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods", - "displayName": "Read and write all users' authentication methods ", - "id": "50483e42-d915-4231-9639-7fdb7fd190e5", - "origin": "Application", - "value": "UserAuthenticationMethod.ReadWrite.All" + "description": "Add and remove members from channels, on behalf of the signed-in user. Also allows changing a member's role, for example from owner to non-owner.", + "displayName": "Add and remove members from channels", + "id": "0c3e411a-ce45-4cd1-8f30-f99a3efa7b11", + "origin": "Delegated (Microsoft Graph)", + "value": "ChannelMember.ReadWrite.All" }, { - "description": " Allows the app to read authentication methods of all users in your organization, without a signed-in user. Authentication methods include things like a user\u2019s phone numbers and Authenticator app settings. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", - "displayName": " Read all users' authentication methods", - "id": "38d9df27-64da-44fd-b7c5-a6fbac20248f", - "origin": "Application", - "value": "UserAuthenticationMethod.Read.All" + "description": "Allows an app to edit channel messages in Microsoft Teams, on behalf of the signed-in user.", + "displayName": "Edit user's channel messages", + "id": "2b61aa8a-6d36-4b2f-ac7b-f29867937c53", + "origin": "Delegated (Microsoft Graph)", + "value": "ChannelMessage.Edit" }, { - "description": "Allows the app to create tabs in any team in Microsoft Teams, without a signed-in user. This does not grant the ability to read, modify or delete tabs after they are created, or give access to the content inside the tabs.", - "displayName": "Create tabs in Microsoft Teams.", - "id": "49981c42-fd7b-4530-be03-e77b21aed25e", - "origin": "Application", - "value": "TeamsTab.Create" + "description": "Allows the app to read events in user calendars, except for properties such as body, attachments, and extensions.", + "displayName": "Read basic details of user calendars", + "id": "662d75ba-a364-42ad-adee-f5f880ea4878", + "origin": "Delegated (Microsoft Graph)", + "value": "Calendars.ReadBasic" }, { - "description": "Read the names and settings of tabs inside any team in Microsoft Teams, without a signed-in user. This does not give access to the content inside the tabs. ", - "displayName": "Read tabs in Microsoft Teams.", - "id": "46890524-499a-4bb2-ad64-1476b4f3e1cf", - "origin": "Application", - "value": "TeamsTab.Read.All" + "description": "Allows an app to read a channel's messages in Microsoft Teams, on behalf of the signed-in user.", + "displayName": "Read user channel messages", + "id": "767156cb-16ae-4d10-8f8b-41b657c8c8c8", + "origin": "Delegated (Microsoft Graph)", + "value": "ChannelMessage.Read.All" }, { - "description": "Read and write tabs in any team in Microsoft Teams, without a signed-in user. This does not give access to the content inside the tabs.", - "displayName": "Read and write tabs in Microsoft Teams.", - "id": "a96d855f-016b-47d7-b51c-1218a98d791c", - "origin": "Application", - "value": "TeamsTab.ReadWrite.All" + "description": "Allows the app to read events in all calendars that the user can access, including delegate and shared calendars.", + "displayName": "Read user and shared calendars", + "id": "2b9c4092-424d-4249-948d-b43879977640", + "origin": "Delegated (Microsoft Graph)", + "value": "Calendars.Read.Shared" }, { - "description": "Allows the app to read all domain properties without a signed-in user.", - "displayName": "Read domains", - "id": "dbb9058a-0e50-45d7-ae91-66909b5d4664", - "origin": "Application", - "value": "Domain.Read.All" + "description": "Allows the app to fully manage all data associated with the business scenarios it owns. Data access and changes will be attributed to the signed-in user.", + "displayName": "Read and write all data for business scenarios this app creates or owns", + "id": "19932d57-2952-4c60-8634-3655c79fc527", + "origin": "Delegated (Microsoft Graph)", + "value": "BusinessScenarioData.ReadWrite.OwnedBy" }, { - "description": "Allows the app to read and write your organization's application configuration policies, without a signed-in user. This includes policies such as activityBasedTimeoutPolicy, claimsMappingPolicy, homeRealmDiscoveryPolicy, tokenIssuancePolicy and tokenLifetimePolicy.", - "displayName": "Read and write your organization's application configuration policies", - "id": "be74164b-cff1-491c-8741-e671cb536e13", - "origin": "Application", - "value": "Policy.ReadWrite.ApplicationConfiguration" + "description": "Allows the app to search the backup snapshots for Microsoft 365 resources, and restore Microsoft 365 resources from a backed-up snapshot, on behalf of the signed in user.", + "displayName": "Read restore sessions and start restore sessions from backups", + "id": "9f89e109-94b9-4c9b-b4fc-98cdaa54f574", + "origin": "Delegated (Microsoft Graph)", + "value": "BackupRestore-Restore.ReadWrite.All" }, { - "description": "Allows the app to read your organization's devices' configuration information without a signed-in user.", - "displayName": "Read all devices", - "id": "7438b122-aefc-4978-80ed-43db9fcc7715", - "origin": "Application", - "value": "Device.Read.All" + "description": "Allows the app to search the backup snapshots for Microsoft 365 resources, on behalf of the signed in user.", + "displayName": "Search for metadata properties in backup snapshots", + "id": "2b24830f-f435-446f-ab5a-b1e70d9a2eb5", + "origin": "Delegated (Microsoft Graph)", + "value": "BackupRestore-Search.Read.All" }, { - "description": "Allows the app to read, update and delete identities that are associated with a user's account, without a signed in user. This controls the identities users can sign-in with.", - "displayName": "Manage all users' identities", - "id": "c529cfca-c91b-489c-af2b-d92990b66ce6", - "origin": "Application", - "value": "User.ManageIdentities.All" + "description": "Allows the app to read and write the billing configuration on all applications on behalf of the signed-in user.", + "displayName": "Read and write application billing configuration", + "id": "2bf6d319-dfca-4c22-9879-f88dcfaee6be", + "origin": "Delegated (Microsoft Graph)", + "value": "BillingConfiguration.ReadWrite.All" }, { - "description": "Allows the app to read all users' shift schedule preferences without a signed-in user.", - "displayName": "Read all user shift preferences", - "id": "de023814-96df-4f53-9376-1e2891ef5a18", - "origin": "Application", - "value": "UserShiftPreferences.Read.All" + "description": "Allows the app to read BitLocker keys on behalf of the signed-in user, for their owned devices. Allows read of the recovery key.", + "displayName": "Read BitLocker keys", + "id": "b27a61ec-b99c-4d6a-b126-c4375d08ae30", + "origin": "Delegated (Microsoft Graph)", + "value": "BitlockerKey.Read.All" }, { - "description": "Allows the app to manage all users' shift schedule preferences without a signed-in user.", - "displayName": "Read and write all user shift preferences", - "id": "d1eec298-80f3-49b0-9efb-d90e224798ac", - "origin": "Application", - "value": "UserShiftPreferences.ReadWrite.All" + "description": "Allows the app to read basic BitLocker key properties on behalf of the signed-in user, for their owned devices. Does not allow read of the recovery key itself.", + "displayName": "Read BitLocker keys basic information", + "id": "5a107bfc-4f00-4e1a-b67e-66451267bc68", + "origin": "Delegated (Microsoft Graph)", + "value": "BitlockerKey.ReadBasic.All" }, { - "description": "Allows the app to read all the OneNote notebooks in your organization, without a signed-in user.", - "displayName": "Read and write all OneNote notebooks", - "id": "0c458cef-11f3-48c2-a568-c66751c238c0", - "origin": "Application", - "value": "Notes.ReadWrite.All" + "description": "Allows an app to read, write and manage bookings appointments, businesses, customers, services, and staff on behalf of the signed-in user.", + "displayName": "Manage bookings information", + "id": "7f36b48e-542f-4d3b-9bcb-8406f0ab9fdb", + "origin": "Delegated (Microsoft Graph)", + "value": "Bookings.Manage.All" }, { - "description": "Allows the app to have full control of all site collections without a signed in user.", - "displayName": "Have full control of all site collections", - "id": "a82116e5-55eb-4c41-a434-62fe8a61c773", - "origin": "Application", - "value": "Sites.FullControl.All" + "description": "Allows an app to read bookings appointments, businesses, customers, services, and staff on behalf of the signed-in user.", + "displayName": "Read bookings information", + "id": "33b1df99-4b29-4548-9339-7a7b83eaeebc", + "origin": "Delegated (Microsoft Graph)", + "value": "Bookings.Read.All" }, { - "description": "Allows the app to create or delete document libraries and lists in all site collections without a signed in user.", - "displayName": "Create, edit, and delete items and lists in all site collections", - "id": "0c0bf378-bf22-4481-8f81-9e89a9b4960a", - "origin": "Application", - "value": "Sites.Manage.All" + "description": "Allows an app to read and write bookings appointments, businesses, customers, services, and staff on behalf of the signed-in user. Does not allow create, delete and publish of booking businesses.", + "displayName": "Read and write bookings information", + "id": "948eb538-f19d-4ec5-9ccc-f059e1ea4c72", + "origin": "Delegated (Microsoft Graph)", + "value": "Bookings.ReadWrite.All" }, { - "description": "Allows the app to read access packages and related entitlement management resources without a signed-in user.", - "displayName": "Read all entitlement management resources", - "id": "c74fd47d-ed3c-45c3-9a9e-b8676de685d2", - "origin": "Application", - "value": "EntitlementManagement.Read.All" + "description": "Allows an app to read and write bookings appointments and customers, and additionally allows read businesses information, services, and staff on behalf of the signed-in user.", + "displayName": "Read and write booking appointments", + "id": "02a5a114-36a6-46ff-a102-954d89d9ab02", + "origin": "Delegated (Microsoft Graph)", + "value": "BookingsAppointment.ReadWrite.All" }, { - "description": "Allows the app to read and write access packages and related entitlement management resources without a signed-in user.", - "displayName": "Read and write all entitlement management resources", - "id": "9acd699f-1e81-4958-b001-93b1d2506e19", - "origin": "Application", - "value": "EntitlementManagement.ReadWrite.All" + "description": "Allows an app to read all bookmarks that the signed-in user can access.", + "displayName": "Read all bookmarks that the user can access", + "id": "98b17b35-f3b1-4849-a85f-9f13733002f0", + "origin": "Delegated (Microsoft Graph)", + "value": "Bookmark.Read.All" }, { - "description": "Create channels in any team, without a signed-in user.", - "displayName": "Create channels", - "id": "f3a65bd4-b703-46df-8f7e-0174fea562aa", - "origin": "Application", - "value": "Channel.Create" + "description": "Allows an app to read the browser site lists configured for your organization, on behalf of the signed-in user.", + "displayName": "Read browser site lists for your organization", + "id": "fb9be2b7-a7fc-4182-aec1-eda4597c43d5", + "origin": "Delegated (Microsoft Graph)", + "value": "BrowserSiteLists.Read.All" }, { - "description": "Delete channels in any team, without a signed-in user.", - "displayName": "Delete channels", - "id": "6a118a39-1227-45d4-af0c-ea7b40d210bc", - "origin": "Application", - "value": "Channel.Delete.All" + "description": "Allows an app to read and write the browser site lists configured for your organization, on behalf of the signed-in user.", + "displayName": "Read and write browser site lists for your organization", + "id": "83b34c85-95bf-497b-a04e-b58eca9d49d0", + "origin": "Delegated (Microsoft Graph)", + "value": "BrowserSiteLists.ReadWrite.All" }, - { - "description": "Read all channel names, channel descriptions, and channel settings, without a signed-in user.", - "displayName": "Read the names, descriptions, and settings of all channels", - "id": "c97b873f-f59f-49aa-8a0e-52b32d762124", - "origin": "Application", - "value": "ChannelSettings.Read.All" + { + "description": "Allows the app to read the configurations of your organization's business scenarios, on behalf of the signed-in user.", + "displayName": "Read business scenario configurations", + "id": "d16480b2-e469-4118-846b-d3d177327bee", + "origin": "Delegated (Microsoft Graph)", + "value": "BusinessScenarioConfig.Read.All" }, { - "description": "Read and write the names, descriptions, and settings of all channels, without a signed-in user.", - "displayName": "Read and write the names, descriptions, and settings of all channels", - "id": "243cded2-bd16-4fd6-a953-ff8177894c3d", - "origin": "Application", - "value": "ChannelSettings.ReadWrite.All" + "description": "Allows the app to read the configurations of business scenarios it owns, on behalf of the signed-in user.", + "displayName": "Read business scenario configurations this app creates or owns", + "id": "c47e7b6e-d6f1-4be9-9ffd-1e00f3e32892", + "origin": "Delegated (Microsoft Graph)", + "value": "BusinessScenarioConfig.Read.OwnedBy" }, { - "description": "Get a list of all teams, without a signed-in user.", - "displayName": "Get a list of all teams", - "id": "2280dda6-0bfd-44ee-a2f4-cb867cfc4c1e", - "origin": "Application", - "value": "Team.ReadBasic.All" + "description": "Allows the app to read and write the configurations of your organization's business scenarios, on behalf of the signed-in user.", + "displayName": "Read and write business scenario configurations", + "id": "755e785b-b658-446f-bb22-5a46abd029ea", + "origin": "Delegated (Microsoft Graph)", + "value": "BusinessScenarioConfig.ReadWrite.All" }, { - "description": "Read all channel names and channel descriptions, without a signed-in user.", - "displayName": "Read the names and descriptions of all channels", - "id": "59a6b24b-4225-4393-8165-ebaec5f55d7a", - "origin": "Application", - "value": "Channel.ReadBasic.All" + "description": "Allows the app to create new business scenarios and fully manage the configurations of scenarios it owns, on behalf of the signed-in user.", + "displayName": "Read and write business scenario configurations this app creates or owns", + "id": "b3b7fcff-b4d4-4230-bf6f-90bd91285395", + "origin": "Delegated (Microsoft Graph)", + "value": "BusinessScenarioConfig.ReadWrite.OwnedBy" }, { - "description": "Read and change all teams' settings, without a signed-in user.", - "displayName": "Read and change all teams' settings", - "id": "bdd80a03-d9bc-451d-b7c4-ce7c63fe3c8f", - "origin": "Application", - "value": "TeamSettings.ReadWrite.All" + "description": "Allows the app to read all data associated with the business scenarios it owns. Data access will be attributed to the signed-in user.", + "displayName": "Read all data for business scenarios this app creates or owns", + "id": "25b265c4-5d34-4e44-952d-b567f6d3b96d", + "origin": "Delegated (Microsoft Graph)", + "value": "BusinessScenarioData.Read.OwnedBy" }, { - "description": "Read all team's settings, without a signed-in user.", - "displayName": "Read all teams' settings", - "id": "242607bd-1d2c-432c-82eb-bdb27baa23ab", - "origin": "Application", - "value": "TeamSettings.Read.All" + "description": "Allows the app to read events in user calendars.", + "displayName": "Read user calendars ", + "id": "465a38f9-76ea-45b9-9f34-9e8b0d4b0b42", + "origin": "Delegated (Microsoft Graph)", + "value": "Calendars.Read" }, { - "description": "Read the members of all teams, without a signed-in user.", - "displayName": "Read the members of all teams", - "id": "660b7406-55f1-41ca-a0ed-0b035e182f3e", - "origin": "Application", - "value": "TeamMember.Read.All" + "description": "Allows the app to read and write channel messages, on behalf of the signed-in user. This doesn't allow the app to edit the policyViolation of a channel message.", + "displayName": "Read and write user channel messages", + "id": "5922d31f-46c8-4404-9eaf-2117e390a8a4", + "origin": "Delegated (Microsoft Graph)", + "value": "ChannelMessage.ReadWrite" }, { - "description": "Add and remove members from all teams, without a signed-in user. Also allows changing a team member's role, for example from owner to non-owner.", - "displayName": "Add and remove members from all teams", - "id": "0121dc95-1b9f-4aed-8bac-58c5ac466691", - "origin": "Application", - "value": "TeamMember.ReadWrite.All" + "description": "Allows an app to send channel messages in Microsoft Teams, on behalf of the signed-in user.", + "displayName": "Send channel messages", + "id": "ebf0f66e-9fb1-49e4-a278-222f76911cf4", + "origin": "Delegated (Microsoft Graph)", + "value": "ChannelMessage.Send" }, { - "description": "Read the members of all channels, without a signed-in user.", - "displayName": "Read the members of all channels", - "id": "3b55498e-47ec-484f-8136-9013221c06a9", - "origin": "Application", - "value": "ChannelMember.Read.All" + "description": "Read all channel names, channel descriptions, and channel settings, on behalf of the signed-in user.", + "displayName": "Read the names, descriptions, and settings of channels", + "id": "233e0cf1-dd62-48bc-b65b-b38fe87fcf8e", + "origin": "Delegated (Microsoft Graph)", + "value": "ChannelSettings.Read.All" }, { - "description": "Add and remove members from all channels, without a signed-in user. Also allows changing a member's role, for example from owner to non-owner.", - "displayName": "Add and remove members from all channels", - "id": "35930dcf-aceb-4bd1-b99a-8ffed403c974", - "origin": "Application", - "value": "ChannelMember.ReadWrite.All" + "description": "Allows the app to read app consent requests and approvals, and deny or approve those requests on behalf of the signed-in user.", + "displayName": "Read and write consent requests", + "id": "497d9dfa-3bd1-481a-baab-90895e54568c", + "origin": "Delegated (Microsoft Graph)", + "value": "ConsentRequest.ReadWrite.All" }, { - "description": "Allows the app to read and write all authentication flow policies for the tenant, without a signed-in user.", - "displayName": "Read and write authentication flow policies", - "id": "25f85f3c-f66c-4205-8cd5-de92dd7f0cec", - "origin": "Application", - "value": "Policy.ReadWrite.AuthenticationFlows" + "description": "Allows the app to read user contacts. ", + "displayName": "Read user contacts ", + "id": "ff74d97f-43af-4b68-9f2a-b77ee6968c5d", + "origin": "Delegated (Microsoft Graph)", + "value": "Contacts.Read" }, { - "description": "Allows the app to read and write all authentication method policies for the tenant, without a signed-in user.\u00a0", - "displayName": "Read and write all authentication method policies\u00a0", - "id": "29c18626-4985-4dcd-85c0-193eef327366", - "origin": "Application", - "value": "Policy.ReadWrite.AuthenticationMethod" + "description": "Allows the app to read contacts a user has permissions to access, including their own and shared contacts.", + "displayName": "Read user and shared contacts", + "id": "242b9d9e-ed24-4d09-9a52-f43769beb9d4", + "origin": "Delegated (Microsoft Graph)", + "value": "Contacts.Read.Shared" }, { - "description": "Allows the app to read and write your organization's authorization policy without a signed in user. For example, authorization policies can control some of the permissions that the out-of-the-box user role has by default.", - "displayName": "Read and write your organization's authorization policy", - "id": "fb221be6-99f2-473f-bd32-01c6a0e9ca3b", - "origin": "Application", - "value": "Policy.ReadWrite.Authorization" + "description": "Allows the app to create, read, update, and delete user contacts.", + "displayName": "Have full access to user contacts ", + "id": "d56682ec-c09e-4743-aaf4-1a3aac4caa21", + "origin": "Delegated (Microsoft Graph)", + "value": "Contacts.ReadWrite" }, { - "description": "Read names and members of all one-to-one and group chats in Microsoft Teams, without a signed-in user.", - "displayName": "Read names and members of all chat threads", - "id": "b2e060da-3baf-4687-9611-f4ebc0f0cbde", - "origin": "Application", - "value": "Chat.ReadBasic.All" + "description": "Allows the app to create, read, update, and delete contacts a user has permissions to, including their own and shared contacts.", + "displayName": "Read and write user and shared contacts", + "id": "afb6c84b-06be-49af-80bb-8f3f77004eab", + "origin": "Delegated (Microsoft Graph)", + "value": "Contacts.ReadWrite.Shared" }, { - "description": "Allows the app to read policies related to consent and permission grants for applications, without a signed-in user.", - "displayName": "Read consent and permission grant policies", - "id": "9e640839-a198-48fb-8b9a-013fd6f6cbcd", - "origin": "Application", - "value": "Policy.Read.PermissionGrant" + "description": "Allows the app to read and update the on-premises sync behavior of contacts a user has permissions to, including their own and shared contacts.", + "displayName": "Read and update the on-premises sync behavior of contacts", + "id": "1e4c6c41-0803-4f52-85ef-0a5d63ad8670", + "origin": "Delegated (Microsoft Graph)", + "value": "Contacts-OnPremisesSyncBehavior.ReadWrite.All" }, { - "description": "Allows the app to manage policies related to consent and permission grants for applications, without a signed-in user.", - "displayName": "Manage consent and permission grant policies", - "id": "a402ca1c-2696-4531-972d-6e5ee4aa11ea", - "origin": "Application", - "value": "Policy.ReadWrite.PermissionGrant" + "description": "Allows the app to process and evaluate content for data security, governance and compliance outcomes at tenant scope.", + "displayName": "Process content for data security, governance and compliance", + "id": "7e2467d1-f874-46bb-828e-24cb06b29d3f", + "origin": "Delegated (Microsoft Graph)", + "value": "Content.Process.All" }, { - "description": "Allows the application to read printers without a signed-in user.\u00a0", - "displayName": "Read printers", - "id": "9709bb33-4549-49d4-8ed9-a8f65e45bb0f", - "origin": "Application", - "value": "Printer.Read.All" + "description": "Allows the app to process and evaluate content for data security, governance and compliance outcomes for a user.", + "displayName": "Process content for data security, governance and compliance", + "id": "1d787a13-f750-4ad6-875a-fcbd2725596b", + "origin": "Delegated (Microsoft Graph)", + "value": "Content.Process.User" }, { - "description": "Allows the application to read and update printers without a signed-in user. Does not allow creating (registering) or deleting (unregistering) printers.", - "displayName": "Read and update printers", - "id": "f5b3f73d-6247-44df-a74c-866173fddab0", - "origin": "Application", - "value": "Printer.ReadWrite.All" + "description": "Read contents activity audit log from the audit store.", + "displayName": "Read contents activity audit log from the audit store.", + "id": "62c55b2f-a2b1-4312-8385-be57afd901b4", + "origin": "Delegated (Microsoft Graph)", + "value": "ContentActivity.Read" }, { - "description": "Allows the application to perform advanced operations like redirecting a print job to another printer without a signed-in user. Also allows the application to read and update the metadata of print jobs.", - "displayName": "Perform advanced operations on print jobs", - "id": "58a52f47-9e36-4b17-9ebe-ce4ef7f3e6c8", - "origin": "Application", - "value": "PrintJob.Manage.All" + "description": "Allows the application to upload bulk contents activity audit logs to the audit store.", + "displayName": "Upload contents activity audit logs to the audit store.", + "id": "948caae6-152a-48cd-a746-4844af30e8e9", + "origin": "Delegated (Microsoft Graph)", + "value": "ContentActivity.Write" }, { - "description": "Allows the application to read the metadata and document content of print jobs without a signed-in user.\u00a0", - "displayName": "Read print jobs", - "id": "ac6f956c-edea-44e4-bd06-64b1b4b9aec9", - "origin": "Application", - "value": "PrintJob.Read.All" + "description": "Allows the app to read available properties of contracts, on behalf of the signed-in user.", + "displayName": "Read contracts", + "id": "9df4d5b0-7921-4437-9ea8-adf0c9e276dc", + "origin": "Delegated (Microsoft Graph)", + "value": "Contracts.Read.All" }, { - "description": "Allows the application to read the metadata of print jobs without a signed-in user.\u00a0Does not allow access to print job document content.", - "displayName": "Read basic information for print jobs", - "id": "fbf67eee-e074-4ef7-b965-ab5ce1c1f689", - "origin": "Application", - "value": "PrintJob.ReadBasic.All" + "description": "Allows the app to delete Microsoft 365 Copilot conversations on behalf of the signed-in user.", + "displayName": "Delete Microsoft 365 Copilot conversations", + "id": "ed510a02-ac32-45f9-93e6-04864f7f7e47", + "origin": "Delegated (Microsoft Graph)", + "value": "CopilotConversation.Delete" }, { - "description": "Allows the application to read and update the metadata and document content of print jobs without a signed-in user.", - "displayName": "Read and write print jobs", - "id": "5114b07b-2898-4de7-a541-53b0004e2e13", - "origin": "Application", - "value": "PrintJob.ReadWrite.All" + "description": "Allows the user to read the packages information", + "displayName": "Read all packages information", + "id": "a2dcfcb9-cbe8-4d42-812d-952e55cf7f3f", + "origin": "Delegated (Microsoft Graph)", + "value": "CopilotPackages.Read.All" }, { - "description": "Allows the application to read and update the metadata of print jobs without a signed-in user.\u00a0Does not allow access to print job document content.", - "displayName": "Read and write basic information for print jobs", - "id": "57878358-37f4-4d3a-8c20-4816e0d457b1", - "origin": "Application", - "value": "PrintJob.ReadWriteBasic.All" + "description": "Allows the user to read and update the packages information", + "displayName": "Read and update all packages information", + "id": "e9c5fd18-ac15-43dd-9f5c-6f9611dd5604", + "origin": "Delegated (Microsoft Graph)", + "value": "CopilotPackages.ReadWrite.All" }, { - "description": "Allows the application to read and update print task definitions without a signed-in user.\u00a0", - "displayName": "Read, write and update print task definitions", - "id": "456b71a7-0ee0-4588-9842-c123fcc8f664", - "origin": "Application", - "value": "PrintTaskDefinition.ReadWrite.All" + "description": "Allows the app to read Copilot policy settings for the organization, on behalf of the signed-in user.", + "displayName": "Read Copilot policy settings", + "id": "b7281c63-cd4d-40c3-b721-73aa8ee7c3a8", + "origin": "Delegated (Microsoft Graph)", + "value": "CopilotPolicySettings.Read" }, { - "description": "Allows the app to create chat and channel messages, without a signed in user. The app specifies which user appears as the sender, and can backdate the message to appear as if it was sent long ago. The messages can be sent to any chat or channel in the organization.", - "displayName": "Create chat and channel messages with anyone's identity and with any timestamp", - "id": "dfb0dd15-61de-45b2-be36-d6a69fba3c79", - "origin": "Application", - "value": "Teamwork.Migrate.All" + "description": "Allows the app to read and write Copilot policy settings for the organization, on behalf of the signed-in user.", + "displayName": "Read and write Copilot policy settings", + "id": "e2edbde8-4448-4e49-8ebb-d53ba72df0f3", + "origin": "Delegated (Microsoft Graph)", + "value": "CopilotPolicySettings.ReadWrite" }, { - "description": "Allows the app to read the Teams apps that are installed in any chat, without a signed-in user. Does not give the ability to read application-specific settings.", - "displayName": "Read installed Teams apps for all chats", - "id": "cc7e7635-2586-41d6-adaa-a8d3bcad5ee5", - "origin": "Application", - "value": "TeamsAppInstallation.ReadForChat.All" + "description": "Allows the app to read organization-wide copilot limited mode setting on behalf of the signed-in user.", + "displayName": "Read organization-wide copilot limited mode setting", + "id": "aeb2982d-632d-4155-b533-18756ab6fdd8", + "origin": "Delegated (Microsoft Graph)", + "value": "CopilotSettings-LimitedMode.Read" }, { - "description": "Allows the app to read the Teams apps that are installed in any team, without a signed-in user. Does not give the ability to read application-specific settings.", - "displayName": "Read installed Teams apps for all teams", - "id": "1f615aea-6bf9-4b05-84bd-46388e138537", - "origin": "Application", - "value": "TeamsAppInstallation.ReadForTeam.All" + "description": "Allows the app to read and approve consent requests on behalf of the signed in user.", + "displayName": "Read and approve consent requests", + "id": "e694a3a1-7878-46d8-8c29-3d195f6589f4", + "origin": "Delegated (Microsoft Graph)", + "value": "ConsentRequest.ReadApprove.All" }, { - "description": "Allows the app to read the Teams apps that are installed for any user, without a signed-in user. Does not give the ability to read application-specific settings.", - "displayName": "Read installed Teams apps for all users", - "id": "9ce09611-f4f7-4abd-a629-a05450422a97", - "origin": "Application", - "value": "TeamsAppInstallation.ReadForUser.All" + "description": "Allows the app to read consent requests and approvals on behalf of the signed-in user.", + "displayName": "Read consent requests", + "id": "f3bfad56-966e-4590-a536-82ecf548ac1e", + "origin": "Delegated (Microsoft Graph)", + "value": "ConsentRequest.Read.All" }, { - "description": "Allows the app to read, install, upgrade, and uninstall Teams apps in any chat, without a signed-in user. Does not give the ability to read application-specific settings.", - "displayName": "Manage Teams apps for all chats", - "id": "9e19bae1-2623-4c4f-ab6e-2664615ff9a0", - "origin": "Application", - "value": "TeamsAppInstallation.ReadWriteForChat.All" + "description": "Allows the app to read consent requests and approvals created by the signed-in user, on behalf of the signed-in user.", + "displayName": "Read consent requests created by the user", + "id": "5942b2f6-5a7b-40af-aa37-4b6ea5447506", + "origin": "Delegated (Microsoft Graph)", + "value": "ConsentRequest.Read" }, { - "description": "Allows the app to read, install, upgrade, and uninstall Teams apps in any team, without a signed-in user. Does not give the ability to read application-specific settings.", - "displayName": "Manage Teams apps for all teams", - "id": "5dad17ba-f6cc-4954-a5a2-a0dcc95154f0", - "origin": "Application", - "value": "TeamsAppInstallation.ReadWriteForTeam.All" + "description": "Allows the app to read create consent requests on behalf of the signed-in user.", + "displayName": "Create consent requests", + "id": "f2143d35-9b4b-480d-951c-d083e69eeb2c", + "origin": "Delegated (Microsoft Graph)", + "value": "ConsentRequest.Create" }, { - "description": "Allows the app to read, install, upgrade, and uninstall Teams apps for any user, without a signed-in user. Does not give the ability to read application-specific settings.", - "displayName": "Manage Teams apps for all users", - "id": "74ef0291-ca83-4d02-8c7e-d2391e6a444f", - "origin": "Application", - "value": "TeamsAppInstallation.ReadWriteForUser.All" + "description": "Read and write the names, descriptions, and settings of all channels, on behalf of the signed-in user.", + "displayName": "Read and write the names, descriptions, and settings of channels", + "id": "d649fb7c-72b4-4eec-b2b4-b15acf79e378", + "origin": "Delegated (Microsoft Graph)", + "value": "ChannelSettings.ReadWrite.All" }, { - "description": "Allows a Teams app to read, install, upgrade, and uninstall itself for any chat, without a signed-in user.", - "displayName": "Allow the Teams app to manage itself for all chats", - "id": "73a45059-f39c-4baf-9182-4954ac0e55cf", - "origin": "Application", - "value": "TeamsAppInstallation.ReadWriteSelfForChat.All" + "description": "Allows the app to create chats on behalf of the signed-in user.", + "displayName": "Create chats", + "id": "38826093-1258-4dea-98f0-00003be2b8d0", + "origin": "Delegated (Microsoft Graph)", + "value": "Chat.Create" }, { - "description": "Allows a Teams app to read, install, upgrade, and uninstall itself in any team, without a signed-in user.", - "displayName": "Allow the Teams app to manage itself for all teams", - "id": "9f67436c-5415-4e7f-8ac1-3014a7132630", - "origin": "Application", - "value": "TeamsAppInstallation.ReadWriteSelfForTeam.All" + "description": "Allows the app to delete and recover deleted chats, on behalf of the signed-in user.", + "displayName": "Delete and recover deleted chats", + "id": "bb64e6fc-6b6d-4752-aea0-dd922dbba588", + "origin": "Delegated (Microsoft Graph)", + "value": "Chat.ManageDeletion.All" }, { - "description": "Allows a Teams app to read, install, upgrade, and uninstall itself to any user, without a signed-in user.", - "displayName": "Allow the app to manage itself for all users", - "id": "908de74d-f8b2-4d6b-a9ed-2a17b3b78179", - "origin": "Application", - "value": "TeamsAppInstallation.ReadWriteSelfForUser.All" + "description": "Allows an app to read 1 on 1 or group chats threads, on behalf of the signed-in user.", + "displayName": "Read user chat messages", + "id": "f501c180-9344-439a-bca0-6cbf209fd270", + "origin": "Delegated (Microsoft Graph)", + "value": "Chat.Read" }, { - "description": "Allows the app to create teams without a signed-in user.\u00a0", - "displayName": "Create teams", - "id": "23fc2474-f741-46ce-8465-674744c5c361", - "origin": "Application", - "value": "Team.Create" + "description": "Allows an app to read the members and descriptions of one-to-one and group chat threads, on behalf of the signed-in user.", + "displayName": "Read names and members of user chat threads", + "id": "9547fcb5-d03f-419d-9948-5928bbf71b0f", + "origin": "Delegated (Microsoft Graph)", + "value": "Chat.ReadBasic" }, { - "description": "Add and remove members from all teams, without a signed-in user. Does not allow adding or removing a member with the owner role. Additionally, does not allow the app to elevate an existing member to the owner role.", - "displayName": "Add and remove members with non-owner role for all teams", - "id": "4437522e-9a86-4a41-a7da-e380edd4a97d", - "origin": "Application", - "value": "TeamMember.ReadWriteNonOwnerRole.All" + "description": "Allows an app to read and write 1 on 1 or group chats threads, on behalf of the signed-in user.", + "displayName": "Read and write user chat messages", + "id": "9ff7295e-131b-4d94-90e1-69fde507ac11", + "origin": "Delegated (Microsoft Graph)", + "value": "Chat.ReadWrite" }, { - "description": "Allows the app to read all term store data, without a signed-in user. This includes all sets, groups and terms in the term store.", - "displayName": "Read all term store data", - "id": "ea047cc2-df29-4f3e-83a3-205de61501ca", - "origin": "Application", - "value": "TermStore.Read.All" + "description": "Allows an app to read and write all one-to-one and group chats in Microsoft Teams, without a signed-in user. Does not allow sending messages.", + "displayName": "Read and write all chat messages", + "id": "7e9a077b-3711-42b9-b7cb-5fa5f3f7fea7", + "origin": "Delegated (Microsoft Graph)", + "value": "Chat.ReadWrite.All" }, { - "description": "Allows the app to read, edit or write all term store data, without a signed-in user. This includes all sets, groups and terms in the term store.", - "displayName": "Read and write all term store data", - "id": "f12eb8d6-28e3-46e6-b2c0-b7e4dc69fc95", - "origin": "Application", - "value": "TermStore.ReadWrite.All" + "description": "Read the members of chats, on behalf of the signed-in user.", + "displayName": "Read the members of chats", + "id": "c5a9e2b1-faf6-41d4-8875-d381aa549b24", + "origin": "Delegated (Microsoft Graph)", + "value": "ChatMember.Read" }, { - "description": "Allows the app to read your tenant's service health information, without a signed-in user. Health information may include service issues or service health overviews.", - "displayName": "Read service health", - "id": "79c261e0-fe76-4144-aad5-bdc68fbe4037", - "origin": "Application", - "value": "ServiceHealth.Read.All" + "description": "Allows the app to read restore sessions, on behalf of the signed in user.", + "displayName": "Read restore sessions", + "id": "94b36f78-434f-4904-8c08-421d9a9c1dc2", + "origin": "Delegated (Microsoft Graph)", + "value": "BackupRestore-Restore.Read.All" + }, + { + "description": "Add and remove members from chats, on behalf of the signed-in user.", + "displayName": "Add and remove members from chats", + "id": "dea13482-7ea6-488f-8b98-eb5bbecf033d", + "origin": "Delegated (Microsoft Graph)", + "value": "ChatMember.ReadWrite" }, { - "description": "Allows the app to read your tenant's service announcement messages, without a signed-in user. Messages may include information about new or changed features.", - "displayName": "Read service messages", - "id": "1b620472-6534-4fe6-9df2-4680e8aa28ec", - "origin": "Application", - "value": "ServiceMessage.Read.All" + "description": "Allows an app to send one-to-one and group chat messages in Microsoft Teams, on behalf of the signed-in user.", + "displayName": "Send user chat messages", + "id": "116b7235-7cc6-461e-b163-8e55691d839e", + "origin": "Delegated (Microsoft Graph)", + "value": "ChatMessage.Send" }, { - "description": "Allows the app to read all the short notes without a signed-in user.", - "displayName": "Read all users' short notes", - "id": "0c7d31ec-31ca-4f58-b6ec-9950b6b0de69", - "origin": "Application", - "value": "ShortNotes.Read.All" + "description": "Allows the app to read details of discovered cloud apps in the organization, on behalf of the signed in user.", + "displayName": "Read discovered cloud applications data", + "id": "ad46d60e-1027-4b75-af88-7c14ccf43a19", + "origin": "Delegated (Microsoft Graph)", + "value": "CloudApp-Discovery.Read.All" }, { - "description": "Allows the app to read, create, edit, and delete all the short notes without a signed-in user.", - "displayName": "Read, create, edit, and delete all users' short notes", - "id": "842c284c-763d-4a97-838d-79787d129bab", - "origin": "Application", - "value": "ShortNotes.ReadWrite.All" + "description": "Allows the app to read the properties of Cloud PCs on behalf of the signed-in user.", + "displayName": "Read Cloud PCs", + "id": "5252ec4e-fd40-4d92-8c68-89dd1d3c6110", + "origin": "Delegated (Microsoft Graph)", + "value": "CloudPC.Read.All" }, { - "description": "Allows the app to read your organization's conditional access policies, without a signed-in user.", - "displayName": "Read your organization's conditional access policies", - "id": "37730810-e9ba-4e46-b07e-8ca78d182097", - "origin": "Application", - "value": "Policy.Read.ConditionalAccess" + "description": "Allows the app to read and write the properties of Cloud PCs on behalf of the signed-in user.", + "displayName": "Read and write Cloud PCs", + "id": "9d77138f-f0e2-47ba-ab33-cd246c8b79d1", + "origin": "Delegated (Microsoft Graph)", + "value": "CloudPC.ReadWrite.All" }, { - "description": "Allows the app to read role-based access control (RBAC) settings for all RBAC providers without a signed-in user. This includes reading role definitions and role assignments.", - "displayName": "Read role management data for all RBAC providers", - "id": "c7fbd983-d9aa-4fa7-84b8-17382c103bc4", - "origin": "Application", - "value": "RoleManagement.Read.All" + "description": "Allows the app to list Viva Engage communities, and to read their properties on behalf of the signed-in user.", + "displayName": "Read all Viva Engage communities", + "id": "12ae2e92-14b5-47b2-babb-4e890bbedc0a", + "origin": "Delegated (Microsoft Graph)", + "value": "Community.Read.All" }, { - "description": "Allows the app to read all PSTN and direct routing call log data without a signed-in user.", - "displayName": "Read PSTN and direct routing call log data", - "id": "a2611786-80b3-417e-adaa-707d4261a5f0", - "origin": "Application", - "value": "CallRecord-PstnCalls.Read.All" + "description": "Allows the app to create Viva Engage communities and read all community properties on behalf of the signed-in user.", + "displayName": "Read and write all Viva Engage communities", + "id": "9e69467d-e0e2-402b-a926-3d796990197f", + "origin": "Delegated (Microsoft Graph)", + "value": "Community.ReadWrite.All" }, { - "description": "Allows the app to read all one-to-one and group chats messages in Microsoft Teams, without a signed-in user.", - "displayName": "Read all chat messages", - "id": "b9bb2381-47a4-46cd-aafb-00cb12f68504", - "origin": "Application", - "value": "ChatMessage.Read.All" + "description": "Allows the app to read all Configuration Monitoring entities on behalf of the signed-in user.", + "displayName": "Read all Configuration Monitoring entities", + "id": "c645bb69-adc4-4242-b620-02e635f03bf6", + "origin": "Delegated (Microsoft Graph)", + "value": "ConfigurationMonitoring.Read.All" }, { - "description": "Allows a Teams app to read, install, upgrade, and uninstall all tabs for any chat, without a signed-in user.", - "displayName": "Allow the Teams app to manage all tabs for all chats", - "id": "fd9ce730-a250-40dc-bd44-8dc8d20f39ea", - "origin": "Application", - "value": "TeamsTab.ReadWriteForChat.All" + "description": "Allows the app to read and write all Configuration Monitoring entities on behalf of the signed-in user.", + "displayName": "Read and write all Configuration Monitoring entities", + "id": "54505ce9-e719-41f7-a7cc-dbe114e1d811", + "origin": "Delegated (Microsoft Graph)", + "value": "ConfigurationMonitoring.ReadWrite.All" }, { - "description": "Allows a Teams app to read, install, upgrade, and uninstall all tabs in any team, without a signed-in user.", - "displayName": "Allow the Teams app to manage all tabs for all teams", - "id": "6163d4f4-fbf8-43da-a7b4-060fe85ed148", - "origin": "Application", - "value": "TeamsTab.ReadWriteForTeam.All" + "description": "Allows an app to read one-to-one and group chat messages, on behalf of the signed-in user.", + "displayName": "Read user chat messages", + "id": "cdcdac3a-fd45-410d-83ef-554db620e5c7", + "origin": "Delegated (Microsoft Graph)", + "value": "ChatMessage.Read" }, { - "description": "Allows a Teams app to read, install, upgrade, and uninstall all tabs for any user, without a signed-in user.", - "displayName": "Allow the app to manage all tabs for all users", - "id": "425b4b59-d5af-45c8-832f-bb0b7402348a", - "origin": "Application", - "value": "TeamsTab.ReadWriteForUser.All" + "description": "Allows the app to read and write anonymous users' virtual event registrations, without a signed-in user", + "displayName": "Read and write anonymous users' virtual event registrations", + "id": "23211fc1-f9d1-4e8e-8e9e-08a5d0a109bb", + "origin": "Application (Microsoft Graph)", + "value": "VirtualEventRegistration-Anon.ReadWrite.All" }, { - "description": "Allows the app to read the API connectors used in user authentication flows, without a signed-in user.", - "displayName": "Read API connectors for authentication flows", - "id": "b86848a7-d5b1-41eb-a9b4-54a4e6306e97", - "origin": "Application", - "value": "APIConnectors.Read.All" + "description": "Allows the app to monitor backup and restore jobs, view quota usage and billing details, on behalf of the signed in user.", + "displayName": "Read monitoring, quota and billing information for the tenant", + "id": "b4e98de1-4600-4e90-b5e1-7c1dfef04e5c", + "origin": "Delegated (Microsoft Graph)", + "value": "BackupRestore-Monitor.Read.All" }, { - "description": "Allows the app to read, create and manage the API connectors used in user authentication flows, without a signed-in user.", - "displayName": "Read and write API connectors for authentication flows", - "id": "1dfe531a-24a6-4f1b-80f4-7a0dc5a0a171", - "origin": "Application", - "value": "APIConnectors.ReadWrite.All" + "description": "Allows the app to read the status of M365 backup service (enable/disable), on behalf of the signed in user.", + "displayName": "Read the status of the M365 backup service", + "id": "af598c63-4292-4437-b925-e996354d3854", + "origin": "Delegated (Microsoft Graph)", + "value": "BackupRestore-Control.Read.All" }, { - "description": "Read the members of all chats, without a signed-in user.", - "displayName": "Read the members of all chats", - "id": "a3410be2-8e48-4f32-8454-c29a7465209d", - "origin": "Application", - "value": "ChatMember.Read.All" + "description": "Allows the client to create agent identities on behalf of the signed-in user, even if the client is not the parent agent identity blueprint.", + "displayName": "Create agent identities without an agent blueprint parent", + "id": "e75eeac6-d759-4ba3-ae5c-773a27efafba", + "origin": "Delegated (Microsoft Graph)", + "value": "AgentIdentity.Create.All" }, { - "description": "Add and remove members from all chats, without a signed-in user.", - "displayName": "Add and remove members from all chats", - "id": "57257249-34ce-4810-a8a2-a03adf0c5693", - "origin": "Application", - "value": "ChatMember.ReadWrite.All" + "description": "Allows the client to delete and restore agent identities on behalf of the signed-in user.", + "displayName": "Delete and restore agent identities", + "id": "c8ee41e5-35e7-4fe9-8ecb-93493adcac5b", + "origin": "Delegated (Microsoft Graph)", + "value": "AgentIdentity.DeleteRestore.All" }, { - "description": "Allows the app to create chats without a signed-in user.\u00a0", - "displayName": "Create chats", - "id": "d9c48af6-9ad9-47ad-82c3-63757137b9af", - "origin": "Application", - "value": "Chat.Create" + "description": "Allows the client to enable or disable agent identities on behalf of the signed-in user.", + "displayName": "Enable or disable agent identities", + "id": "a501206a-e364-4a3f-be6e-765806d0e323", + "origin": "Delegated (Microsoft Graph)", + "value": "AgentIdentity.EnableDisable.All" }, { - "description": "Allows the application to read tenant-wide print settings without a signed-in user.", - "displayName": "Read tenant-wide print settings", - "id": "b5991872-94cf-4652-9765-29535087c6d8", - "origin": "Application", - "value": "PrintSettings.Read.All" + "description": "Allows the client to read all agent identities on behalf of the signed-in user.", + "displayName": "Read all agent identities", + "id": "5e850691-d86a-4b24-bfa6-8a52fb37a0c1", + "origin": "Delegated (Microsoft Graph)", + "value": "AgentIdentity.Read.All" }, { - "description": "Allows an app to read and write all browser site lists configured for your organization, without a signed-in user.", - "displayName": "Read and write all browser site lists for your organization", - "id": "8349ca94-3061-44d5-9bfb-33774ea5e4f9", - "origin": "Application", - "value": "BrowserSiteLists.ReadWrite.All" + "description": "Allows the client to read, update, create, and delete agent identities on behalf of the signed-in user.", + "displayName": "Read and write all agent identities", + "id": "4a4facd5-0ee1-49b7-a5b2-fdcc2491685e", + "origin": "Delegated (Microsoft Graph)", + "value": "AgentIdentity.ReadWrite.All" }, { - "description": "Allows the application to read and change the tenant-level settings of SharePoint and OneDrive, without a signed-in user.", - "displayName": "Read and change SharePoint and OneDrive tenant settings", - "id": "19b94e34-907c-4f43-bde9-38b1909ed408", - "origin": "Application", - "value": "SharePointTenantSettings.ReadWrite.All" + "description": "Allows updating agent identity blueprint credentials on behalf of the signed-in user.", + "displayName": "Update agent identity blueprint credentials", + "id": "75b5feb2-bfe7-423f-907d-cc505186f246", + "origin": "Delegated (Microsoft Graph)", + "value": "AgentIdentityBlueprint.AddRemoveCreds.All" }, { - "description": "Allows the app to read your organization's authentication event listeners without a signed-in user.", - "displayName": "Read all authentication event listeners", - "id": "b7f6385c-6ce6-4639-a480-e23c42ed9784", - "origin": "Application", - "value": "EventListener.Read.All" + "description": "Allows creating new agent identity blueprints on behalf of the signed-in user.", + "displayName": "Create agent identity blueprints.", + "id": "8fc15edd-ba24-494e-9bf6-d38e1b7ba8fd", + "origin": "Delegated (Microsoft Graph)", + "value": "AgentIdentityBlueprint.Create" }, { - "description": "Allows the app to read or write your organization's authentication event listeners without a signed-in user.", - "displayName": "Read and write all authentication event listeners", - "id": "0edf5e9e-4ce8-468a-8432-d08631d18c43", - "origin": "Application", - "value": "EventListener.ReadWrite.All" + "description": "Allows deleting or restoring agent identity blueprints on behalf of the signed-in user.", + "displayName": "Delete and restore agent identity blueprints.", + "id": "f12ba1f6-afb7-4685-9a30-21e8c3f551d8", + "origin": "Delegated (Microsoft Graph)", + "value": "AgentIdentityBlueprint.DeleteRestore.All" }, { - "description": "Allows the app to read your organization's custom authentication extensions without a signed-in user.", - "displayName": "Read all custom authentication extensions", - "id": "88bb2658-5d9e-454f-aacd-a3933e079526", - "origin": "Application", - "value": "CustomAuthenticationExtension.Read.All" + "description": "Allows the client to read all agent identity blueprints on behalf of the signed-in user.", + "displayName": "Read all agent identity blueprints", + "id": "26512dc8-1364-4e9f-867c-6d8b22a9e162", + "origin": "Delegated (Microsoft Graph)", + "value": "AgentIdentityBlueprint.Read.All" }, { - "description": "Allows the app to read all users\u2019 tasks and task lists in your organization, without a signed-in user.", - "displayName": "Read all users\u2019 tasks and tasklist", - "id": "f10e1f91-74ed-437f-a6fd-d6ae88e26c1f", - "origin": "Application", - "value": "Tasks.Read.All" + "description": "Allows the client to read, update, create, and delete agent identity blueprints on behalf of the signed-in user.", + "displayName": "Read and write all agent identity blueprints.", + "id": "4fd490fc-1467-48eb-8a4c-421597ab0402", + "origin": "Delegated (Microsoft Graph)", + "value": "AgentIdentityBlueprint.ReadWrite.All" }, { - "description": "Allows the app to create, update, list, read and delete all workflows, tasks and related lifecycle workflows resources without a signed-in user.", - "displayName": "Read and write all lifecycle workflows resources", - "id": "5c505cf4-8424-4b8e-aa14-ee06e3bb23e3", - "origin": "Application", - "value": "LifecycleWorkflows.ReadWrite.All" + "description": "Allows updating agent identity blueprint authorization and authentication properties on behalf of the signed-in user.", + "displayName": "Update agent identity blueprint authorization and authentication properties", + "id": "6f677aa9-25af-49a5-8a1d-628dc7f0d009", + "origin": "Delegated (Microsoft Graph)", + "value": "AgentIdentityBlueprint.UpdateAuthProperties.All" }, { - "description": "Allows an app to read all bookmarks without a signed-in user.", - "displayName": "Read all bookmarks", - "id": "be95e614-8ef3-49eb-8464-1c9503433b86", - "origin": "Application", - "value": "Bookmark.Read.All" + "description": "Allows updating agent identity blueprint branding on behalf of the signed-in user.", + "displayName": "Update agent identity blueprint branding", + "id": "60960e31-67cb-4d25-9d36-4922109923a2", + "origin": "Delegated (Microsoft Graph)", + "value": "AgentIdentityBlueprint.UpdateBranding.All" }, { - "description": "Allows the application to obtain basic tenant information about another target tenant within the Azure AD ecosystem without a signed-in user.", - "displayName": "Read cross-tenant basic information", - "id": "cac88765-0581-4025-9725-5ebc13f729ee", - "origin": "Application", - "value": "CrossTenantInformation.ReadBasic.All" + "description": "Allows creating new agent identity blueprint principals on behalf of the signed-in user.", + "displayName": "Create agent identity blueprint principals.", + "id": "00dcd896-6b23-42ce-b5de-c58493c05e22", + "origin": "Delegated (Microsoft Graph)", + "value": "AgentIdentityBlueprintPrincipal.Create" }, { - "description": "Allows the application to list and query any shared user profile information associated with the current tenant without a signed-in user.\u00a0 It also permits the application to export and remove external user data (e.g. customer content or system-generated logs), for any user associated with the current tenant without a signed-in user.", - "displayName": "Read all shared cross-tenant user profiles and export or delete their data", - "id": "306785c5-c09b-4ba0-a4ee-023f3da165cb", - "origin": "Application", - "value": "CrossTenantUserProfileSharing.ReadWrite.All" + "description": "Allows deleting or restoring agent identity blueprint principals on behalf of the signed-in user.", + "displayName": "Delete and restore agent identity blueprint principals.", + "id": "2c70023e-a482-4af2-9ff1-51ded53e6bad", + "origin": "Delegated (Microsoft Graph)", + "value": "AgentIdentityBlueprintPrincipal.DeleteRestore.All" }, { - "description": "Allows the app to read all learning content in the organization's directory, without a signed-in user.", - "displayName": "Read all learning content", - "id": "8740813e-d8aa-4204-860e-2a0f8f84dbc8", - "origin": "Application", - "value": "LearningContent.Read.All" + "description": "Allows enabling or disabling agent identity blueprint principals on behalf of the signed-in user.", + "displayName": "Enable or disable agent identity blueprint principals.", + "id": "e7475e0a-9f02-43e2-a250-5c2ea74ccd0e", + "origin": "Delegated (Microsoft Graph)", + "value": "AgentIdentityBlueprintPrincipal.EnableDisable.All" }, { - "description": "Allows the app to read and update the authentication context information in your organization without a signed-in user.", - "displayName": "Read and write all authentication context information", - "id": "a88eef72-fed0-4bf7-a2a9-f19df33f8b83", - "origin": "Application", - "value": "AuthenticationContext.ReadWrite.All" + "description": "Allows reading agent identity blueprint principals on behalf of the signed-in user.", + "displayName": "Read agent identity blueprint principals.", + "id": "88c856a2-de61-4632-b2d4-ac503cbc8dd2", + "origin": "Delegated (Microsoft Graph)", + "value": "AgentIdentityBlueprintPrincipal.Read.All" }, { - "description": "Allows the app to read all admin report settings, such as whether to display concealed information in reports, without a signed-in user.", - "displayName": "Read all admin report settings", - "id": "ee353f83-55ef-4b78-82da-555bfa2b4b95", - "origin": "Application", - "value": "ReportSettings.Read.All" + "description": "Allows the app to read, update, create, and delete agent identity blueprint principals on behalf of the signed-in user.", + "displayName": "Read and write all agent identity blueprint principals.", + "id": "bf2cad6a-9082-438a-9a63-95fa2687af65", + "origin": "Delegated (Microsoft Graph)", + "value": "AgentIdentityBlueprintPrincipal.ReadWrite.All" }, { - "description": "Allows the app to read the members of all chats where the associated Teams application is installed, without a signed-in user.", - "displayName": "Read the members of all chats where the associated Teams application is installed.", - "id": "93e7c9e4-54c5-4a41-b796-f2a5adaacda7", - "origin": "Application", - "value": "ChatMember.Read.WhereInstalled" + "description": "Allows the app to read and update the communication configuration of agent blueprints on behalf of the signed-in user.", + "displayName": "Read and write agent communication configuration", + "id": "15e0db35-0641-4175-b014-c2cb39286338", + "origin": "Delegated (Microsoft Graph)", + "value": "AgentCommunicationConfiguration.ReadWrite" }, { - "description": "Allows the app to add and remove members from all chats where the associated Teams application is installed, without a signed-in user.", - "displayName": "Add and remove members from all chats where the associated Teams application is installed.", - "id": "e32c2cd9-0124-4e44-88fc-772cd98afbdb", - "origin": "Application", - "value": "ChatMember.ReadWrite.WhereInstalled" + "description": "Allows the app to create agent users, read and write the full set of profile properties, reports, and managers of agent ID users, delete and restore agent users in your organization, and read basic company properties, on behalf of the signed-in user.", + "displayName": "Read and write all agent ID users' full profiles", + "id": "ad57fb88-4658-4fd6-ab7d-e43184b08e4e", + "origin": "Delegated (Microsoft Graph)", + "value": "AgentIdUser.ReadWrite.All" }, { - "description": "Allows the app to read your organization's threat submissions and to view threat submission policies without a signed-in user.", - "displayName": "Read all of the organization's threat submissions", - "id": "86632667-cd15-4845-ad89-48a88e8412e1", - "origin": "Application", - "value": "ThreatSubmission.Read.All" + "description": "Allows the app to read the communication configuration of agent blueprints on behalf of the signed-in user.", + "displayName": "Read agent communication configuration", + "id": "57ff8075-2fb3-4879-8693-5d51ee8d5e9e", + "origin": "Delegated (Microsoft Graph)", + "value": "AgentCommunicationConfiguration.Read" }, { - "description": "Allows an app to sign digests for data without a signed-in user.", - "displayName": "Sign digests for data", - "id": "cbe6c7e4-09aa-4b8d-b3c3-2dbb59af4b54", - "origin": "Application", - "value": "InformationProtectionContent.Sign.All" + "description": "Allows the app to read and update global collection and manage its membership in your organization's Agent Registry on behalf of the signed-in user.", + "displayName": "Read and write global collection in Agent Registry", + "id": "c001dd65-8a6b-4349-ab0c-4e8a410d28d2", + "origin": "Delegated (Microsoft Graph)", + "value": "AgentCollection.ReadWrite.Global" }, { - "description": "Allows the app to read your organization's threat submission policies without a signed-in user. Also allows the app to create new threat submission polices without a signed-in user.", - "displayName": "Read and write all of the organization's threat submission policies", - "id": "926a6798-b100-4a20-a22f-a4918f13951d", - "origin": "Application", - "value": "ThreatSubmissionPolicy.ReadWrite.All" + "description": "Allows the app to read and write all Windows update deployment settings for the organization without a signed-in user.", + "displayName": "Read and write all Windows update deployment settings", + "id": "7dd1be58-6e76-4401-bf8d-31d1e8180d5b", + "origin": "Application (Microsoft Graph)", + "value": "WindowsUpdates.ReadWrite.All" }, { - "description": "Allows the app to read all one-to-one or group chat messages in Microsoft Teams for chats where the associated Teams application is installed, without a signed-in user.", - "displayName": "Read all chat messages for chats where the associated Teams application is installed.", - "id": "1c1b4c8e-3cc7-4c58-8470-9b92c9d5848b", - "origin": "Application", - "value": "Chat.Read.WhereInstalled" + "description": "Allows the app to read workforce integrations without a signed-in user.", + "displayName": "Read workforce integrations", + "id": "f10b94b9-37d1-4c88-8b7e-bf75a1152d39", + "origin": "Application (Microsoft Graph)", + "value": "WorkforceIntegration.Read.All" }, { - "description": "Allows the app to read and write all chat messages in Microsoft Teams for chats where the associated Teams application is installed, without a signed-in user.", - "displayName": "Read and write all chat messages for chats where the associated Teams application is installed.", - "id": "ad73ce80-f3cd-40ce-b325-df12c33df713", - "origin": "Application", - "value": "Chat.ReadWrite.WhereInstalled" + "description": "Allows the app to manage workforce integrations to synchronize data from Microsoft Teams Shifts, without a signed-in user.", + "displayName": "Read and write workforce integrations", + "id": "202bf709-e8e6-478e-bcfd-5d63c50b68e3", + "origin": "Application (Microsoft Graph)", + "value": "WorkforceIntegration.ReadWrite.All" }, { - "description": "Allows the app to read and update all Azure AD recommendations, without a signed-in user. ", - "displayName": "Read and update all Azure AD recommendations", - "id": "0e9eea12-4f01-45f6-9b8d-3ea4c8144158", - "origin": "Application", - "value": "DirectoryRecommendations.ReadWrite.All" + "description": "Allows the app to read access reviews, reviewers, decisions and settings that the signed-in user has access to in the organization.", + "displayName": "Read all access reviews that user can access", + "id": "ebfcd32b-babb-40f4-a14b-42706e83bd28", + "origin": "Delegated (Microsoft Graph)", + "value": "AccessReview.Read.All" }, { - "description": "Allows the app to read all recordings of all online meetings, without a signed-in user.", - "displayName": "Read all recordings of online meetings.", - "id": "a4a08342-c95d-476b-b943-97e100569c8d", - "origin": "Application", - "value": "OnlineMeetingRecording.Read.All" + "description": "Allows the app to read, update, delete and perform actions on access reviews, reviewers, decisions and settings that the signed-in user has access to in the organization.", + "displayName": "Manage all access reviews that user can access", + "id": "e4aa47b9-9a69-4109-82ed-36ec70d85ff1", + "origin": "Delegated (Microsoft Graph)", + "value": "AccessReview.ReadWrite.All" }, { - "description": "Allows an app to manage license assignments for users and groups, without a signed-in user.", - "displayName": "Manage all license assignments", - "id": "5facf0c1-8979-4e95-abcf-ff3d079771c0", - "origin": "Application", - "value": "LicenseAssignment.ReadWrite.All" + "description": "Allows the app to read, update, delete and perform actions on access reviews, reviewers, decisions and settings for group and app memberships that the signed-in user has access to in the organization.", + "displayName": "Manage access reviews for group and app memberships", + "id": "5af8c3f5-baca-439a-97b0-ea58a435e269", + "origin": "Delegated (Microsoft Graph)", + "value": "AccessReview.ReadWrite.Membership" }, { - "description": "Allows the app to read and write the Teams app settings without a signed-in user.", - "displayName": "Read and write Teams app settings", - "id": "ab5b445e-8f10-45f4-9c79-dd3f8062cc4e", - "origin": "Application", - "value": "TeamworkAppSettings.ReadWrite.All" + "description": "Allows an app to read all acronyms that the signed-in user can access.", + "displayName": "Read all acronyms that the user can access", + "id": "9084c10f-a2d6-4713-8732-348def50fe02", + "origin": "Delegated (Microsoft Graph)", + "value": "Acronym.Read.All" }, { - "description": "Allows the app to read and write the lifecycle information like employeeLeaveDateTime of users in your organization, without a signed-in user.", - "displayName": "Read and write all users' lifecycle information", - "id": "925f1248-0f97-47b9-8ec8-538c54e01325", - "origin": "Application", - "value": "User-LifeCycleInfo.ReadWrite.All" + "description": "Allows the app to read administrative units and administrative unit membership on behalf of the signed-in user.", + "displayName": "Read administrative units", + "id": "3361d15d-be43-4de6-b441-3c746d05163d", + "origin": "Delegated (Microsoft Graph)", + "value": "AdministrativeUnit.Read.All" }, { - "description": "Allows the app to read all Azure AD recommendations, without a signed-in user. ", - "displayName": "Read all Azure AD recommendations", - "id": "ae73097b-cb2a-4447-b064-5d80f6093921", - "origin": "Application", - "value": "DirectoryRecommendations.Read.All" + "description": "Allows the app to create, read, update, and delete administrative units and manage administrative unit membership on behalf of the signed-in user.", + "displayName": "Read and write administrative units", + "id": "7b8a2d34-6b3f-4542-a343-54651608ad81", + "origin": "Delegated (Microsoft Graph)", + "value": "AdministrativeUnit.ReadWrite.All" + }, + { + "description": "Allows the app to read agent cards and their skills in your organization's Agent Registry on behalf of the signed-in user.", + "displayName": "Read agent cards in Agent Registry", + "id": "73ea6732-992c-4292-98f7-9feff18d3ade", + "origin": "Delegated (Microsoft Graph)", + "value": "AgentCard.Read.All" }, { - "description": "Allows the application to list and query any shared user profile information associated with the current tenant without a signed-in user.\u00a0 It also permits the application to export external user data (e.g. customer content or system-generated logs), for any user associated with the current tenant without a signed-in user.", - "displayName": "Read all shared cross-tenant user profiles and export their data", - "id": "8b919d44-6192-4f3d-8a3b-f86f8069ae3c", - "origin": "Application", - "value": "CrossTenantUserProfileSharing.Read.All" + "description": "Allows the app to create, read, update, and delete agent cards and manage their skills in your organization's Agent Registry on behalf of the signed-in user.", + "displayName": "Read and write agent cards in Agent Registry", + "id": "b0f726a8-0fa2-4ce2-937b-fd17a446261f", + "origin": "Delegated (Microsoft Graph)", + "value": "AgentCard.ReadWrite.All" }, { - "description": "Allows the app to manage restricted resources based on the other permissions granted to the app, without a signed-in user.", - "displayName": "Manage restricted resources in the directory", - "id": "f20584af-9290-4153-9280-ff8bb2c0ea7f", - "origin": "Application", - "value": "Directory.Write.Restricted" + "description": "Allows the app to read agent card manifests in your organization's Agent Registry on behalf of the signed-in user.", + "displayName": "Read all agent card manifests in Agent Registry", + "id": "ada96a26-9579-4c29-a578-c3482a765716", + "origin": "Delegated (Microsoft Graph)", + "value": "AgentCardManifest.Read.All" }, { - "description": "Allows the app to read all transcripts of all online meetings, without a signed-in user.", - "displayName": "Read all transcripts of online meetings.", - "id": "a4a80d8d-d283-4bd8-8504-555ec3870630", - "origin": "Application", - "value": "OnlineMeetingTranscript.Read.All" + "description": "Allows the app to read and write agent card manifests in your organization's Agent Registry on behalf of the signed-in user.", + "displayName": "Read and write agent card manifests in Agent Registry", + "id": "80151b1a-1c31-4846-ae0d-c79939ee13d1", + "origin": "Delegated (Microsoft Graph)", + "value": "AgentCardManifest.ReadWrite.All" }, { - "description": "Allows\u00a0the\u00a0app\u00a0to\u00a0manage all learning\u00a0content\u00a0in\u00a0the\u00a0organization's\u00a0directory, without a signed-in user.", - "displayName": "Manage all\u00a0learning\u00a0content", - "id": "444d6fcb-b738-41e5-b103-ac4f2a2628a3", - "origin": "Application", - "value": "LearningContent.ReadWrite.All" + "description": "Allows the app to read collections and their membership in your organization's Agent Registry on behalf of the signed-in user.", + "displayName": "Read all collections in Agent Registry, except quarantined and global", + "id": "fa50be38-fdff-469c-96dc-ef5fce3c64bf", + "origin": "Delegated (Microsoft Graph)", + "value": "AgentCollection.Read.All" }, { - "description": "Allows the application to read the tenant-level settings of SharePoint and OneDrive, without a signed-in user.", - "displayName": "Read SharePoint and OneDrive tenant settings", - "id": "83d4163d-a2d8-4d3b-9695-4ae3ca98f888", - "origin": "Application", - "value": "SharePointTenantSettings.Read.All" + "description": "Allows the app to read global collection and its membership in your organization's Agent Registry on behalf of the signed-in user.", + "displayName": "Read global collection in Agent Registry", + "id": "b14924c8-87f1-438a-81f2-dc370ba2f45d", + "origin": "Delegated (Microsoft Graph)", + "value": "AgentCollection.Read.Global" }, { - "description": "Allows the app to read or write your organization's custom authentication extensions without a signed-in user.", - "displayName": "Read and write all custom authentication extensions", - "id": "c2667967-7050-4e7e-b059-4cbbb3811d03", - "origin": "Application", - "value": "CustomAuthenticationExtension.ReadWrite.All" + "description": "Allows the app to read quarantined collection and its membership in your organization's Agent Registry on behalf of the signed-in user.", + "displayName": "Read quarantined collection in Agent Registry", + "id": "43acfda3-daf3-4aa4-955d-b051d0024e82", + "origin": "Delegated (Microsoft Graph)", + "value": "AgentCollection.Read.Quarantined" }, { - "description": "Allows the app to read names and members of all one-to-one and group chats in Microsoft Teams where the associated Teams application is installed, without a signed-in user.", - "displayName": "Read names and members of all chat threads where the associated Teams application is installed.", - "id": "818ba5bd-5b3e-4fe0-bbe6-aa4686669073", - "origin": "Application", - "value": "Chat.ReadBasic.WhereInstalled" + "description": "Allows the app to create, read, update, and delete collections and manage their membership in your organization's Agent Registry on behalf of the signed-in user.", + "displayName": "Read and write collections in Agent Registry, except quarantined and global", + "id": "6d8a7002-a05e-4b95-a768-0e6f0badc6c8", + "origin": "Delegated (Microsoft Graph)", + "value": "AgentCollection.ReadWrite.All" }, { - "description": "Allows the app to list and read all workflows, tasks and related lifecycle workflows resources without a signed-in user.", - "displayName": "Read all lifecycle workflows resources", - "id": "7c67316a-232a-4b84-be22-cea2c0906404", - "origin": "Application", - "value": "LifecycleWorkflows.Read.All" + "description": "Allows the app to read and update quarantined collection and manage its membership in your organization's Agent Registry on behalf of the signed-in user.", + "displayName": "Read and write quarantined collection in Agent Registry", + "id": "ae331cc9-9f51-484b-a90b-124f2e4a6398", + "origin": "Delegated (Microsoft Graph)", + "value": "AgentCollection.ReadWrite.Quarantined" }, { - "description": "Allows the app to create protected content without a signed-in user. ", - "displayName": "Create protected content", - "id": "287bd98c-e865-4e8c-bade-1a85523195b9", - "origin": "Application", - "value": "InformationProtectionContent.Write.All" + "description": "Allows the app to create agent users, read and write the full set of profile properties, reports, and managers of agent ID users in your organization, delete and restore agent users under an agent blueprint, and read basic company properties, on behalf of the signed-in user.", + "displayName": "Read and write full profiles of agent ID users under an agent blueprint", + "id": "52a417d9-0b3c-4466-9a3b-66960de73d74", + "origin": "Delegated (Microsoft Graph)", + "value": "AgentIdUser.ReadWrite.IdentityParentedBy" }, { - "description": "Allows the app to create, read, update and delete all users\u2019 tasks and task lists in your organization, without a signed-in user", - "displayName": "Read and write all users\u2019 tasks and tasklists", - "id": "44e666d1-d276-445b-a5fc-8815eeb81d55", - "origin": "Application", - "value": "Tasks.ReadWrite.All" + "description": "Allows the app to read agent instances and their related collections in your organization's Agent Registry on behalf of the signed-in user.", + "displayName": "Read all agent instances in Agent Registry", + "id": "4c3c738a-2df0-4877-bf4a-f796950ff34c", + "origin": "Delegated (Microsoft Graph)", + "value": "AgentInstance.Read.All" }, { - "description": "Allows the app to read the Teams app settings without a signed-in user.", - "displayName": "Read Teams app settings", - "id": "475ebe88-f071-4bd7-af2b-642952bd4986", - "origin": "Application", - "value": "TeamworkAppSettings.Read.All" + "description": "Allows the app to create, read, update, and delete agent instances in your organization's Agent Registry on behalf of the signed-in user.", + "displayName": "Read and write agent instances in Agent Registry", + "id": "fc79e324-da24-497a-b5ec-e7de08320375", + "origin": "Delegated (Microsoft Graph)", + "value": "AgentInstance.ReadWrite.All" }, { - "description": "Allows the app to read the authentication context information in your organization without a signed-in user.", - "displayName": "Read all authentication context information", - "id": "381f742f-e1f8-4309-b4ab-e3d91ae4c5c1", - "origin": "Application", - "value": "AuthenticationContext.Read.All" + "description": "Allows the app to read and respond to approvals on behalf of the signed-in user.", + "displayName": "Read and respond to approvals assigned to the current user", + "id": "89d944f2-2011-44ad-830c-aa9bf5ef2319", + "origin": "Delegated (Microsoft Graph)", + "value": "ApprovalSolutionResponse.ReadWrite" }, { - "description": "Allows the app to read and update all admin report settings, such as whether to display concealed information in reports, without a signed-in user.", - "displayName": "Read and write all admin report settings", - "id": "2a60023f-3219-47ad-baa4-40e17cd02a1d", - "origin": "Application", - "value": "ReportSettings.ReadWrite.All" + "description": "Allows the app to read attack simulation and training data for an organization for the signed-in user.", + "displayName": "Read attack simulation data of an organization", + "id": "104a7a4b-ca76-4677-b7e7-2f4bc482f381", + "origin": "Delegated (Microsoft Graph)", + "value": "AttackSimulation.Read.All" }, { - "description": "Allows an app to read all browser site lists configured for your organization, without a signed-in user.", - "displayName": "Read all browser site lists for your organization", - "id": "c5ee1f21-fc7f-4937-9af0-c91648ff9597", - "origin": "Application", - "value": "BrowserSiteLists.Read.All" + "description": "Allows the app to read, create, and update attack simulation and training data for an organization for the signed-in user.", + "displayName": "Read, create, and update attack simulation data of an organization", + "id": "27608d7c-2c66-4cad-a657-951d575f5a60", + "origin": "Delegated (Microsoft Graph)", + "value": "AttackSimulation.ReadWrite.All" }, { - "description": "Allows the app to read the lifecycle information like employeeLeaveDateTime of users in your organization, without a signed-in user.", - "displayName": "Read all users' lifecycle information", - "id": "8556a004-db57-4d7a-8b82-97a13428e96f", - "origin": "Application", - "value": "User-LifeCycleInfo.Read.All" + "description": "Read activity audit log from the audit store.", + "displayName": "Read activity audit log from the audit store.", + "id": "16786f81-40d2-4116-bb26-d1a753bf0b20", + "origin": "Delegated (Microsoft Graph)", + "value": "AuditActivity.Read" }, { - "description": "Allows an app to read all acronyms without a signed-in user.", - "displayName": "Read all acronyms", - "id": "8c0aed2c-0c61-433d-b63c-6370ddc73248", - "origin": "Application", - "value": "Acronym.Read.All" + "description": "Allows the application to upload bulk activity audit logs to the audit store.", + "displayName": "Upload activity audit logs to the audit store.", + "id": "a78fd341-0672-4792-a8ae-a5925b2546eb", + "origin": "Delegated (Microsoft Graph)", + "value": "AuditActivity.Write" }, { - "description": "Allows the app to see your users' basic profile (e.g., name, picture, user name, email address)", - "displayName": "View users' basic profile", - "id": "14dad69e-099b-42c9-810b-d002981feec1", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to see your basic profile (e.g., name, picture, user name, email address)", - "userConsentDisplayName": "View your basic profile", - "value": "profile" + "description": "Allows the app to read and query your audit log activities, on behalf of the signed-in user.", + "displayName": "Read audit log data", + "id": "e4c9e354-4dc5-45b8-9e7c-e1393b0b1a20", + "origin": "Delegated (Microsoft Graph)", + "value": "AuditLog.Read.All" }, { - "description": "Allows the app to read attack simulation and training data for an organization for the signed-in user.", - "displayName": "Read attack simulation data of an organization", - "id": "104a7a4b-ca76-4677-b7e7-2f4bc482f381", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read attack simulation and training data for an organization on your behalf.", - "userConsentDisplayName": "Read attack simulation data of an organization", - "value": "AttackSimulation.Read.All" + "description": "Allows the app to read and query audit logs from all services, on behalf of a signed-in user", + "displayName": "Read audit logs data from all services", + "id": "1d9e7ac3-0eca-442c-82f9-e92625af6e6d", + "origin": "Delegated (Microsoft Graph)", + "value": "AuditLogsQuery.Read.All" }, { - "description": "Allows the app to read and write your organization's directory access review default policy on behalf of the signed-in user.", - "displayName": "Read and write your organization's directory access review default policy", - "id": "4f5bc9c8-ea54-4772-973a-9ca119cb0409", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write your organization's directory access review default policy on your behalf.", - "userConsentDisplayName": "Read and write your organization's directory access review default policy", - "value": "Policy.ReadWrite.AccessReview" + "description": "Allows the app to read and query audit logs from Dynamics CRM workload, on behalf of the signed-in user.", + "displayName": "Read audit logs data from Dynamics CRM workload", + "id": "ba78b16f-1e01-41b6-89ca-73e0a32b304c", + "origin": "Delegated (Microsoft Graph)", + "value": "AuditLogsQuery-CRM.Read.All" }, { - "description": "Allows the app to read your organization's threat submissions and threat submission policies on behalf of the signed-in user. Also allows the app to create new threat submissions on behalf of the signed-in user.", - "displayName": "Read and write all threat submissions", - "id": "8458e264-4eb9-4922-abe9-768d58f13c7f", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read your organization's threat submissions and threat submission policies on your behalf. Also allows the app to create new threat submissions on your behalf.", - "userConsentDisplayName": "Read and write all threat submissions", - "value": "ThreatSubmission.ReadWrite.All" + "description": "Allows the app to read and query audit logs from Endpoint Data Loss Prevention workload, on behalf of the signed-in user.", + "displayName": "Read audit logs data from Endpoint Data Loss Prevention workload", + "id": "ee3409fe-617f-43cf-bd1e-fc8b38049e69", + "origin": "Delegated (Microsoft Graph)", + "value": "AuditLogsQuery-Endpoint.Read.All" }, { - "description": "Allows the application to read any data from Records Management, such as configuration, labels, and policies on behalf of the signed-in user.", - "displayName": "Read Records Management configuration,\u00a0labels, and policies", - "id": "07f995eb-fc67-4522-ad66-2b8ca8ea3efd", - "Origin": "Delegated", - "userConsentDescription": "Allows the application to read any data from Records Management, such as configuration, labels and policies on your behalf.", - "userConsentDisplayName": "Read Records Management configuration,\u00a0labels, and policies", - "value": "RecordsManagement.Read.All" + "description": "Allows the app to read and query audit logs from Entra (Azure AD) workload, on behalf of the signed-in user.", + "displayName": "Read audit logs data from Entra (Azure AD) workload", + "id": "5ff2f415-e0f1-4d11-bfd0-6d87c0f667fd", + "origin": "Delegated (Microsoft Graph)", + "value": "AuditLogsQuery-Entra.Read.All" }, { - "description": "Allow the application to create, update and delete any data from Records Management, such as configuration, labels, and policies on behalf of the signed-in user.", - "displayName": "Read and write Records Management configuration, labels, and policies", - "id": "f2833d75-a4e6-40ab-86d4-6dfe73c97605", - "Origin": "Delegated", - "userConsentDescription": "Allow the application to create, update and delete any data from Records Management, such as configuration, labels, and policies on your behalf.", - "userConsentDisplayName": "Read and write Records Management configuration, labels, and policies", - "value": "RecordsManagement.ReadWrite.All" + "description": "Allows the app to read and query audit logs from Exchange workload, on behalf of a signed-in user.", + "displayName": "Read audit logs data from Exchange workload", + "id": "6c8c71d2-c7e1-45b0-ac6d-1d2724fba6ae", + "origin": "Delegated (Microsoft Graph)", + "value": "AuditLogsQuery-Exchange.Read.All" }, { - "description": "Allows the app to read details of delegated admin relationships with customers like access details (that includes roles) and the duration as well as specific role assignments to security groups on behalf of the signed-in user.", - "displayName": "Read Delegated Admin relationships with customers", - "id": "0c0064ea-477b-4130-82a5-4c2cc4ff68aa", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read details of Delegated Admin relationships with customers like access details (that includes roles) and the duration as well as specific role assignments to security groups on your behalf.", - "userConsentDisplayName": "Read Delegated Admin relationships with customers", - "value": "DelegatedAdminRelationship.Read.All" + "description": "Allows the app to read and query audit logs from OneDrive workload, on behalf of a signed-in user.", + "displayName": "Read audit logs data from OneDrive workload", + "id": "4a72c235-a50d-4870-b598-fd88fd1fa074", + "origin": "Delegated (Microsoft Graph)", + "value": "AuditLogsQuery-OneDrive.Read.All" }, { - "description": "Allows the app to manage (create-update-terminate) Delegated Admin relationships with customers as well as role assignments to security groups for active Delegated Admin relationships on behalf of the signed-in user.", - "displayName": "Manage Delegated Admin relationships with customers", - "id": "885f682f-a990-4bad-a642-36736a74b0c7", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to manage (create-update-terminate) Delegated Admin relationships with customers and role assignments to security groups for active Delegated Admin relationships on your behalf.", - "userConsentDisplayName": "Manage Delegated Admin relationships with customers", - "value": "DelegatedAdminRelationship.ReadWrite.All" + "description": "Allows the app to read and query audit logs from SharePoint workload, on behalf of a signed-in user.", + "displayName": "Read audit logs data from SharePoint workload", + "id": "30630b65-ed12-4a81-9130-e3a964109fae", + "origin": "Delegated (Microsoft Graph)", + "value": "AuditLogsQuery-SharePoint.Read.All" }, { - "description": "Allows the app to read and write all managed tenant information on behalf of the signed-in user.", - "displayName": "Read and write all managed tenant information", - "id": "b31fa710-c9b3-4d9e-8f5e-8036eecddab9", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write all managed tenant information on your behalf.", - "userConsentDisplayName": "Read and write all managed tenant information", - "value": "ManagedTenants.ReadWrite.All" + "description": "Allows the app to read all authentication context information in your organization on behalf of the signed-in user.", + "displayName": "Read all authentication context information", + "id": "57b030f1-8c35-469c-b0d9-e4a077debe70", + "origin": "Delegated (Microsoft Graph)", + "value": "AuthenticationContext.Read.All" }, { - "description": "Allows the app to read all managed tenant information on behalf of the signed-in user.", - "displayName": "Read all managed tenant information", - "id": "dc34164e-6c4a-41a0-be89-3ae2fbad7cd3", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read all managed tenant information on your behalf.", - "userConsentDisplayName": "Read all managed tenant information", - "value": "ManagedTenants.Read.All" + "description": "Allows the app to read and update all authentication context information in your organization on behalf of the signed-in user.", + "displayName": "Read and write all authentication context information", + "id": "ba6d575a-1344-4516-b777-1404f5593057", + "origin": "Delegated (Microsoft Graph)", + "value": "AuthenticationContext.ReadWrite.All" }, { - "description": "Allows the app to read and manage the Cloud PC role-based access control (RBAC) settings, on behalf of the signed-in user. This includes reading and managing Cloud PC role definitions and role assignments.", - "displayName": "Read and write Cloud PC RBAC settings", - "id": "501d06f8-07b8-4f18-b5c6-c191a4af7a82", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and manage the Cloud PC role-based access control (RBAC) settings, on your behalf. This includes reading and managing Cloud PC role definitions and memberships.", - "userConsentDisplayName": "Read and write Cloud PC RBAC settings", - "value": "RoleManagement.ReadWrite.CloudPC" + "description": "Allows the app to read the backup configuration, and list of Microsoft 365 service resources to be backed-up, on behalf of the signed in user.", + "displayName": "Read backup configuration policies", + "id": "444ed4b6-0554-4dc6-8e9c-3f9a34ee3ff6", + "origin": "Delegated (Microsoft Graph)", + "value": "BackupRestore-Configuration.Read.All" }, { - "description": "Allows the app to read the Cloud PC role-based access control (RBAC) settings, on behalf of the signed-in user.\u00a0 This includes reading Cloud PC role definitions and role assignments.", - "displayName": "Read Cloud PC RBAC settings", - "id": "9619b88a-8a25-48a7-9571-d23be0337a79", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read the Cloud PC role-based access control (RBAC) settings, on your behalf.\u00a0 This includes reading Cloud PC role definitions and role assignments.", - "userConsentDisplayName": "Read Cloud PC RBAC settings", - "value": "RoleManagement.Read.CloudPC" + "description": "Allows the app to read and update the backup configuration, and list of Microsoft 365 service resources to be backed-up, on behalf of the signed in user.", + "displayName": "Read and edit backup configuration policies", + "id": "a0244d16-171c-4496-8ffb-7b9b6954d339", + "origin": "Delegated (Microsoft Graph)", + "value": "BackupRestore-Configuration.ReadWrite.All" }, { - "description": "Allows the app to read and write settings of external connections on behalf of a signed-in user. The signed-in user must be an administrator. The app can only read and write settings of connections that it is authorized to.", - "displayName": "Read and write external connections", - "id": "4082ad95-c812-4f02-be92-780c4c4f1830", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write external connections on your behalf. The signed-in user must be an administrator. The app can only read and write external connections that it is authorized to, or it can create new external connections. ", - "userConsentDisplayName": "Read and write external connections", - "value": "ExternalConnection.ReadWrite.OwnedBy" + "description": "Allows the app to provision, read, create, and respond to approvals on behalf of the signed-in user.", + "displayName": "Read, create, and respond to approvals", + "id": "6768d3af-4562-48ff-82d2-c5e19eb21b9c", + "origin": "Delegated (Microsoft Graph)", + "value": "ApprovalSolution.ReadWrite" }, { - "description": "Allows the app to read all external connections on behalf of a signed-in user. The signed-in user must be an administrator.", - "displayName": "Read all external connections", - "id": "a38267a5-26b6-4d76-9493-935b7599116b", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read all external connections on your behalf. The signed-in user must be an administrator.", - "userConsentDisplayName": "Read all external connections", - "value": "ExternalConnection.Read.All" + "description": "Allows the app to read approvals on behalf of the signed-in user.", + "displayName": "Read approvals", + "id": "b0df437d-d341-4df0-aa3e-89ca81a1207f", + "origin": "Delegated (Microsoft Graph)", + "value": "ApprovalSolution.Read" }, { - "description": "Allows the app to read and write all external connections on behalf of a signed-in user. The signed-in user must be an administrator.", - "displayName": "Read and write all external connections", - "id": "bbbbd9b3-3566-4931-ac37-2b2180d9e334", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write all external connections on your behalf. The signed-in user must be an administrator.", - "userConsentDisplayName": "Read and write all external connections", - "value": "ExternalConnection.ReadWrite.All" + "description": "Allows the app to manage permission grants for application permissions to any API (including Microsoft Graph) and application assignments for any app, on behalf of the signed-in user.", + "displayName": "Manage app permission grants and app role assignments", + "id": "84bccea3-f856-4a8a-967b-dbe0a3d53a64", + "origin": "Delegated (Microsoft Graph)", + "value": "AppRoleAssignment.ReadWrite.All" }, { - "description": "Allows the app to read and write external items on behalf of a signed-in user. The signed-in user must be an administrator. The app can only read external items of the connection that it is authorized to.", - "displayName": "Read and write external items", - "id": "4367b9d7-cee7-4995-853c-a0bdfe95c1f9", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write external items on your behalf. The signed-in user must be an administrator. The app can only read external items of the connection that it is authorized to.", - "userConsentDisplayName": "Read and write external items", - "value": "ExternalItem.ReadWrite.OwnedBy" + "description": "Allows the app to read and write other apps' remote desktop security configuration, on behalf of the signed-in user.", + "displayName": "Read and write the remote desktop security configuration for apps", + "id": "ffa91d43-2ad8-45cc-b592-09caddeb24bb", + "origin": "Delegated (Microsoft Graph)", + "value": "Application-RemoteDesktopConfig.ReadWrite.All" }, { - "description": "Allows the app to read and write all external items on behalf of a signed-in user. The signed-in user must be an administrator.", - "displayName": "Read and write all external items", - "id": "b02c54f8-eb48-4c50-a9f0-a149e5a2012f", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write all external items on your behalf. The signed-in user must be an administrator.", - "userConsentDisplayName": "Read and write all external items", - "value": "ExternalItem.ReadWrite.All" + "description": "Allows the user to read all agent registration information", + "displayName": "Read all agent registrations", + "id": "ef96ce0b-b2ea-4ae4-a783-108212d8ecee", + "origin": "Delegated (Microsoft Graph)", + "value": "AgentRegistration.Read.All" }, { - "description": "Allows the app to read custom security attribute assignments for all principals in the tenant on behalf of a signed in user.", - "displayName": "Read custom security attribute assignments", - "id": "b46ffa80-fe3d-4822-9a1a-c200932d54d0", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read custom security attribute assignments for all principals in the tenant on your behalf.", - "userConsentDisplayName": "Read custom security attribute assignments", - "value": "CustomSecAttributeAssignment.Read.All" + "description": "Allows the user to read and write all agent registration information", + "displayName": "Read and write all agent registrations", + "id": "20f263bf-7d50-4e66-912c-16b4b4194fd4", + "origin": "Delegated (Microsoft Graph)", + "value": "AgentRegistration.ReadWrite.All" }, { - "description": "Allows the app to read custom security attribute definitions for the tenant on behalf of a signed in user.", - "displayName": "Read custom security attribute definitions", - "id": "ce026878-a0ff-4745-a728-d4fedd086c07", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read custom security attribute definitions for the tenant on your behalf.", - "userConsentDisplayName": "Read custom security attribute definitions", - "value": "CustomSecAttributeDefinition.Read.All" + "description": "Allows the app to read terms of use agreements on behalf of the signed-in user.", + "displayName": "Read all terms of use agreements", + "id": "af2819c9-df71-4dd3-ade7-4d7c9dc653b7", + "origin": "Delegated (Microsoft Graph)", + "value": "Agreement.Read.All" }, { - "description": "Allows the app to read and write your organization's cross tenant access policies on behalf of the signed-in user.", - "displayName": "Read and write your organization's cross tenant access policies", - "id": "014b43d0-6ed4-4fc6-84dc-4b6f7bae7d85", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write your organization's cross tenant access policies on your behalf.", - "userConsentDisplayName": "Read and write your organization's cross tenant access policies", - "value": "Policy.ReadWrite.CrossTenantAccess" + "description": "Allows the app to read and write terms of use agreements on behalf of the signed-in user.", + "displayName": "Read and write all terms of use agreements", + "id": "ef4b5d93-3104-4664-9053-a5c49ab44218", + "origin": "Delegated (Microsoft Graph)", + "value": "Agreement.ReadWrite.All" }, { - "description": "Allows the app to read and write tags in Teams, on behalf of the signed-in user.", - "displayName": "Read and write tags in Teams", - "id": "539dabd7-b5b6-4117-b164-d60cd15a8671", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write tags in Teams, on your behalf.", - "userConsentDisplayName": "Read and write tags in Teams", - "value": "TeamworkTag.ReadWrite" + "description": "Allows the app to read terms of use acceptance statuses on behalf of the signed-in user.", + "displayName": "Read user terms of use acceptance statuses", + "id": "0b7643bb-5336-476f-80b5-18fbfbc91806", + "origin": "Delegated (Microsoft Graph)", + "value": "AgreementAcceptance.Read" }, { - "description": "Allows the app to read tags in Teams, on behalf of the signed-in user.", - "displayName": "Read tags in Teams", - "id": "57587d0b-8399-45be-b207-8050cec54575", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read tags in Teams, on your behalf.", - "userConsentDisplayName": "Read tags in Teams", - "value": "TeamworkTag.Read" + "description": "Allows the app to read terms of use acceptance statuses on behalf of the signed-in user.", + "displayName": "Read terms of use acceptance statuses that user can access", + "id": "a66a5341-e66e-4897-9d52-c2df58c2bfb9", + "origin": "Delegated (Microsoft Graph)", + "value": "AgreementAcceptance.Read.All" }, { - "description": "Allows the app to read and write security incidents, on behalf of the signed-in user.", - "displayName": "Read and write to incidents", - "id": "128ca929-1a19-45e6-a3b8-435ec44a36ba", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write to all security incidents that you have access to.", - "userConsentDisplayName": "Read and write to security incidents", - "value": "SecurityIncident.ReadWrite.All" + "description": "Allows the app to read user AI enterprise interactions, on behalf of the signed-in user.", + "displayName": "Read user AI enterprise interactions.", + "id": "859cceb9-2ec2-4e48-bcd7-b8490b5248a5", + "origin": "Delegated (Microsoft Graph)", + "value": "AiEnterpriseInteraction.Read" }, { - "description": "Allows the app to read security incidents, on behalf of the signed-in user.", - "displayName": "Read incidents", - "id": "b9abcc4f-94fc-4457-9141-d20ce80ec952", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read all security incidents that you have access to.", - "userConsentDisplayName": "Read security incidents", - "value": "SecurityIncident.Read.All" + "description": "Allows the app to read the signed-in user's activity statistics, such as how much time the user has spent on emails, in meetings, or in chat sessions.", + "displayName": "Read user activity statistics", + "id": "e03cf23f-8056-446a-8994-7d93dfc8b50e", + "origin": "Delegated (Microsoft Graph)", + "value": "Analytics.Read" }, { - "description": "Allows the app to read and write to all security alerts, on behalf of the signed-in user.", - "displayName": "Read and write to all security alerts", - "id": "471f2a7f-2a42-4d45-a2bf-594d0838070d", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write all alerts that you have access to.", - "userConsentDisplayName": "Read and write all alerts", - "value": "SecurityAlert.ReadWrite.All" + "description": "Allows the app to update or read the status of M365 backup service (enable/disable), on behalf of the signed in user.", + "displayName": "Update or read the status of the M365 backup service", + "id": "96d46335-d92d-41b8-bc9f-273a692381ea", + "origin": "Delegated (Microsoft Graph)", + "value": "BackupRestore-Control.ReadWrite.All" }, { - "description": "Allows the app to read all security alerts, on behalf of the signed-in user.", - "displayName": "Read all security alerts", - "id": "bc257fb8-46b4-4b15-8713-01e91bfbe4ea", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read all security alerts that you have access to.", - "userConsentDisplayName": "Read all alerts", - "value": "SecurityAlert.Read.All" + "description": "Allows the app to read the API connectors used in user authentication flows, on behalf of the signed-in user.", + "displayName": "Read API connectors for authentication flows", + "id": "1b6ff35f-31df-4332-8571-d31ea5a4893f", + "origin": "Delegated (Microsoft Graph)", + "value": "APIConnectors.Read.All" }, { - "description": "Allows the app to update service announcement messages' user status on behalf of the signed-in user. The message status can be marked as read, archive, or favorite.", - "displayName": "Update user status on service announcement messages", - "id": "636e1b0b-1cc2-4b1c-9aa9-4eeed9b9761b", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to update service announcement messages' status on your behalf. Your status for messages can be marked as read, archive, or favorite.", - "userConsentDisplayName": "Update your user status on service announcement messages", - "value": "ServiceMessageViewpoint.Write" + "description": "Allows the app to read the apps in the app catalogs.", + "displayName": "Read all app catalogs", + "id": "88e58d74-d3df-44f3-ad47-e89edf4472e4", + "origin": "Delegated (Microsoft Graph)", + "value": "AppCatalog.Read.All" }, { - "description": "Allows the app to run hunting queries, on behalf of the signed-in user.", - "displayName": "Run hunting queries", - "id": "b152eca8-ea73-4a48-8c98-1a6742673d99", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to run hunting queries that you can execute.", - "userConsentDisplayName": "Run hunting queries", - "value": "ThreatHunting.Read.All" + "description": "Allows the app to create, read, update, and delete apps in the app catalogs.", + "displayName": "Read and write to all app catalogs", + "id": "1ca167d5-1655-44a1-8adf-1414072e1ef9", + "origin": "Delegated (Microsoft Graph)", + "value": "AppCatalog.ReadWrite.All" }, { - "description": "Allows a Teams app to read, install, upgrade, and uninstall itself to teams the signed-in user can access.", - "displayName": "Allow the app to manage itself in teams", - "id": "0f4595f7-64b1-4e13-81bc-11a249df07a9", - "Origin": "Delegated", - "userConsentDescription": "Allows a Teams app to read, install, upgrade, and uninstall itself to teams you can access.", - "userConsentDisplayName": "Allow the Teams app to manage itself in teams", - "value": "TeamsAppInstallation.ReadWriteSelfForTeam" + "description": "Allows the app to submit application packages to the catalog and cancel submissions that are pending review on behalf of the signed-in user.", + "displayName": "Submit application packages to the catalog and cancel pending submissions", + "id": "3db89e36-7fa6-4012-b281-85f3d9d9fd2e", + "origin": "Delegated (Microsoft Graph)", + "value": "AppCatalog.Submit" }, { - "description": "Allow the app to read the management data for Teams devices on behalf of the signed-in user.", - "displayName": "Read Teams devices", - "id": "b659488b-9d28-4208-b2be-1c6652b3c970", - "Origin": "Delegated", - "userConsentDescription": "Allow the app to read the management data for Teams devices on your behalf.", - "userConsentDisplayName": "Read Teams devices", - "value": "TeamworkDevice.Read.All" + "description": "Allows the app to read the trusted certificate authority configuration which can be used to restrict application certificates based on their issuing authority, on behalf of the signed-in user.", + "displayName": "Read the trusted certificate authority configuration for applications", + "id": "af281d3a-030d-4122-886e-146fb30a0413", + "origin": "Delegated (Microsoft Graph)", + "value": "AppCertTrustConfiguration.Read.All" }, { - "description": "Allow the app to read and write the management data for Teams devices on behalf of the signed-in user.", - "displayName": "Read and write Teams devices", - "id": "ddd97ecb-5c31-43db-a235-0ee20e635c40", - "Origin": "Delegated", - "userConsentDescription": "Allow the app to read and write the management data for Teams devices on your behalf.", - "userConsentDisplayName": "Read and write Teams devices", - "value": "TeamworkDevice.ReadWrite.All" + "description": "Allows the app to create, read, update and delete the trusted certificate authority configuration which can be used to restrict application certificates based on their issuing authority, on behalf of the signed-in user.", + "displayName": "Read and write the trusted certificate authority configuration for applications", + "id": "4bae2ed4-473e-4841-a493-9829cfd51d48", + "origin": "Delegated (Microsoft Graph)", + "value": "AppCertTrustConfiguration.ReadWrite.All" }, { - "description": "Allows the app to read all identity risky service principal information for your organization, on behalf of the signed-in user.", - "displayName": "Read all identity risky service principal information", - "id": "ea5c4ab0-5a73-4f35-8272-5d5337884e5d", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read all identity risky service principal information for your organization, on your behalf.", - "userConsentDisplayName": "Read all identity risky service principal information", - "value": "IdentityRiskyServicePrincipal.Read.All" + "description": "Allows the app to read applications and service principals on behalf of the signed-in user.", + "displayName": "Read applications", + "id": "c79f8feb-a9db-4090-85f9-90d820caa0eb", + "origin": "Delegated (Microsoft Graph)", + "value": "Application.Read.All" }, { - "description": "Allows the app to read and update identity risky service principal information for all service principals in your organization, on behalf of the signed-in user. Update operations include dismissing risky service principals.", - "displayName": "Read and write all identity risky service principal information", - "id": "bb6f654c-d7fd-4ae3-85c3-fc380934f515", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and update identity risky service principal information for all service principals in your organization, on your behalf. Update operations include dismissing risky service principals.", - "userConsentDisplayName": "Read and write all identity risky service principal information", - "value": "IdentityRiskyServicePrincipal.ReadWrite.All" + "description": "Allows the app to read and update all apps in your organization, on behalf of the signed-in user.", + "displayName": "Read and update all apps", + "id": "0586a906-4d89-4de8-b3c8-1aacdcc0c679", + "origin": "Delegated (Microsoft Graph)", + "value": "Application.ReadUpdate.All" }, { - "description": "Allows a Teams app to read, install, upgrade, and uninstall its own tabs to teams the signed-in user can access.", - "displayName": "Allow the Teams app to manage only its own tabs in teams", - "id": "f266662f-120a-4314-b26a-99b08617c7ef", - "Origin": "Delegated", - "userConsentDescription": "Allows a Teams app to read, install, upgrade, and uninstall its own tabs to teams you can access.", - "userConsentDisplayName": "Allow the Teams app to manage only its own tabs in teams", - "value": "TeamsTab.ReadWriteSelfForTeam" + "description": "Allows the app to create, read, update and delete applications and service principals on behalf of the signed-in user. Allows management of app role assignments, except those exposed by Microsoft Graph. Does not allow management of delegated permission grants.", + "displayName": "Read and write applications", + "id": "bdfbf15f-ee85-4955-8675-146e8e5296b5", + "origin": "Delegated (Microsoft Graph)", + "value": "Application.ReadWrite.All" }, { - "description": "Allows the app to read the presence information and write activity and availability on behalf of the signed-in user. Presence information includes activity, availability, status note, calendar out-of-office message, timezone and location.", - "displayName": "Read and write a user's presence information", - "id": "8d3c54a7-cf58-4773-bf81-c0cd6ad522bb", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read the presence information and write activity and availability on your behalf. Presence information includes activity, availability, status note, calendar out-of-office message, timezone and location.", - "userConsentDisplayName": "Read and write your presence information", - "value": "Presence.ReadWrite" + "description": "Allows the app to read, create and manage the API connectors used in user authentication flows, on behalf of the signed-in user.", + "displayName": "Read and write API connectors for authentication flows", + "id": "c67b52c5-7c69-48b6-9d48-7b3af3ded914", + "origin": "Delegated (Microsoft Graph)", + "value": "APIConnectors.ReadWrite.All" }, { - "description": "Allows the app to read subject rights requests on behalf of the signed-in user", - "displayName": "Read subject rights requests", - "id": "9c3af74c-fd0f-4db4-b17a-71939e2a9d77", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read subject rights requests on your behalf.", - "userConsentDisplayName": "Read data subject requests", - "value": "SubjectRightsRequest.Read.All" + "description": "Allows the app to read eDiscovery objects such as cases, custodians, review sets and other related objects without a signed-in user.", + "displayName": "Read all eDiscovery objects", + "id": "50180013-6191-4d1e-a373-e590ff4e66af", + "origin": "Application (Microsoft Graph)", + "value": "eDiscovery.Read.All" }, { - "description": "Allows the app to read and write subject rights requests on behalf of the signed-in user", - "displayName": "Read and write subject rights requests", - "id": "2b8fcc74-bce1-4ae3-a0e8-60c53739299d", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write subject rights requests on your behalf.", - "userConsentDisplayName": "Read and write data subject requests", - "value": "SubjectRightsRequest.ReadWrite.All" + "description": "Allows the app to read, create, edit, and delete all the short notes without a signed-in user.", + "displayName": "Read, create, edit, and delete all users' short notes", + "id": "842c284c-763d-4a97-838d-79787d129bab", + "origin": "Application (Microsoft Graph)", + "value": "ShortNotes.ReadWrite.All" }, { - "description": "Allows a Teams app to read, install, upgrade, and uninstall its own tabs for the signed-in user.", - "displayName": "Allow the Teams app to manage only its own tabs for a user", - "id": "395dfec1-a0b9-465f-a783-8250a430cb8c", - "Origin": "Delegated", - "userConsentDescription": "Allows a Teams app to read, install, upgrade, and uninstall its own tabs for you.", - "userConsentDisplayName": "Allow the Teams app to manage only its own tabs for you", - "value": "TeamsTab.ReadWriteSelfForUser" + "description": "Allows the application to read and change the tenant-level settings of SharePoint and OneDrive, without a signed-in user.", + "displayName": "Read and change SharePoint and OneDrive tenant settings", + "id": "19b94e34-907c-4f43-bde9-38b1909ed408", + "origin": "Application (Microsoft Graph)", + "value": "SharePointTenantSettings.ReadWrite.All" }, { - "description": "Allows a Teams app to read, install, upgrade, and uninstall its own tabs in chats the signed-in user can access.", - "displayName": "Allow the Teams app to manage only its own tabs in chats", - "id": "0c219d04-3abf-47f7-912d-5cca239e90e6", - "Origin": "Delegated", - "userConsentDescription": "Allows a Teams app to read, install, upgrade, and uninstall its own tabs in chats you can access.", - "userConsentDisplayName": "Allow the Teams app to manage only its own tabs in chats", - "value": "TeamsTab.ReadWriteSelfForChat" + "description": "Allows the app to list and read all workflows, tasks and related lifecycle workflows resources without a signed-in user.", + "displayName": "Read all lifecycle workflows resources", + "id": "7c67316a-232a-4b84-be22-cea2c0906404", + "origin": "Application (Microsoft Graph)", + "value": "LifecycleWorkflows.Read.All" }, { - "description": "Allows the app to read and write search configuration, on behalf of the signed-in user.", - "displayName": "Read and write your organization's search configuration", - "id": "b1a7d408-cab0-47d2-a2a5-a74a3733600d", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write search configuration, on your behalf.", - "userConsentDisplayName": "Read and write your organization's search configuration", - "value": "SearchConfiguration.ReadWrite.All" + "description": "Allows the app to create, update, list, read and delete all workflows, tasks and related lifecycle workflows resources without a signed-in user.", + "displayName": "Read and write all lifecycle workflows resources", + "id": "5c505cf4-8424-4b8e-aa14-ee06e3bb23e3", + "origin": "Application (Microsoft Graph)", + "value": "LifecycleWorkflows.ReadWrite.All" }, { - "description": "Allows the app to read search configuration, on behalf of the signed-in user.", - "displayName": "Read your organization's search configuration", - "id": "7d307522-aa38-4cd0-bd60-90c6f0ac50bd", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read search configuration, on your behalf.", - "userConsentDisplayName": "Read your organization's search configuration", - "value": "SearchConfiguration.Read.All" + "description": "Allows the app to read all Lifecycle workflows custom task extensions without a signed-in user.", + "displayName": "Read all Lifecycle workflows custom task extensionss", + "id": "2cb19e7d-9012-40bf-9a22-69fc776af8b0", + "origin": "Application (Microsoft Graph)", + "value": "LifecycleWorkflows-CustomExt.Read.All" }, { - "description": "Allows the app to read online meeting artifacts on behalf of the signed-in user.", - "displayName": "Read user's online meeting artifacts", - "id": "110e5abb-a10c-4b59-8b55-9b4daa4ef743", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read online meeting artifacts on your behalf.", - "userConsentDisplayName": "Read user's online meeting artifacts", - "value": "OnlineMeetingArtifact.Read.All" + "description": "Allows the app to create, update, list, read and delete all Lifecycle workflows custom task extensions without a signed-in user.", + "displayName": "Read and write all Lifecycle workflows custom task extensions", + "id": "3351c766-bacc-4d93-94fa-f2c8b1986ee7", + "origin": "Application (Microsoft Graph)", + "value": "LifecycleWorkflows-CustomExt.ReadWrite.All" }, { - "description": "Allows the app to read and manage the active role-based access control (RBAC) assignments for your company's directory, on behalf of the signed-in user. This includes managing active directory role membership, and reading directory role templates, directory roles and active memberships.", - "displayName": "Read, update, and delete all active role assignments for your company's directory", - "id": "8c026be3-8e26-4774-9372-8d5d6f21daff", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and manage the active role-based access control (RBAC) assignments for your company's directory, on your behalf. This includes managing active directory role membership, and reading directory role templates, directory roles and active memberships.", - "userConsentDisplayName": "Read, update, and delete all active role assignments for your company's directory", - "value": "RoleAssignmentSchedule.ReadWrite.Directory" + "description": "Allows the app to read all Lifecycle workflows reports without a signed-in user.", + "displayName": "Read all Lifecycle workflows reports", + "id": "fe615156-48b5-4c83-b613-e6e31a43c446", + "origin": "Application (Microsoft Graph)", + "value": "LifecycleWorkflows-Reports.Read.All" }, { - "description": "Allows the app to read and manage the eligible role-based access control (RBAC) assignments for your company's directory, on behalf of the signed-in user. This includes managing eligible directory role membership, and reading directory role templates, directory roles and eligible memberships.", - "displayName": "Read, update, and delete all eligible role assignments for your company's directory", - "id": "62ade113-f8e0-4bf9-a6ba-5acb31db32fd", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and manage the eligible role-based access control (RBAC) assignments for your company's directory, on your behalf. This includes managing eligible directory role membership, and reading directory role templates, directory roles and eligible memberships.", - "userConsentDisplayName": "Read, update, and delete all eligible role assignments for your company's directory", - "value": "RoleEligibilitySchedule.ReadWrite.Directory" + "description": "Allows the app run workflows on-demand without a signed-in user.", + "displayName": "Run workflows on-demand in Lifecycle workflows", + "id": "3a87a643-13d2-47aa-8d6a-b0a8377cb03b", + "origin": "Application (Microsoft Graph)", + "value": "LifecycleWorkflows-Workflow.Activate" }, { - "description": "Allows the app to read, update, and delete policies for privileged role-based access control (RBAC) assignments of your company's directory, on behalf of the signed-in user.", - "displayName": "Read, update, and delete all policies for privileged role assignments of your company's directory", - "id": "1ff1be21-34eb-448c-9ac9-ce1f506b2a68", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read, update, and delete policies for privileged role-based access control (RBAC) assignments of your company's directory, on your behalf.", - "userConsentDisplayName": "Read, update, and delete all policies for privileged role assignments of your company's directory", - "value": "RoleManagementPolicy.ReadWrite.Directory" + "description": "Allows the app to list and read all workflows and tasks without a signed-in user.", + "displayName": "Read all workflows in Lifecycle workflows", + "id": "03b0ad3e-fc2b-4ef1-b0ff-252e865cb608", + "origin": "Application (Microsoft Graph)", + "value": "LifecycleWorkflows-Workflow.Read.All" }, { - "description": "Allows the app to read the active role-based access control (RBAC) assignments for your company's directory, on behalf of the signed-in user. This includes reading directory role templates, and directory roles.", - "displayName": "Read all active role assignments for your company's directory", - "id": "344a729c-0285-42c6-9014-f12b9b8d6129", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read the active role-based access control (RBAC) assignments for your company's directory, on your behalf. This includes reading directory role templates, and directory roles.", - "userConsentDisplayName": "Read all active role assignments for your company's directory", - "value": "RoleAssignmentSchedule.Read.Directory" + "description": "Allows the app to list all workflows without a signed-in user.", + "displayName": "List all workflows in Lifecycle workflows", + "id": "021ea6db-c06b-45c6-8c9c-c1cd9a37a483", + "origin": "Application (Microsoft Graph)", + "value": "LifecycleWorkflows-Workflow.ReadBasic.All" }, { - "description": "Allows the app to read the eligible role-based access control (RBAC) assignments for your company's directory, on behalf of the signed-in user. This includes reading directory role templates, and directory roles.", - "displayName": "Read all eligible role assignments for your company's directory", - "id": "eb0788c2-6d4e-4658-8c9e-c0fb8053f03d", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read the eligible role-based access control (RBAC) assignments for your company's directory, on your behalf. This includes reading directory role templates, and directory roles.", - "userConsentDisplayName": "Read all eligible role assignments for your company's directory", - "value": "RoleEligibilitySchedule.Read.Directory" + "description": "Allows the app to create, update, list, read and delete all workflows and tasks in lifecycle workflows without a signed-in user.", + "displayName": "Read and write all workflows in Lifecycle workflows", + "id": "94c88098-1d9d-4c42-a356-4d5a95312554", + "origin": "Application (Microsoft Graph)", + "value": "LifecycleWorkflows-Workflow.ReadWrite.All" }, { - "description": "Allows the app to read policies for privileged role-based access control (RBAC) assignments of your company's directory, on behalf of the signed-in user.", - "displayName": "Read all policies for privileged role assignments of your company's directory", - "id": "3de2cdbe-0ff5-47d5-bdee-7f45b4749ead", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read policies for privileged role-based access control (RBAC) assignments of your company's directory, on your behalf.", - "userConsentDisplayName": "Read all policies for privileged role assignments of your company's directory", - "value": "RoleManagementPolicy.Read.Directory" + "description": "Allow the application to access a subset of listitems without a signed in user. The specific listitems and the permissions granted will be configured in SharePoint Online.", + "displayName": "Access selected ListItems without a signed in user.", + "id": "de4e4161-a10a-4dfd-809c-e328d89aefeb", + "origin": "Application (Microsoft Graph)", + "value": "ListItems.SelectedOperations.Selected" }, { - "description": "Allows the app to read and write all Windows update deployment settings for the organization on behalf of the signed-in user.", - "displayName": "Read and write all Windows update deployment settings", - "id": "11776c0c-6138-4db3-a668-ee621bea2555", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write all Windows update deployment settings for the organization on your behalf.", - "userConsentDisplayName": "Read and write all Windows update deployment settings", - "value": "WindowsUpdates.ReadWrite.All" + "description": "Allow the application to access a subset of lists without a signed in user. The specific lists and the permissions granted will be configured in SharePoint Online.", + "displayName": "Access selected Lists without a signed in user.", + "id": "23c5a9bd-d900-4ecf-be26-a0689755d9e5", + "origin": "Application (Microsoft Graph)", + "value": "Lists.SelectedOperations.Selected" }, { - "description": "Allows the app to read and write your organization's mobility management policies on behalf of the signed-in user. For example, a mobility management policy can set the enrollment scope for a given mobility management application.", - "displayName": "Read and write your organization's mobility management policies", - "id": "a8ead177-1889-4546-9387-f25e658e2a79", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write your organization's mobility management policies on your behalf. For example, a mobility management policy can set the enrollment scope for a given mobility management application.", - "userConsentDisplayName": "Read and write your organization's mobility management policies", - "value": "Policy.ReadWrite.MobilityManagement" + "description": "Allows the app to read basic mail properties in all mailboxes without a signed-in user. Includes all properties except body, previewBody, attachments and any extended properties.", + "displayName": "Read basic mail in all mailboxes", + "id": "6be147d2-ea4f-4b5a-a3fa-3eab6f3c140a", + "origin": "Application (Microsoft Graph)", + "value": "Mail.ReadBasic" }, { - "description": "Allows the app to read basic unified group properties, memberships and owners of the group the signed-in guest is a member of.", - "displayName": "Read unified group memberships as guest", - "id": "73e75199-7c3e-41bb-9357-167164dbb415", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read basic unified group properties, memberships and owners of the group you are a member of.", - "userConsentDisplayName": "Read unified group memberships as guest", - "value": "UnifiedGroupMember.Read.AsGuest" + "description": "Allows the app to read basic mail properties in all mailboxes without a signed-in user. Includes all properties except body, previewBody, attachments and any extended properties.", + "displayName": "Read basic mail in all mailboxes", + "id": "693c5e45-0940-467d-9b8a-1022fb9d42ef", + "origin": "Application (Microsoft Graph)", + "value": "Mail.ReadBasic.All" }, { - "description": "Allows the app to update service principal endpoints", - "displayName": "Read and update service principal endpoints", - "id": "7297d82c-9546-4aed-91df-3d4f0a9b3ff0", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to update service principal endpoints", - "userConsentDisplayName": "Read and update service principal endpoints", - "value": "ServicePrincipalEndpoint.ReadWrite.All" + "description": "Allows the app to create, read, update, and delete all email, including contents of non-draft emails in user mailboxes, without a signed-in user. Does not include permission to send mail.", + "displayName": "Read and write mail in all mailboxes, including modifying existing non-draft mails", + "id": "e118f1da-5c1c-46cf-bff6-8858d786f46f", + "origin": "Application (Microsoft Graph)", + "value": "Mail-Advanced.ReadWrite.All" }, { - "description": "Allows the app to read service principal endpoints", - "displayName": "Read service principal endpoints", - "id": "9f9ce928-e038-4e3b-8faf-7b59049a8ddc", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read service principal endpoints", - "userConsentDisplayName": "Read service principal endpoints", - "value": "ServicePrincipalEndpoint.Read.All" + "description": "Allows the app to read, create, update and delete identity lifecycle policies for agent identities in the organization, without a signed-in user.", + "displayName": "Read and write identity lifecycle policies for agent identities", + "id": "00d1c504-8dc7-461b-8a0b-dc15c8f1bd5a", + "origin": "Application (Microsoft Graph)", + "value": "LifecyclePolicies-AgentId.ReadWrite.All" }, { - "description": "Allows the app to create new notifications in users' teamwork activity feeds on behalf of the signed in user. These notifications may not be discoverable or be held or governed by compliance policies.", - "displayName": "Send a teamwork activity as the user", - "id": "7ab1d787-bae7-4d5d-8db6-37ea32df9186", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to create new activities in your teamwork activity feed, and send new activities to other users' activity feed, on your behalf.", - "userConsentDisplayName": "Send a teamwork activity", - "value": "TeamsActivity.Send" + "description": "Allows the app to read all users' UserConfiguration objects.", + "displayName": "Read all users' UserConfiguration objects", + "id": "27d9d776-f4d2-426d-80ad-5f22f2b01b0a", + "origin": "Application (Microsoft Graph)", + "value": "MailboxConfigItem.Read" }, { - "description": "Allows the app to read and write eDiscovery objects such as cases, custodians, review sets and other related objects on behalf of the signed-in user.", - "displayName": "Read and write all eDiscovery objects", - "id": "acb8f680-0834-4146-b69e-4ab1b39745ad", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write eDiscovery objects such as cases, custodians, review sets and other related objects on your behalf.", - "userConsentDisplayName": "Read and write all eDiscovery objects", - "value": "eDiscovery.ReadWrite.All" + "description": "Allows the app to read identity lifecycle policies for agent identities in the organization, without a signed-in user.", + "displayName": "Read identity lifecycle policies for agent identities", + "id": "6343d63f-034f-45b5-832d-9f9d7632e182", + "origin": "Application (Microsoft Graph)", + "value": "LifecyclePolicies-AgentId.Read.All" }, { - "description": "Allows the app to read eDiscovery objects such as cases, custodians, review sets and other related objects on behalf of the signed-in user.", - "displayName": "Read all eDiscovery objects", - "id": "99201db3-7652-4d5a-809a-bdb94f85fe3c", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read eDiscovery objects such as cases, custodians, review sets and other related objects on your behalf.", - "userConsentDisplayName": "Read all eDiscovery objects", - "value": "eDiscovery.Read.All" + "description": "Allows an app to read license assignments for users and groups, without a signed-in user.", + "displayName": "Read all license assignments.", + "id": "e2f98668-2877-4f38-a2f4-8202e0717aa1", + "origin": "Application (Microsoft Graph)", + "value": "LicenseAssignment.Read.All" }, { - "description": "Allows the app to read and write custom security attribute assignments for all principals in the tenant on behalf of a signed in user.", - "displayName": "Read and write custom security attribute assignments", - "id": "ca46335e-8453-47cd-a001-8459884efeae", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write custom security attribute assignments for all principals in the tenant on your behalf.", - "userConsentDisplayName": "Read and write custom security attribute assignments", - "value": "CustomSecAttributeAssignment.ReadWrite.All" + "description": "Allows the app to read current and previous IndustryData runs without a signed-in user.", + "displayName": "View current and previous runs", + "id": "f6f5d10b-3024-4d1d-b674-aae4df4a1a73", + "origin": "Application (Microsoft Graph)", + "value": "IndustryData-Run.Read.All" }, { - "description": "Allows the app to read and write custom security attribute definitions for the tenant on behalf of a signed in user.", - "displayName": "Read and write custom security attribute definitions", - "id": "8b0160d4-5743-482b-bb27-efc0a485ca4a", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write custom security attribute definitions for the tenant on your behalf.", - "userConsentDisplayName": "Read and write custom security attribute definitions", - "value": "CustomSecAttributeDefinition.ReadWrite.All" + "description": "Allows the app to view and start IndustryData runs without a signed-in user.", + "displayName": "View and start runs", + "id": "7e429772-5b5e-47c0-8fd6-7279294c8033", + "origin": "Application (Microsoft Graph)", + "value": "IndustryData-Run.Start" }, { - "description": "Allows the app to read email in the signed-in user's mailbox except body, previewBody, attachments and any extended properties.", - "displayName": "Read user basic mail", - "id": "a4b8392a-d8d1-4954-a029-8e668a39a170", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read email in the signed-in user's mailbox except body, previewBody, attachments and any extended properties.", - "userConsentDisplayName": "Read user basic mail", - "value": "Mail.ReadBasic" + "description": "Allows the app to read source system definitions without a signed-in user.", + "displayName": "View source system definitions", + "id": "bc167a60-39fe-4865-8b44-78400fc6ed03", + "origin": "Application (Microsoft Graph)", + "value": "IndustryData-SourceSystem.Read.All" }, { - "description": "Allows the app to read and write your organization's feature rollout policies on behalf of the signed-in user. Includes abilities to assign and remove users and groups to rollout of a specific feature.", - "displayName": "Read and write your organization's feature rollout policies", - "id": "92a38652-f13b-4875-bc77-6e1dbb63e1b2", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write your organization's feature rollout policies on your behalf. Includes abilities to assign and remove users and groups to rollout of a specific feature.", - "userConsentDisplayName": "Read and write your organization's feature rollout policies", - "value": "Policy.ReadWrite.FeatureRollout" + "description": "Allows the app to read and write source system definitions without a signed-in user.", + "displayName": "Manage source system definitions", + "id": "7d866958-e06e-4dd6-91c6-a086b3f5cfeb", + "origin": "Application (Microsoft Graph)", + "value": "IndustryData-SourceSystem.ReadWrite.All" }, { - "description": "Allows the app to read and manage the role-based access control (RBAC) settings for your company's directory, on behalf of the signed-in user. This includes instantiating directory roles and managing directory role membership, and reading directory role templates, directory roles and memberships.", - "displayName": "Read and write directory RBAC settings", - "id": "d01b97e9-cbc0-49fe-810a-750afd5527a3", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and manage the role-based access control (RBAC) settings for your company's directory, on your behalf. This includes instantiating directory roles and managing directory role membership, and reading directory role templates, directory roles and memberships.", - "userConsentDisplayName": "Read and write directory RBAC settings", - "value": "RoleManagement.ReadWrite.Directory" + "description": "Allows the app to read time period definitions without a signed-in user.", + "displayName": "Read time period definitions", + "id": "7c55c952-b095-4c23-a522-022bce4cc1e3", + "origin": "Application (Microsoft Graph)", + "value": "IndustryData-TimePeriod.Read.All" }, { - "description": "Allows the app to read the role-based access control (RBAC) settings for your company's directory, on behalf of the signed-in user. This includes reading directory role templates, directory roles and memberships.", - "displayName": "Read directory RBAC settings", - "id": "741c54c3-0c1e-44a1-818b-3f97ab4e8c83", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read the role-based access control (RBAC) settings for your company's directory, on your behalf. This includes reading directory role templates, directory roles and memberships.", - "userConsentDisplayName": "Read directory RBAC settings", - "value": "RoleManagement.Read.Directory" + "description": "Allows the app to read and write time period definitions without a signed-in user.", + "displayName": "Manage time period definitions", + "id": "7afa7744-a782-4a32-b8c2-e3db637e8de7", + "origin": "Application (Microsoft Graph)", + "value": "IndustryData-TimePeriod.ReadWrite.All" }, { - "description": "Allows the app to read and write the organization and related resources, on behalf of the signed-in user.\u00a0Related resources include things like subscribed skus and tenant branding information.", - "displayName": "Read and write organization information", - "id": "46ca0847-7e6b-426e-9775-ea810a948356", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write the organization and related resources, on your behalf.\u00a0Related resources include things like subscribed skus and tenant branding information.", - "userConsentDisplayName": "Read and write organization information", - "value": "Organization.ReadWrite.All" + "description": "Allows the app to read all configurations applicable to users for protecting organizational data, without a signed-in user.", + "displayName": "Read all configurations for protecting organizational data applicable to users", + "id": "14f49b9f-4bf2-4d24-b80e-b27ec58409bd", + "origin": "Application (Microsoft Graph)", + "value": "InformationProtectionConfig.Read.All" }, { - "description": "Allows the app to read the organization and related resources, on behalf of the signed-in user.\u00a0Related resources include things like subscribed skus and tenant branding information.", - "displayName": "Read organization information", - "id": "4908d5b9-3fb2-4b1e-9336-1888b7937185", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read the organization and related resources, on your behalf.\u00a0Related resources include things like subscribed skus and tenant branding information.", - "userConsentDisplayName": "Read organization information", - "value": "Organization.Read.All" + "description": "Allows an app to sign digests for data without a signed-in user.", + "displayName": "Sign digests for data", + "id": "cbe6c7e4-09aa-4b8d-b3c3-2dbb59af4b54", + "origin": "Application (Microsoft Graph)", + "value": "InformationProtectionContent.Sign.All" }, { - "description": "Allows the app to read your company's places (conference rooms and room lists) for calendar events and other applications, on behalf of the signed-in user.", - "displayName": "Read all company places", - "id": "cb8f45a0-5c2e-4ea1-b803-84b870a7d7ec", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read your company's places (conference rooms and room lists) for calendar events and other applications, on your behalf.", - "userConsentDisplayName": "Read all company places", - "value": "Place.Read.All" + "description": "Allows the app to create protected content without a signed-in user.", + "displayName": "Create protected content", + "id": "287bd98c-e865-4e8c-bade-1a85523195b9", + "origin": "Application (Microsoft Graph)", + "value": "InformationProtectionContent.Write.All" }, { - "description": "Allows the app to manage workforce integrations, to synchronize data from Microsoft Teams Shifts, on behalf of the signed-in user.", - "displayName": "Read and write workforce integrations", - "id": "08c4b377-0d23-4a8b-be2a-23c1c1d88545", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to manage workforce integrations, to synchronize data from Microsoft Teams Shifts, on your behalf.", - "userConsentDisplayName": "Read and write workforce integrations", - "value": "WorkforceIntegration.ReadWrite.All" + "description": "Allows an app to read published sensitivity labels and label policy settings for the entire organization or a specific user, without a signed in user.", + "displayName": "Read all published labels and label policies for an organization.", + "id": "19da66cb-0fb0-4390-b071-ebc76a349482", + "origin": "Application (Microsoft Graph)", + "value": "InformationProtectionPolicy.Read.All" }, { - "description": "Allows the app to read workforce integrations, to synchronize data from Microsoft Teams Shifts, on behalf of the signed-in user.", - "displayName": "Read workforce integrations", - "id": "f1ccd5a7-6383-466a-8db8-1a656f7d06fa", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read workforce integrations, to synchronize data from Microsoft Teams Shifts, on your behalf.", - "userConsentDisplayName": "Read workforce integrations", - "value": "WorkforceIntegration.Read.All" + "description": "Allows an app to read all user metrics insights, such as daily and monthly active users, without a signed-in user.", + "displayName": "Read all user metrics insights", + "id": "34cbd96c-d824-4755-90d3-1008ef47efc1", + "origin": "Application (Microsoft Graph)", + "value": "Insights-UserMetric.Read.All" }, { - "description": "Allows the app to read, update, delete and perform actions on access reviews, reviewers, decisions and settings for group and app memberships that the signed-in user has access to in the organization.", - "displayName": "Manage access reviews for group and app memberships", - "id": "5af8c3f5-baca-439a-97b0-ea58a435e269", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read, update and perform action on access reviews, reviewers, decisions and settings that you have access to.", - "userConsentDisplayName": "Manage access reviews for group and app memberships", - "value": "AccessReview.ReadWrite.Membership" + "description": "Allows the app to read data for all assignments in the organization's directory, without a signed-in user.", + "displayName": "Read all assignments", + "id": "535e6066-2894-49ef-ab33-e2c6d064bb81", + "origin": "Application (Microsoft Graph)", + "value": "LearningAssignedCourse.Read.All" }, { - "description": "Allows the app to manage hybrid identity service configuration by creating, viewing, updating and deleting on-premises published resources, on-premises agents and agent groups, on behalf of the signed-in user.", - "displayName": "Manage on-premises published resources", - "id": "8c4d5184-71c2-4bf8-bb9d-bc3378c9ad42", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to manage hybrid identity service configuration by creating, viewing, updating and deleting on-premises published resources, on-premises agents and agent groups, on your behalf.", - "userConsentDisplayName": "Manage on-premises published resources", - "value": "OnPremisesPublishingProfiles.ReadWrite.All" + "description": "Allows the app to create, update, read and delete all assignments in the organization's directory, without a signed-in user.", + "displayName": "Read and write all assignments", + "id": "236c1cbd-1187-427f-b0f5-b1852454973b", + "origin": "Application (Microsoft Graph)", + "value": "LearningAssignedCourse.ReadWrite.All" }, { - "description": "Allows an app to read information protection sensitivity labels and label policy settings, on behalf of the signed-in user.", - "displayName": "Read user sensitivity labels and label policies.", - "id": "4ad84827-5578-4e18-ad7a-86530b12f884", - "Origin": "Delegated", - "userConsentDescription": "Allows an app to read information protection sensitivity labels and label policy settings, on behalf of the signed-in user.", - "userConsentDisplayName": "Read user sensitivity labels and label policies.", - "value": "InformationProtectionPolicy.Read" + "description": "Allows the app to read all learning content in the organization's directory, without a signed-in user.", + "displayName": "Read all learning content", + "id": "8740813e-d8aa-4204-860e-2a0f8f84dbc8", + "origin": "Application (Microsoft Graph)", + "value": "LearningContent.Read.All" }, { - "description": "Allows the app to read administrative units and administrative unit membership on behalf of the signed-in user.", - "displayName": "Read administrative units", - "id": "3361d15d-be43-4de6-b441-3c746d05163d", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read administrative units and administrative unit membership on your behalf.", - "userConsentDisplayName": "Read administrative units", - "value": "AdministrativeUnit.Read.All" + "description": "Allows the app to manage all learning content in the organization's directory, without a signed-in user.", + "displayName": "Manage all learning content", + "id": "444d6fcb-b738-41e5-b103-ac4f2a2628a3", + "origin": "Application (Microsoft Graph)", + "value": "LearningContent.ReadWrite.All" }, { - "description": "Allows the app to create, read, update, and delete administrative units and manage administrative unit membership on behalf of the signed-in user.", - "displayName": "Read and write administrative units", - "id": "7b8a2d34-6b3f-4542-a343-54651608ad81", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to create, read, update, and delete administrative units and manage administrative unit membership on your behalf.", - "userConsentDisplayName": "Read and write administrative units", - "value": "AdministrativeUnit.ReadWrite.All" + "description": "Allows the app to read data for all self-initiated courses in the organization's directory, without a signed-in user.", + "displayName": "Read all self-initiated courses", + "id": "467524fc-ed22-4356-a910-af61191e3503", + "origin": "Application (Microsoft Graph)", + "value": "LearningSelfInitiatedCourse.Read.All" }, { - "description": "Allows the app to read your family information, members and their basic profile.", - "displayName": "Read your family info", - "id": "3a1e4806-a744-4c70-80fc-223bf8582c46", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read your family information, members and their basic profile.", - "userConsentDisplayName": "Read your family info", - "value": "Family.Read" + "description": "Allows the app to create, update, read and delete all self-initiated courses in the organization's directory, without a signed-in user.", + "displayName": "Read and write all self-initiated courses", + "id": "7654ed61-8965-4025-846a-0856ec02b5b0", + "origin": "Application (Microsoft Graph)", + "value": "LearningSelfInitiatedCourse.ReadWrite.All" }, { - "description": "Allows the app to create threat indicators, and fully manage those threat indicators (read, update and delete), on behalf of the signed-in user. \u00a0It cannot update any threat indicators it does not own.", - "displayName": "Manage threat indicators this app creates or owns", - "id": "91e7d36d-022a-490f-a748-f8e011357b42", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to create threat indicators, and fully manage those threat indicators (read, update and delete), on your behalf. \u00a0It cannot update any threat indicators that it is not an owner of.", - "userConsentDisplayName": "Manage threat indicators this app creates or owns", - "value": "ThreatIndicators.ReadWrite.OwnedBy" + "description": "Allows an app to manage license assignments for users and groups, without a signed-in user.", + "displayName": "Manage all license assignments", + "id": "5facf0c1-8979-4e95-abcf-ff3d079771c0", + "origin": "Application (Microsoft Graph)", + "value": "LicenseAssignment.ReadWrite.All" }, { - "description": "Allows the app to read or update security actions, on behalf of the signed-in user.", - "displayName": "Read and update your organization's security actions", - "id": "dc38509c-b87d-4da0-bd92-6bec988bac4a", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and update security actions, on your behalf.", - "userConsentDisplayName": "Read and update your organization's security actions", - "value": "SecurityActions.ReadWrite.All" + "description": "Allows the app to create, read, update and delete all users' UserConfiguration objects.", + "displayName": "Read and write all users' UserConfiguration objects", + "id": "aa6d92d4-b25a-4640-aefe-3e3231e5e736", + "origin": "Application (Microsoft Graph)", + "value": "MailboxConfigItem.ReadWrite" }, { - "description": "Allows the app to read security actions, on behalf of the signed-in user.", - "displayName": "Read your organization's security actions", - "id": "1638cddf-07a4-4de2-8645-69c96cacad73", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read security actions, on your behalf.", - "userConsentDisplayName": "Read your organization's security actions", - "value": "SecurityActions.Read.All" + "description": "Allows the app to read all the users' mailbox folders, without signed-in user.", + "displayName": "Read all the users' mailbox folders", + "id": "99280d24-a782-4793-93cc-0888549957f6", + "origin": "Application (Microsoft Graph)", + "value": "MailboxFolder.Read.All" }, { - "description": "Allows an app to read 1 on 1 or group chats threads, on behalf of the signed-in user.", - "displayName": "Read user chat messages", - "id": "f501c180-9344-439a-bca0-6cbf209fd270", - "Origin": "Delegated", - "userConsentDescription": "Allows an app to read your 1 on 1 or group chat messages in Microsoft Teams, on your behalf.", - "userConsentDisplayName": "Read your chat messages", - "value": "Chat.Read" + "description": "Allows the app to read and write all the users' mailbox folders, without signed-in user.", + "displayName": "Read and write all the users' mailbox folders", + "id": "fef87b92-8391-4589-9da7-eb93dab7dc8a", + "origin": "Application (Microsoft Graph)", + "value": "MailboxFolder.ReadWrite.All" }, { - "description": "Allows an app to read and write 1 on 1 or group chats threads, on behalf of the signed-in user.", - "displayName": "Read and write user chat messages", - "id": "9ff7295e-131b-4d94-90e1-69fde507ac11", - "Origin": "Delegated", - "userConsentDescription": "Allows an app to read and write your 1 on 1 or group chat messages in Microsoft Teams, on your behalf.", - "userConsentDisplayName": "Read and write your chat messages", - "value": "Chat.ReadWrite" + "description": "Allows the app to read all AI Insights for all online meetings, without a signed-in user.", + "displayName": "Read all AI Insights for online meetings.", + "id": "c0cf7895-985f-42d4-a693-b618f36674ad", + "origin": "Application (Microsoft Graph)", + "value": "OnlineMeetingAiInsight.Read.All" }, { - "description": "Allows the app to read and write your organization's trust framework policies on behalf of the signed-in user.", - "displayName": "Read and write your organization's trust framework policies", - "id": "cefba324-1a70-4a6e-9c1d-fd670b7ae392", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write your organization's trust framework policies on your behalf.", - "userConsentDisplayName": "Read and write trust framework policies", - "value": "Policy.ReadWrite.TrustFramework" + "description": "Allows the teams-app to read all aiInsights for online meetings where the Teams-app is installed, without a signed-in user.", + "displayName": "Read all AI Insights for online meetings where the Teams application is installed.", + "id": "01892c31-3b66-4bcf-b5f5-bf0a03d5ed9f", + "origin": "Application (Microsoft Graph)", + "value": "OnlineMeetingAiInsight.Read.Chat" }, { - "description": "Allows the app to read trust framework key set properties on behalf of the signed-in user.", - "displayName": "Read trust framework key sets", - "id": "7ad34336-f5b1-44ce-8682-31d7dfcd9ab9", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read trust framework key sets, on your behalf.", - "userConsentDisplayName": "Read trust framework key sets", - "value": "TrustFrameworkKeySet.Read.All" + "description": "Allows the app to read online meeting artifacts in your organization, without a signed-in user.", + "displayName": "Read online meeting artifacts", + "id": "df01ed3b-eb61-4eca-9965-6b3d789751b2", + "origin": "Application (Microsoft Graph)", + "value": "OnlineMeetingArtifact.Read.All" }, - { - "description": "Allows the app to read and write trust framework key set properties on behalf of the signed-in user.", - "displayName": "Read and write trust framework key sets", - "id": "39244520-1e7d-4b4a-aee0-57c65826e427", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read or write trust framework key sets, on your behalf.", - "userConsentDisplayName": "Read and write trust framework key sets", - "value": "TrustFrameworkKeySet.ReadWrite.All" + { + "description": "Allows the app to read all recordings of all online meetings, without a signed-in user.", + "displayName": "Read all recordings of online meetings.", + "id": "a4a08342-c95d-476b-b943-97e100569c8d", + "origin": "Application (Microsoft Graph)", + "value": "OnlineMeetingRecording.Read.All" }, { - "description": "Allows the app to read and update identity risk event information for all users in your organization on behalf of the signed-in user.\u00a0Update operations include confirming risk event detections.\u00a0", - "displayName": "Read and write risk event information", - "id": "9e4862a5-b68f-479e-848a-4e07e25c9916", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and update identity risk event information for all users in your organization on your behalf.\u00a0Update operations include confirming risk event detections.\u00a0", - "userConsentDisplayName": "Read and write risk event information", - "value": "IdentityRiskEvent.ReadWrite.All" + "description": "Allows the app to read online meeting details in your organization, without a signed-in user.", + "displayName": "Read online meeting details", + "id": "c1684f21-1984-47fa-9d61-2dc8c296bb70", + "origin": "Application (Microsoft Graph)", + "value": "OnlineMeetings.Read.All" }, { - "description": "Allows the app to read and update identity risky user information for all users in your organization on behalf of the signed-in user.\u00a0Update operations include dismissing risky users.", - "displayName": "Read and write risky user information", - "id": "e0a7cdbb-08b0-4697-8264-0069786e9674", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and update identity risky user information for all users in your organization on your behalf.\u00a0Update operations include dismissing risky users.", - "userConsentDisplayName": "Read and write identity risky user information", - "value": "IdentityRiskyUser.ReadWrite.All" + "description": "Allows the app to read and create online meetings as an application in your organization.", + "displayName": "Read and create online meetings", + "id": "b8bb2037-6e08-44ac-a4ea-4674e010e2a4", + "origin": "Application (Microsoft Graph)", + "value": "OnlineMeetings.ReadWrite.All" }, { - "description": "Allows the app to read the signed-in user's mailbox.", - "displayName": "Read user mail ", - "id": "570282fd-fa5c-430d-a7fd-fc8dc98a9dca", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read email in your mailbox. ", - "userConsentDisplayName": "Read your mail ", - "value": "Mail.Read" + "description": "Allows the app to read all transcripts of all online meetings, without a signed-in user.", + "displayName": "Read all transcripts of online meetings.", + "id": "a4a80d8d-d283-4bd8-8504-555ec3870630", + "origin": "Application (Microsoft Graph)", + "value": "OnlineMeetingTranscript.Read.All" }, { - "description": "Allows the app to read identity risky user information for all users in your organization on behalf of the signed-in user.", - "displayName": "Read identity risky user information", - "id": "d04bb851-cb7c-4146-97c7-ca3e71baf56c", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read identity risky user information for all users in your organization on behalf of the signed-in user.", - "userConsentDisplayName": "Read identity risky user information", - "value": "IdentityRiskyUser.Read.All" + "description": "Allows the app to read all on-premises directory synchronization information for the organization, without a signed-in user.", + "displayName": "Read all on-premises directory synchronization information", + "id": "bb70e231-92dc-4729-aff5-697b3f04be95", + "origin": "Application (Microsoft Graph)", + "value": "OnPremDirectorySynchronization.Read.All" }, { - "description": "Allows the app to read the signed-in user's activity statistics, such as how much time the user has spent on emails, in meetings, or in chat sessions.", - "displayName": "Read user activity statistics", - "id": "e03cf23f-8056-446a-8994-7d93dfc8b50e", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read your activity statistics, such as how much time you've spent on emails, in meetings, or in chat sessions.", - "userConsentDisplayName": "Read your activity statistics", - "value": "Analytics.Read" + "description": "Allows the app to read and write all on-premises directory synchronization information for the organization, without a signed-in user.", + "displayName": "Read and write all on-premises directory synchronization information", + "id": "c22a92cc-79bf-4bb1-8b6c-e0a05d3d80ce", + "origin": "Application (Microsoft Graph)", + "value": "OnPremDirectorySynchronization.ReadWrite.All" }, { - "description": "Allows the app to see and update the data you gave it access to, even when users are not currently using the app. This does not give the app any additional permissions.", - "displayName": "Maintain access to data you have given it access to", - "id": "7427e0e9-2fba-42fe-b0c0-848c9e6a8182", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to see and update the data you gave it access to, even when you are not currently using the app. This does not give the app any additional permissions.", - "userConsentDisplayName": "Maintain access to data you have given it access to", - "value": "offline_access" + "description": "Allows the app to create, view, update and delete on-premises published resources, on-premises agents and agent groups, as part of a hybrid identity configuration, without a signed in user.", + "displayName": "Manage on-premises published resources", + "id": "0b57845e-aa49-4e6f-8109-ce654fffa618", + "origin": "Application (Microsoft Graph)", + "value": "OnPremisesPublishingProfiles.ReadWrite.All" }, { - "description": "Allows the app to have the same access to mailboxes as the signed-in user via Exchange Web Services.", - "displayName": "Access mailboxes as the signed-in user via Exchange Web Services", - "id": "9769c687-087d-48ac-9cb3-c37dde652038", - "Origin": "Delegated", - "userConsentDescription": "Allows the app full access to your mailboxes on your behalf.", - "userConsentDisplayName": "Access your mailboxes", - "value": "EWS.AccessAsUser.All" + "description": "Allows the app to read the organization and related resources, without a signed-in user. Related resources include things like subscribed skus and tenant branding information.", + "displayName": "Read organization information", + "id": "498476ce-e0fe-48b0-b801-37ba7e2685c6", + "origin": "Application (Microsoft Graph)", + "value": "Organization.Read.All" }, { - "description": "Allows the app to export data (e.g. customer content or system-generated logs), associated with any user in your company, when the app is used by a privileged user (e.g. a Company Administrator).", - "displayName": "Export user's data", - "id": "405a51b5-8d8d-430b-9842-8be4b0e9f324", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to export data (e.g. customer content or system-generated logs), associated with any user in your company, when the app is used by a privileged user (e.g. a Company Administrator).", - "userConsentDisplayName": "Export user's data", - "value": "User.Export.All" + "description": "Allows the app to read and write the organization and related resources, without a signed-in user. Related resources include things like subscribed skus and tenant branding information.", + "displayName": "Read and write organization information", + "id": "292d869f-3427-49a8-9dab-8c70152b74e9", + "origin": "Application (Microsoft Graph)", + "value": "Organization.ReadWrite.All" }, { - "description": "Allows the app to deliver its notifications on behalf of signed-in users. Also allows the app to read, update, and delete the user's notification items for this app.", - "displayName": "Deliver and manage user notifications for this app", - "id": "89497502-6e42-46a2-8cb2-427fd3df970a", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to deliver its notifications, on your behalf. Also allows the app to read, update, and delete your notification items for this app.", - "userConsentDisplayName": "Deliver and manage your notifications for this app", - "value": "Notifications.ReadWrite.CreatedByApp" + "description": "Allows the app to read the organizational branding information, without a signed-in user.", + "displayName": "Read organizational branding information", + "id": "eb76ac34-0d62-4454-b97c-185e4250dc20", + "origin": "Application (Microsoft Graph)", + "value": "OrganizationalBranding.Read.All" }, { - "description": "Allows the app to read and write your organization's conditional access policies on behalf of the signed-in user.", - "displayName": "Read and write your organization's conditional access policies", - "id": "ad902697-1014-4ef5-81ef-2b4301988e8c", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write your organization's conditional access policies on your behalf.", - "userConsentDisplayName": "Read and write your organization's conditional access policies", - "value": "Policy.ReadWrite.ConditionalAccess" + "description": "Allows the app to read and write the organizational branding information, without a signed-in user.", + "displayName": "Read and write organizational branding information", + "id": "d2ebfbc1-a5f8-424b-83a6-56ab5927a73c", + "origin": "Application (Microsoft Graph)", + "value": "OrganizationalBranding.ReadWrite.All" }, { - "description": "Allows the app to read your organization's policies on behalf of the signed-in user.", - "displayName": "Read your organization's policies", - "id": "572fea84-0151-49b2-9301-11cb16974376", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read your organization's policies on your behalf.", - "userConsentDisplayName": "Read your organization's policies", - "value": "Policy.Read.All" + "description": "Allows the app to read all organizational contacts without a signed-in user. These contacts are managed by the organization and are different from a user's personal contacts.", + "displayName": "Read organizational contacts", + "id": "e1a88a34-94c4-4418-be12-c87b00e26bea", + "origin": "Application (Microsoft Graph)", + "value": "OrgContact.Read.All" }, { - "description": "Allows the app to read access reviews, reviewers, decisions and settings that the signed-in user has access to in the organization.", - "displayName": "Read all access reviews that user can access", - "id": "ebfcd32b-babb-40f4-a14b-42706e83bd28", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read information on access reviews, reviewers, decisions and settings that you have access to.", - "userConsentDisplayName": "Read access reviews that you can access", - "value": "AccessReview.Read.All" + "description": "Allows the app to read organization-wide apps and services settings, without a signed-in user.", + "displayName": "Read organization-wide apps and services settings", + "id": "56c84fa9-ea1f-4a15-90f2-90ef41ece2c9", + "origin": "Application (Microsoft Graph)", + "value": "OrgSettings-AppsAndServices.Read.All" }, { - "description": "Allows the app to read, update, delete and perform actions on access reviews, reviewers, decisions and settings that the signed-in user has access to in the organization.", - "displayName": "Manage all access reviews that user can access", - "id": "e4aa47b9-9a69-4109-82ed-36ec70d85ff1", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read, update and perform action on access reviews, reviewers, decisions and settings that you have access to.", - "userConsentDisplayName": "Manage access reviews that you can access", - "value": "AccessReview.ReadWrite.All" + "description": "Allows the app to read and write organization-wide apps and services settings, without a signed-in user.", + "displayName": "Read and write organization-wide apps and services settings", + "id": "4a8e4191-c1c8-45f8-b801-f9a1a5ee6ad3", + "origin": "Application (Microsoft Graph)", + "value": "OrgSettings-AppsAndServices.ReadWrite.All" }, { - "description": "Allows the app to read programs and program controls that the signed-in user has access to in the organization.", - "displayName": "Read all programs that user can access", - "id": "c492a2e1-2f8f-4caa-b076-99bbf6e40fe4", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read information on programs and program controls that you have access to.", - "userConsentDisplayName": "Read programs that you can access", - "value": "ProgramControl.Read.All" + "description": "Allows the app to read all the OneNote notebooks in your organization, without a signed-in user.", + "displayName": "Read and write all OneNote notebooks", + "id": "0c458cef-11f3-48c2-a568-c66751c238c0", + "origin": "Application (Microsoft Graph)", + "value": "Notes.ReadWrite.All" }, { - "description": "Allows the app to read, update, delete and perform actions on programs and program controls that the signed-in user has access to in the organization.", - "displayName": "Manage all programs that user can access", - "id": "50fd364f-9d93-4ae1-b170-300e87cccf84", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read, update and perform action on programs and program controls that you have access to.", - "userConsentDisplayName": "Manage programs that you can access", - "value": "ProgramControl.ReadWrite.All" + "description": "Allows the app to read all the OneNote notebooks in your organization, without a signed-in user.", + "displayName": "Read all OneNote notebooks", + "id": "3aeca27b-ee3a-4c2b-8ded-80376e2134a4", + "origin": "Application (Microsoft Graph)", + "value": "Notes.Read.All" }, { - "description": "Allows the app to create, read, update, and delete apps in the app catalogs.", - "displayName": "Read and write to all app catalogs", - "id": "1ca167d5-1655-44a1-8adf-1414072e1ef9", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to create, read, update, and delete apps in the app catalogs.", - "userConsentDisplayName": "Read and write to all app catalogs", - "value": "AppCatalog.ReadWrite.All" + "description": "Allows the app to read all network access reports without a signed-in user.", + "displayName": "Read all network access reports", + "id": "40049381-3cc1-42af-94ec-5ce755db4b0d", + "origin": "Application (Microsoft Graph)", + "value": "NetworkAccess-Reports.Read.All" }, { - "description": "Allows the app to request and manage just in time elevation (including scheduled elevation) of users to Azure AD built-in administrative roles, on behalf of signed-in users.", - "displayName": "Read and write privileged access to Azure AD", - "id": "3c3c74f5-cdaa-4a97-b7e0-4e788bfcfb37", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to request and manage just in time elevation (including scheduled elevation) of users to Azure AD built-in administrative roles, on your behalf.", - "userConsentDisplayName": "Read and write privileged access to Azure AD", - "value": "PrivilegedAccess.ReadWrite.AzureAD" + "description": "Allows the app to read and write your organization's network access policies, without a signed-in user.", + "displayName": "Read and write all security and routing policies for network access", + "id": "f0c341be-8348-4989-8e43-660324294538", + "origin": "Application (Microsoft Graph)", + "value": "NetworkAccessPolicy.ReadWrite.All" }, { - "description": "Allows the app to read terms of use agreements on behalf of the signed-in user.", - "displayName": "Read all terms of use agreements", - "id": "af2819c9-df71-4dd3-ade7-4d7c9dc653b7", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read terms of use agreements on your behalf.", - "userConsentDisplayName": "Read all terms of use agreements", - "value": "Agreement.Read.All" + "description": "Allows the app to export all the users' mailbox items, without signed-in user.", + "displayName": "Export all the users' mailbox items", + "id": "937550e9-33a3-494b-88ae-d9cd394b1fbb", + "origin": "Application (Microsoft Graph)", + "value": "MailboxItem.Export.All" }, { - "description": "Allows the app to read and write terms of use agreements on behalf of the signed-in user.", - "displayName": "Read and write all terms of use agreements", - "id": "ef4b5d93-3104-4664-9053-a5c49ab44218", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write terms of use agreements on your behalf.", - "userConsentDisplayName": "Read and write all terms of use agreements", - "value": "Agreement.ReadWrite.All" + "description": "Allows the app to export and import all the users' mailbox items, without signed-in user.", + "displayName": "Export and import all the users' mailbox items", + "id": "76577085-e73d-4f1d-b26a-85fb33892327", + "origin": "Application (Microsoft Graph)", + "value": "MailboxItem.ImportExport.All" }, { - "description": "Allows the app to read terms of use acceptance statuses on behalf of the signed-in user.", - "displayName": "Read user terms of use acceptance statuses", - "id": "0b7643bb-5336-476f-80b5-18fbfbc91806", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read your terms of use acceptance statuses.", - "userConsentDisplayName": "Read your terms of use acceptance statuses", - "value": "AgreementAcceptance.Read" + "description": "Allows the app to read all the users' mailbox items, without signed-in user.", + "displayName": "Read all the users' mailbox items", + "id": "7d9f353d-a7bd-4fbb-822a-26d5dd39a3ce", + "origin": "Application (Microsoft Graph)", + "value": "MailboxItem.Read.All" }, { - "description": "Allows the app to read terms of use acceptance statuses on behalf of the signed-in user.", - "displayName": "Read terms of use acceptance statuses that user can access", - "id": "a66a5341-e66e-4897-9d52-c2df58c2bfb9", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read terms of use acceptance statuses on your behalf.", - "userConsentDisplayName": "Read all terms of use acceptance statuses", - "value": "AgreementAcceptance.Read.All" + "description": "Allows the app to read, write, and delete all users' mailbox items, without a signed-in user.", + "displayName": "Read and write all users' mailbox items", + "id": "1583d471-fede-4e7c-b062-57e9d60cfb49", + "origin": "Application (Microsoft Graph)", + "value": "MailboxItem.ReadWrite.All" }, { - "description": "Read activity data for your organization", - "displayName": "Allows the application to read activity data for your organization.", - "id": "594c1fb6-4f81-4475-ae41-0c394909246c", - "Origin": "Delegated (Office 365 Management)", - "userConsentDescription": "Read activity data for your organization", - "userConsentDisplayName": "Allows the application to read activity data for your organization.", - "value": "ActivityFeed.Read" + "description": "Allows the app to read user's mailbox settings without a signed-in user. Does not include permission to send mail.", + "displayName": "Read all user mailbox settings", + "id": "40f97065-369a-49f4-947c-6a255697ae91", + "origin": "Application (Microsoft Graph)", + "value": "MailboxSettings.Read" }, { - "description": "Allows the app to read and query your audit log activities, on behalf of the signed-in user.", - "displayName": "Read audit log data", - "id": "e4c9e354-4dc5-45b8-9e7c-e1393b0b1a20", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and query your audit log activities, on your behalf.", - "userConsentDisplayName": "Read audit log data", - "value": "AuditLog.Read.All" + "description": "Allows the app to create, read, update, and delete user's mailbox settings without a signed-in user. Does not include permission to send mail.", + "displayName": "Read and write all user mailbox settings", + "id": "6931bccd-447a-43d1-b442-00a195474933", + "origin": "Application (Microsoft Graph)", + "value": "MailboxSettings.ReadWrite" }, { - "description": "Allows the app to read and report the signed-in user's activity in the app.", - "displayName": "Read and write app activity to users' activity feed", - "id": "47607519-5fb1-47d9-99c7-da4b48f369b1", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and report your activity in the app.", - "userConsentDisplayName": "Read and write app activity to your activity feed", - "value": "UserActivity.ReadWrite.CreatedByApp" + "description": "Allows the app to read mail tips for all users in the organization without a signed-in user. Mail tips include automatic replies, mailbox status, custom tips, and delivery information.", + "displayName": "Read mail tips for all users", + "id": "a2c9652d-4d7f-4e4e-9d75-ac32fdc6f413", + "origin": "Application (Microsoft Graph)", + "value": "MailTips.ReadBasic.All" }, { - "description": "Allows the app to read properties of Microsoft Intune-managed device configuration and device compliance policies and their assignment to groups.", - "displayName": "Read Microsoft Intune Device Configuration and Policies", - "id": "f1493658-876a-4c87-8fa7-edb559b3476a", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read properties of Microsoft Intune-managed device configuration and device compliance policies and their assignment to groups.", - "userConsentDisplayName": "Read Microsoft Intune Device Configuration and Policies", - "value": "DeviceManagementConfiguration.Read.All" + "description": "Allows the app to read the memberships of hidden groups and administrative units without a signed-in user.", + "displayName": "Read all hidden memberships", + "id": "658aa5d8-239f-45c4-aa12-864f4fc7e490", + "origin": "Application (Microsoft Graph)", + "value": "Member.Read.Hidden" }, { - "description": "Allows the app to read and write properties of Microsoft Intune-managed device configuration and device compliance policies and their assignment to groups.", - "displayName": "Read and write Microsoft Intune Device Configuration and Policies", - "id": "0883f392-0a7a-443d-8c76-16a6d39c7b63", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write properties of Microsoft Intune-managed device configuration and device compliance policies and their assignment to groups.", - "userConsentDisplayName": "Read and write Microsoft Intune Device Configuration and Policies", - "value": "DeviceManagementConfiguration.ReadWrite.All" + "description": "Allows the app to read and write reference definitions without a signed-in user.", + "displayName": "Manage reference definitions", + "id": "bda16293-63d3-45b7-b16b-833841d27d56", + "origin": "Application (Microsoft Graph)", + "value": "IndustryData-ReferenceDefinition.ReadWrite.All" }, { - "description": "Allows the app to read the properties, group assignments and status of apps, app configurations and app protection policies managed by Microsoft Intune.", - "displayName": "Read Microsoft Intune apps", - "id": "4edf5f54-4666-44af-9de9-0144fb4b6e8c", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read the properties, group assignments and status of apps, app configurations and app protection policies managed by Microsoft Intune.", - "userConsentDisplayName": "Read Microsoft Intune apps", - "value": "DeviceManagementApps.Read.All" + "description": "Allows the app to read all multi-tenant organization details and tenants, without a signed-in user.", + "displayName": "Read all multi-tenant organization details and tenants", + "id": "4f994bc0-31bb-44bb-b480-7a7c1be8c02e", + "origin": "Application (Microsoft Graph)", + "value": "MultiTenantOrganization.Read.All" }, { - "description": "Allows the app to read and write the properties, group assignments and status of apps, app configurations and app protection policies managed by Microsoft Intune.", - "displayName": "Read and write Microsoft Intune apps", - "id": "7b3f05d5-f68c-4b8d-8c59-a2ecd12f24af", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write the properties, group assignments and status of apps, app configurations and app protection policies managed by Microsoft Intune.", - "userConsentDisplayName": "Read and write Microsoft Intune apps", - "value": "DeviceManagementApps.ReadWrite.All" + "description": "Allows the app to read and write all multi-tenant organization details and tenants, without a signed-in user.", + "displayName": "Read and write all multi-tenant organization details and tenants", + "id": "920def01-ca61-4d2d-b3df-105b46046a70", + "origin": "Application (Microsoft Graph)", + "value": "MultiTenantOrganization.ReadWrite.All" }, { - "description": "Allows the app to read the properties relating to the Microsoft Intune Role-Based Access Control (RBAC) settings.", - "displayName": "Read Microsoft Intune RBAC settings", - "id": "49f0cc30-024c-4dfd-ab3e-82e137ee5431", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read the properties relating to the Microsoft Intune Role-Based Access Control (RBAC) settings.", - "userConsentDisplayName": "Read Microsoft Intune RBAC settings", - "value": "DeviceManagementRBAC.Read.All" + "description": "Allows the app to read configuration used for OAuth 2.0 mutual-TLS client authentication, without a signed-in user. This includes reading trusted certificate authorities.", + "displayName": "Read all configurations used for mutual-TLS client authentication.", + "id": "6daaff82-2880-496d-9d80-57e8e31195e2", + "origin": "Application (Microsoft Graph)", + "value": "MutualTlsOauthConfiguration.Read.All" }, { - "description": "Allows the app to read and write the properties relating to the Microsoft Intune Role-Based Access Control (RBAC) settings.", - "displayName": "Read and write Microsoft Intune RBAC settings", - "id": "0c5e8a55-87a6-4556-93ab-adc52c4d862d", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write the properties relating to the Microsoft Intune Role-Based Access Control (RBAC) settings.", - "userConsentDisplayName": "Read and write Microsoft Intune RBAC settings", - "value": "DeviceManagementRBAC.ReadWrite.All" + "description": "Allows the app to read and update configuration used for OAuth 2.0 mutual-TLS client authentication, without a signed-in user. This includes reading and updating trusted certificate authorities.", + "displayName": "Read and write all configurations used for mutual-TLS client authentication.", + "id": "78bbf8cf-07d8-45ba-b0eb-1a7b48efbcf1", + "origin": "Application (Microsoft Graph)", + "value": "MutualTlsOauthConfiguration.ReadWrite.All" }, { - "description": "Allows the app to read the properties of devices managed by Microsoft Intune.", - "displayName": "Read Microsoft Intune devices", - "id": "314874da-47d6-4978-88dc-cf0d37f0bb82", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read the properties of devices managed by Microsoft Intune.", - "userConsentDisplayName": "Read devices Microsoft Intune devices", - "value": "DeviceManagementManagedDevices.Read.All" + "description": "Allows the app to read all network access information and configuration settings without a signed-in user.", + "displayName": "Read all network access information", + "id": "e30060de-caa5-4331-99d3-6ac6c966a9a4", + "origin": "Application (Microsoft Graph)", + "value": "NetworkAccess.Read.All" }, { - "description": "Allows the app to read and write the properties of devices managed by Microsoft Intune. Does not allow high impact operations such as remote wipe and password reset on the device\u2019s owner.", - "displayName": "Read and write Microsoft Intune devices", - "id": "44642bfe-8385-4adc-8fc6-fe3cb2c375c3", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write the properties of devices managed by Microsoft Intune. Does not allow high impact operations such as remote wipe and password reset on the device\u2019s owner.", - "userConsentDisplayName": "Read and write Microsoft Intune devices", - "value": "DeviceManagementManagedDevices.ReadWrite.All" + "description": "Allows the app to read and write all network access information and configuration settings without a signed-in user.", + "displayName": "Read and write all network access information", + "id": "b10642fc-a6cf-4c46-87f9-e1f96c2a18aa", + "origin": "Application (Microsoft Graph)", + "value": "NetworkAccess.ReadWrite.All" }, { - "description": "Allows the app to perform remote high impact actions such as wiping the device or resetting the passcode on devices managed by Microsoft Intune.", - "displayName": "Perform user-impacting remote actions on Microsoft Intune devices", - "id": "3404d2bf-2b13-457e-a330-c24615765193", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to perform remote high impact actions such as wiping the device or resetting the passcode on devices managed by Microsoft Intune.", - "userConsentDisplayName": "Perform user-impacting remote actions on Microsoft Intune devices", - "value": "DeviceManagementManagedDevices.PrivilegedOperations.All" + "description": "Allows the app to read your organization's network access branches, without a signed-in user.", + "displayName": "Read properties of all branches for network access", + "id": "39ae4a24-1ef0-49e8-9d63-2a66f5c39edd", + "origin": "Application (Microsoft Graph)", + "value": "NetworkAccessBranch.Read.All" }, { - "description": "Allows the app to read and write Microsoft Intune service properties including device enrollment and third party service connection configuration.", - "displayName": "Read and write Microsoft Intune configuration", - "id": "662ed50a-ac44-4eef-ad86-62eed9be2a29", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write Microsoft Intune service properties including device enrollment and third party service connection configuration.", - "userConsentDisplayName": "Read and write Microsoft Intune configuration", - "value": "DeviceManagementServiceConfig.ReadWrite.All" + "description": "Allows the app to read and write your organization's network access branches, without a signed-in user.", + "displayName": "Read and write properties of all branches for network access", + "id": "8137102d-ec16-4191-aaf8-7aeda8026183", + "origin": "Application (Microsoft Graph)", + "value": "NetworkAccessBranch.ReadWrite.All" }, { - "description": "Allows the app to read Microsoft Intune service properties including device enrollment and third party service connection configuration.", - "displayName": "Read Microsoft Intune configuration", - "id": "8696daa5-bce5-4b2e-83f9-51b6defc4e1e", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read Microsoft Intune service properties including device enrollment and third party service connection configuration.", - "userConsentDisplayName": "Read Microsoft Intune configuration", - "value": "DeviceManagementServiceConfig.Read.All" + "description": "Allows the app to read your organization's network access policies, without a signed-in user.", + "displayName": "Read all security and routing policies for network access", + "id": "8a3d36bf-cb46-4bcc-bec9-8d92829dab84", + "origin": "Application (Microsoft Graph)", + "value": "NetworkAccessPolicy.Read.All" }, { - "description": "Allows the app to read your organization\u2019s security events on behalf of the signed-in user.", - "displayName": "Read your organization\u2019s security events", - "id": "64733abd-851e-478a-bffb-e47a14b18235", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read your organization\u2019s security events on your behalf.", - "userConsentDisplayName": "Read your organization\u2019s security events", - "value": "SecurityEvents.Read.All" + "description": "Allows the app to read multi-tenant organization basic details and active tenants, without a signed-in user.", + "displayName": "Read multi-tenant organization basic details and active tenants", + "id": "f9c2b2a7-3895-4b2e-80f6-c924b456e50b", + "origin": "Application (Microsoft Graph)", + "value": "MultiTenantOrganization.ReadBasic.All" }, { - "description": "Allows the app to read your organization\u2019s security events on behalf of the signed-in user. Also allows the app to update editable properties in security events on behalf of the signed-in user.", - "displayName": "Read and update your organization\u2019s security events", - "id": "6aedf524-7e1c-45a7-bd76-ded8cab8d0fc", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read your organization\u2019s security events on your behalf. Also allows you to update editable properties in security events.", - "userConsentDisplayName": "Read and update your organization\u2019s security events", - "value": "SecurityEvents.ReadWrite.All" + "description": "Allows the app to read organization-wide Dynamics customer voice settings, without a signed-in user.", + "displayName": "Read organization-wide Dynamics customer voice settings", + "id": "c18ae2dc-d9f3-4495-a93f-18980a0e159f", + "origin": "Application (Microsoft Graph)", + "value": "OrgSettings-DynamicsVoice.Read.All" }, { - "description": "Allows the app to read a scored list of relevant people of the signed-in user or other users in the signed-in user's organization. The list can include local contacts, contacts from social networking, your organization's directory, and people from recent communications (such as email and Skype).", - "displayName": "Read all users' relevant people lists", - "id": "b89f9189-71a5-4e70-b041-9887f0bc7e4a", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read a list of people in the order that is most relevant to you. Allows the app to read a list of people in the order that is most relevant to another user in your organization. These can include local contacts, contacts from social networking, people listed in your organization\u2019s directory, and people from recent communications.", - "userConsentDisplayName": "Read all users\u2019 relevant people lists", - "value": "People.Read.All" + "description": "Allows the app to read reference definitions without a signed-in user.", + "displayName": "View reference definitions", + "id": "6ee891c3-74a4-4148-8463-0c834375dfaf", + "origin": "Application (Microsoft Graph)", + "value": "IndustryData-ReferenceDefinition.Read.All" }, { - "description": "Manage the state and settings of all Microsoft education apps on behalf of the user.", - "displayName": "Manage education app settings", - "id": "63589852-04e3-46b4-bae9-15d5b1050748", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to manage the state and settings of all Microsoft education apps on your behalf.", - "userConsentDisplayName": "Manage your education app settings", - "value": "EduAdministration.ReadWrite" + "description": "Allows the app to read outbound data flows without a signed-in user.", + "displayName": "View outbound flow definitions", + "id": "61d0354c-5d88-483c-b974-a37ec3395a2c", + "origin": "Application (Microsoft Graph)", + "value": "IndustryData-OutboundFlow.Read.All" }, { - "description": "Read the state and settings of all Microsoft education apps on behalf of the user.", - "displayName": "Read education app settings", - "id": "8523895c-6081-45bf-8a5d-f062a2f12c9f", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to view the state and settings of all Microsoft education apps on your behalf.", - "userConsentDisplayName": "View your education app settings", - "value": "EduAdministration.Read" + "description": "Allows the app to assign Viva Engage role to a user, and remove a Viva Engage role from a user without a signed-in user.", + "displayName": "Modify Viva Engage role membership", + "id": "3ede5358-7366-4da8-a2f7-472bf9c7cc34", + "origin": "Application (Microsoft Graph)", + "value": "EngagementRole.ReadWrite.All" }, { - "description": "Allows the app to read and write assignments and their grades on behalf of the user.", - "displayName": "Read and write users' class assignments and their grades", - "id": "2f233e90-164b-4501-8bce-31af2559a2d3", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to view and modify your assignments on your behalf including \u00a0grades.", - "userConsentDisplayName": "View and modify your assignments and grades", - "value": "EduAssignments.ReadWrite" + "description": "Allows the app to read access packages and related entitlement management resources without a signed-in user.", + "displayName": "Read all entitlement management resources", + "id": "c74fd47d-ed3c-45c3-9a9e-b8676de685d2", + "origin": "Application (Microsoft Graph)", + "value": "EntitlementManagement.Read.All" }, { - "description": "Allows the app to read assignments and their grades on behalf of the user.", - "displayName": "Read users' class assignments and their grades", - "id": "091460c9-9c4a-49b2-81ef-1f3d852acce2", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to view your assignments on your behalf including grades.", - "userConsentDisplayName": "View your assignments and grades", - "value": "EduAssignments.Read" + "description": "Allows the app to read and write access packages and related entitlement management resources without a signed-in user.", + "displayName": "Read and write all entitlement management resources", + "id": "9acd699f-1e81-4958-b001-93b1d2506e19", + "origin": "Application (Microsoft Graph)", + "value": "EntitlementManagement.ReadWrite.All" }, { - "description": "Allows the app to read and write assignments without grades on behalf of the user.", - "displayName": "Read and write users' class assignments without grades", - "id": "2ef770a1-622a-47c4-93ee-28d6adbed3a0", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to view and modify your assignments on your behalf without seeing grades.", - "userConsentDisplayName": "View and modify your assignments without grades", - "value": "EduAssignments.ReadWriteBasic" + "description": "Allows the app to list the all the snapshots, jobs and enumerate the changes of a specific preview job, on behalf of the signed-in user.", + "displayName": "Read Preview jobs and snapshots", + "id": "56eda3c5-3834-4815-bd41-6f8fa1295247", + "origin": "Application (Microsoft Graph)", + "value": "EntraBackup.Read.All" }, { - "description": "Allows the app to read assignments without grades on behalf of the user.", - "displayName": "Read users' class assignments without grades", - "id": "c0b0103b-c053-4b2e-9973-9f3a544ec9b8", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to view your assignments on your behalf without seeing grades.", - "userConsentDisplayName": "View your assignments without grades", - "value": "EduAssignments.ReadBasic" + "description": "Allows the app to read your organization's authentication event listeners without a signed-in user.", + "displayName": "Read all authentication event listeners", + "id": "b7f6385c-6ce6-4639-a480-e23c42ed9784", + "origin": "Application (Microsoft Graph)", + "value": "EventListener.Read.All" }, { - "description": "Allows the app to read and write the structure of schools and classes in an organization's roster and education-specific information about users to be read and written on behalf of the user.", - "displayName": "Read and write users' view of the roster", - "id": "359e19a6-e3fa-4d7f-bcab-d28ec592b51e", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to view and modify information about schools and classes in your organization and education-related information about you and other users on your behalf.", - "userConsentDisplayName": "View and modify your school, class and user information", - "value": "EduRoster.ReadWrite" + "description": "Allows the app to read or write your organization's authentication event listeners without a signed-in user.", + "displayName": "Read and write all authentication event listeners", + "id": "0edf5e9e-4ce8-468a-8432-d08631d18c43", + "origin": "Application (Microsoft Graph)", + "value": "EventListener.ReadWrite.All" }, { - "description": "Allows the app to read the structure of schools and classes in an organization's roster and education-specific information about users to be read on behalf of the user.", - "displayName": "Read users' view of the roster", - "id": "a4389601-22d9-4096-ac18-36a927199112", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to view information about schools and classes in your organization and education-related information about you and other users on your behalf.", - "userConsentDisplayName": "View your school, class and user information", - "value": "EduRoster.Read" + "description": "Allows the app to search the email message trace, without a signed-in user.", + "displayName": "Search the email message trace", + "id": "89b20d8a-76e2-4057-867b-9961f800b9a4", + "origin": "Application (Microsoft Graph)", + "value": "ExchangeMessageTrace.Read.All" }, { - "description": "Allows the app to read a limited subset of the properties from the structure of schools and classes in an organization's roster and a limited subset of properties about users to be read on behalf of the user.\u00a0Includes name, status, education role, email address and photo.", - "displayName": "Read a limited subset of users' view of the roster", - "id": "5d186531-d1bf-4f07-8cea-7c42119e1bd9", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to view minimal \u00a0information about both schools and classes in your organization and education-related information about you and other users on your behalf.", - "userConsentDisplayName": "View a limited subset of your school, class and user information", - "value": "EduRoster.ReadBasic" + "description": "Allows the app to read all external connections without a signed-in user.", + "displayName": "Read all external connections", + "id": "1914711b-a1cb-4793-b019-c2ce0ed21b8c", + "origin": "Application (Microsoft Graph)", + "value": "ExternalConnection.Read.All" }, { - "description": "Allows the app to report the signed-in user's app activity information to Microsoft Timeline.", - "displayName": "Write app activity to users' timeline", - "id": "367492fc-594d-4972-a9b5-0d58c622c91c", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to report your app activity information to Microsoft Timeline.", - "userConsentDisplayName": "Write app activity to your timeline", - "value": "UserTimelineActivity.Write.CreatedByApp" + "description": "Allows the app to read and write all external connections without a signed-in user.", + "displayName": "Read and write all external connections", + "id": "34c37bc0-2b40-4d5e-85e1-2365cd256d79", + "origin": "Application (Microsoft Graph)", + "value": "ExternalConnection.ReadWrite.All" }, { - "description": "Allows the app to create, read, update, and delete user's mailbox settings. Does not include permission to send mail.", - "displayName": "Read and write user mailbox settings", - "id": "818c620a-27a9-40bd-a6a5-d96f7d610b4b", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read, update, create, and delete your mailbox settings.", - "userConsentDisplayName": "Read and write to your mailbox settings", - "value": "MailboxSettings.ReadWrite" + "description": "Allows the app to read and write external connections without a signed-in user. The app can only read and write external connections that it is authorized to, or it can create new external connections.", + "displayName": "Read and write external connections", + "id": "f431331c-49a6-499f-be1c-62af19c34a9d", + "origin": "Application (Microsoft Graph)", + "value": "ExternalConnection.ReadWrite.OwnedBy" }, { - "description": "Allows the app to launch another app or communicate with another app on a user's device on behalf of the signed-in user.", - "displayName": "Communicate with user devices", - "id": "bac3b9c2-b516-4ef4-bd3b-c2ef73d8d804", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to launch another app or communicate with another app on a device that you own.", - "userConsentDisplayName": "Communicate with your other devices", - "value": "Device.Command" + "description": "Allows the app to read all external items without a signed-in user.", + "displayName": "Read all external items", + "id": "7a7cffad-37d2-4f48-afa4-c6ab129adcc2", + "origin": "Application (Microsoft Graph)", + "value": "ExternalItem.Read.All" }, { - "description": "Allows the app to read a user's list of devices on behalf of the signed-in user.", - "displayName": "Read user devices", - "id": "11d4cd79-5ba5-460f-803f-e22c8ab85ccd", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to see your list of devices.", - "userConsentDisplayName": "View your list of devices", - "value": "Device.Read" + "description": "Allows the app to read and write external items without a signed-in user. The app can only read external items of the connection that it is authorized to.", + "displayName": "Read and write external items", + "id": "8116ae0f-55c2-452d-9944-d18420f5b2c8", + "origin": "Application (Microsoft Graph)", + "value": "ExternalItem.ReadWrite.OwnedBy" }, { - "description": "Allows the app to read, share, and modify OneNote notebooks that the signed-in user has access to in the organization.", - "displayName": "Read and write all OneNote notebooks that user can access", - "id": "64ac0503-b4fa-45d9-b544-71a463f05da0", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read, share, and modify all the OneNote notebooks that you have access to.", - "userConsentDisplayName": "Read and write all OneNote notebooks that you can access", - "value": "Notes.ReadWrite.All" + "description": "Allows the app to read available properties of external user profiles, without a signed-in user.", + "displayName": "Read all external user profiles", + "id": "1987d7a0-d602-4262-ab90-cfdd43b37545", + "origin": "Application (Microsoft Graph)", + "value": "ExternalUserProfile.Read.All" }, { - "description": "Allows the app to read OneNote notebooks that the signed-in user has access to in the organization.", - "displayName": "Read all OneNote notebooks that user can access", - "id": "dfabfca6-ee36-4db2-8208-7a28381419b3", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read all the OneNote notebooks that you have access to.", - "userConsentDisplayName": "Read all OneNote notebooks that you can access", - "value": "Notes.Read.All" + "description": "Allows the app to read and write available properties of external user profiles, without a signed-in user.", + "displayName": "Read and write all external user profiles", + "id": "761327c9-d819-4c08-9a5f-874cd2826608", + "origin": "Application (Microsoft Graph)", + "value": "ExternalUserProfile.ReadWrite.All" }, { - "description": "Allows the app to read, share, and modify OneNote notebooks on behalf of the signed-in user.", - "displayName": "Read and write user OneNote notebooks", - "id": "615e26af-c38a-4150-ae3e-c3b0d4cb1d6a", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read, share, and modify OneNote notebooks on your behalf.", - "userConsentDisplayName": "Read and write your OneNote notebooks", - "value": "Notes.ReadWrite" + "description": "Allows the app to ingest SharePoint and OneDrive content to make it available in the search index, without a signed-in user.", + "displayName": "Ingest SharePoint and OneDrive content to make it available in the search index", + "id": "65891b00-2fd9-4e33-be27-04a53132e3df", + "origin": "Application (Microsoft Graph)", + "value": "FileIngestion.Ingest" }, { - "description": "Allows the app to read OneNote notebooks on behalf of the signed-in user.", - "displayName": "Read user OneNote notebooks", - "id": "371361e4-b9e2-4a3f-8315-2a301a3b0a3d", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read OneNote notebooks on your behalf.", - "userConsentDisplayName": "Read your OneNote notebooks", - "value": "Notes.Read" + "description": "Allows the app to manage onboarding for a Hybrid Cloud tenant, without a signed-in user.", + "displayName": "Manage onboarding for a Hybrid Cloud tenant", + "id": "766c601b-c009-4438-8290-c8b05fa00c4b", + "origin": "Application (Microsoft Graph)", + "value": "FileIngestionHybridOnboarding.Manage" }, { - "description": "This is deprecated! Do not use! This permission no longer has any effect. You can safely consent to it. No additional privileges will be granted to the app.", - "displayName": "Limited notebook access (deprecated)", - "id": "ed68249d-017c-4df5-9113-e684c7f8760b", - "Origin": "Delegated", - "userConsentDescription": "This permission no longer has any effect. You can safely consent to it. No additional privileges will be granted to the app.", - "userConsentDisplayName": "Limited access to your OneNote notebooks for this app (preview)", - "value": "Notes.ReadWrite.CreatedByApp" + "description": "Allows the app to list all Viva Engage roles and role memberships without a signed-in user.", + "displayName": "Read all Viva Engage roles and role memberships", + "id": "30614864-4114-45ef-bdd9-0dd7894a1cc4", + "origin": "Application (Microsoft Graph)", + "value": "EngagementRole.Read.All" }, { - "description": "Allows the app to read the titles of OneNote notebooks and sections and to create new pages, notebooks, and sections on behalf of the signed-in user.", - "displayName": "Create user OneNote notebooks", - "id": "9d822255-d64d-4b7a-afdb-833b9a97ed02", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to view the titles of your OneNote notebooks and sections and to create new pages, notebooks, and sections on your behalf.", - "userConsentDisplayName": "Create your OneNote notebooks", - "value": "Notes.Create" + "description": "Allows the app to read all files in all site collections without a signed in user.", + "displayName": "Read files in all site collections", + "id": "01d4889c-1287-42c6-ac1f-5d1e02578ef6", + "origin": "Application (Microsoft Graph)", + "value": "Files.Read.All" }, { - "description": "Allows the app to invite guest users to the organization, on behalf of the signed-in user.", - "displayName": "Invite guest users to the organization", - "id": "63dd7cd9-b489-4adf-a28c-ac38b9a0f962", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to invite guest users to the organization, on your behalf.", - "userConsentDisplayName": "Invite guest users to the organization", - "value": "User.Invite.All" + "description": "Allows the app to list Viva Engage Teams QA conversations, and to read their properties without a signed-in user.", + "displayName": "Read all Viva Engage Teams QA conversations", + "id": "d746beae-b46e-446e-924a-5b805a5c4467", + "origin": "Application (Microsoft Graph)", + "value": "EngagementMeetingConversation.Read.All" }, { - "description": "Allows the app to the read user's mailbox settings. Does not include permission to send mail.", - "displayName": "Read user mailbox settings", - "id": "87f447af-9fa4-4c32-9dfa-4a57a73d18ce", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read your mailbox settings.", - "userConsentDisplayName": "Read your mailbox settings", - "value": "MailboxSettings.Read" + "description": "Allows the app to create Viva Engage conversations, read all conversation properties, update conversation properties, and delete conversations without a signed-in user.", + "displayName": "Read and write all Viva Engage conversations", + "id": "bfbd4840-fba0-43a7-93a9-465b687e47d0", + "origin": "Application (Microsoft Graph)", + "value": "EngagementConversation.ReadWrite.All" }, { - "description": "(Preview) Allows the app to read files that the user selects. The app has access for several hours after the user selects a file.", - "displayName": "Read files that the user selects (preview)", - "id": "5447fe39-cb82-4c1a-b977-520e67e724eb", - "Origin": "Delegated", - "userConsentDescription": "(Preview) Allows the app to read files that you select. After you select a file, the app has access to the file for several hours.", - "userConsentDisplayName": "Read selected files", - "value": "Files.Read.Selected" + "description": "Read the state and settings of all Microsoft education apps.", + "displayName": "Read Education app settings", + "id": "7c9db06a-ec2d-4e7b-a592-5a1e30992566", + "origin": "Application (Microsoft Graph)", + "value": "EduAdministration.Read.All" }, { - "description": "(Preview) Allows the app to read and write files that the user selects. The app has access for several hours after the user selects a file.", - "displayName": "Read and write files that the user selects (preview)", - "id": "17dde5bd-8c17-420f-a486-969730c1b827", - "Origin": "Delegated", - "userConsentDescription": "(Preview) Allows the app to read and write files that you select. After you select a file, the app has access to the file for several hours.", - "userConsentDisplayName": "Read and write selected files", - "value": "Files.ReadWrite.Selected" + "description": "Manage the state and settings of all Microsoft education apps.", + "displayName": "Manage education app settings", + "id": "9bc431c3-b8bc-4a8d-a219-40f10f92eff6", + "origin": "Application (Microsoft Graph)", + "value": "EduAdministration.ReadWrite.All" }, { - "description": "(Preview) Allows the app to read, create, update and delete files in the application's folder.", - "displayName": "Have full access to the application's folder (preview)", - "id": "8019c312-3263-48e6-825e-2b833497195b", - "Origin": "Delegated", - "userConsentDescription": "(Preview) Allows the app to read, create, update and delete files in the application's folder.", - "userConsentDisplayName": "Have full access to the application's folder", - "value": "Files.ReadWrite.AppFolder" + "description": "Allows the app to read all class assignments with grades for all users without a signed-in user.", + "displayName": "Read all class assignments with grades", + "id": "4c37e1b6-35a1-43bf-926a-6f30f2cdf585", + "origin": "Application (Microsoft Graph)", + "value": "EduAssignments.Read.All" }, { - "description": "Allows an app to read all service usage reports on behalf of the signed-in user. Services that provide usage reports include Office 365 and Azure Active Directory.", - "displayName": "Read all usage reports", - "id": "02e97553-ed7b-43d0-ab3c-f8bace0d040c", - "Origin": "Delegated", - "userConsentDescription": "Allows an app to read all service usage reports on your behalf. Services that provide usage reports include Office 365 and Azure Active Directory.", - "userConsentDisplayName": "Read all usage reports", - "value": "Reports.Read.All" + "description": "Allows the app to read all class assignments without grades for all users without a signed-in user.", + "displayName": "Read all class assignments without grades", + "id": "6e0a958b-b7fc-4348-b7c4-a6ab9fd3dd0e", + "origin": "Application (Microsoft Graph)", + "value": "EduAssignments.ReadBasic.All" }, { - "description": "Allows the application to edit or delete documents and list items in all site collections on behalf of the signed-in user.", - "displayName": "Edit or delete items in all site collections", - "id": "89fe6a52-be36-487e-b7d8-d061c450a026", - "Origin": "Delegated", - "userConsentDescription": "Allow the application to edit or delete documents and list items in all site collections on your behalf.", - "userConsentDisplayName": "Edit or delete items in all site collections", - "value": "Sites.ReadWrite.All" + "description": "Allows the app to create, read, update and delete all class assignments with grades for all users without a signed-in user.", + "displayName": "Create, read, update and delete all class assignments with grades", + "id": "0d22204b-6cad-4dd0-8362-3e3f2ae699d9", + "origin": "Application (Microsoft Graph)", + "value": "EduAssignments.ReadWrite.All" }, { - "description": "Allows the app to create, read, update, and delete tasks a user has permissions to, including their own and shared tasks.", - "displayName": "Read and write user and shared tasks", - "id": "c5ddf11b-c114-4886-8558-8a4e557cd52b", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read, update, create, and delete tasks you have permissions to access, including your own and shared tasks.", - "userConsentDisplayName": "Read and write to your and shared tasks", - "value": "Tasks.ReadWrite.Shared" + "description": "Allows the app to create, read, update and delete all class assignments without grades for all users without a signed-in user.", + "displayName": "Create, read, update and delete all class assignments without grades", + "id": "f431cc63-a2de-48c4-8054-a34bc093af84", + "origin": "Application (Microsoft Graph)", + "value": "EduAssignments.ReadWriteBasic.All" }, { - "description": "Allows the app to read tasks a user has permissions to access, including their own and shared tasks.", - "displayName": "Read user and shared tasks", - "id": "88d21fd4-8e5a-4c32-b5e2-4a1c95f34f72", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read tasks you have permissions to access, including your own and shared tasks.", - "userConsentDisplayName": "Read your and shared tasks", - "value": "Tasks.Read.Shared" + "description": "Allows the app to read all modules and resources, without a signed-in user.", + "displayName": "Read all class modules and resources", + "id": "6cdb464c-3a03-40f8-900b-4cb7ea1da9c0", + "origin": "Application (Microsoft Graph)", + "value": "EduCurricula.Read.All" }, { - "description": "Allows the app to create, read, update, and delete contacts a user has permissions to, including their own and shared contacts.", - "displayName": "Read and write user and shared contacts", - "id": "afb6c84b-06be-49af-80bb-8f3f77004eab", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read, update, create, and delete contacts you have permissions to access, including your own and shared contacts.", - "userConsentDisplayName": "Read and write to your and shared contacts", - "value": "Contacts.ReadWrite.Shared" + "description": "Allows the app to read and write all modules and resources, without a signed-in user.", + "displayName": "Read and write all class modules and resources", + "id": "6a0c2318-d59d-4c7d-bf2e-5f3902dc2593", + "origin": "Application (Microsoft Graph)", + "value": "EduCurricula.ReadWrite.All" }, { - "description": "Allows the app to read contacts a user has permissions to access, including their own and shared contacts.", - "displayName": "Read user and shared contacts", - "id": "242b9d9e-ed24-4d09-9a52-f43769beb9d4", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read contacts you have permissions to access, including your own and shared contacts.", - "userConsentDisplayName": "Read your and shared contacts", - "value": "Contacts.Read.Shared" + "description": "Allows the app to read all tenant users reading assignments submissions data without a signed-in user.", + "displayName": "Read all tenant reading assignments submissions data", + "id": "ad248c30-1919-40c8-b3d2-304481894e88", + "origin": "Application (Microsoft Graph)", + "value": "EduReports-Reading.Read.All" }, { - "description": "Allows the app to create, read, update and delete events in all calendars in the organization user has permissions to access. This includes delegate and shared calendars.", - "displayName": "Read and write user and shared calendars", - "id": "12466101-c9b8-439a-8589-dd09ee67e8e9", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read, update, create and delete events in all calendars in your organization you have permissions to access. This includes delegate and shared calendars.", - "userConsentDisplayName": "Read and write to your and shared calendars", - "value": "Calendars.ReadWrite.Shared" + "description": "Allows the app to read all tenant users reading assignments submissions data (excludes student-identifying information) without a signed-in user.", + "displayName": "Read all tenant reading assignments submissions data", + "id": "040330d7-be7e-4130-b349-a6eb3a56e2f8", + "origin": "Application (Microsoft Graph)", + "value": "EduReports-Reading.ReadAnonymous.All" }, { - "description": "Allows the app to read events in all calendars that the user can access, including delegate and shared calendars.", - "displayName": "Read user and shared calendars", - "id": "2b9c4092-424d-4249-948d-b43879977640", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read events in all calendars that you can access, including delegate and shared calendars.\u00a0", - "userConsentDisplayName": "Read calendars\u00a0you can access", - "value": "Calendars.Read.Shared" + "description": "Allows the app to read all tenant users reflect check-ins submissions data without a signed-in user.", + "displayName": "Read all tenant reflect check-ins submissions data", + "id": "c5debf73-bdc8-473d-bf07-f4074ad05f71", + "origin": "Application (Microsoft Graph)", + "value": "EduReports-Reflect.Read.All" }, { - "description": "Allows the app to send mail as the signed-in user, including sending on-behalf of others.", - "displayName": "Send mail on behalf of others", - "id": "a367ab51-6b49-43bf-a716-a1fb06d2a174", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to send mail as you or on-behalf of someone else.", - "userConsentDisplayName": "Send mail on behalf of others or yourself", - "value": "Mail.Send.Shared" + "description": "Allows the app to read all tenant users reflect check-ins submissions data (excludes responder-identifying information) without a signed-in user.", + "displayName": "Read all tenant reflect check-ins submissions data", + "id": "f5d05dba-7ef0-46fc-b62c-a7282555f428", + "origin": "Application (Microsoft Graph)", + "value": "EduReports-Reflect.ReadAnonymous.All" }, { - "description": "Allows the app to create, read, update, and delete mail a user has permission to access, including their own and shared mail. Does not include permission to send mail.", - "displayName": "Read and write user and shared mail", - "id": "5df07973-7d5d-46ed-9847-1271055cbd51", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read, update, create, and delete mail you have permission to access, including your own and shared mail. Does not allow the app to send mail on your behalf.", - "userConsentDisplayName": "Read and write mail\u00a0you can access", - "value": "Mail.ReadWrite.Shared" + "description": "Allows the app to read the structure of schools and classes in the organization's roster and education-specific information about all users to be read.", + "displayName": "Read the organization's roster", + "id": "e0ac9e1b-cb65-4fc5-87c5-1a8bc181f648", + "origin": "Application (Microsoft Graph)", + "value": "EduRoster.Read.All" }, { - "description": "Allows the app to read mail a user can access, including their own and shared mail.", - "displayName": "Read user and shared mail", - "id": "7b9103a5-4610-446b-9670-80643382c1fa", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read mail you can access, including shared mail.", - "userConsentDisplayName": "Read mail you can access", - "value": "Mail.Read.Shared" + "description": "Allows the app to read a limited subset of properties from both the structure of schools and classes in the organization's roster and education-specific information about all users. Includes name, status, role, email address and photo.", + "displayName": "Read a limited subset of the organization's roster", + "id": "0d412a8c-a06c-439f-b3ec-8abcf54d2f96", + "origin": "Application (Microsoft Graph)", + "value": "EduRoster.ReadBasic.All" }, { - "description": "Allows users to sign-in to the app, and allows the app to read the profile of signed-in users. It also allows the app to read basic company information of signed-in users.", - "displayName": "Sign in and read user profile", - "id": "e1fe6dd8-ba31-4d61-89e7-88639da4683d", - "Origin": "Delegated", - "userConsentDescription": "Allows you to sign in to the app with your organizational account and let the app read your profile. It also allows the app to read basic company information.", - "userConsentDisplayName": "Sign you in and read your profile", - "value": "User.Read" + "description": "Allows the app to read and write the structure of schools and classes in the organization's roster and education-specific information about all users to be read and written.", + "displayName": "Read and write the organization's roster", + "id": "d1808e82-ce13-47af-ae0d-f9b254e6d58a", + "origin": "Application (Microsoft Graph)", + "value": "EduRoster.ReadWrite.All" }, { - "description": "Allows the app to read your profile. It also allows the app to update your profile information on your behalf.", - "displayName": "Read and write access to user profile", - "id": "b4e74841-8e56-480b-be8b-910348b18b4c", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read your profile, and discover your group membership, reports and manager. It also allows the app to update your profile information on your behalf.", - "userConsentDisplayName": "Read and update your profile", - "value": "User.ReadWrite" + "description": "Allows the app to create Viva Engage conversations without a signed-in user.", + "displayName": "Read and write all Viva Engage conversations", + "id": "e1d2136d-eaaf-427a-a7db-f97dbe847c27", + "origin": "Application (Microsoft Graph)", + "value": "EngagementConversation.Migration.All" }, { - "description": "Allows the app to read a basic set of profile properties of other users in your organization on behalf of the signed-in user. This includes display name, first and last name, email address and photo.", - "displayName": "Read all users' basic profiles", - "id": "b340eb25-3456-403f-be2f-af7a0d370277", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read a basic set of profile properties of other users in your organization on your behalf. Includes display name, first and last name, email address and photo.", - "userConsentDisplayName": "Read all users' basic profiles", - "value": "User.ReadBasic.All" + "description": "Allows the app to list Viva Engage conversations, and to read their properties without a signed-in user.", + "displayName": "Read all Viva Engage conversations", + "id": "2c495153-cd0e-41b4-9980-3bcecf1ca22f", + "origin": "Application (Microsoft Graph)", + "value": "EngagementConversation.Read.All" }, { - "description": "Allows the app to read the full set of profile properties, reports, and managers of other users in your organization, on behalf of the signed-in user.", - "displayName": "Read all users' full profiles", - "id": "a154be20-db9c-4678-8ab7-66f6cc099a59", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read the full set of profile properties, reports, and managers of other users in your organization, on your behalf.", - "userConsentDisplayName": "Read all users' full profiles", - "value": "User.Read.All" + "description": "Allows the app to export Viva Engage data for compliance, GDPR, and admin scenarios without a signed-in user.", + "displayName": "Export Viva Engage data", + "id": "eda8c187-a7d5-42cb-b2e1-c9142f63899f", + "origin": "Application (Microsoft Graph)", + "value": "EngagementExport.Read.All" }, { - "description": "Allows the app to read and write the full set of profile properties, reports, and managers of other users in your organization, on behalf of the signed-in user.", - "displayName": "Read and write all users' full profiles", - "id": "204e0828-b5ca-4ad8-b9f3-f32a958e7cc4", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write the full set of profile properties, reports, and managers of other users in your organization, on your behalf.", - "userConsentDisplayName": "Read and write all users' full profiles", - "value": "User.ReadWrite.All" + "description": "Allows the app to read, create, update and delete all files in all site collections without a signed in user.", + "displayName": "Read and write files in all site collections", + "id": "75359482-378d-4052-8f01-80520e7db3cd", + "origin": "Application (Microsoft Graph)", + "value": "Files.ReadWrite.All" }, { - "description": "Allows the app to list groups, and to read their properties and all group memberships on behalf of the signed-in user. Also allows the app to read calendar, conversations, files, and other group content for all groups the signed-in user can access. ", - "displayName": "Read all groups", - "id": "5f8c59db-677d-491f-a6b8-5f174b11ec1d", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to list groups, and to read their properties and all group memberships on your behalf. Also allows the app to read calendar, conversations, files, and other group content for all groups you can access. ", - "userConsentDisplayName": "Read all groups", - "value": "Group.Read.All" + "description": "Allows the app to read, create, update and delete files in the application's folder without a signed in user.", + "displayName": "Have full access to the application's folder without a signed in user.", + "id": "b47b160b-1054-4efd-9ca0-e2f614696086", + "origin": "Application (Microsoft Graph)", + "value": "Files.ReadWrite.AppFolder" }, { - "description": "Allows the app to create groups and read all group properties and memberships on behalf of the signed-in user. Additionally allows group owners to manage their groups and allows group members to update group content.", - "displayName": "Read and write all groups", - "id": "4e46008b-f24c-477d-8fff-7bb4ec7aafe0", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to create groups and read all group properties and memberships on your behalf. Additionally allows the app to manage your groups and to update group content for groups you are a member of.", - "userConsentDisplayName": "Read and write all groups", - "value": "Group.ReadWrite.All" + "description": "Allow the application to access a subset of files without a signed in user. The specific files and the permissions granted will be configured in SharePoint Online or OneDrive.", + "displayName": "Access selected Files without a signed in user.", + "id": "bd61925e-3bf4-4d62-bc0b-06b06c96d95c", + "origin": "Application (Microsoft Graph)", + "value": "Files.SelectedOperations.Selected" }, { - "description": "Allows the app to read data in your organization's directory, such as users, groups and apps.", - "displayName": "Read directory data", - "id": "06da0dbc-49e2-44d2-8312-53f166ab848a", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read data in your organization's directory.", - "userConsentDisplayName": "Read directory data", - "value": "Directory.Read.All" + "description": "Allows the app to read and write your organization’s identity (authentication) providers’ properties without a signed in user.", + "displayName": "Read and write identity providers", + "id": "90db2b9a-d928-4d33-a4dd-8442ae3d41e4", + "origin": "Application (Microsoft Graph)", + "value": "IdentityProvider.ReadWrite.All" }, { - "description": "Allows the app to read and write data in your organization's directory, such as users, and groups. It does not allow the app to delete users or groups, or reset user passwords.", - "displayName": "Read and write directory data", - "id": "c5366453-9fb0-48a5-a156-24f0c49a4b84", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write data in your organization's directory, such as other users, groups. It does not allow the app to delete users or groups, or reset user passwords.", - "userConsentDisplayName": "Read and write directory data", - "value": "Directory.ReadWrite.All" + "description": "Allows the app to read the identity risk event information for your organization without a signed in user.", + "displayName": "Read all identity risk event information", + "id": "6e472fd1-ad78-48da-a0f0-97ab2c6b769e", + "origin": "Application (Microsoft Graph)", + "value": "IdentityRiskEvent.Read.All" }, { - "description": "Allows the app to have the same access to information in the directory as the signed-in user.", - "displayName": "Access directory as the signed in user", - "id": "0e263e50-5827-48a4-b97c-d940288653c7", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to have the same access to information in your work or school directory as you do.", - "userConsentDisplayName": "Access the directory as you", - "value": "Directory.AccessAsUser.All" + "description": "Allows the app to read and update identity risk detection information for your organization without a signed-in user. Update operations include confirming risk event detections. ", + "displayName": "Read and write all risk detection information", + "id": "db06fb33-1953-4b7b-a2ac-f1e2c854f7ae", + "origin": "Application (Microsoft Graph)", + "value": "IdentityRiskEvent.ReadWrite.All" }, { - "description": "Allows the app to create, read, update, and delete email in user mailboxes. Does not include permission to send mail. ", - "displayName": "Read and write access to user mail ", - "id": "024d486e-b451-40bb-833d-3e66d98c5c73", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read, update, create and delete email in your mailbox. Does not include permission to send mail. ", - "userConsentDisplayName": "Read and write access to your mail ", - "value": "Mail.ReadWrite" + "description": "Allows the app to read the risky agents information in your organization without a signed-in user.", + "displayName": "Read all risky agents information", + "id": "4aadfb66-d49a-414a-a883-d8c240b6fa33", + "origin": "Application (Microsoft Graph)", + "value": "IdentityRiskyAgent.Read.All" }, { - "description": "Allows the app to send mail as users in the organization. ", - "displayName": "Send mail as a user ", - "id": "e383f46e-2787-4529-855e-0e479a3ffac0", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to send mail as you. ", - "userConsentDisplayName": "Send mail as you ", - "value": "Mail.Send" + "description": "Allows the app to read and update risky agents information in your organization without a signed-in user.", + "displayName": "Read and write risky agents information", + "id": "dca4e4fd-a7cf-4e6f-86d1-d1ec094d766e", + "origin": "Application (Microsoft Graph)", + "value": "IdentityRiskyAgent.ReadWrite.All" }, { - "description": "Allows the app to read events in user calendars . ", - "displayName": "Read user calendars ", - "id": "465a38f9-76ea-45b9-9f34-9e8b0d4b0b42", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read events in your calendars. ", - "userConsentDisplayName": "Read your calendars ", - "value": "Calendars.Read" + "description": "Allows the app to read all risky service principal information for your organization, without a signed-in user.", + "displayName": "Read all identity risky service principal information", + "id": "607c7344-0eed-41e5-823a-9695ebe1b7b0", + "origin": "Application (Microsoft Graph)", + "value": "IdentityRiskyServicePrincipal.Read.All" }, { - "description": "Allows the app to create, read, update, and delete events in user calendars. ", - "displayName": "Have full access to user calendars ", - "id": "1ec239c2-d7c9-4623-a91a-a9775856bb36", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read, update, create and delete events in your calendars. ", - "userConsentDisplayName": "Have full access to your calendars ", - "value": "Calendars.ReadWrite" + "description": "Allows the app to read and update identity risky service principal for your organization, without a signed-in user.", + "displayName": "Read and write all identity risky service principal information", + "id": "cb8d6980-6bcb-4507-afec-ed6de3a2d798", + "origin": "Application (Microsoft Graph)", + "value": "IdentityRiskyServicePrincipal.ReadWrite.All" }, { - "description": "Allows the app to read user contacts. ", - "displayName": "Read user contacts ", - "id": "ff74d97f-43af-4b68-9f2a-b77ee6968c5d", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read contacts in your contact folders. ", - "userConsentDisplayName": "Read your contacts ", - "value": "Contacts.Read" + "description": "Allows the app to read the identity risky user information for your organization without a signed in user.", + "displayName": "Read all identity risky user information", + "id": "dc5007c0-2d7d-4c42-879c-2dab87571379", + "origin": "Application (Microsoft Graph)", + "value": "IdentityRiskyUser.Read.All" }, { - "description": "Allows the app to create, read, update, and delete user contacts. ", - "displayName": "Have full access to user contacts ", - "id": "d56682ec-c09e-4743-aaf4-1a3aac4caa21", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read, update, create and delete contacts in your contact folders. ", - "userConsentDisplayName": "Have full access of your contacts ", - "value": "Contacts.ReadWrite" + "description": "Allows the app to read and update identity risky user information for your organization without a signed-in user. Update operations include dismissing risky users.", + "displayName": "Read and write all risky user information", + "id": "656f6061-f9fe-4807-9708-6a2e0934df76", + "origin": "Application (Microsoft Graph)", + "value": "IdentityRiskyUser.ReadWrite.All" }, { - "description": "Allows the app to read the signed-in user's files.", - "displayName": "Read user files", - "id": "10465720-29dd-4523-a11a-6a75c743c9d9", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read your files.", - "userConsentDisplayName": "Read your files", - "value": "Files.Read" + "description": "Allows the app to read your organization's user flows, without a signed-in user.", + "displayName": "Read all identity user flows", + "id": "1b0c317f-dd31-4305-9932-259a8b6e8099", + "origin": "Application (Microsoft Graph)", + "value": "IdentityUserFlow.Read.All" }, { - "description": "Allows the app to read, create, update and delete the signed-in user's files.", - "displayName": "Have full access to user files", - "id": "5c28f0bf-8a70-41f1-8ab2-9032436ddb65", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read, create, update, and delete your files.", - "userConsentDisplayName": "Have full access to your files", - "value": "Files.ReadWrite" + "description": "Allows the app to read or write your organization's user flows, without a signed-in user.", + "displayName": "Read and write all identity user flows", + "id": "65319a09-a2be-469d-8782-f6b07debf789", + "origin": "Application (Microsoft Graph)", + "value": "IdentityUserFlow.ReadWrite.All" }, { - "description": "Allows the app to read all files the signed-in user can access.", - "displayName": "Read all files that user can access", - "id": "df85f4d6-205c-4ac5-a5ea-6bf408dba283", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read all files you can access.", - "userConsentDisplayName": "Read all files that you have access to", - "value": "Files.Read.All" + "description": "Allows the app to read basic service and resource information without a signed-in user.", + "displayName": "View basic service and resource information", + "id": "4f5ac95f-62fd-472c-b60f-125d24ca0bc5", + "origin": "Application (Microsoft Graph)", + "value": "IndustryData.ReadBasic.All" + }, + { + "description": "Allows the app to read data connectors without a signed-in user.", + "displayName": "View data connector definitions", + "id": "7ab52c2f-a2ee-4d98-9ebc-725e3934aae2", + "origin": "Application (Microsoft Graph)", + "value": "IndustryData-DataConnector.Read.All" }, { - "description": "Allows the app to read, create, update and delete all files the signed-in user can access.", - "displayName": "Have full access to all files user can access", - "id": "863451e7-0667-486c-a5d6-d135439485f0", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read, create, update and delete all files that you can access.", - "userConsentDisplayName": "Have full access to all files you have access to", - "value": "Files.ReadWrite.All" + "description": "Allows the app to read and write data connectors without a signed-in user.", + "displayName": "Manage data connector definitions", + "id": "eda0971c-482e-4345-b28f-69c309cb8a34", + "origin": "Application (Microsoft Graph)", + "value": "IndustryData-DataConnector.ReadWrite.All" }, { - "description": "Allows the application to read documents and list items in all site collections on behalf of the signed-in user", - "displayName": "Read items in all site collections", - "id": "205e70e5-aba6-4c52-a976-6d2d46c48043", - "Origin": "Delegated", - "userConsentDescription": "Allow the application to read documents and list items in all site collections on your behalf", - "userConsentDisplayName": "Read items in all site collections", - "value": "Sites.Read.All" + "description": "Allows the app to upload data files to a data connector without a signed-in user.", + "displayName": "Upload files to a data connector", + "id": "9334c44b-a7c6-4350-8036-6bf8e02b4c1f", + "origin": "Application (Microsoft Graph)", + "value": "IndustryData-DataConnector.Upload" }, { - "description": "Allows users to sign in to the app with their work or school accounts and allows the app to see basic user profile information.", - "displayName": "Sign users in", - "id": "37f7f235-527c-4136-accd-4a02d197296e", - "Origin": "Delegated", - "userConsentDescription": "Allows you to sign in to the app with your work or school account and allows the app to read your basic profile information.", - "userConsentDisplayName": "Sign in as you", - "value": "openid" + "description": "Allows the app to read inbound data flows without a signed-in user.", + "displayName": "View inbound flow definitions", + "id": "305f6ba2-049a-4b1b-88bb-fe7e08758a00", + "origin": "Application (Microsoft Graph)", + "value": "IndustryData-InboundFlow.Read.All" }, { - "description": "Allows the app to read your users' primary email address", - "displayName": "View users' email address", - "id": "64a6cdd6-aab1-4aaf-94b8-3cc8405e90d0", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read your primary email address", - "userConsentDisplayName": "View your email address", - "value": "email" + "description": "Allows the app to read and write inbound data flows without a signed-in user.", + "displayName": "Manage inbound flow definitions", + "id": "e688c61f-d4c6-4d64-a197-3bcf6ba1d6ad", + "origin": "Application (Microsoft Graph)", + "value": "IndustryData-InboundFlow.ReadWrite.All" }, { - "description": "Allows the app to read identity risk event information for all users in your organization on behalf of the signed-in user. ", - "displayName": "Read identity risk event information", - "id": "8f6a01e7-0391-4ee5-aa22-a3af122cef27", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read identity risk event information for all users in your organization on behalf of the signed-in user. ", - "userConsentDisplayName": "Read identity risk event information", - "value": "IdentityRiskEvent.Read.All" + "description": "Allows the app to read your organization’s identity (authentication) providers’ properties without a signed in user.", + "displayName": "Read identity providers", + "id": "e321f0bb-e7f7-481e-bb28-e3b0b32d4bd0", + "origin": "Application (Microsoft Graph)", + "value": "IdentityProvider.Read.All" }, { - "description": "Allows the app to read the memberships of hidden groups and administrative units on behalf of the signed-in user, for those hidden groups and administrative units that the signed-in user has access to.", - "displayName": "Read hidden memberships", - "id": "f6a3db3e-f7e8-4ed2-a414-557c8c9830be", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read the memberships of hidden groups or administrative units on your behalf, for those hidden groups or adminstrative units that you have access to.", - "userConsentDisplayName": "Read your hidden memberships", - "value": "Member.Read.Hidden" + "description": "Allows the app to read and write identity notification settings, customize email templates, and send test emails without a signed-in user.", + "displayName": "Read and write all identity notification settings and templates", + "id": "d9fe7b9f-cb27-4289-9cb4-54debd9d3c25", + "origin": "Application (Microsoft Graph)", + "value": "IdentityNotifications.ReadWrite.All" }, { - "description": "Allows the app to read a ranked list of relevant people of the signed-in user. The list includes local contacts, contacts from social networking, your organization's directory, and people from recent communications (such as email and Skype).", - "displayName": "Read users' relevant people lists", - "id": "ba47897c-39ec-4d83-8086-ee8256fa737d", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read a list of people in the order that's most relevant to you. This includes your local contacts, your contacts from social networking, people listed in your organization's directory, and people from recent communications.", - "userConsentDisplayName": "Read your relevant people list", - "value": "People.Read" + "description": "Allows the app to read identity notification settings, email templates, and prerequisites without a signed-in user.", + "displayName": "Read all identity notification settings and templates", + "id": "52ced3dd-dbb6-41a0-9ce5-61a056be97b8", + "origin": "Application (Microsoft Graph)", + "value": "IdentityNotifications.Read.All" }, { - "description": "Allows the application to create or delete document libraries and lists in all site collections on behalf of the signed-in user.", - "displayName": "Create, edit, and delete items and lists in all site collections", - "id": "65e50fdc-43b7-4915-933e-e8138f11f40a", - "Origin": "Delegated", - "userConsentDescription": "Allow the application to create or delete document libraries and lists in all site collections on your behalf.", - "userConsentDisplayName": "Create, edit, and delete items and lists in all your site collections", - "value": "Sites.Manage.All" + "description": "Allows the app to read and write all scenario monitoring alerts, without a signed-in user.", + "displayName": "Read and write all scenario monitoring alerts", + "id": "432e76f0-8af6-4315-a853-66ab9538f480", + "origin": "Application (Microsoft Graph)", + "value": "HealthMonitoringAlertConfig.ReadWrite.All" }, { - "description": "Allows the application to have full control of all site collections on behalf of the signed-in user.", - "displayName": "Have full control of all site collections", - "id": "5a54b8b3-347c-476d-8f8e-42d5c7424d29", - "Origin": "Delegated", - "userConsentDescription": "Allow the application to have full control of all site collections on your behalf.", - "userConsentDisplayName": "Have full control of all your site collections", - "value": "Sites.FullControl.All" + "description": "Allows the application to utilize the file storage container platform to manage containers, without a signed-in user. The specific file storage containers and the permissions granted to them will be configured in Microsoft 365 by the developer of each container type.", + "displayName": "Access selected file storage containers", + "id": "40dc41bc-0f7e-42ff-89bd-d9516947e474", + "origin": "Application (Microsoft Graph)", + "value": "FileStorageContainer.Selected" }, { - "description": "Allows the app to read and write your organization\u2019s identity (authentication) providers\u2019 properties on behalf of the user.", - "displayName": "Read and write identity providers", - "id": "f13ce604-1677-429f-90bd-8a10b9f01325", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write your organization\u2019s identity (authentication) providers\u2019 properties on your behalf.", - "userConsentDisplayName": "Read and write identity providers", - "value": "IdentityProvider.ReadWrite.All" + "description": "Allows the application to manage file storage container type registrations without a signed-in user.", + "displayName": "Access selected file storage container type registrations", + "id": "2dcc6599-bd30-442b-8f11-90f88ad441dc", + "origin": "Application (Microsoft Graph)", + "value": "FileStorageContainerTypeReg.Selected" }, { - "description": "Allows the app to read your organization\u2019s identity (authentication) providers\u2019 properties on behalf of the user.", - "displayName": "Read identity providers", - "id": "43781733-b5a7-4d1b-98f4-e8edff23e1a9", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read your organization\u2019s identity (authentication) providers\u2019 properties on your behalf.", - "userConsentDisplayName": "Read identity providers", - "value": "IdentityProvider.Read.All" + "description": "Allows the app to create groups without a signed-in user.", + "displayName": "Create groups", + "id": "bf7b1a76-6e77-406b-b258-bf5c7720e98f", + "origin": "Application (Microsoft Graph)", + "value": "Group.Create" }, { - "description": "Allows an app to read bookings appointments, businesses, customers, services, and staff on behalf of the signed-in user.", - "displayName": "Read bookings information", - "id": "33b1df99-4b29-4548-9339-7a7b83eaeebc", - "Origin": "Delegated", - "userConsentDescription": "Allows an app to read bookings appointments, businesses, customers, services, and staff on your behalf.", - "userConsentDisplayName": "Read bookings information", - "value": "Bookings.Read.All" + "description": "Allows the app to list groups, and to read their basic properties and manage the MIP label for all label enabled groups without a signed-in user.", + "displayName": "Manage the Microsoft Information Protection (MIP) label for M365 and security groups.", + "id": "60f8cea0-2476-45c9-ab18-70e79e60ad14", + "origin": "Application (Microsoft Graph)", + "value": "Group.ManageProtection.All" }, { - "description": "Allows an app to read and write bookings appointments and customers, and additionally allows read businesses information, services, and staff on behalf of the signed-in user.", - "displayName": "Read and write booking appointments", - "id": "02a5a114-36a6-46ff-a102-954d89d9ab02", - "Origin": "Delegated", - "userConsentDescription": "Allows an app to read and write bookings appointments and customers, and additionally allows read businesses information, services, and staff on your behalf.", - "userConsentDisplayName": "Read and write booking appointments", - "value": "BookingsAppointment.ReadWrite.All" + "description": "Allows the app to read group properties and memberships, and read conversations for all groups, without a signed-in user.", + "displayName": "Read all groups", + "id": "5b567255-7703-4780-807c-7be8301ae99b", + "origin": "Application (Microsoft Graph)", + "value": "Group.Read.All" }, { - "description": "Allows an app to read and write bookings appointments, businesses, customers, services, and staff on behalf of the signed-in user. Does not allow create, delete and publish of booking businesses.", - "displayName": "Read and write bookings information", - "id": "948eb538-f19d-4ec5-9ccc-f059e1ea4c72", - "Origin": "Delegated", - "userConsentDescription": "Allows an app to read and write Bookings appointments, businesses, customers, services, and staff on your behalf. Does not allow create, delete and publish of booking businesses.", - "userConsentDisplayName": "Read and write bookings information", - "value": "Bookings.ReadWrite.All" + "description": "Allows the app to create groups, read all group properties and memberships, update group properties and memberships, and delete groups. Also allows the app to read and write conversations. All of these operations can be performed by the app without a signed-in user.", + "displayName": "Read and write all groups", + "id": "62a82d76-70ea-41e2-9197-370581804d09", + "origin": "Application (Microsoft Graph)", + "value": "Group.ReadWrite.All" }, { - "description": "Allows an app to read, write and manage bookings appointments, businesses, customers, services, and staff on behalf of the signed-in user.", - "displayName": "Manage bookings information", - "id": "7f36b48e-542f-4d3b-9bcb-8406f0ab9fdb", - "Origin": "Delegated", - "userConsentDescription": "Allows an app to read, write and manage bookings appointments, businesses, customers, services, and staff on your behalf.", - "userConsentDisplayName": "Manage bookings information", - "value": "Bookings.Manage.All" + "description": "Allows the app to read conversations of the groups this app has access to without a signed-in user.", + "displayName": "Read all group conversations", + "id": "4f0a8235-6f6f-4ec7-9500-34b452a4a0c3", + "origin": "Application (Microsoft Graph)", + "value": "Group-Conversation.Read.All" }, { - "description": "Allows the app to have the same access to mailboxes as the signed-in user via Exchange ActiveSync.", - "displayName": "Access mailboxes via Exchange ActiveSync", - "id": "ff91d191-45a0-43fd-b837-bd682c4a0b0f", - "Origin": "Delegated", - "userConsentDescription": "Allows the app full access to your mailboxes on your behalf.", - "userConsentDisplayName": "Access your mailboxes", - "value": "EAS.AccessAsUser.All" + "description": "Allows the app to read and write conversations of the groups this app has access to without a signed-in user.", + "displayName": "Read and write all group conversations", + "id": "6679c91b-820a-4900-ab47-e97b197a89c4", + "origin": "Application (Microsoft Graph)", + "value": "Group-Conversation.ReadWrite.All" }, { - "description": "Allows the app to read and write financials data on behalf of the signed-in user.", - "displayName": "Read and write financials data", - "id": "f534bf13-55d4-45a9-8f3c-c92fe64d6131", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write financials data on your behalf.", - "userConsentDisplayName": "Read and write financials data", - "value": "Financials.ReadWrite.All" + "description": "Allows the app to read and write outbound data flows without a signed-in user.", + "displayName": "Manage outbound flow definitions", + "id": "24a65b4a-e501-47e2-8849-d679517887f0", + "origin": "Application (Microsoft Graph)", + "value": "IndustryData-OutboundFlow.ReadWrite.All" }, { - "description": "Allows the app to read your organization's user flows, on behalf of the signed-in user.", - "displayName": "Read all identity user flows", - "id": "2903d63d-4611-4d43-99ce-a33f3f52e343", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read your organization's user flows, on your behalf.", - "userConsentDisplayName": "Read all identity user flows", - "value": "IdentityUserFlow.Read.All" + "description": "Allows the app to read memberships and basic group properties for all groups without a signed-in user.", + "displayName": "Read all group memberships", + "id": "98830695-27a2-44f7-8c18-0c3ebc9698f6", + "origin": "Application (Microsoft Graph)", + "value": "GroupMember.Read.All" }, { - "description": "Allows the app to read or write your organization's user flows, on behalf of the signed-in user.", - "displayName": "Read and write all identity user flows", - "id": "281892cc-4dbf-4e3a-b6cc-b21029bb4e82", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read or write your organization's user flows, on your behalf.", - "userConsentDisplayName": "Read and write all identity user flows", - "value": "IdentityUserFlow.ReadWrite.All" + "description": "Allows the app to read and write groups' disableNesting property without a signed-in user.", + "displayName": "Read and write groups' disableNesting property", + "id": "2d53948b-d2c5-4008-9c4e-6361bf192555", + "origin": "Application (Microsoft Graph)", + "value": "Group-NestingSupport.ReadWrite.All" }, { - "description": "Allows the app to read all organizational contacts on behalf of the signed-in user. \u00a0These contacts are managed by the organization and are different from a user's personal contacts.", - "displayName": "Read organizational contacts", - "id": "08432d1b-5911-483c-86df-7980af5cdee0", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read all organizational contacts on your behalf.\u00a0 These contacts are managed by the organization and are different from your personal contacts.", - "userConsentDisplayName": "Read organizational contacts", - "value": "OrgContact.Read.All" + "description": "Allows the app to update the on-premises sync behavior of all groups without a signed-in user.", + "displayName": "Read and update the on-premises sync behavior of groups", + "id": "2d9bd318-b883-40be-9df7-63ec4fcdc424", + "origin": "Application (Microsoft Graph)", + "value": "Group-OnPremisesSyncBehavior.ReadWrite.All" }, { - "description": "Allows the app to manage permission grants for application permissions to any API (including Microsoft Graph) and application assignments for any app, on behalf of the signed-in user.", - "displayName": "Manage app permission grants and app role assignments", - "id": "84bccea3-f856-4a8a-967b-dbe0a3d53a64", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to manage permission grants for application permissions to any API (including Microsoft Graph) and application assignments for any app, on your behalf.", - "userConsentDisplayName": "Manage app permission grants and app role assignments", - "value": "AppRoleAssignment.ReadWrite.All" + "description": "Allows the app to read a list of tenant-level or group-specific group settings objects, without a signed-in user.", + "displayName": "Read all group settings", + "id": "f3c4f514-c65a-43f5-bfce-1735872258dd", + "origin": "Application (Microsoft Graph)", + "value": "GroupSettings.Read.All" }, { - "description": "Allows the app to manage permission grants for delegated permissions exposed by any API (including Microsoft Graph), on behalf of the signed in user.", - "displayName": "Manage all delegated permission grants", - "id": "41ce6ca6-6826-4807-84f1-1c82854f7ee5", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to manage permission grants for delegated permissions exposed by any API (including Microsoft Graph), on your behalf. ", - "userConsentDisplayName": "Manage all delegated permission grants", - "value": "DelegatedPermissionGrant.ReadWrite.All" + "description": "Allows the app to create, read, update, and delete on the list of tenant-level or group-specific group settings objects, without a signed-in user.", + "displayName": "Read and write all group settings", + "id": "546168c3-1183-4281-9491-fafb24dea37e", + "origin": "Application (Microsoft Graph)", + "value": "GroupSettings.ReadWrite.All" }, { - "description": "Allows the app to read online meeting details on behalf of the signed-in user.", - "displayName": "Read user's online meetings", - "id": "9be106e1-f4e3-4df5-bdff-e4bc531cbe43", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read online meeting details on your behalf.", - "userConsentDisplayName": "Read your online meetings", - "value": "OnlineMeetings.Read" + "description": "Allows the app to read all Cross-Tenant Identity Synchronization properties on Groups, without a signed-in user.", + "displayName": "Read all Group Cross-Tenant Identity Synchronization properties", + "id": "35b96aac-d839-4362-abd4-7381f2b27ccd", + "origin": "Application (Microsoft Graph)", + "value": "Group-XTenantIdentitySync.Read.All" }, { - "description": "Allows the app to read and create online meetings on behalf of the signed-in user.", - "displayName": "Read and create user's online meetings", - "id": "a65f2972-a4f8-4f5e-afd7-69ccb046d5dc", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and create online meetings on your behalf.", - "userConsentDisplayName": "Read and create your online meetings", - "value": "OnlineMeetings.ReadWrite" + "description": "Allows the app to read all scenario health monitoring alerts, without a signed-in user.", + "displayName": "Read all scenario health monitoring alert", + "id": "5183ed5d-b7f8-4e9a-915e-dafb46b9cb62", + "origin": "Application (Microsoft Graph)", + "value": "HealthMonitoringAlert.Read.All" }, { - "description": "Allows the app to read the signed-in user's teamwork activity feed.", - "displayName": "Read user's teamwork activity feed", - "id": "0e755559-83fb-4b44-91d0-4cc721b9323e", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read your teamwork activity feed.", - "userConsentDisplayName": "Read your teamwork activity feed", - "value": "TeamsActivity.Read" + "description": "Allows the app to read and write all scenario monitoring alerts, without a signed-in user.", + "displayName": "Read and write all scenario monitoring alerts", + "id": "ac29eb50-f2f9-4518-a117-4bef18e84c7d", + "origin": "Application (Microsoft Graph)", + "value": "HealthMonitoringAlert.ReadWrite.All" }, { - "description": "Allows the app to request and manage time-based assignment and just-in-time elevation of user privileges to manage Azure resources (like subscriptions, resource groups, storage, compute) on behalf of the signed-in users.", - "displayName": "Read and write privileged access to Azure resources", - "id": "a84a9652-ffd3-496e-a991-22ba5529156a", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to request and manage time-based assignment and just-in-time elevation of user privileges to manage \u00a0your Azure resources (like your subscriptions, resource groups, storage, compute) on your behalf.", - "userConsentDisplayName": "Read and write privileged access to Azure resources", - "value": "PrivilegedAccess.ReadWrite.AzureResources" + "description": "Allows the app to read all scenario health monitoring alert configurations, without a signed-in user.", + "displayName": "Read all scenario health monitoring alert configurations", + "id": "bb424d73-e898-4c97-9d42-688c32810003", + "origin": "Application (Microsoft Graph)", + "value": "HealthMonitoringAlertConfig.Read.All" }, { - "description": "Allows the app to read time-based assignment and just-in-time elevation (including scheduled elevation) of Azure AD built-in and custom administrative roles, on behalf of the signed-in user.", - "displayName": "Read privileged access to Azure AD", - "id": "b3a539c9-59cb-4ad5-825a-041ddbdc2bdb", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read time-based assignment and just-in-time elevation (including scheduled elevation) of Azure AD built-in and custom administrative roles, on your behalf.", - "userConsentDisplayName": "Read privileged access to Azure AD", - "value": "PrivilegedAccess.Read.AzureAD" + "description": "Allows the app to list groups, read basic properties, read and update the membership of the groups this app has access to without a signed-in user. Group properties and owners cannot be updated and groups cannot be deleted.", + "displayName": "Read and write all group memberships", + "id": "dbaae8cf-10b5-4b86-a4a1-f871c94c6695", + "origin": "Application (Microsoft Graph)", + "value": "GroupMember.ReadWrite.All" }, { - "description": "Allows the app to read time-based assignment and just-in-time elevation (including scheduled elevation) of Azure AD groups, on behalf of the signed-in user.", - "displayName": "Read privileged access to Azure AD groups", - "id": "d329c81c-20ad-4772-abf9-3f6fdb7e5988", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read time-based assignment and just-in-time elevation (including scheduled elevation) of Azure AD groups, on your behalf.", - "userConsentDisplayName": "Read privileged access to Azure AD groups", - "value": "PrivilegedAccess.Read.AzureADGroup" + "description": "Allows the app to read all the short notes without a signed-in user.", + "displayName": "Read all users' short notes", + "id": "0c7d31ec-31ca-4f58-b6ec-9950b6b0de69", + "origin": "Application (Microsoft Graph)", + "value": "ShortNotes.Read.All" }, { - "description": "Allows the app to read time-based assignment and just-in-time elevation of Azure resources (like your subscriptions, resource groups, storage, compute) on behalf of the signed-in user.", - "displayName": "Read privileged access to Azure resources", - "id": "1d89d70c-dcac-4248-b214-903c457af83a", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read time-based assignment and just-in-time elevation of Azure resources (like your subscriptions, resource groups, storage, compute) on your behalf.", - "userConsentDisplayName": "Read privileged access to your Azure resources", - "value": "PrivilegedAccess.Read.AzureResources" + "description": "Allows the app to read and write organization-wide Dynamics customer voice settings, without a signed-in user.", + "displayName": "Read and write organization-wide Dynamics customer voice settings", + "id": "c3f1cc32-8bbd-4ab6-bd33-f270e0d9e041", + "origin": "Application (Microsoft Graph)", + "value": "OrgSettings-DynamicsVoice.ReadWrite.All" }, { - "description": "Allows the app to request and manage time-based assignment and just-in-time elevation (including scheduled elevation) of Azure AD groups, on behalf of the signed-in user.", - "displayName": "Read and write privileged access to Azure AD groups", - "id": "32531c59-1f32-461f-b8df-6f8a3b89f73b", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to request and manage time-based assignment and just-in-time elevation (including scheduled elevation) of Azure AD groups, on your behalf.", - "userConsentDisplayName": "Read and write privileged access to Azure AD groups", - "value": "PrivilegedAccess.ReadWrite.AzureADGroup" + "description": "Allows the app to read and write organization-wide Microsoft Forms settings, without a signed-in user.", + "displayName": "Read and write organization-wide Microsoft Forms settings", + "id": "2cb92fee-97a3-4034-8702-24a6f5d0d1e9", + "origin": "Application (Microsoft Graph)", + "value": "OrgSettings-Forms.ReadWrite.All" }, { - "description": "Allows the app to read all the indicators for your organization, on behalf of the signed-in user.", - "displayName": "Read all threat indicators", - "id": "9cc427b4-2004-41c5-aa22-757b755e9796", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read all the indicators for your organization, on your behalf.", - "userConsentDisplayName": "Read all threat indicators", - "value": "ThreatIndicators.Read.All" + "description": "Allows the app to read the role-based access control (RBAC) settings for your company's directory, without a signed-in user.", + "displayName": "Read M365 Defender RBAC configuration", + "id": "4d6e30d1-e64e-4ae7-bf9d-c706cc928cef", + "origin": "Application (Microsoft Graph)", + "value": "RoleManagement.Read.Defender" }, { - "description": "Allow the app to read external datasets and content, on behalf of the signed-in user.", - "displayName": "Read items in external datasets", - "id": "922f9392-b1b7-483c-a4be-0089be7704fb", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read external datasets and content that you have access to.", - "userConsentDisplayName": "Read items in external datasets", - "value": "ExternalItem.Read.All" + "description": "Allows the app to read the role-based access control (RBAC) settings for your company's directory, without a signed-in user. This includes reading directory role templates, directory roles and memberships.", + "displayName": "Read all directory RBAC settings", + "id": "483bed4a-2ad3-4361-a73b-c83ccdbdc53c", + "origin": "Application (Microsoft Graph)", + "value": "RoleManagement.Read.Directory" }, { - "description": "Allows an app to edit channel messages in Microsoft Teams, on behalf of the signed-in user.", - "displayName": "Edit user's channel messages", - "id": "2b61aa8a-6d36-4b2f-ac7b-f29867937c53", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to edit channel messages in Microsoft Teams, on your behalf.", - "userConsentDisplayName": "Edit your channel messages", - "value": "ChannelMessage.Edit" + "description": "Allows the app to read the role-based access control (RBAC) configuration for your organization's Exchange Online service, without a signed-in user. This includes reading Exchange management role definitions, role groups, role group membership, role assignments, management scopes, and role assignment policies.", + "displayName": "Read Exchange Online RBAC configuration", + "id": "c769435f-f061-4d0b-8ff1-3d39870e5f85", + "origin": "Application (Microsoft Graph)", + "value": "RoleManagement.Read.Exchange" }, { - "description": "Allows an app to send channel messages in Microsoft Teams, on behalf of the signed-in user.", - "displayName": "Send channel messages", - "id": "ebf0f66e-9fb1-49e4-a278-222f76911cf4", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to send channel messages in Microsoft Teams, on your behalf.", - "userConsentDisplayName": "Send channel messages", - "value": "ChannelMessage.Send" + "description": "Allows the app to read and manage the Cloud PC role-based access control (RBAC) settings, without a signed-in user. This includes reading and managing Cloud PC role definitions and memberships.", + "displayName": "Read and write all Cloud PC RBAC settings", + "id": "274d0592-d1b6-44bd-af1d-26d259bcb43a", + "origin": "Application (Microsoft Graph)", + "value": "RoleManagement.ReadWrite.CloudPC" }, { - "description": "Allows the app to manage organization places (conference rooms and room lists) for calendar events and other applications, on behalf of the signed-in user.", - "displayName": "Read and write organization places", - "id": "4c06a06a-098a-4063-868e-5dfee3827264", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to manage organization places (conference rooms and room lists) for calendar events and other applications, on your behalf.", - "userConsentDisplayName": "Read and write organization places", - "value": "Place.ReadWrite.All" + "description": "Allows the app to read the role-based access control (RBAC) settings for your company's directory, without a signed-in user.", + "displayName": "Read M365 Defender RBAC configuration", + "id": "8b7e8c0a-7e9d-4049-97ec-04b5e1bcaf05", + "origin": "Application (Microsoft Graph)", + "value": "RoleManagement.ReadWrite.Defender" }, { - "description": "Allows the app to request access to and management of access packages and related entitlement management resources on behalf of the signed-in user.", - "displayName": "Read and write entitlement management resources", - "id": "ae7a573d-81d7-432b-ad44-4ed5c9d89038", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to request access to and management of access packages and related entitlement management resources that you have access to.", - "userConsentDisplayName": "Read and write entitlement management resources", - "value": "EntitlementManagement.ReadWrite.All" + "description": "Allows the app to read and manage the role-based access control (RBAC) settings for your company's directory, without a signed-in user. This includes instantiating directory roles and managing directory role membership, and reading directory role templates, directory roles and memberships.", + "displayName": "Read and write all directory RBAC settings", + "id": "9e3f62cf-ca93-4989-b6ce-bf83c28f9fe8", + "origin": "Application (Microsoft Graph)", + "value": "RoleManagement.ReadWrite.Directory" }, { - "description": "Allows the app to send, read, update and delete user\u2019s notifications.", - "displayName": "Deliver and manage user's notifications", - "id": "26e2f3e8-b2a1-47fc-9620-89bb5b042024", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to send, read, update and delete your app-specific notifications.", - "userConsentDisplayName": "Deliver and manage your notifications", - "value": "UserNotification.ReadWrite.CreatedByApp" + "description": "Allows the app to read and manage the role-based access control (RBAC) settings for your organization's Exchange Online service, without a signed-in user. This includes reading, creating, updating, and deleting Exchange management role definitions, role groups, role group membership, role assignments, management scopes, and role assignment policies.", + "displayName": "Read and write Exchange Online RBAC configuration", + "id": "025d3225-3f02-4882-b4c0-cd5b541a4e80", + "origin": "Application (Microsoft Graph)", + "value": "RoleManagement.ReadWrite.Exchange" }, { - "description": "Allows the app to read applications and service principals on behalf of the signed-in user.", - "displayName": "Read applications", - "id": "c79f8feb-a9db-4090-85f9-90d820caa0eb", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read applications and service principals on your behalf.", - "userConsentDisplayName": "Read applications", - "value": "Application.Read.All" + "description": "Allows the app to read all role-based access control (RBAC) alerts for your company's directory, without a signed-in user. This includes reading alert statuses, alert definitions, alert configurations and incidents that lead to an alert.", + "displayName": "Read all alert data for your company's directory", + "id": "ef31918f-2d50-4755-8943-b8638c0a077e", + "origin": "Application (Microsoft Graph)", + "value": "RoleManagementAlert.Read.Directory" }, { - "description": "Allows the app to create, read, update and delete applications and service principals on behalf of the signed-in user. Does not allow management of consent grants.", - "displayName": "Read and write all applications", - "id": "bdfbf15f-ee85-4955-8675-146e8e5296b5", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to create, read, update and delete applications and service principals on your behalf. Does not allow management of consent grants.", - "userConsentDisplayName": "Read and write applications", - "value": "Application.ReadWrite.All" + "description": "Allows the app to read and manage all role-based access control (RBAC) alerts for your company's directory, without a signed-in user. This includes managing alert settings, initiating alert scans, dismissing alerts, remediating alert incidents, and reading alert statuses, alert definitions, alert configurations and incidents that lead to an alert.", + "displayName": "Read all alert data, configure alerts, and take actions on all alerts for your company's directory", + "id": "11059518-d6a6-4851-98ed-509268489c4a", + "origin": "Application (Microsoft Graph)", + "value": "RoleManagementAlert.ReadWrite.Directory" }, { - "description": "Allows the app to read BitLocker keys on behalf of the signed-in user, for their owned devices. Allows read of the recovery key.", - "displayName": "Read BitLocker keys", - "id": "b27a61ec-b99c-4d6a-b126-c4375d08ae30", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read BitLocker keys for your owned devices. Allows read of the recovery key.", - "userConsentDisplayName": "Read your BitLocker keys", - "value": "BitlockerKey.Read.All" + "description": "Allows the app to read policies in Privileged Identity Management for Groups, without a signed-in user.", + "displayName": "Read all policies in PIM for Groups", + "id": "69e67828-780e-47fd-b28c-7b27d14864e6", + "origin": "Application (Microsoft Graph)", + "value": "RoleManagementPolicy.Read.AzureADGroup" }, { - "description": "Allows the app to read basic BitLocker key properties on behalf of the signed-in user, for their owned devices. Does not allow read of the recovery key itself.", - "displayName": "Read BitLocker keys basic information", - "id": "5a107bfc-4f00-4e1a-b67e-66451267bc68", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read basic BitLocker key properties for your owned devices. Does not allow read of the recovery key itself.", - "userConsentDisplayName": "Read your BitLocker keys basic information", - "value": "BitlockerKey.ReadBasic.All" + "description": "Allows the app to read policies for privileged role-based access control (RBAC) assignments of your company's directory, without a signed-in user.", + "displayName": "Read all policies for privileged role assignments of your company's directory", + "id": "fdc4c997-9942-4479-bfcb-75a36d1138df", + "origin": "Application (Microsoft Graph)", + "value": "RoleManagementPolicy.Read.Directory" }, { - "description": "Allows the app to list groups, read basic group properties and read membership of all groups the signed-in user has access to.", - "displayName": "Read group memberships", - "id": "bc024368-1153-4739-b217-4326f2e966d0", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to list groups, read basic group properties and read membership of all your groups.", - "userConsentDisplayName": "Read group memberships", - "value": "GroupMember.Read.All" + "description": "Allows the app to read policies in Privileged Identity Management for App Roles, without a signed-in user.", + "displayName": "Read all policies in PIM for App Roles", + "id": "3d201a4e-90f1-420d-bd4f-3beec28a46b9", + "origin": "Application (Microsoft Graph)", + "value": "RoleManagementPolicy.Read.EntraAppRole" }, { - "description": "Allows the app to list groups, read basic properties, read and update the membership of the groups the signed-in user has access to. Group properties and owners cannot be updated and groups cannot be deleted.", - "displayName": "Read and write group memberships", - "id": "f81125ac-d3b7-4573-a3b2-7099cc39df9e", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to list groups, read basic properties, read and update the membership of your groups. Group properties and owners cannot be updated and groups cannot be deleted.", - "userConsentDisplayName": "Read and write group memberships", - "value": "GroupMember.ReadWrite.All" + "description": "Allows the app to read, update, and delete policies in Privileged Identity Management for Groups, without a signed-in user.", + "displayName": "Read, update, and delete all policies in PIM for Groups", + "id": "b38dcc4d-a239-4ed6-aa84-6c65b284f97c", + "origin": "Application (Microsoft Graph)", + "value": "RoleManagementPolicy.ReadWrite.AzureADGroup" }, { - "description": "Allows an app to read your organization's threat assessment requests on behalf of the signed-in user. Also allows the app to create new requests to assess threats received by your organization on behalf of the signed-in user.", - "displayName": "Read and write threat assessment requests", - "id": "cac97e40-6730-457d-ad8d-4852fddab7ad", - "Origin": "Delegated", - "userConsentDescription": "Allows an app to read your organization's threat assessment requests on your behalf. Also allows the app to create new requests to assess threats received by your organization on your behalf.", - "userConsentDisplayName": "Read and write threat assessment requests", - "value": "ThreatAssessment.ReadWrite.All" + "description": "Allows the app to read, update, and delete policies for privileged role-based access control (RBAC) assignments of your company's directory, without a signed-in user.", + "displayName": "Read, update, and delete all policies for privileged role assignments of your company's directory", + "id": "31e08e0a-d3f7-4ca2-ac39-7343fb83e8ad", + "origin": "Application (Microsoft Graph)", + "value": "RoleManagementPolicy.ReadWrite.Directory" }, { - "description": "Allows the app to read schedule, schedule groups, shifts and associated entities in the Teams or Shifts application on behalf of the signed-in user.", - "displayName": "Read user schedule items", - "id": "fccf6dd8-5706-49fa-811f-69e2e1b585d0", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read schedule, schedule groups, shifts and associated entities in the Teams or Shifts application on your behalf.", - "userConsentDisplayName": "Read your schedule items", - "value": "Schedule.Read.All" + "description": "Allows the app to manage policies in Privileged Identity Management for App Roles, without a signed-in user.", + "displayName": "Manage all policies in PIM for App Roles", + "id": "ec563bdb-80dc-47c0-81d3-bff47cc6ac06", + "origin": "Application (Microsoft Graph)", + "value": "RoleManagementPolicy.ReadWrite.EntraAppRole" }, { - "description": "Allows the app to manage schedule, schedule groups, shifts and associated entities in the Teams or Shifts application on behalf of the signed-in user.", - "displayName": "Read and write user schedule items", - "id": "63f27281-c9d9-4f29-94dd-6942f7f1feb0", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to manage schedule, schedule groups, shifts and associated entities in the Teams or Shifts application on your behalf.", - "userConsentDisplayName": "Read and write your schedule items", - "value": "Schedule.ReadWrite.All" + "description": "Allows the app to read all schedules, schedule groups, shifts and associated entities in the Teams or Shifts application without a signed-in user.", + "displayName": "Read all schedule items", + "id": "7b2ebf90-d836-437f-b90d-7b62722c4456", + "origin": "Application (Microsoft Graph)", + "value": "Schedule.Read.All" }, { - "description": " Allows the app to read and write authentication methods of all users in your organization that the signed-in user has access to. Authentication methods include things like a user\u2019s phone numbers and Authenticator app settings. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", - "displayName": "Read and write all users' authentication methods.", - "id": "b7887744-6746-4312-813d-72daeaee7e2d", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write authentication methods of all users you have access to in your organization. Authentication methods include things like a user\u2019s phone numbers and Authenticator app settings. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", - "userConsentDisplayName": "Read and write all users' authentication methods", - "value": "UserAuthenticationMethod.ReadWrite.All" + "description": "Allows the app to manage all schedules, schedule groups, shifts and associated entities in the Teams or Shifts application without a signed-in user.", + "displayName": "Read and write all schedule items", + "id": "b7760610-0545-4e8a-9ec3-cce9e63db01c", + "origin": "Application (Microsoft Graph)", + "value": "Schedule.ReadWrite.All" }, { - "description": "Allows the app to read and write the signed-in user's authentication methods, including phone numbers and Authenticator app settings. This does not allow the app to see secret information like the signed-in user's passwords, or to sign-in or otherwise use the signed-in user's authentication methods. ", - "displayName": "Read and write user authentication methods", - "id": "48971fc1-70d7-4245-af77-0beb29b53ee2", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write your authentication methods, including phone numbers and Authenticator app settings.This does not allow the app to see secret information like your passwords, or to sign-in or otherwise use your authentication methods.", - "userConsentDisplayName": "Read and write your authentication methods", - "value": "UserAuthenticationMethod.ReadWrite" + "description": "Allows the app to read the Cloud PC role-based access control (RBAC) settings, without a signed-in user.", + "displayName": "Read Cloud PC RBAC settings", + "id": "031a549a-bb80-49b6-8032-2068448c6a3c", + "origin": "Application (Microsoft Graph)", + "value": "RoleManagement.Read.CloudPC" }, { - "description": "Allows the app to read authentication methods of all users in your organization that the signed-in user has access to. Authentication methods include things like a user\u2019s phone numbers and Authenticator app settings. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", - "displayName": "Read all users' authentication methods", - "id": "aec28ec7-4d02-4e8c-b864-50163aea77eb", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read authentication methods of all users you have access to in your organization. Authentication methods include things like a user\u2019s phone numbers and Authenticator app settings. This does not allow the app to see secret information like passwords, or to sign-in or otherwise use the authentication methods.", - "userConsentDisplayName": "Read all users' authentication methods", - "value": "UserAuthenticationMethod.Read.All" + "description": "Allows the app to read/write schedule permissions for a specific role in Shifts application without a signed-in user.", + "displayName": "Read/Write schedule permissions for a role", + "id": "7239b71d-b402-4150-b13d-78ecfe8df441", + "origin": "Application (Microsoft Graph)", + "value": "SchedulePermissions.ReadWrite.All" }, { - "description": "Allows the app to read the signed-in user's authentication methods, including phone numbers and Authenticator app settings. This does not allow the app to see secret information like the signed-in user's passwords, or to sign-in or otherwise use the signed-in user's authentication methods.", - "displayName": "Read user authentication methods.", - "id": "1f6b61c5-2f65-4135-9c9f-31c0f8d32b52", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read your authentication methods, including phone numbers and Authenticator app settings. This does not allow the app to see secret information like your passwords, or to sign-in or otherwise use your authentication methods.", - "userConsentDisplayName": "Read your authentication methods.", - "value": "UserAuthenticationMethod.Read" + "description": "Allows the app to read role-based access control (RBAC) settings for all RBAC providers without a signed-in user. This includes reading role definitions and role assignments.", + "displayName": "Read role management data for all RBAC providers", + "id": "c7fbd983-d9aa-4fa7-84b8-17382c103bc4", + "origin": "Application (Microsoft Graph)", + "value": "RoleManagement.Read.All" }, { - "description": "Allows the app to create tabs in any team in Microsoft Teams, on behalf of the signed-in user. This does not grant the ability to read, modify or delete tabs after they are created, or give access to the content inside the tabs.", - "displayName": "Create tabs in Microsoft Teams.", - "id": "a9ff19c2-f369-4a95-9a25-ba9d460efc8e", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to create tabs in any team in Microsoft Teams, on your behalf. This does not grant the ability to read, modify or delete tabs after they are created, or give access to the content inside the tabs.", - "userConsentDisplayName": "Create tabs in Microsoft Teams.", - "value": "TeamsTab.Create" + "description": "Allows the app to read and manage the eligible role-based access control (RBAC) assignments and schedules for your company's directory, without a signed-in user. This includes managing eligible directory role membership, and reading directory role templates, directory roles and eligible memberships.", + "displayName": "Read, update, and delete all eligible role assignments and schedules for your company's directory", + "id": "fee28b28-e1f3-4841-818e-2704dc62245f", + "origin": "Application (Microsoft Graph)", + "value": "RoleEligibilitySchedule.ReadWrite.Directory" }, { - "description": "Read the names and settings of tabs inside any team in Microsoft Teams, on behalf of the signed-in user. This does not give access to the content inside the tabs.", - "displayName": "Read tabs in Microsoft Teams.", - "id": "59dacb05-e88d-4c13-a684-59f1afc8cc98", - "Origin": "Delegated", - "userConsentDescription": "Read the names and settings of tabs inside any team in Microsoft Teams, on your behalf. This does not give access to the content inside the tabs.", - "userConsentDisplayName": "Read tabs in Microsoft Teams.", - "value": "TeamsTab.Read.All" + "description": "Allows an app to read all question and answers, without a signed-in user.", + "displayName": "Read all Question and Answers ", + "id": "ee49e170-1dd1-4030-b44c-61ad6e98f743", + "origin": "Application (Microsoft Graph)", + "value": "QnA.Read.All" }, { - "description": "Read and write tabs in any team in Microsoft Teams, on behalf of the signed-in user. This does not give access to the content inside the tabs.", - "displayName": "Read and write tabs in Microsoft Teams.", - "id": "b98bfd41-87c6-45cc-b104-e2de4f0dafb9", - "Origin": "Delegated", - "userConsentDescription": "Read and write tabs in any team in Microsoft Teams, on your behalf. This does not give access to the content inside the tabs.", - "userConsentDisplayName": "Read and write tabs in Microsoft Teams.", - "value": "TeamsTab.ReadWrite.All" + "description": "Allows the app to get direct access to real-time enriched data in a meeting, without a signed-in user.", + "displayName": "Access real-time enriched data in a meeting as an app", + "id": "abafe00f-ea87-4c63-b8a8-0e7bb0a88144", + "origin": "Application (Microsoft Graph)", + "value": "RealTimeActivityFeed.Read.All" }, { - "description": "Allows the app to have the same access to mailboxes as the signed-in user via IMAP protocol.", - "displayName": "Read and write access to mailboxes via IMAP.", - "id": "652390e4-393a-48de-9484-05f9b1212954", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read, update, create and delete email in your mailbox. Does not include permission to send mail.", - "userConsentDisplayName": "Read and write access to your mail.", - "value": "IMAP.AccessAsUser.All" + "description": "Allows the application to read any data from Records Management, such as configuration, labels, and policies without the signed in user.", + "displayName": "Read Records Management configuration, labels and policies", + "id": "ac3a2b8e-03a3-4da9-9ce0-cbe28bf1accd", + "origin": "Application (Microsoft Graph)", + "value": "RecordsManagement.Read.All" }, { - "description": "Allows the app to have the same access to mailboxes as the signed-in user via POP protocol.", - "displayName": "Read and write access to mailboxes via POP.", - "id": "d7b7f2d9-0f45-4ea1-9d42-e50810c06991", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read, update, create and delete email in your mailbox. Does not include permission to send mail.", - "userConsentDisplayName": "Read and write access to your mail.", - "value": "POP.AccessAsUser.All" + "description": "Allow the application to create, update and delete any data from Records Management, such as configuration, labels, and policies without the signed in user.", + "displayName": "Read and write Records Management configuration, labels and policies", + "id": "eb158f57-df43-4751-8b21-b8932adb3d34", + "origin": "Application (Microsoft Graph)", + "value": "RecordsManagement.ReadWrite.All" }, { - "description": "Allows the app to be able to send emails from the user\u2019s mailbox using the SMTP AUTH client submission protocol.", - "displayName": "Send emails from mailboxes using SMTP AUTH.", - "id": "258f6531-6087-4cc4-bb90-092c5fb3ed3f", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to send emails on your behalf from your mailbox.", - "userConsentDisplayName": "Access to sending emails from your mailbox.", - "value": "SMTP.Send" + "description": "Allows the app to read available properties on remoteTenantGroups, without a signed-in user.", + "displayName": "Read RemoteTenantGroups information", + "id": "faa08cc0-0dcd-4ea9-9f9c-8b16f842b36e", + "origin": "Application (Microsoft Graph)", + "value": "RemoteTenantGroups.Read.All" }, { - "description": "Allows the app to read all domain properties on behalf of the signed-in user.", - "displayName": "Read domains.", - "id": "2f9ee017-59c1-4f1d-9472-bd5529a7b311", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read all domain properties on your behalf.", - "userConsentDisplayName": "Read domains.", - "value": "Domain.Read.All" + "description": "Allows an app to read all service usage reports without a signed-in user. Services that provide usage reports include Office 365 and Azure Active Directory.", + "displayName": "Read all usage reports", + "id": "230c1aed-a721-4c5d-9cb4-a90514e508ef", + "origin": "Application (Microsoft Graph)", + "value": "Reports.Read.All" }, { - "description": "Allows the app to read and write all domain properties on behalf of the signed-in user. Also allows the app to add, verify and remove domains.", - "displayName": "Read and write domains", - "id": "0b5d694c-a244-4bde-86e6-eb5cd07730fe", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write all domain properties on your behalf. Also allows the app to add, verify and remove domains.", - "userConsentDisplayName": "Read and write domains", - "value": "Domain.ReadWrite.All" + "description": "Allows the app to read all admin report settings, such as whether to display concealed information in reports, without a signed-in user.", + "displayName": "Read all admin report settings", + "id": "ee353f83-55ef-4b78-82da-555bfa2b4b95", + "origin": "Application (Microsoft Graph)", + "value": "ReportSettings.Read.All" }, { - "description": "Allows the app to read and write your organization's application configuration policies on behalf of the signed-in user. This includes policies such as activityBasedTimeoutPolicy, claimsMappingPolicy, homeRealmDiscoveryPolicy, tokenIssuancePolicy and tokenLifetimePolicy.", - "displayName": "Read and write your organization's application configuration policies", - "id": "b27add92-efb2-4f16-84f5-8108ba77985c", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write your organization's application configuration policies on your behalf. This includes policies such as activityBasedTimeoutPolicy, claimsMappingPolicy, homeRealmDiscoveryPolicy, tokenIssuancePolicy and tokenLifetimePolicy.", - "userConsentDisplayName": "Read and write your organization's application configuration policies", - "value": "Policy.ReadWrite.ApplicationConfiguration" + "description": "Allows the app to read and update all admin report settings, such as whether to display concealed information in reports, without a signed-in user.", + "displayName": "Read and write all admin report settings", + "id": "2a60023f-3219-47ad-baa4-40e17cd02a1d", + "origin": "Application (Microsoft Graph)", + "value": "ReportSettings.ReadWrite.All" }, { - "description": "Allows the app to read your organization's devices' configuration information on behalf of the signed-in user.", - "displayName": "Read all devices", - "id": "951183d1-1a61-466f-a6d1-1fde911bfd95", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read devices' configuration information on your behalf.", - "userConsentDisplayName": "Read all devices", - "value": "Device.Read.All" + "description": "Allows the app to read the resource specific permissions granted on the chat without a signed-in user.", + "displayName": "Read resource specific permissions granted on a chat", + "id": "2ff643d8-43e4-4a9b-88c1-86cb4a4b4c2f", + "origin": "Application (Microsoft Graph)", + "value": "ResourceSpecificPermissionGrant.ReadForChat.All" }, { - "description": "Allows the app to read, update and delete identities that are associated with a user's account that the signed-in user has access to. This controls the identities users can sign-in with.", - "displayName": "Manage user identities", - "id": "637d7bec-b31e-4deb-acc9-24275642a2c9", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read, update and delete identities that are associated with a user's account that you have access to. This controls the identities users can sign-in with.", - "userConsentDisplayName": "Manage user identities", - "value": "User.ManageIdentities.All" + "description": "Allows the app to read the resource specific permissions granted on the team without a signed-in user.", + "displayName": "Read resource specific permissions granted on a team", + "id": "ad4600ae-d900-42cb-a9a2-2415d05593d0", + "origin": "Application (Microsoft Graph)", + "value": "ResourceSpecificPermissionGrant.ReadForTeam.All" }, { - "description": "Allows the app to read access packages and related entitlement management resources on behalf of the signed-in user.", - "displayName": "Read all entitlement management resources", - "id": "5449aa12-1393-4ea2-a7c7-d0e06c1a56b2", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read access packages and related entitlement management resources that you have access to.", - "userConsentDisplayName": "Read all entitlement management resources", - "value": "EntitlementManagement.Read.All" + "description": "Allows the app to read all resource specific permissions granted on user accounts, without a signed-in user.", + "displayName": "Read all resource specific permissions granted on user accounts", + "id": "acfca4d5-f49f-40ed-9648-84068b474c73", + "origin": "Application (Microsoft Graph)", + "value": "ResourceSpecificPermissionGrant.ReadForUser.All" }, { - "description": "Create channels in any team, on behalf of the signed-in user.", - "displayName": "Create channels", - "id": "101147cf-4178-4455-9d58-02b5c164e759", - "Origin": "Delegated", - "userConsentDescription": "Create channels in any team, on your behalf.", - "userConsentDisplayName": "Create channels", - "value": "Channel.Create" + "description": "Allows the app to read your organization's risk prevention providers, without a signed-in user.", + "displayName": "Read all identity risk prevention providers", + "id": "2a6baefd-edea-4ff6-b24e-bebcaa27a50d", + "origin": "Application (Microsoft Graph)", + "value": "RiskPreventionProviders.Read.All" }, { - "description": "Delete channels in any team, on behalf of the signed-in user.", - "displayName": "Delete channels", - "id": "cc83893a-e232-4723-b5af-bd0b01bcfe65", - "Origin": "Delegated", - "userConsentDescription": "Delete channels in any team, on your behalf.", - "userConsentDisplayName": "Delete channels", - "value": "Channel.Delete.All" + "description": "Allows the app to read and write your organization's risk prevention providers, without a signed-in user.", + "displayName": "Read and write all identity risk prevention providers", + "id": "7fc7225d-eb37-4c39-90f3-a33a57cf1081", + "origin": "Application (Microsoft Graph)", + "value": "RiskPreventionProviders.ReadWrite.All" }, { - "description": "Read all channel names, channel descriptions, and channel settings, on behalf of the signed-in user.", - "displayName": "Read the names, descriptions, and settings of channels", - "id": "233e0cf1-dd62-48bc-b65b-b38fe87fcf8e", - "Origin": "Delegated", - "userConsentDescription": "Read all channel names, channel descriptions, and channel settings, on your behalf.", - "userConsentDisplayName": "Read the names, descriptions, and settings of channels", - "value": "ChannelSettings.Read.All" + "description": "Allows the app to read the active role-based access control (RBAC) assignments and schedules for your company's directory, without a signed-in user. This includes reading directory role templates, and directory roles.", + "displayName": "Read all active role assignments and role schedules for your company's directory", + "id": "d5fe8ce8-684c-4c83-a52c-46e882ce4be1", + "origin": "Application (Microsoft Graph)", + "value": "RoleAssignmentSchedule.Read.Directory" }, { - "description": "Read and write the names, descriptions, and settings of all channels, on behalf of the signed-in user.", - "displayName": "Read and write the names, descriptions, and settings of channels", - "id": "d649fb7c-72b4-4eec-b2b4-b15acf79e378", - "Origin": "Delegated", - "userConsentDescription": "Read and write the names, descriptions, and settings of all channels, on your behalf.", - "userConsentDisplayName": "Read and write the names, descriptions, and settings of channels", - "value": "ChannelSettings.ReadWrite.All" + "description": "Allows the app to read, update, and delete policies for privileged role-based access control (RBAC) assignments of your company's directory, without a signed-in user.", + "displayName": "Read, update, and delete all policies for privileged role assignments of your company's directory", + "id": "dd199f4a-f148-40a4-a2ec-f0069cc799ec", + "origin": "Application (Microsoft Graph)", + "value": "RoleAssignmentSchedule.ReadWrite.Directory" }, { - "description": "Allows the app to read all webhook subscriptions on behalf of the signed-in user.", - "displayName": "Read all webhook subscriptions ", - "id": "5f88184c-80bb-4d52-9ff2-757288b2e9b7", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read all webhook subscriptions on your behalf.", - "userConsentDisplayName": "Read all webhook subscriptions ", - "value": "Subscription.Read.All" + "description": "Delete all active privileged role-based access control (RBAC) assignments of your company's directory, without a signed-in user.", + "displayName": "Delete all active role assignments of your company's directory", + "id": "d3495511-98b7-4df3-b317-4e35c19f6129", + "origin": "Application (Microsoft Graph)", + "value": "RoleAssignmentSchedule.Remove.Directory" }, { - "description": "Read the names and descriptions of teams, on behalf of the signed-in user.", - "displayName": "Read the names and descriptions of teams", - "id": "485be79e-c497-4b35-9400-0e3fa7f2a5d4", - "Origin": "Delegated", - "userConsentDescription": "Read the names and descriptions of teams, on your behalf.", - "userConsentDisplayName": "Read the names and descriptions of teams", - "value": "Team.ReadBasic.All" + "description": "Allows the app to read the eligible role-based access control (RBAC) assignments and schedules for your company's directory, without a signed-in user. This includes reading directory role templates, and directory roles.", + "displayName": "Read all eligible role assignments and role schedules for your company's directory", + "id": "ff278e11-4a33-4d0c-83d2-d01dc58929a5", + "origin": "Application (Microsoft Graph)", + "value": "RoleEligibilitySchedule.Read.Directory" }, { - "description": "Read channel names and channel descriptions, on behalf of the signed-in user.", - "displayName": "Read the names and descriptions of channels", - "id": "9d8982ae-4365-4f57-95e9-d6032a4c0b87", - "Origin": "Delegated", - "userConsentDescription": "Read channel names and channel descriptions, on your behalf.", - "userConsentDisplayName": "Read the names and descriptions of channels", - "value": "Channel.ReadBasic.All" + "description": "Delete all eligible privileged role-based access control (RBAC) assignments of your company's directory, without a signed-in user.", + "displayName": "Delete all eligible role assignments of your company's directory", + "id": "79c7e69c-0d9f-4eff-97a8-49170a5a08ba", + "origin": "Application (Microsoft Graph)", + "value": "RoleEligibilitySchedule.Remove.Directory" }, { - "description": "Read all teams' settings, on behalf of the signed-in user.", - "displayName": "Read teams' settings", - "id": "48638b3c-ad68-4383-8ac4-e6880ee6ca57", - "Origin": "Delegated", - "userConsentDescription": "Read all teams' settings, on your behalf.", - "userConsentDisplayName": "Read teams' settings", - "value": "TeamSettings.Read.All" + "description": "Allows the app to trigger the working time policies and read the working time status for other users in your organization, without a signed-in user.", + "displayName": "Trigger working time policies and read the working time status", + "id": "0b21c159-dbf4-4dbb-a6f6-490e412c716e", + "origin": "Application (Microsoft Graph)", + "value": "Schedule-WorkingTime.ReadWrite.All" }, { - "description": "Read and change all teams' settings, on behalf of the signed-in user.", - "displayName": "Read and change teams' settings", - "id": "39d65650-9d3e-4223-80db-a335590d027e", - "Origin": "Delegated", - "userConsentDescription": "Read and change all teams' settings, on your behalf.", - "userConsentDisplayName": "Read and change teams' settings", - "value": "TeamSettings.ReadWrite.All" + "description": "Allows the app to read search configurations, without a signed-in user.", + "displayName": "Read your organization's search configuration", + "id": "ada977a5-b8b1-493b-9a91-66c206d76ecf", + "origin": "Application (Microsoft Graph)", + "value": "SearchConfiguration.Read.All" }, { - "description": "Read the members of teams, on behalf of the signed-in user.", - "displayName": "Read the members of teams", - "id": "2497278c-d82d-46a2-b1ce-39d4cdde5570", - "Origin": "Delegated", - "userConsentDescription": "Read the members of teams, on your behalf.", - "userConsentDisplayName": "Read the members of teams", - "value": "TeamMember.Read.All" + "description": "Allows the app to read and write search configurations, without a signed-in user.", + "displayName": "Read and write your organization's search configuration", + "id": "0e778b85-fefa-466d-9eec-750569d92122", + "origin": "Application (Microsoft Graph)", + "value": "SearchConfiguration.ReadWrite.All" }, { - "description": "Add and remove members from teams, on behalf of the signed-in user. Also allows changing a member's role, for example from owner to non-owner.", - "displayName": "Add and remove members from teams", - "id": "4a06efd2-f825-4e34-813e-82a57b03d1ee", - "Origin": "Delegated", - "userConsentDescription": "Add and remove members from teams, on your behalf. Also allows changing a member's role, for example from owner to non-owner.", - "userConsentDisplayName": "Add and remove members from teams and channels", - "value": "TeamMember.ReadWrite.All" + "description": "Allows the app to read and write to all security incidents, without a signed-in user.", + "displayName": "Read and write to all security incidents", + "id": "34bf0e97-1971-4929-b999-9e2442d941d7", + "origin": "Application (Microsoft Graph)", + "value": "SecurityIncident.ReadWrite.All" }, { - "description": "Allows the app to read consent requests and approvals on behalf of the signed-in user.", - "displayName": "Read consent requests", - "id": "f3bfad56-966e-4590-a536-82ecf548ac1e", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read consent requests and approvals, on your behalf.", - "userConsentDisplayName": "Read consent requests", - "value": "ConsentRequest.Read.All" + "description": "Allow the app to determine if there is any sensitivity label to be applied automatically to the content or recommended to the user for manual application, without a signed-in user.", + "displayName": "Evaluate sensitivity labels", + "id": "57f0b71b-a759-45a0-9a0f-cc099fbd9a44", + "origin": "Application (Microsoft Graph)", + "value": "SensitivityLabel.Evaluate" }, { - "description": "Allows the app to read app consent requests and approvals, and deny or approve those requests on behalf of the signed-in user.", - "displayName": "Read and write consent requests", - "id": "497d9dfa-3bd1-481a-baab-90895e54568c", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read app consent requests for your approval, and deny or approve those request on your behalf.", - "userConsentDisplayName": "Read and write consent requests", - "value": "ConsentRequest.ReadWrite.All" + "description": "Allows the app to evaluate all sensitivity label.", + "displayName": "Evaluate labels tenant scope.", + "id": "986fa56a-6680-4aac-af09-4d1765376739", + "origin": "Application (Microsoft Graph)", + "value": "SensitivityLabel.Evaluate.All" }, { - "description": "Allows the app to read and write your organization's consent requests policy on behalf of the signed-in user.", - "displayName": "Read and write consent request policy", - "id": "4d135e65-66b8-41a8-9f8b-081452c91774", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write your organization's consent request policy on your behalf.", - "userConsentDisplayName": "Read and write consent request policy", - "value": "Policy.ReadWrite.ConsentRequest" + "description": "Allows the app to get sensitivity labels.", + "displayName": "Get labels application scope.", + "id": "3b8e7aad-f6e3-4299-83f8-6fc6a5777f0b", + "origin": "Application (Microsoft Graph)", + "value": "SensitivityLabel.Read" }, { - "description": "Allows the app to read presence information on behalf of the signed-in user. Presence information includes activity, availability, status note, calendar out-of-office message, timezone and location.", - "displayName": "Read user's presence information", - "id": "76bc735e-aecd-4a1d-8b4c-2b915deabb79", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read your presence information on your behalf. Presence information includes activity, availability, status note, calendar out-of-office message, timezone and location.", - "userConsentDisplayName": "Read your presence information", - "value": "Presence.Read" + "description": "Allows the app to get sensitivity labels.", + "displayName": "Get labels tenant scope.", + "id": "e46a01e9-b2cf-4d89-8424-bcdc6dd445ab", + "origin": "Application (Microsoft Graph)", + "value": "SensitivityLabels.Read.All" }, { - "description": "Allows the app to read presence information of all users in the directory on behalf of the signed-in user. Presence information includes activity, availability, status note, calendar out-of-office message, timezone and location.", - "displayName": "Read presence information of all users in your organization", - "id": "9c7a330d-35b3-4aa1-963d-cb2b9f927841", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read presence information of all users in the directory on your behalf. Presence information includes activity, availability, status note, calendar out-of-office message, timezone and location.", - "userConsentDisplayName": "Read presence information of all users in your organization", - "value": "Presence.Read.All" + "description": "Allows the app to read all Sentiment Survey, without a signed-in user. ", + "displayName": "Export all Sentiment Survey", + "id": "84fa35c1-f997-4c1c-894c-bb52108cfbbf", + "origin": "Application (Microsoft Graph)", + "value": "SentimentSurvey.Export.All" }, { - "description": "Read the members of channels, on behalf of the signed-in user.", - "displayName": "Read the members of channels", - "id": "2eadaff8-0bce-4198-a6b9-2cfc35a30075", - "Origin": "Delegated", - "userConsentDescription": "Read the members of channels, on your behalf.", - "userConsentDisplayName": "Read the members of teams and channels", - "value": "ChannelMember.Read.All" + "description": "Allows the app to read all Exchange service activity, without a signed-in user.", + "displayName": "Read all Exchange service activity", + "id": "2b655018-450a-4845-81e7-d603b1ebffdb", + "origin": "Application (Microsoft Graph)", + "value": "ServiceActivity-Exchange.Read.All" }, { - "description": "Add and remove members from channels, on behalf of the signed-in user. Also allows changing a member's role, for example from owner to non-owner.", - "displayName": "Add and remove members from channels", - "id": "0c3e411a-ce45-4cd1-8f30-f99a3efa7b11", - "Origin": "Delegated", - "userConsentDescription": "Add and remove members from channels, on your behalf. Also allows changing a member's role, for example from owner to non-owner.", - "userConsentDisplayName": "Add and remove members from teams and channels", - "value": "ChannelMember.ReadWrite.All" + "description": "Allows the app to read all Microsoft 365 Web service activity, without a signed-in user.", + "displayName": "Read all Microsoft 365 Web service activity", + "id": "c766cb16-acc4-4663-ba09-6eedef5876c5", + "origin": "Application (Microsoft Graph)", + "value": "ServiceActivity-Microsoft365Web.Read.All" }, { - "description": "Allows the app to read and write the authentication flow policies, on behalf of the signed-in user. ", - "displayName": "Read and write authentication flow policies", - "id": "edb72de9-4252-4d03-a925-451deef99db7", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write the authentication flow policies for your tenant, on your behalf.", - "userConsentDisplayName": "Read and write your authentication flow policies", - "value": "Policy.ReadWrite.AuthenticationFlows" + "description": "Allows the app to read all One Drive service activity, without a signed-in user.", + "displayName": "Read all One Drive service activity", + "id": "57b4f899-b8c5-47c7-bdd3-c410c55602b7", + "origin": "Application (Microsoft Graph)", + "value": "ServiceActivity-OneDrive.Read.All" }, { - "description": "Allows an app to read a channel's messages in Microsoft Teams, on behalf of the signed-in user.", - "displayName": "Read user channel messages", - "id": "767156cb-16ae-4d10-8f8b-41b657c8c8c8", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read a channel's messages in Microsoft Teams, on your behalf.", - "userConsentDisplayName": "Read your channel messages", - "value": "ChannelMessage.Read.All" + "description": "Allows the app to read all Teams service activity, without a signed-in user.", + "displayName": "Read all Teams service activity", + "id": "4dfee10b-fa4a-41b5-b34d-ccf54cc0c394", + "origin": "Application (Microsoft Graph)", + "value": "ServiceActivity-Teams.Read.All" }, { - "description": "Allows the app to read the apps in the app catalogs.", - "displayName": "Read all app catalogs", - "id": "88e58d74-d3df-44f3-ad47-e89edf4472e4", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read apps in the app catalogs.", - "userConsentDisplayName": "Read all app catalogs", - "value": "AppCatalog.Read.All" + "description": "Allows the app to read your tenant's service health information, without a signed-in user. Health information may include service issues or service health overviews.", + "displayName": "Read service health", + "id": "79c261e0-fe76-4144-aad5-bdc68fbe4037", + "origin": "Application (Microsoft Graph)", + "value": "ServiceHealth.Read.All" }, { - "description": "Allows the app to read and write the authentication method policies, on behalf of the signed-in user.\u00a0", - "displayName": "Read and write authentication method policies", - "id": "7e823077-d88e-468f-a337-e18f1f0e6c7c", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write the authentication method policies for your tenant, on your behalf.", - "userConsentDisplayName": "Read and write your authentication method policies ", - "value": "Policy.ReadWrite.AuthenticationMethod" + "description": "Allows the app to read your tenant's service announcement messages, without a signed-in user. Messages may include information about new or changed features.", + "displayName": "Read service messages", + "id": "1b620472-6534-4fe6-9df2-4680e8aa28ec", + "origin": "Application (Microsoft Graph)", + "value": "ServiceMessage.Read.All" }, { - "description": "Allows the app to read and write your organization's authorization policy on behalf of the signed-in user. For example, authorization policies can control some of the permissions that the out-of-the-box user role has by default.", - "displayName": "Read and write your organization's authorization policy", - "id": "edd3c878-b384-41fd-95ad-e7407dd775be", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write your organization's authorization policy on your behalf. For example, authorization policies can control some of the permissions that the out-of-the-box user role has by default.", - "userConsentDisplayName": "Read and write your organization's authorization policy", - "value": "Policy.ReadWrite.Authorization" + "description": "Allows the app to read service principal endpoints", + "displayName": "Read service principal endpoints", + "id": "5256681e-b7f6-40c0-8447-2d9db68797a0", + "origin": "Application (Microsoft Graph)", + "value": "ServicePrincipalEndpoint.Read.All" }, { - "description": "Allows the app to read policies related to consent and permission grants for applications, on behalf of the signed-in user.", - "displayName": "Read consent and permission grant policies", - "id": "414de6ea-2d92-462f-b120-6e2a809a6d01", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read policies related to consent and permission grants for applications, on your behalf.", - "userConsentDisplayName": "Read consent and permission grant policies", - "value": "Policy.Read.PermissionGrant" + "description": "Allows the app to update service principal endpoints", + "displayName": "Read and update service principal endpoints", + "id": "89c8469c-83ad-45f7-8ff2-6e3d4285709e", + "origin": "Application (Microsoft Graph)", + "value": "ServicePrincipalEndpoint.ReadWrite.All" }, { - "description": "Allows the app to manage policies related to consent and permission grants for applications, on behalf of the signed-in user.", - "displayName": "Manage consent and permission grant policies", - "id": "2672f8bb-fd5e-42e0-85e1-ec764dd2614e", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to manage policies related to consent and permission grants for applications, on behalf of the signed-in user.", - "userConsentDisplayName": "Manage consent and permission grant policies", - "value": "Policy.ReadWrite.PermissionGrant" + "description": "Allows the app to read, write and manage your tenant's SharePoint Cross-Tenant migration settings and tasks, without a signed-in user.", + "displayName": "Read, write and manage SharePoint Cross-Tenant migration settings and tasks", + "id": "a0521574-fcd8-4742-b29c-f796df57ea70", + "origin": "Application (Microsoft Graph)", + "value": "SharePointCrossTenantMigration.Manage.All" }, { - "description": "Allows the application to create (register) printers on behalf of the signed-in user.\u00a0", - "displayName": "Register printers\u202f\u00a0", - "id": "90c30bed-6fd1-4279-bf39-714069619721", - "Origin": "Delegated", - "userConsentDescription": "Allows the application to create (register) printers on your behalf.\u00a0", - "userConsentDisplayName": "Register printers\u202f\u00a0", - "value": "Printer.Create" + "description": "Allows the app to read your tenant's SharePoint Cross-Tenant migration settings and tasks, without a signed-in user.", + "displayName": "Read SharePoint Cross-Tenant migration settings and tasks", + "id": "f5fa52a5-b9ab-4dc3-885e-9e5b4a67068e", + "origin": "Application (Microsoft Graph)", + "value": "SharePointCrossTenantMigration.Read.All" }, { - "description": "Allows the application to create (register), read, update, and delete (unregister) printers on behalf of the signed-in user.\u00a0", - "displayName": "Register, read, update, and unregister printers", - "id": "93dae4bd-43a1-4a23-9a1a-92957e1d9121", - "Origin": "Delegated", - "userConsentDescription": "Allows the application to create (register), read, update, and delete (unregister) printers on your behalf.\u00a0\u00a0", - "userConsentDisplayName": "Register, read, update, and unregister printers", - "value": "Printer.FullControl.All" + "description": "Allows the application to read the tenant-level settings of SharePoint and OneDrive, without a signed-in user.", + "displayName": "Read SharePoint and OneDrive tenant settings", + "id": "83d4163d-a2d8-4d3b-9695-4ae3ca98f888", + "origin": "Application (Microsoft Graph)", + "value": "SharePointTenantSettings.Read.All" }, { - "description": "Allows the application to read printers on behalf of the signed-in user.\u00a0", - "displayName": "Read printers", - "id": "3a736c8a-018e-460a-b60c-863b2683e8bf", - "Origin": "Delegated", - "userConsentDescription": "Allows the application to read printers on your behalf.\u00a0", - "userConsentDisplayName": "Read printers", - "value": "Printer.Read.All" + "description": "Allows the app to read all security incidents, without a signed-in user.", + "displayName": "Read all security incidents", + "id": "45cc0394-e837-488b-a098-1918f48d186c", + "origin": "Application (Microsoft Graph)", + "value": "SecurityIncident.Read.All" }, { - "description": "Allows the application to read and update printers on behalf of the signed-in user.\u00a0Does not allow creating (registering) or deleting (unregistering) printers.", - "displayName": "Read and update printers", - "id": "89f66824-725f-4b8f-928e-e1c5258dc565", - "Origin": "Delegated", - "userConsentDescription": "Allows the application to read and update printers on your behalf.\u00a0Does not allow creating (registering) or deleting (unregistering) printers.", - "userConsentDisplayName": "Read and update printers", - "value": "Printer.ReadWrite.All" + "description": "Allows the app to read and write identity security available user actions without a signed-in user.", + "displayName": "Read and perform all identity security available user actions", + "id": "b4146a3a-dd4f-4af4-8d91-7cc0eef3d041", + "origin": "Application (Microsoft Graph)", + "value": "SecurityIdentitiesUserActions.ReadWrite.All" }, { - "description": "Allows the application to read printer shares on behalf of the signed-in user.\u00a0", - "displayName": "Read printer shares", - "id": "ed11134d-2f3f-440d-a2e1-411efada2502", - "Origin": "Delegated", - "userConsentDescription": "Allows the application to read printer shares on your behalf.\u00a0", - "userConsentDisplayName": "Read printer shares", - "value": "PrinterShare.Read.All" + "description": "Allows the app to read all the identity security available user actions without a signed-in user.", + "displayName": "Read all identity security available user actions", + "id": "3e5d0bee-973f-4736-a123-4e1ab146f3a8", + "origin": "Application (Microsoft Graph)", + "value": "SecurityIdentitiesUserActions.Read.All" }, { - "description": "Allows the application to read and update printer shares on behalf of the signed-in user.\u00a0", - "displayName": "Read and write printer shares", - "id": "06ceea37-85e2-40d7-bec3-91337a46038f", - "Origin": "Delegated", - "userConsentDescription": "Allows the application to read and update printer shares on your behalf.\u00a0", - "userConsentDisplayName": "Read and update printer shares", - "value": "PrinterShare.ReadWrite.All" + "description": "Allows the app to read and write identity security sensors without a signed-in user.", + "displayName": "Read and write all identity security sensors", + "id": "d4dcee6d-0774-412a-b06c-aeabbd99e816", + "origin": "Application (Microsoft Graph)", + "value": "SecurityIdentitiesSensors.ReadWrite.All" }, { - "description": "Allows the application to read the metadata and document content of print jobs that the signed-in user created.", - "displayName": "Read user's print jobs", - "id": "248f5528-65c0-4c88-8326-876c7236df5e", - "Origin": "Delegated", - "userConsentDescription": "Allows the application to read the metadata and document content of print jobs that you created.", - "userConsentDisplayName": "Read your print jobs", - "value": "PrintJob.Read" + "description": "Allows the app to read security actions, without a signed-in user.", + "displayName": "Read your organization's security actions", + "id": "5e0edab9-c148-49d0-b423-ac253e121825", + "origin": "Application (Microsoft Graph)", + "value": "SecurityActions.Read.All" }, { - "description": "Allows the application to read the metadata and document content of print jobs on behalf of the signed-in user.\u00a0", - "displayName": "Read print jobs", - "id": "afdd6933-a0d8-40f7-bd1a-b5d778e8624b", - "Origin": "Delegated", - "userConsentDescription": "Allows the application to read the metadata and document content of print jobs on your behalf.\u00a0", - "userConsentDisplayName": "Read print jobs", - "value": "PrintJob.Read.All" + "description": "Allows the app to read or update security actions, without a signed-in user.", + "displayName": "Read and update your organization's security actions", + "id": "f2bf083f-0179-402a-bedb-b2784de8a49b", + "origin": "Application (Microsoft Graph)", + "value": "SecurityActions.ReadWrite.All" }, { - "description": "Allows the application to read the metadata of print jobs that the signed-in user created. Does not allow access to print job document content.", - "displayName": "Read basic information of user's print jobs", - "id": "6a71a747-280f-4670-9ca0-a9cbf882b274", - "Origin": "Delegated", - "userConsentDescription": "Allows the application to read the metadata of print jobs that you created. Does not allow access to print job document content.", - "userConsentDisplayName": "Read basic information of your print jobs", - "value": "PrintJob.ReadBasic" + "description": "Allows the app to create security alerts, without a signed-in user.", + "displayName": "Create security alerts", + "id": "06870c4c-7370-4a2a-ad10-239a337af816", + "origin": "Application (Microsoft Graph)", + "value": "SecurityAlert.Create.All" }, { - "description": "Allows the application to read the metadata of print jobs on behalf of the signed-in user.\u00a0Does not allow access to print job document content.", - "displayName": "Read basic information of print jobs", - "id": "04ce8d60-72ce-4867-85cf-6d82f36922f3", - "Origin": "Delegated", - "userConsentDescription": "Allows the application to read the metadata of print jobs on your behalf.\u00a0Does not allow access to print job document content.", - "userConsentDisplayName": "Read basic information of print jobs", - "value": "PrintJob.ReadBasic.All" + "description": "Allows the app to read all security alerts, without a signed-in user.", + "displayName": "Read all security alerts", + "id": "472e4a4d-bb4a-4026-98d1-0b0d74cb74a5", + "origin": "Application (Microsoft Graph)", + "value": "SecurityAlert.Read.All" }, { - "description": "Allows the application to read and update the metadata and document content of print jobs that the signed-in user created.", - "displayName": "Read and write user's print jobs", - "id": "b81dd597-8abb-4b3f-a07a-820b0316ed04", - "Origin": "Delegated", - "userConsentDescription": "Allows the application to read and update the metadata and document content of print jobs that you created.", - "userConsentDisplayName": "Read and update your print jobs", - "value": "PrintJob.ReadWrite" + "description": "Allows the app to read and write to all security alerts, without a signed-in user.", + "displayName": "Read and write to all security alerts", + "id": "ed4fca05-be46-441f-9803-1873825f8fdb", + "origin": "Application (Microsoft Graph)", + "value": "SecurityAlert.ReadWrite.All" }, { - "description": "Allows the application to read and update the metadata and document content of print jobs on behalf of the signed-in user.\u00a0", - "displayName": "Read and write print jobs", - "id": "036b9544-e8c5-46ef-900a-0646cc42b271", - "Origin": "Delegated", - "userConsentDescription": "Allows the application to read and update the metadata and document content of print jobs on your behalf.\u00a0", - "userConsentDisplayName": "Read and update print jobs", - "value": "PrintJob.ReadWrite.All" + "description": "Read email metadata and security detection details, without a signed-in user. ", + "displayName": "Read metadata and detection details for all emails in your organization", + "id": "b48f7ac2-044d-4281-b02f-75db744d6f5f", + "origin": "Application (Microsoft Graph)", + "value": "SecurityAnalyzedMessage.Read.All" }, { - "description": "Allows the application to read and update the metadata of print jobs that the signed-in user created. Does not allow access to print job document content.", - "displayName": "Read and write basic information of user's print jobs", - "id": "6f2d22f2-1cb6-412c-a17c-3336817eaa82", - "Origin": "Delegated", - "userConsentDescription": "Allows the application to read and update the metadata of print jobs that you created. Does not allow access to print job document content.", - "userConsentDisplayName": "Read and write basic information of your print jobs", - "value": "PrintJob.ReadWriteBasic" + "description": "Read email metadata and security detection details, and execute remediation actions like deleting an email, without a signed-in user.", + "displayName": "Read metadata, detection details, and execute remediation actions on all emails in your organization", + "id": "04c55753-2244-4c25-87fc-704ab82a4f69", + "origin": "Application (Microsoft Graph)", + "value": "SecurityAnalyzedMessage.ReadWrite.All" }, { - "description": "Allows the application to read and update the metadata of print jobs on behalf of the signed-in user.\u00a0Does not allow access to print job document content.", - "displayName": "Read and write basic information of print jobs", - "id": "3a0db2f6-0d2a-4c19-971b-49109b19ad3d", - "Origin": "Delegated", - "userConsentDescription": "Allows the application to read and update the metadata of print jobs on your behalf.\u00a0Does not allow access to print job document content.", - "userConsentDisplayName": "Read and write basic information of print jobs", - "value": "PrintJob.ReadWriteBasic.All" + "description": "Allows the app to read your organization’s security events without a signed-in user.", + "displayName": "Read your organization’s security events", + "id": "bf394140-e372-4bf9-a898-299cfc7564e5", + "origin": "Application (Microsoft Graph)", + "value": "SecurityEvents.Read.All" }, { - "description": "Allows the app to read and write your organization's device configuration policies on behalf of the signed-in user. For example, device registration policy can limit initial provisioning controls using quota restrictions, additional authentication and authorization checks.", - "displayName": "Read and write your organization's device configuration policies", - "id": "40b534c3-9552-4550-901b-23879c90bcf9", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write your organization's device configuration policies on your behalf. For example, device registration policy can limit initial provisioning controls using quota restrictions, additional authentication and authorization checks.", - "userConsentDisplayName": "Read and write your organization's device configuration policies", - "value": "Policy.ReadWrite.DeviceConfiguration" + "description": "Allows the application to read pull-print printers without a signed-in user. ", + "displayName": "Read pull-print printers", + "id": "f369d3b8-fe98-4772-85e1-b23e7cf41982", + "origin": "Application (Microsoft Graph)", + "value": "PullPrintPrinter.Read.All" }, { - "description": "Allows the app to submit application packages to the catalog and cancel submissions that are pending review on behalf of the signed-in user.", - "displayName": "Submit application packages to the catalog and cancel pending submissions", - "id": "3db89e36-7fa6-4012-b281-85f3d9d9fd2e", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to submit application packages to the catalog and cancel submissions that are pending review on your behalf.", - "userConsentDisplayName": "Submit application packages to your organization's catalog and cancel pending submissions", - "value": "AppCatalog.Submit" + "description": "Allows the app to read your organization’s security events without a signed-in user. Also allows the app to update editable properties in security events.", + "displayName": "Read and update your organization’s security events", + "id": "d903a879-88e0-4c09-b0c9-82f6a1333f84", + "origin": "Application (Microsoft Graph)", + "value": "SecurityEvents.ReadWrite.All" }, { - "description": "Allows the app to read the Teams apps that are installed in chats the signed-in user can access. Does not give the ability to read application-specific settings.", - "displayName": "Read installed Teams apps in chats", - "id": "bf3fbf03-f35f-4e93-963e-47e4d874c37a", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read the Teams apps that are installed in chats that you can access. Does not give the ability to read application-specific settings.", - "userConsentDisplayName": "Read installed Teams apps in chats", - "value": "TeamsAppInstallation.ReadForChat" + "description": "Allows the app to read and write identity security available actions without a signed-in user.", + "displayName": "Read and perform all identity security available actions", + "id": "af2bf46f-7bf1-4be3-8bad-e17e279e8462", + "origin": "Application (Microsoft Graph)", + "value": "SecurityIdentitiesActions.ReadWrite.All" }, { - "description": "Allows the app to read the Teams apps that are installed in teams the signed-in user can access. Does not give the ability to read application-specific settings.", - "displayName": "Read installed Teams apps in teams", - "id": "5248dcb1-f83b-4ec3-9f4d-a4428a961a72", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read the Teams apps that are installed in teams that you can access. Does not give the ability to read application-specific settings.", - "userConsentDisplayName": "Read installed Teams apps in teams", - "value": "TeamsAppInstallation.ReadForTeam" + "description": "Allows the app to read sensors window auditing configuration without a signed-in user", + "displayName": "Read sensors window auditing configuration", + "id": "58971758-9844-4fe4-9fba-7e4ce7a659bf", + "origin": "Application (Microsoft Graph)", + "value": "SecurityIdentitiesAutoConfig.Read.All" }, { - "description": "Allows the app to read the Teams apps that are installed for the signed-in user. Does not give the ability to read application-specific settings.", - "displayName": "Read user's installed Teams apps", - "id": "c395395c-ff9a-4dba-bc1f-8372ba9dca84", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read the Teams apps that are installed for you. Does not give the ability to read application-specific settings.", - "userConsentDisplayName": "Read your installed Teams apps", - "value": "TeamsAppInstallation.ReadForUser" + "description": "Allows the app to read and write sensors window auditing configuration without a signed-in user", + "displayName": "Read and write sensors window auditing configuration", + "id": "4f1f0deb-08d1-4ffb-8cca-21dfc362b7c0", + "origin": "Application (Microsoft Graph)", + "value": "SecurityIdentitiesAutoConfig.ReadWrite.All" }, { - "description": "Allows the app to read, install, upgrade, and uninstall Teams apps in teams the signed-in user can access. Does not give the ability to read application-specific settings.", - "displayName": "Manage installed Teams apps in teams", - "id": "2e25a044-2580-450d-8859-42eeb6e996c0", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read, install, upgrade, and uninstall Teams apps in teams you can access. Does not give the ability to read application-specific settings.", - "userConsentDisplayName": "Manage installed Teams apps in teams", - "value": "TeamsAppInstallation.ReadWriteForTeam" + "description": "Allows the app to read all the identity security health issues without a signed-in user.", + "displayName": "Read all identity security health issues", + "id": "f8dcd971-5d83-4e1e-aa95-ef44611ad351", + "origin": "Application (Microsoft Graph)", + "value": "SecurityIdentitiesHealth.Read.All" }, { - "description": "Allows a Teams app to read, install, upgrade, and uninstall itself in chats the signed-in user can access.", - "displayName": "Allow the Teams app to manage itself in chats", - "id": "0ce33576-30e8-43b7-99e5-62f8569a4002", - "Origin": "Delegated", - "userConsentDescription": "Allows a Teams app to read, install, upgrade, and uninstall itself in chats you can access.", - "userConsentDisplayName": "Allow the Teams app to manage itself in chats", - "value": "TeamsAppInstallation.ReadWriteSelfForChat" + "description": "Allows the app to read and write identity security health issues without a signed-in user.", + "displayName": "Read and write all identity security health issues", + "id": "ab03ddd5-7ae4-4f2e-8af8-86654f7e0a27", + "origin": "Application (Microsoft Graph)", + "value": "SecurityIdentitiesHealth.ReadWrite.All" }, { - "description": "Allows a Teams app to read, install, upgrade, and uninstall itself for the signed-in user.", - "displayName": "Allow the Teams app to manage itself for a user", - "id": "207e0cb1-3ce7-4922-b991-5a760c346ebc", - "Origin": "Delegated", - "userConsentDescription": "Allows a Teams app to read, install, upgrade, and uninstall itself for you.", - "userConsentDisplayName": "Allow the Teams app to manage itself for you", - "value": "TeamsAppInstallation.ReadWriteSelfForUser" + "description": "Allows the app to read all the identity security sensor migration information without a signed-in user.", + "displayName": "Read all identity security sensor migration", + "id": "b018cc1c-c680-4e91-bb6e-462ee243fdb5", + "origin": "Application (Microsoft Graph)", + "value": "SecurityIdentitiesMigration.Read.All" }, { - "description": "Allows the app to read, install, upgrade, and uninstall Teams apps installed for the signed-in user. Does not give the ability to read application-specific settings.", - "displayName": "Manage user's installed Teams apps", - "id": "093f8818-d05f-49b8-95bc-9d2a73e9a43c", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read, install, upgrade, and uninstall Teams apps installed for you. Does not give the ability to read application-specific settings.", - "userConsentDisplayName": "Manage your installed Teams apps", - "value": "TeamsAppInstallation.ReadWriteForUser" + "description": "Allows the app to read and write identity security sensor migration without a signed-in user.", + "displayName": "Read and write all identity security sensor migration", + "id": "afd28a5a-707f-4edf-85c2-c446291e63da", + "origin": "Application (Microsoft Graph)", + "value": "SecurityIdentitiesMigration.ReadWrite.All" }, { - "description": "Allows the app to create teams on behalf of the signed-in user.", - "displayName": "Create teams", - "id": "7825d5d6-6049-4ce7-bdf6-3b8d53f4bcd0", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to create teams on your behalf.\u00a0", - "userConsentDisplayName": "Create teams", - "value": "Team.Create" + "description": "Allows the app to read all the identity security sensors without a signed-in user.", + "displayName": "Read all identity security sensors", + "id": "5f0ffea2-f474-4cf2-9834-61cda2bcea5c", + "origin": "Application (Microsoft Graph)", + "value": "SecurityIdentitiesSensors.Read.All" }, { - "description": "Add and remove members from all teams, on behalf of the signed-in user. Does not allow adding or removing a member with the owner role. Additionally, does not allow the app to elevate an existing member to the owner role.", - "displayName": "Add and remove members with non-owner role for all teams", - "id": "2104a4db-3a2f-4ea0-9dba-143d457dc666", - "Origin": "Delegated", - "userConsentDescription": "Add and remove members from all teams, on your behalf. Does not allow adding or removing a member with the owner role. Additionally, does not allow the app to elevate an existing member to the owner role.", - "userConsentDisplayName": "Add and remove members with non-owner role for all teams", - "value": "TeamMember.ReadWriteNonOwnerRole.All" + "description": "Allows the app to read all the identity security available identity accounts without a signed-in user.", + "displayName": "Read all identity security available identity accounts", + "id": "c5bc96f5-b4a1-4cfc-8189-d5f0d772278f", + "origin": "Application (Microsoft Graph)", + "value": "SecurityIdentitiesAccount.Read.All" }, { - "description": "Allows the app to read the term store data that the signed-in user has access to. This includes all sets, groups and terms in the term store.", - "displayName": "Read term store data", - "id": "297f747b-0005-475b-8fef-c890f5152b38", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read the term store data that you have access to. This includes all sets, groups and terms in the term store.", - "userConsentDisplayName": "Read term store data", - "value": "TermStore.Read.All" + "description": "Allows the app to read organization-wide Microsoft Forms settings, without a signed-in user.", + "displayName": "Read organization-wide Microsoft Forms settings", + "id": "434d7c66-07c6-4b1f-ab21-417cf2cdaaca", + "origin": "Application (Microsoft Graph)", + "value": "OrgSettings-Forms.Read.All" }, { - "description": "Allows the app to read or modify data that the signed-in user has access to.\u00a0This includes all sets, groups and terms in the term store.", - "displayName": "Read and write term store data", - "id": "6c37c71d-f50f-4bff-8fd3-8a41da390140", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read or modify data that you have access to. This includes all sets, groups and terms in the term store.", - "userConsentDisplayName": "Read and write term store data", - "value": "TermStore.ReadWrite.All" + "description": "Allows the application to read and write certificate-based authentication configuration such as all public key infrastructures (PKI) and certificate authorities (CA) configured for the organization, without a signed-in user.", + "displayName": "Read and write all certificate based authentication configurations", + "id": "a2b63618-5350-462d-b1b3-ba6eb3684e26", + "origin": "Application (Microsoft Graph)", + "value": "PublicKeyInfrastructure.ReadWrite.All" }, { - "description": "Allows the app to read your tenant's service announcement messages on behalf of the signed-in user. Messages may include information about new or changed features.", - "displayName": "Read service announcement messages", - "id": "eda39fa6-f8cf-4c3c-a909-432c683e4c9b", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read your tenant's service announcement messages on your behalf. Messages may include information about new or changed features.", - "userConsentDisplayName": "Read service messages", - "value": "ServiceMessage.Read.All" + "description": "Allows the app to read and query your provisioning log activities, without a signed-in user.", + "displayName": "Read all provisioning log data", + "id": "091937d3-3e38-47a1-8649-b2f99d3035f1", + "origin": "Application (Microsoft Graph)", + "value": "ProvisioningLog.Read.All" }, { - "description": "Allows the app to read your tenant's service health information on behalf of the signed-in user. Health information may include service issues or service health overviews.", - "displayName": "Read service health", - "id": "55896846-df78-47a7-aa94-8d3d4442ca7f", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read your tenant's service health information on your behalf.Health information may include service issues or service health overviews.", - "userConsentDisplayName": "Read service health", - "value": "ServiceHealth.Read.All" + "description": "Allows the app to read your organization's cross tenant access policies without a signed-in user.", + "displayName": "Read your organization's cross tenant access policies", + "id": "8fc84b9a-5c21-479b-ba9d-02d252d062aa", + "origin": "Application (Microsoft Graph)", + "value": "Policy.Read.CrossTenantAccess" }, { - "description": "Allows the app to read all the short notes a sign-in user has access to.", - "displayName": "Read short notes of the signed-in user", - "id": "50f66e47-eb56-45b7-aaa2-75057d9afe08", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read your short notes.", - "userConsentDisplayName": "Read your short notes", - "value": "ShortNotes.Read" + "description": "Allows the application to read your organization's device configuration policies without a signed-in user. For example, device registration policy can limit initial provisioning controls using quota restrictions, additional authentication and authorization checks.", + "displayName": "Read your organization's device configuration policies", + "id": "bdba4817-6ba1-4a7c-8a01-be9bc7c242dd", + "origin": "Application (Microsoft Graph)", + "value": "Policy.Read.DeviceConfiguration" }, { - "description": "Allows the app to read, create, edit, and delete short notes of a signed-in user.", - "displayName": "Read, create, edit, and delete short notes of the signed-in user", - "id": "328438b7-4c01-4c07-a840-e625a749bb89", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read, create, edit, and delete your short notes.", - "userConsentDisplayName": "Read, create, edit, and delete your short notes", - "value": "ShortNotes.ReadWrite" + "description": "Allows the app to read your organization’s identity protection policy without a signed-in user.", + "displayName": "Read your organization’s identity protection policy", + "id": "b21b72f6-4e6a-4533-9112-47eea9f97b28", + "origin": "Application (Microsoft Graph)", + "value": "Policy.Read.IdentityProtection" }, { - "description": "Allows the app to read your organization's conditional access policies on behalf of the signed-in user.", - "displayName": "Read your organization's conditional access policies", - "id": "633e0fce-8c58-4cfb-9495-12bbd5a24f7c", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read your organization's conditional access policies on your behalf.", - "userConsentDisplayName": "Read your organization's conditional access policies", - "value": "Policy.Read.ConditionalAccess" + "description": "Allows the app to read policies related to consent and permission grants for applications, without a signed-in user.", + "displayName": "Read consent and permission grant policies", + "id": "9e640839-a198-48fb-8b9a-013fd6f6cbcd", + "origin": "Application (Microsoft Graph)", + "value": "Policy.Read.PermissionGrant" }, { - "description": "Allows the app to read the role-based access control (RBAC) settings for all RBAC providers, on behalf of the signed-in user. This includes reading role definitions and role assignments.", - "displayName": "Read role management data for all RBAC providers", - "id": "48fec646-b2ba-4019-8681-8eb31435aded", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read the role-based access control (RBAC) settings for all RBAC providers, on your behalf. This includes reading role definitions and role assignments.", - "userConsentDisplayName": "Read role management data for all RBAC providers", - "value": "RoleManagement.Read.All" + "description": "Allows the application to read and update the organization's recovery policy without a signed-in user.", + "displayName": "Read your organization's recovery policy", + "id": "447f996a-7c58-4ce7-9a9e-da80381a45ab", + "origin": "Application (Microsoft Graph)", + "value": "Policy.Read.Recovery" }, { - "description": "Allows an app to send one-to-one and group chat messages in Microsoft Teams, on behalf of the signed-in user.", - "displayName": "Send user chat messages", - "id": "116b7235-7cc6-461e-b163-8e55691d839e", - "Origin": "Delegated", - "userConsentDescription": "Allows an app to send one-to-one and group chat messages in Microsoft Teams, on your behalf.", - "userConsentDisplayName": "Send chat messages", - "value": "ChatMessage.Send" + "description": "Allows the app to read and write your organization's directory access review default policy without a signed-in user.", + "displayName": "Read and write your organization's directory access review default policy", + "id": "77c863fd-06c0-47ce-a7eb-49773e89d319", + "origin": "Application (Microsoft Graph)", + "value": "Policy.ReadWrite.AccessReview" }, { - "description": "Allows an app to read the members and descriptions of one-to-one and group chat threads, on behalf of the signed-in user.", - "displayName": "Read names and members of user chat threads", - "id": "9547fcb5-d03f-419d-9948-5928bbf71b0f", - "Origin": "Delegated", - "userConsentDescription": "Allows an app to read the members and descriptions of one-to-one and group chat threads, on your behalf.", - "userConsentDisplayName": "Read names and members of your chat threads", - "value": "Chat.ReadBasic" + "description": "Allows the app to read and write your organization's application configuration policies, without a signed-in user. This includes policies such as activityBasedTimeoutPolicy, claimsMappingPolicy, homeRealmDiscoveryPolicy, tokenIssuancePolicy and tokenLifetimePolicy.", + "displayName": "Read and write your organization's application configuration policies", + "id": "be74164b-cff1-491c-8741-e671cb536e13", + "origin": "Application (Microsoft Graph)", + "value": "Policy.ReadWrite.ApplicationConfiguration" }, { - "description": "Allows the app to read and write the properties of Cloud PCs on behalf of the signed-in user.", - "displayName": "Read and write Cloud PCs", - "id": "9d77138f-f0e2-47ba-ab33-cd246c8b79d1", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write the properties of Cloud PCs, on your behalf.", - "userConsentDisplayName": "Read and write Cloud PCs", - "value": "CloudPC.ReadWrite.All" + "description": "Allows the app to read and write all authentication flow policies for the tenant, without a signed-in user.", + "displayName": "Read and write authentication flow policies", + "id": "25f85f3c-f66c-4205-8cd5-de92dd7f0cec", + "origin": "Application (Microsoft Graph)", + "value": "Policy.ReadWrite.AuthenticationFlows" }, { - "description": "Allows the app to read the properties of Cloud PCs on behalf of the signed-in user.", - "displayName": "Read Cloud PCs", - "id": "5252ec4e-fd40-4d92-8c68-89dd1d3c6110", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read the properties of Cloud PCs, on your behalf.", - "userConsentDisplayName": "Read Cloud PCs", - "value": "CloudPC.Read.All" + "description": "Allows the app to read and write all authentication method policies for the tenant, without a signed-in user. ", + "displayName": "Read and write all authentication method policies ", + "id": "29c18626-4985-4dcd-85c0-193eef327366", + "origin": "Application (Microsoft Graph)", + "value": "Policy.ReadWrite.AuthenticationMethod" }, { - "description": "Allows the app to read, install, upgrade, and uninstall Teams apps in chats the signed-in user can access. Does not give the ability to read application-specific settings.", - "displayName": "Manage installed Teams apps in chats", - "id": "aa85bf13-d771-4d5d-a9e6-bca04ce44edf", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read, install, upgrade, and uninstall Teams apps in chats you can access. Does not give the ability to read application-specific settings.", - "userConsentDisplayName": "Manage installed Teams apps in chats", - "value": "TeamsAppInstallation.ReadWriteForChat" + "description": "Allows the app to read and write your organization's authorization policy without a signed in user. For example, authorization policies can control some of the permissions that the out-of-the-box user role has by default.", + "displayName": "Read and write your organization's authorization policy", + "id": "fb221be6-99f2-473f-bd32-01c6a0e9ca3b", + "origin": "Application (Microsoft Graph)", + "value": "Policy.ReadWrite.Authorization" }, { - "description": "Allows the app to create, read, update, and delete the signed-in user's tasks and task lists, including any shared with the user.", - "displayName": "Create, read, update, and delete user\u2019s tasks and task lists", - "id": "2219042f-cab5-40cc-b0d2-16b1540b4c5f", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to create, read, update, and delete your tasks and task lists, including any shared with you.", - "userConsentDisplayName": "Create, read, update, and delete your tasks and task lists", - "value": "Tasks.ReadWrite" + "description": "Allows the app to read and write all your organization's B2BManagement policies without a signed in user.", + "displayName": "Read and write your organization's B2BManagement policies", + "id": "886bd2d9-5b8b-4b49-adea-ca75fb50d9ef", + "origin": "Application (Microsoft Graph)", + "value": "Policy.ReadWrite.B2BManagementPolicy" }, { - "description": "Allows the app to read the signed-in user\u2019s tasks and task lists, including any shared with the user. Doesn't include permission to create, delete, or update anything.", - "displayName": "Read user's tasks and task lists", - "id": "f45671fb-e0fe-4b4b-be20-3d3ce43f1bcb", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read your tasks and task lists, including any shared with you. Doesn't include permission to create, delete, or update anything.", - "userConsentDisplayName": "Read your tasks and task lists", - "value": "Tasks.Read" + "description": "Allows the app to read and write your organization's conditional access policies, without a signed-in user.", + "displayName": "Read and write your organization's conditional access policies", + "id": "01c0a623-fc9b-48e9-b794-0756f8e8f067", + "origin": "Application (Microsoft Graph)", + "value": "Policy.ReadWrite.ConditionalAccess" }, { - "description": "Allows an app to read one-to-one and group chat messages, on behalf of the signed-in user.", - "displayName": "Read user chat messages", - "id": "cdcdac3a-fd45-410d-83ef-554db620e5c7", - "Origin": "Delegated", - "userConsentDescription": "Allows an app to read one-to-one or group chat messages in Microsoft Teams, on your behalf.", - "userConsentDisplayName": "Read user chat messages", - "value": "ChatMessage.Read" + "description": "Allows the app to read and write your organization's consent requests policy without a signed-in user.", + "displayName": "Read and write your organization's consent request policy", + "id": "999f8c63-0a38-4f1b-91fd-ed1947bdd1a9", + "origin": "Application (Microsoft Graph)", + "value": "Policy.ReadWrite.ConsentRequest" }, { - "description": "Allows a Teams app to read, install, upgrade, and uninstall all tabs in chats the signed-in user can access.", - "displayName": "Allow the Teams app to manage all tabs in chats", - "id": "ee928332-e9c2-4747-b4a0-f8c164b68de6", - "Origin": "Delegated", - "userConsentDescription": "Allows a Teams app to read, install, upgrade, and uninstall all tabs in chats you can access.", - "userConsentDisplayName": "Allow the Teams app to manage all tabs in chats", - "value": "TeamsTab.ReadWriteForChat" + "description": "Allows the app to read and write your organization's cross-tenant access policies and configuration for automatic user consent settings to suppress consent prompts for users of the other tenant on behalf of the signed-in user.", + "displayName": "Read and write your organization's cross tenant access policies", + "id": "338163d7-f101-4c92-94ba-ca46fe52447c", + "origin": "Application (Microsoft Graph)", + "value": "Policy.ReadWrite.CrossTenantAccess" }, { - "description": "Allows a Teams app to read, install, upgrade, and uninstall all tabs to teams the signed-in user can access.", - "displayName": "Allow the Teams app to manage all tabs in teams", - "id": "c975dd04-a06e-4fbb-9704-62daad77bb49", - "Origin": "Delegated", - "userConsentDescription": "Allows a Teams app to read, install, upgrade, and uninstall all tabs to teams you can access.", - "userConsentDisplayName": "Allow the app to manage all tabs in teams", - "value": "TeamsTab.ReadWriteForTeam" + "description": "Allows the app to read and write your organization's M365 cross tenant access capabilities without a signed-in user.", + "displayName": "Read and write your organization's M365 cross tenant access capabilities", + "id": "a6325ae7-2b73-4dbd-abed-fbeacfbf8696", + "origin": "Application (Microsoft Graph)", + "value": "Policy.ReadWrite.CrossTenantCapability" }, { - "description": "Allows a Teams app to read, install, upgrade, and uninstall all tabs for the signed-in user.", - "displayName": "Allow the Teams app to manage all tabs for a user", - "id": "c37c9b61-7762-4bff-a156-afc0005847a0", - "Origin": "Delegated", - "userConsentDescription": "Allows a Teams app to read, install, upgrade, and uninstall all tabs for you.", - "userConsentDisplayName": "Allow the Teams app to manage all tabs for you", - "value": "TeamsTab.ReadWriteForUser" + "description": "Allows the application to read and write your organization's device configuration policies without a signed-in user. For example, device registration policy can limit initial provisioning controls using quota restrictions, additional authentication and authorization checks.", + "displayName": "Read and write your organization's device configuration policies", + "id": "230fb2d5-aa21-49c1-bfa7-ae1be179d867", + "origin": "Application (Microsoft Graph)", + "value": "Policy.ReadWrite.DeviceConfiguration" }, { - "description": "Allows the app to read the API connectors used in user authentication flows, on behalf of the signed-in user.", - "displayName": "Read API connectors for authentication flows", - "id": "1b6ff35f-31df-4332-8571-d31ea5a4893f", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read the API connectors used in user authentication flows, on your behalf.", - "userConsentDisplayName": "Read API connectors for authentication flows", - "value": "APIConnectors.Read.All" + "description": "Allows the application to read and update the organization's external identities policy without a signed-in user. For example, external identities policy controls if users invited to access resources in your organization via B2B collaboration or B2B direct connect are allowed to self-service leave.", + "displayName": "Read and write your organization's external identities policy", + "id": "03cc4f92-788e-4ede-b93f-199424d144a5", + "origin": "Application (Microsoft Graph)", + "value": "Policy.ReadWrite.ExternalIdentities" }, { - "description": "Allows the app to read, create and manage the API connectors used in user authentication flows, on behalf of the signed-in user.", - "displayName": "Read and write API connectors for authentication flows", - "id": "c67b52c5-7c69-48b6-9d48-7b3af3ded914", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read, create and manage the API connectors used in user authentication flows, on your behalf.", - "userConsentDisplayName": "Read and write API connectors for authentication flows", - "value": "APIConnectors.ReadWrite.All" + "description": "Allows the app to read your organization's conditional access policies, without a signed-in user.", + "displayName": "Read your organization's conditional access policies", + "id": "37730810-e9ba-4e46-b07e-8ca78d182097", + "origin": "Application (Microsoft Graph)", + "value": "Policy.Read.ConditionalAccess" }, { - "description": "Read the members of chats, on behalf of the signed-in user.", - "displayName": "Read the members of chats", - "id": "c5a9e2b1-faf6-41d4-8875-d381aa549b24", - "Origin": "Delegated", - "userConsentDescription": "Read the members of chats, on your behalf.", - "userConsentDisplayName": "Read the members of chats", - "value": "ChatMember.Read" + "description": "Allows the app to read and write feature rollout policies without a signed-in user. Includes abilities to assign and remove users and groups to rollout of a specific feature.", + "displayName": "Read and write feature rollout policies", + "id": "2044e4f1-e56c-435b-925c-44cd8f6ba89a", + "origin": "Application (Microsoft Graph)", + "value": "Policy.ReadWrite.FeatureRollout" }, { - "description": "Add and remove members from chats, on behalf of the signed-in user.", - "displayName": "Add and remove members from chats", - "id": "dea13482-7ea6-488f-8b98-eb5bbecf033d", - "Origin": "Delegated", - "userConsentDescription": "Add and remove members from chats, on your behalf.", - "userConsentDisplayName": "Add and remove members from chats", - "value": "ChatMember.ReadWrite" + "description": "Allows the app to read all your organization's B2BManagement policies without a signed in user.", + "displayName": "Read your organization's B2BManagement policies", + "id": "227900ff-df89-40f8-90e2-8157cf6995d5", + "origin": "Application (Microsoft Graph)", + "value": "Policy.Read.B2BManagementPolicy" }, { - "description": "Allows the app to create chats on behalf of the signed-in user.", - "displayName": "Create chats", - "id": "38826093-1258-4dea-98f0-00003be2b8d0", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to create chats on your behalf.\u00a0", - "userConsentDisplayName": "Create chats", - "value": "Chat.Create" + "description": "Allows the app to read all your organization's policies without a signed in user.", + "displayName": "Read your organization's policies", + "id": "246dd0d5-5bd0-4def-940b-0421030a5b68", + "origin": "Application (Microsoft Graph)", + "value": "Policy.Read.All" }, { - "description": "Allows the application to read and write tenant-wide print settings on behalf of the signed-in user.", - "displayName": "Read and write tenant-wide print settings", - "id": "9ccc526a-c51c-4e5c-a1fd-74726ef50b8f", - "Origin": "Delegated", - "userConsentDescription": "Allows the application to read and write tenant-wide print settings on your behalf.", - "userConsentDisplayName": "Read and write tenant-wide print settings", - "value": "PrintSettings.ReadWrite.All" + "description": "Allows the app to read organization-wide Microsoft 365 apps installation settings, without a signed-in user.", + "displayName": "Read organization-wide Microsoft 365 apps installation settings", + "id": "6cdf1fb1-b46f-424f-9493-07247caa22e2", + "origin": "Application (Microsoft Graph)", + "value": "OrgSettings-Microsoft365Install.Read.All" }, { - "description": "Allows the application to read tenant-wide print settings on behalf of the signed-in user.", - "displayName": "Read tenant-wide print settings", - "id": "490f32fd-d90f-4dd7-a601-ff6cdc1a3f6c", - "Origin": "Delegated", - "userConsentDescription": "Allows the application to read tenant-wide print settings on your behalf.", - "userConsentDisplayName": "Read tenant-wide print settings", - "value": "PrintSettings.Read.All" + "description": "Allows the app to read and write organization-wide Microsoft 365 apps installation settings, without a signed-in user.", + "displayName": "Read and write organization-wide Microsoft 365 apps installation settings", + "id": "83f7232f-763c-47b2-a097-e35d2cbe1da5", + "origin": "Application (Microsoft Graph)", + "value": "OrgSettings-Microsoft365Install.ReadWrite.All" }, { - "description": "Allows the application to read and write print connectors on behalf of the signed-in user. ", - "displayName": "Read and write print connectors", - "id": "79ef9967-7d59-4213-9c64-4b10687637d8", - "Origin": "Delegated", - "userConsentDescription": "Allows the application to read and write print connectors on your behalf.", - "userConsentDisplayName": "Read and write print connectors", - "value": "PrintConnector.ReadWrite.All" + "description": "Allows the app to read organization-wide Microsoft To Do settings, without a signed-in user.", + "displayName": "Read organization-wide Microsoft To Do settings", + "id": "e4d9cd09-d858-4363-9410-abb96737f0cf", + "origin": "Application (Microsoft Graph)", + "value": "OrgSettings-Todo.Read.All" }, { - "description": "Allows the application to read print connectors on behalf of the signed-in user.", - "displayName": "Read print connectors", - "id": "d69c2d6d-4f72-4f99-a6b9-663e32f8cf68", - "Origin": "Delegated", - "userConsentDescription": "Allows the application to read print connectors on your behalf.", - "userConsentDisplayName": "Read print connectors", - "value": "PrintConnector.Read.All" + "description": "Allows the app to read and write organization-wide Microsoft To Do settings, without a signed-in user.", + "displayName": "Read and write organization-wide Microsoft To Do settings", + "id": "5febc9da-e0d0-4576-bd13-ae70b2179a39", + "origin": "Application (Microsoft Graph)", + "value": "OrgSettings-Todo.ReadWrite.All" }, { - "description": "Allows the application to read basic information about printer shares on behalf of the signed-in user. Does not allow reading access control information.", - "displayName": "Read basic information about printer shares", - "id": "5fa075e9-b951-4165-947b-c63396ff0a37", - "Origin": "Delegated", - "userConsentDescription": "Allows the application to read basic information about printer shares on your behalf.", - "userConsentDisplayName": "Read basic information about printer shares", - "value": "PrinterShare.ReadBasic.All" + "description": "Allows the app to read all of billing data from Microsoft for your company's tenant, without a signed-in user. This includes reading billed and unbilled azure usage and invoice reconciliation data.", + "displayName": "Read all billing data for your company's tenant", + "id": "7c3e1994-38ff-4412-a99b-9369f6bb7706", + "origin": "Application (Microsoft Graph)", + "value": "PartnerBilling.Read.All" }, { - "description": "Allows the application to create print jobs on behalf of the signed-in user and upload document content to print jobs that the signed-in user created.", - "displayName": "Create print jobs", - "id": "21f0d9c0-9f13-48b3-94e0-b6b231c7d320", - "Origin": "Delegated", - "userConsentDescription": "Allows the application to create print jobs on your behalf and upload document content to print jobs that you created.", - "userConsentDisplayName": "Create your print jobs", - "value": "PrintJob.Create" + "description": "Allows the app to read security alerts of customer with CSP relationship, without a signed-in user.", + "displayName": "Read security alerts of customer with CSP relationship", + "id": "21ffa320-2e7f-47d3-a466-7ff04d2dd68d", + "origin": "Application (Microsoft Graph)", + "value": "PartnerSecurity.Read.All" }, { - "description": "Allows the app to read Azure AD recommendations, on behalf of the signed-in user.", - "displayName": "Read Azure AD recommendations", - "id": "34d3bd24-f6a6-468c-b67c-0c365c1d6410", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read Azure AD recommendations, on your behalf.", - "userConsentDisplayName": "Read Azure AD recommendations", - "value": "DirectoryRecommendations.Read.All" + "description": "Allows the app to read security alerts and update status of alerts of customer with CSP relationship, without a signed-in user.", + "displayName": "Read security alerts and update status of security alerts of customer with CSP relationship", + "id": "04a2c935-5b4b-474a-be42-11f53111f271", + "origin": "Application (Microsoft Graph)", + "value": "PartnerSecurity.ReadWrite.All" }, { - "description": "Allows the application to list and query user profile information associated with the current tenant on behalf of the signed-in user.\u00a0 It also permits the application to export and remove external user data (e.g. customer content or system-generated logs), associated with the current tenant on behalf of the signed-in user.", - "displayName": "Read shared cross-tenant user profile and export or delete data", - "id": "eed0129d-dc60-4f30-8641-daf337a39ffd", - "Origin": "Delegated", - "userConsentDescription": "Allows the application to list and query shared user profile information associated with the current tenant on your behalf.\u00a0 It also permits the application to export and remove your external user data (e.g. customer content or system-generated logs), associated with the current tenant on your behalf.", - "userConsentDisplayName": "Read shared cross-tenant user profile and export or delete data", - "value": "CrossTenantUserProfileSharing.ReadWrite" + "description": "Allows the app to read available properties of pending external user profiles, without a signed-in user.", + "displayName": "Read all pending external user profiles", + "id": "bdfb26d9-bb36-49be-9b4c-b8cbf4b05808", + "origin": "Application (Microsoft Graph)", + "value": "PendingExternalUserProfile.Read.All" }, { - "description": "Allows the app to manage restricted resources based on the other permissions granted to the app, on behalf of the signed-in user.", - "displayName": "Manage restricted resources in the directory", - "id": "cba5390f-ed6a-4b7f-b657-0efc2210ed20", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to manage restricted resources based on the other permissions granted to the app, on your behalf.", - "userConsentDisplayName": "Manage restricted resources in the directory", - "value": "Directory.Write.Restricted" + "description": "Allows the app to read and write available properties of pending external user profiles, without a signed-in user.", + "displayName": "Read and write all pending external user profiles", + "id": "8363c2b8-6ff7-420b-9966-c5884c2d48bc", + "origin": "Application (Microsoft Graph)", + "value": "PendingExternalUserProfile.ReadWrite.All" }, { - "description": "Allows the app to read your organization's threat submission policies on behalf of the signed-in user. Also allows the app to create new threat submission policies on behalf of the signed-in user.", - "displayName": "Read and write all threat submission policies", - "id": "059e5840-5353-4c68-b1da-666a033fc5e8", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read your organization's threat submission policies on your behalf. Also allows the app to create new threat submission policies on your behalf.", - "userConsentDisplayName": "Read and write all threat submission policies", - "value": "ThreatSubmissionPolicy.ReadWrite.All" + "description": "Allows the app to read any user's scored list of relevant people, without a signed-in user. The list can include local contacts, contacts from social networking, your organization's directory, and people from recent communications (such as email and Skype).", + "displayName": "Read all users' relevant people lists", + "id": "b528084d-ad10-4598-8b93-929746b4d7d6", + "origin": "Application (Microsoft Graph)", + "value": "People.Read.All" }, { - "description": "Allows an app to read the browser site lists configured for your organization, on behalf of the signed-in user.", - "displayName": "Read browser site lists for your organization", - "id": "fb9be2b7-a7fc-4182-aec1-eda4597c43d5", - "Origin": "Delegated", - "userConsentDescription": "Allows an app to read the browser site lists configured for your organization, on your behalf.", - "userConsentDisplayName": "Read browser site lists for your organization", - "value": "BrowserSiteLists.Read.All" + "description": "Allows the application to read tenant-wide people settings without a signed-in user.", + "displayName": "Read all tenant-wide people settings", + "id": "ef02f2e7-e22d-4c77-8614-8f765683b86e", + "origin": "Application (Microsoft Graph)", + "value": "PeopleSettings.Read.All" }, { - "description": "Allows the application to list and query any shared user profile information associated with the current tenant on behalf of the signed-in user.\u00a0 It also permits the application to export and remove external user data (e.g. customer content or system-generated logs), for any user associated with the current tenant on behalf of the signed-in user.", - "displayName": "Read all shared cross-tenant user profiles and export or delete their data", - "id": "64dfa325-cbf8-48e3-938d-51224a0cac01", - "Origin": "Delegated", - "userConsentDescription": "Allows the application to list and query any shared user profile information associated with the current tenant on your behalf.\u00a0 It also permits the application to export and remove external user data (e.g. customer content or system-generated logs), for any user associated with the current tenant on your behalf.", - "userConsentDisplayName": "Read any shared cross-tenant user profiles and export or delete data", - "value": "CrossTenantUserProfileSharing.ReadWrite.All" + "description": "Allows the application to read and write tenant-wide people settings without a signed-in user.", + "displayName": "Read and write all tenant-wide people settings", + "id": "b6890674-9dd5-4e42-bb15-5af07f541ae1", + "origin": "Application (Microsoft Graph)", + "value": "PeopleSettings.ReadWrite.All" }, { - "description": "Allows the app to read the threat submissions and threat submission policies owned by the signed-in user.", - "displayName": "Read threat submissions", - "id": "fd5353c6-26dd-449f-a565-c4e16b9fce78", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read the threat submissions and threat submission policies that you own on your behalf.", - "userConsentDisplayName": "Read threat submissions", - "value": "ThreatSubmission.Read" + "description": "Allows the app to read company places (conference rooms and room lists) for calendar events and other applications, without a signed-in user.", + "displayName": "Read all company places", + "id": "913b9306-0ce1-42b8-9137-6a7df690a760", + "origin": "Application (Microsoft Graph)", + "value": "Place.Read.All" }, { - "description": "Allows the app to read the threat submissions and threat submission policies owned by the signed-in user. Also allows the app to create new threat submissions on behalf of the signed-in user.", - "displayName": "Read and write threat submissions", - "id": "68a3156e-46c9-443c-b85c-921397f082b5", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read the threat submissions and threat submission policies that you own. Also allows the app to create new threat submissions on your behalf.", - "userConsentDisplayName": "Read and write threat submissions", - "value": "ThreatSubmission.ReadWrite" + "description": "Allows the app to manage organization places (conference rooms and room lists) for calendar events and other applications, on behalf of the signed-in user.", + "displayName": "Read and write organization places", + "id": "f1f5e9aa-ad18-4b97-883e-6aa7e95b7a5f", + "origin": "Application (Microsoft Graph)", + "value": "Place.ReadWrite.All" }, { - "description": "Allows the app to read all recordings of online meetings, on behalf of the signed-in user.", - "displayName": "Read all recordings of online meetings.", - "id": "190c2bb6-1fdd-4fec-9aa2-7d571b5e1fe3", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read all recordings of online meetings, on your behalf.\u00a0", - "userConsentDisplayName": "Read all recordings of online meetings.\u00a0", - "value": "OnlineMeetingRecording.Read.All" + "description": "Allows the app to read all workplace devices, without a signed-in user.", + "displayName": "Read all workplace devices", + "id": "8b724a84-ceac-4fd9-897e-e31ba8f2d7a3", + "origin": "Application (Microsoft Graph)", + "value": "PlaceDevice.Read.All" }, { - "description": "Allows the application to obtain basic tenant information about another target tenant within the Azure AD ecosystem on behalf of the signed-in user.", - "displayName": "Read cross-tenant basic information", - "id": "81594d25-e88e-49cf-ac8c-fecbff49f994", - "Origin": "Delegated", - "userConsentDescription": "Allows the application to obtain basic tenant information about another target tenant within the Azure AD ecosystem on your behalf.", - "userConsentDisplayName": "Read cross-tenant basic information", - "value": "CrossTenantInformation.ReadBasic.All" + "description": "Allows the app to read and write all workplace devices, without a signed-in user.", + "displayName": "Read and write all workplace devices", + "id": "2d510721-5c4e-43cd-bfdb-ac0f8819fb92", + "origin": "Application (Microsoft Graph)", + "value": "PlaceDevice.ReadWrite.All" }, { - "description": "Allows the app to read your organization's authentication event listeners on behalf of the signed-in user.", - "displayName": "Read your organization's authentication event listeners", - "id": "f7dd3bed-5eec-48da-bc73-1c0ef50bc9a1", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read your organization's authentication event listeners on your behalf.", - "userConsentDisplayName": "Read your organization's authentication event listeners", - "value": "EventListener.Read.All" + "description": "Allows the app to read and write telemetry for all workplace devices, without a signed-in user.", + "displayName": "Read and write telemetry for all workplace devices.", + "id": "27fc435f-44e2-4b30-bf3c-e0ce74aed618", + "origin": "Application (Microsoft Graph)", + "value": "PlaceDeviceTelemetry.ReadWrite.All" }, { - "description": "Allows the app to read the Teams app settings on behalf of the signed-in user.", - "displayName": "Read Teams app settings", - "id": "44e060c4-bbdc-4256-a0b9-dcc0396db368", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read the Teams app settings on your behalf.", - "userConsentDisplayName": "Read Teams app settings", - "value": "TeamworkAppSettings.Read.All" + "description": "Allows the app to read all authentication method policies for the tenant, without a signed-in user. ", + "displayName": "Read authentication method policies", + "id": "8e3bc81b-d2f3-4b7b-838c-32c88218d2f0", + "origin": "Application (Microsoft Graph)", + "value": "Policy.Read.AuthenticationMethod" }, { - "description": "Allows\u00a0the\u00a0app\u00a0to\u00a0manage learning\u00a0content\u00a0in\u00a0the\u00a0organization's\u00a0directory, on behalf of the signed-in user.", - "displayName": "Manage\u00a0learning\u00a0content", - "id": "53cec1c4-a65f-4981-9dc1-ad75dbf1c077", - "Origin": "Delegated", - "userConsentDescription": "Allows\u00a0the\u00a0app\u00a0to\u00a0manage learning\u00a0content\u00a0in\u00a0the\u00a0organization's\u00a0directory, on your behalf.", - "userConsentDisplayName": "Manage learning content", - "value": "LearningContent.ReadWrite.All" + "description": "Allows the application to read and update the organization's federated token validation policy without a signed-in user.", + "displayName": "Read and write your organization's federated token validation policy", + "id": "90bbca0b-227c-4cdc-8083-1c6cfb95bac6", + "origin": "Application (Microsoft Graph)", + "value": "Policy.ReadWrite.FedTokenValidation" }, { - "description": "Allows the app to create, update, read, and delete data for the learning provider in the organization's directory, on behalf of the signed-in user.", - "displayName": "Manage\u00a0learning\u00a0provider", - "id": "40c2eb57-abaf-49f5-9331-e90fd01f7130", - "Origin": "Delegated", - "userConsentDescription": "Allows\u00a0the\u00a0app\u00a0to\u00a0create, update, read, and delete\u00a0data\u00a0for\u00a0the learning\u00a0provider\u00a0in\u00a0the organization's\u00a0directory, on your behalf.", - "userConsentDisplayName": "Manage learning provider", - "value": "LearningProvider.ReadWrite" + "description": "Allows the app to read and write your organization’s identity protection policy without a signed-in user.", + "displayName": "Read and write your organization’s identity protection policy ", + "id": "2dcf8603-09eb-4078-b1ec-d30a1a76b873", + "origin": "Application (Microsoft Graph)", + "value": "Policy.ReadWrite.IdentityProtection" }, { - "description": "Allows the app to read the lifecycle information like employeeLeaveDateTime of users in your organization, on behalf of the signed-in user.", - "displayName": "Read all users' lifecycle information", - "id": "ed8d2a04-0374-41f1-aefe-da8ac87ccc87", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read the lifecycle information like employeeLeaveDateTime of users in your organization, on behalf of the signed-in user.", - "userConsentDisplayName": "Read all users' lifecycle information", - "value": "User-LifeCycleInfo.Read.All" + "description": "Allows the app to manage policies related to consent and permission grants for applications, without a signed-in user.", + "displayName": "Manage consent and permission grant policies", + "id": "a402ca1c-2696-4531-972d-6e5ee4aa11ea", + "origin": "Application (Microsoft Graph)", + "value": "Policy.ReadWrite.PermissionGrant" }, { - "description": "Allows an app to read and write the browser site lists configured for your organization, on behalf of the signed-in user.", - "displayName": "Read and write browser site lists for your organization", - "id": "83b34c85-95bf-497b-a04e-b58eca9d49d0", - "Origin": "Delegated", - "userConsentDescription": "Allows an app to read and write the browser site lists configured for your organization, on your behalf.", - "userConsentDisplayName": "Read and write browser site lists for your organization", - "value": "BrowserSiteLists.ReadWrite.All" + "description": "Allows the app to read and write Privileged Access (PIM) custom extensions for your organization, without a signed-in user.", + "displayName": "Read and write Privileged Access (PIM) custom extensions", + "id": "124325f3-0c46-4c57-a050-d6d1a82510f6", + "origin": "Application (Microsoft Graph)", + "value": "PrivilegedAccess-CustomExt.ReadWrite.All" }, { - "description": "Allows the application to list and query user profile information associated with the current tenant on behalf of the signed-in user.\u00a0 It also permits the application to export external user data (e.g. customer content or system-generated logs), associated with the current tenant on behalf of the signed-in user.", - "displayName": "Read shared cross-tenant user profile and export data", - "id": "cb1ba48f-d22b-4325-a07f-74135a62ee41", - "Origin": "Delegated", - "userConsentDescription": "Allows the application to list and query shared user profile information associated with the current tenant on your behalf.\u00a0 It also permits the application to export your external user data (e.g. customer content or system-generated logs), associated with the current tenant on your behalf.", - "userConsentDisplayName": "Read shared cross-tenant user profile and export data", - "value": "CrossTenantUserProfileSharing.Read" + "description": "Allows the app to read time-based assignment schedules for access to Azure AD groups, without a signed-in user.", + "displayName": "Read assignment schedules for access to Azure AD groups", + "id": "cd4161cb-f098-48f8-a884-1eda9a42434c", + "origin": "Application (Microsoft Graph)", + "value": "PrivilegedAssignmentSchedule.Read.AzureADGroup" }, { - "description": "Allows the app to read admin report settings, such as whether to display concealed information in reports, on behalf of the signed-in user", - "displayName": "Read admin report settings", - "id": "84fac5f4-33a9-4100-aa38-a20c6d29e5e7", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read admin report settings, such as whether to display concealed information in reports, on your behalf.", - "userConsentDisplayName": "Read admin report settings", - "value": "ReportSettings.Read.All" + "description": "Allows the app to read time-based assignment schedules for permission grants for application permissions to any API (including Microsoft Graph) and application assignments for any app, without a signed-in user.", + "displayName": "Read assignment schedules for app permission grants and app role assignments", + "id": "3a728f2e-df1d-4294-9899-86f601fae70a", + "origin": "Application (Microsoft Graph)", + "value": "PrivilegedAssignmentSchedule.Read.EntraAppRole" }, { - "description": "Allows the app to read and write the lifecycle information like employeeLeaveDateTime of users in your organization, on behalf of the signed-in user.", - "displayName": "Read and write all users' lifecycle information", - "id": "7ee7473e-bd4b-4c9f-987c-bd58481f5fa2", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write the lifecycle information like employeeLeaveDateTime of users in your organization, on behalf of the signed-in user.", - "userConsentDisplayName": "Read and write all users' lifecycle information", - "value": "User-LifeCycleInfo.ReadWrite.All" + "description": "Allows the app to read, create, and delete time-based assignment schedules for access to Azure AD groups, without a signed-in user.", + "displayName": "Read, create, and delete assignment schedules for access to Azure AD groups", + "id": "41202f2c-f7ab-45be-b001-85c9728b9d69", + "origin": "Application (Microsoft Graph)", + "value": "PrivilegedAssignmentSchedule.ReadWrite.AzureADGroup" }, { - "description": "Allows the app to read and update Azure AD recommendations, on behalf of the signed-in user. ", - "displayName": "Read and update Azure AD recommendations", - "id": "f37235e8-90a0-4189-93e2-e55b53867ccd", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and update Azure AD recommendations, on your behalf.", - "userConsentDisplayName": "Read and update Azure AD recommendations", - "value": "DirectoryRecommendations.ReadWrite.All" + "description": "Allows the app to read, create, and delete time-based assignment schedules for permission grants for application permissions to any API (including Microsoft Graph) and application assignments for any app, without a signed-in user.", + "displayName": "Read, create, and delete assignment schedules for app permission grants and app role assignments", + "id": "81adad77-a25a-489d-ac43-321115620139", + "origin": "Application (Microsoft Graph)", + "value": "PrivilegedAssignmentSchedule.ReadWrite.EntraAppRole" }, { - "description": "Allows the app to read your organization's threat submissions and threat submission policies on behalf of the signed-in user.", - "displayName": "Read all threat submissions", - "id": "7083913a-4966-44b6-9886-c5822a5fd910", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read your organization's threat submissions and threat submission policies on your behalf.", - "userConsentDisplayName": "Read all threat submissions", - "value": "ThreatSubmission.Read.All" + "description": "Delete time-based assignment schedules for access to Azure AD groups, without a signed-in user.", + "displayName": "Delete assignment schedules for access to Azure AD groups", + "id": "55d1104b-3821-413d-b3ca-e2393d333cd3", + "origin": "Application (Microsoft Graph)", + "value": "PrivilegedAssignmentSchedule.Remove.AzureADGroup" }, { - "description": "Allows the app to read learning content in the organization's directory, on behalf of the signed-in user.", - "displayName": "Read learning content", - "id": "ea4c1fd9-6a9f-4432-8e5d-86e06cc0da77", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read learning content in the organization's directory, on your behalf.", - "userConsentDisplayName": "Read learning content", - "value": "LearningContent.Read.All" + "description": "Allows the app to read time-based eligibility schedules for access to Azure AD groups, without a signed-in user.", + "displayName": "Read eligibility schedules for access to Azure AD groups", + "id": "edb419d6-7edc-42a3-9345-509bfdf5d87c", + "origin": "Application (Microsoft Graph)", + "value": "PrivilegedEligibilitySchedule.Read.AzureADGroup" }, { - "description": "Allows the app to read data for the learning provider in the organization's directory, on behalf of the signed-in user.", - "displayName": "Read learning provider", - "id": "dd8ce36f-9245-45ea-a99e-8ac398c22861", - "Origin": "Delegated", - "userConsentDescription": "Allows\u00a0the\u00a0app\u00a0to\u00a0read\u00a0data\u00a0for\u00a0the learning\u00a0provider\u00a0in\u00a0the organization's\u00a0directory, on your behalf.", - "userConsentDisplayName": "Read learning provider", - "value": "LearningProvider.Read" + "description": "Allows the app to read time-based eligibility schedules for permission grants for application permissions to any API (including Microsoft Graph) and application assignments for any app, without a signed-in user.", + "displayName": "Read eligibility schedules for app permission grants and app role assignments", + "id": "d2ab45a0-ed46-4f7f-806a-0f1146144d5a", + "origin": "Application (Microsoft Graph)", + "value": "PrivilegedEligibilitySchedule.Read.EntraAppRole" }, { - "description": "Allows the app to create, update, list, read and delete all workflows, tasks and related lifecycle workflows resources on behalf of the signed-in user.", - "displayName": "Read and write all lifecycle workflows resources", - "id": "84b9d731-7db8-4454-8c90-fd9e95350179", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to create, update, list, read and delete all workflows, tasks and related lifecycle workflows resources on your behalf.", - "userConsentDisplayName": "Read and write all lifecycle workflows resources", - "value": "LifecycleWorkflows.ReadWrite.All" + "description": "Allows the app to read, create, and delete time-based eligibility schedules for access to Azure AD groups, without a signed-in user.", + "displayName": "Read, create, and delete eligibility schedules for access to Azure AD groups", + "id": "618b6020-bca8-4de6-99f6-ef445fa4d857", + "origin": "Application (Microsoft Graph)", + "value": "PrivilegedEligibilitySchedule.ReadWrite.AzureADGroup" }, { - "description": "Allows an app to read all bookmarks that the signed-in user can access.", - "displayName": "Read all bookmarks that the user can access", - "id": "98b17b35-f3b1-4849-a85f-9f13733002f0", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read all bookmarks you can access.", - "userConsentDisplayName": "Read all bookmarks that you have access to", - "value": "Bookmark.Read.All" + "description": "Allows the app to read, create, and delete time-based eligibility schedules for permission grants for application permissions to any API (including Microsoft Graph) and application assignments for any app, without a signed-in user.", + "displayName": "Read, create, and delete eligibility schedules for app permission grants and app role assignments", + "id": "7f4c39f1-1aa7-44b7-ab05-38df2609c37a", + "origin": "Application (Microsoft Graph)", + "value": "PrivilegedEligibilitySchedule.ReadWrite.EntraAppRole" }, { - "description": "Allows the application to read and change the tenant-level settings of SharePoint and OneDrive on behalf of the signed-in user.", - "displayName": "Read and change SharePoint and OneDrive tenant settings", - "id": "aa07f155-3612-49b8-a147-6c590df35536", - "Origin": "Delegated", - "userConsentDescription": "Allows the application to read and change the tenant-level settings of SharePoint and OneDrive on your behalf.", - "userConsentDisplayName": "Read and change SharePoint and OneDrive tenant settings", - "value": "SharePointTenantSettings.ReadWrite.All" + "description": "Delete time-based eligibility schedules for access to Azure AD groups, without a signed-in user.", + "displayName": "Delete eligibility schedules for access to Azure AD groups", + "id": "55745561-7572-4314-a737-a2c2a1b0dd2e", + "origin": "Application (Microsoft Graph)", + "value": "PrivilegedEligibilitySchedule.Remove.AzureADGroup" }, { - "description": "Allows the app to read or write your organization's authentication event listeners on behalf of the signed-in user.", - "displayName": "Read and write your organization's authentication event listeners", - "id": "d11625a6-fe21-4fc6-8d3d-063eba5525ad", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read or write your organization's authentication event listeners on your behalf.", - "userConsentDisplayName": "Read and write your organization's authentication event listeners", - "value": "EventListener.ReadWrite.All" + "description": "Allows the app to read all profile photos of users and groups, without a signed-in user", + "displayName": "Read profile photo of a user or group", + "id": "e24d31aa-e1ab-4c80-85fe-23018690335d", + "origin": "Application (Microsoft Graph)", + "value": "ProfilePhoto.Read.All" }, { - "description": "Allows the app to read and write the Teams app settings on behalf of the signed-in user.", - "displayName": "Read and write Teams app settings", - "id": "87c556f0-2bd9-4eed-bd74-5dd8af6eaf7e", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write the Teams app settings on your behalf.", - "userConsentDisplayName": "Read and write Teams app settings", - "value": "TeamworkAppSettings.ReadWrite.All" + "description": "Allows the app to read and write all profile photos of users and groups, without a signed-in user", + "displayName": "Read and write profile photo of a user or group", + "id": "27baa7f6-5dfb-4ba8-b1d3-1e812c143013", + "origin": "Application (Microsoft Graph)", + "value": "ProfilePhoto.ReadWrite.All" }, { - "description": "Allows the app to read all authentication context information in your organization on behalf of the signed-in user.", - "displayName": "Read all authentication context information", - "id": "57b030f1-8c35-469c-b0d9-e4a077debe70", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read all authentication context information in your organization on your behalf.", - "userConsentDisplayName": "Read all authentication context information", - "value": "AuthenticationContext.Read.All" + "description": "Allows the app to read programs and program controls in the organization, without a signed-in user.", + "displayName": "Read all programs", + "id": "eedb7fdd-7539-4345-a38b-4839e4a84cbd", + "origin": "Application (Microsoft Graph)", + "value": "ProgramControl.Read.All" }, { - "description": "Allows the app to read and update all authentication context information in your organization on behalf of the signed-in user.", - "displayName": "Read and write all authentication context information", - "id": "ba6d575a-1344-4516-b777-1404f5593057", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and update all authentication context information in your organization on your behalf.", - "userConsentDisplayName": "Read and write all authentication context information", - "value": "AuthenticationContext.ReadWrite.All" + "description": "Allows the app to read, update, delete and perform actions on programs and program controls in the organization, without a signed-in user.", + "displayName": "Manage all programs", + "id": "60a901ed-09f7-4aa5-a16e-7dd3d6f9de36", + "origin": "Application (Microsoft Graph)", + "value": "ProgramControl.ReadWrite.All" }, { - "description": "Allows the app to read and update admin report settings, such as whether to display concealed information in reports, on behalf of the signed-in user.", - "displayName": "Read and write admin report settings", - "id": "b955410e-7715-4a88-a940-dfd551018df3", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and update admin report settings, such as whether to display concealed information in reports, on your behalf.", - "userConsentDisplayName": "Read and write admin report settings", - "value": "ReportSettings.ReadWrite.All" + "description": "Allows the app to identify Purview data protection, compliance and governance policy scopes defined for all users across tenant.", + "displayName": "Compute Purview policies at tenant scope", + "id": "e5a76501-dbb0-492c-ab55-5d09e8837263", + "origin": "Application (Microsoft Graph)", + "value": "ProtectionScopes.Compute.All" }, { - "description": "Allows the app to list and read all workflows, tasks and related lifecycle workflows resources on behalf of the signed-in user.", - "displayName": "Read all lifecycle workflows resources", - "id": "9bcb9916-765a-42af-bf77-02282e26b01a", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to list and read all workflows, tasks and related lifecycle workflows resources on your behalf.", - "userConsentDisplayName": "Read all lifecycle workflows resources", - "value": "LifecycleWorkflows.Read.All" + "description": "Allows the app to identify Purview data protection, compliance and governance policy scopes defined for an individual user.", + "displayName": "Compute Purview policies for an individual user", + "id": "fe696d63-5e1f-4515-8232-cccc316903c6", + "origin": "Application (Microsoft Graph)", + "value": "ProtectionScopes.Compute.User" }, { - "description": "Allows the application to list and query any shared user profile information associated with the current tenant on behalf of the signed-in user.\u00a0 It also permits the application to export external user data (e.g. customer content or system-generated logs), for any user associated with the current tenant on behalf of the signed-in user.", - "displayName": "Read all shared cross-tenant user profiles and export their data", - "id": "759dcd16-3c90-463c-937e-abf89f991c18", - "Origin": "Delegated", - "userConsentDescription": "Allows the application to list and query any shared user profile information associated with the current tenant on your behalf.\u00a0 It also permits the application to export external user data (e.g. customer content or system-generated logs), for any user associated with the current tenant on your behalf.", - "userConsentDisplayName": "Read any shared cross-tenant user profiles and export data", - "value": "CrossTenantUserProfileSharing.Read.All" + "description": "Allows the app to read Privileged Access (PIM) custom extensions for your organization, without a signed-in user.", + "displayName": "Read Privileged Access (PIM) custom extensions", + "id": "e7ebe2d9-6e26-487a-8286-191d623a6904", + "origin": "Application (Microsoft Graph)", + "value": "PrivilegedAccess-CustomExt.Read.All" }, { - "description": "Allows the application to read the tenant-level settings in SharePoint and OneDrive on behalf of the signed-in user.", - "displayName": "Read SharePoint and OneDrive tenant settings", - "id": "2ef70e10-5bfd-4ede-a5f6-67720500b258", - "Origin": "Delegated", - "userConsentDescription": "Allows the application to read the tenant-level settings in SharePoint and OneDrive on your behalf.", - "userConsentDisplayName": "Read SharePoint and OneDrive tenant settings", - "value": "SharePointTenantSettings.Read.All" + "description": "Allows the app to request and manage time-based assignment and just-in-time elevation of Azure resources (like your subscriptions, resource groups, storage, compute) in your organization, without a signed-in user.", + "displayName": "Read and write privileged access to Azure resources", + "id": "6f9d5abc-2db6-400b-a267-7de22a40fb87", + "origin": "Application (Microsoft Graph)", + "value": "PrivilegedAccess.ReadWrite.AzureResources" }, { - "description": "Allows the app to read or write your organization's custom authentication extensions on behalf of the signed-in user.", - "displayName": "Read and write your organization's custom authentication extensions", - "id": "8dfcf82f-15d0-43b3-bc78-a958a13a5792", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read or write your organization's custom authentication extensions on your behalf.", - "userConsentDisplayName": "Read and write your organization's custom authentication extensions", - "value": "CustomAuthenticationExtension.ReadWrite.All" + "description": "Allows the app to request and manage time-based assignment and just-in-time elevation (including scheduled elevation) of Azure AD groups in your organization, without a signed-in user.", + "displayName": "Read and write privileged access to Azure AD groups", + "id": "2f6817f8-7b12-4f0f-bc18-eeaf60705a9e", + "origin": "Application (Microsoft Graph)", + "value": "PrivilegedAccess.ReadWrite.AzureADGroup" }, { - "description": "Allows an app to manage license assignments for users and groups, on behalf of the signed-in user.", - "displayName": "Manage all license assignments", - "id": "f55016cc-149c-447e-8f21-7cf3ec1d6350", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to manage all license assignments, on your behalf.", - "userConsentDisplayName": "Manage all license assignments", - "value": "LicenseAssignment.ReadWrite.All" + "description": "Allows the app to request and manage time-based assignment and just-in-time elevation (including scheduled elevation) of Azure AD built-in and custom administrative roles in your organization, without a signed-in user.", + "displayName": "Read and write privileged access to Azure AD roles", + "id": "854d9ab1-6657-4ec8-be45-823027bcd009", + "origin": "Application (Microsoft Graph)", + "value": "PrivilegedAccess.ReadWrite.AzureAD" }, { - "description": "Allows an app to read all acronyms that the signed-in user can access.", - "displayName": "Read all acronyms that the user can access", - "id": "9084c10f-a2d6-4713-8732-348def50fe02", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read all acronyms you can access.", - "userConsentDisplayName": "Read all acronyms that you have access to", - "value": "Acronym.Read.All" + "description": "Allows the application to read and update the organization's recovery policy without a signed-in user.", + "displayName": "Read and write your organization's recovery policy", + "id": "795fc94d-3deb-4632-b1eb-e6d1a5f44918", + "origin": "Application (Microsoft Graph)", + "value": "Policy.ReadWrite.Recovery" }, { - "description": "Allows the app to read your organization's custom authentication extensions on behalf of the signed-in user.", - "displayName": "Read your oganization's custom authentication extensions", - "id": "b2052569-c98c-4f36-a5fb-43e5c111e6d0", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read your organization's custom authentication extensions on your behalf.", - "userConsentDisplayName": "Read your organization's custom authentication extensions", - "value": "CustomAuthenticationExtension.Read.All" + "description": "Allows the app to read and write your organization's security defaults policy, without a signed-in user.", + "displayName": "Read and write your organization's security defaults policy", + "id": "1c6e93a6-28e2-4cbb-9f64-1a46a821124d", + "origin": "Application (Microsoft Graph)", + "value": "Policy.ReadWrite.SecurityDefaults" }, { - "description": "Allows the app to read all transcripts of online meetings, on behalf of the signed-in user.", - "displayName": "Read all transcripts of online meetings. ", - "id": "30b87d18-ebb1-45db-97f8-82ccb1f0190c", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read all transcripts of online meetings, on your behalf.", - "userConsentDisplayName": "Read all transcripts of online meetings.", - "value": "OnlineMeetingTranscript.Read.All" + "description": "Allows the app to read and write your organization's trust framework policies without a signed in user.", + "displayName": "Read and write your organization's trust framework policies", + "id": "79a677f7-b79d-40d0-a36a-3e6f8688dd7a", + "origin": "Application (Microsoft Graph)", + "value": "Policy.ReadWrite.TrustFramework" }, { - "description": "Allows the app to read and write channel messages, on behalf of the signed-in user. This doesn't allow the app to edit the policyViolation of a channel message.", - "displayName": "Read and write user channel messages", - "id": "5922d31f-46c8-4404-9eaf-2117e390a8a4", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write channel messages, on your behalf. This doesn't allow the app to edit the policyViolation of a channel message.", - "userConsentDisplayName": "Read and write user channel messages", - "value": "ChannelMessage.ReadWrite" + "description": "Allows the app to read preauthorization grants for service principals without a signed-in user.", + "displayName": "Read all preauthorization grants", + "id": "66ae8ecc-328f-47f6-97ca-4d9e952df081", + "origin": "Application (Microsoft Graph)", + "value": "PreAuthorizationGrant.Read.All" }, { - "description": "Read Threat and Vulnerability Management vulnerability information", - "displayName": "Allows the app to read any Threat and Vulnerability Management vulnerability information", - "id": "63a677ce-818c-4409-9d12-5c6d2e2a6bfe", - "Origin": "Application (WindowsDefenderATP)", - "userConsentDescription": "Allows the app to read any Threat and Vulnerability Management vulnerability information", - "userConsentDisplayName": "Allows the app to read any Threat and Vulnerability Management vulnerability information", - "value": "Vulnerability.Read.All" + "description": "Allows the app to read presence information of all users in the directory without a signed-in user. Presence information includes activity, availability, status note, calendar out-of-office message, timezone and location.", + "displayName": "Read presence information for all users", + "id": "a70e0c2d-e793-494c-94c4-118fa0a67f42", + "origin": "Application (Microsoft Graph)", + "value": "Presence.Read.All" }, { - "description": "Allows the app to read Threat and Vulnerability Management vulnerability information on behalf of the signed-in user", - "displayName": "Read Threat and Vulnerability Management vulnerability information", - "id": "41269fc5-d04d-4bfd-bce7-43a51cea049a", - "Origin": "Delegated (WindowsDefenderATP)", - "userConsentDescription": "Allows the app to read Threat and Vulnerability Management vulnerability information on behalf of the signed-in user", - "userConsentDisplayName": "Read Threat and Vulnerability Management vulnerability information", - "value": "Vulnerability.Read" + "description": "Allows the app to read all presence information and write activity and availability of all users in the directory without a signed-in user. Presence information includes activity, availability, status note, calendar out-of-office message, time zone and location.", + "displayName": "Read and write presence information for all users", + "id": "83cded22-8297-4ff6-a7fa-e97e9545a259", + "origin": "Application (Microsoft Graph)", + "value": "Presence.ReadWrite.All" }, { - "description": "Allows the app to manage Exchange Online", - "displayName": "Manage Exchange online", - "id": "ab4f2b77-0b06-4fc1-a9de-02113fc2ab7c", - "Origin": "Delegated (Office 365 Exchange Online)", - "userConsentDescription": "Allows the app to read Threat and Vulnerability Management vulnerability information on behalf of the signed-in user", - "userConsentDisplayName": "Read Threat and Vulnerability Management vulnerability information", - "value": "Exchange.Manage" + "description": "Allows the application to read printers without a signed-in user. ", + "displayName": "Read printers", + "id": "9709bb33-4549-49d4-8ed9-a8f65e45bb0f", + "origin": "Application (Microsoft Graph)", + "value": "Printer.Read.All" }, { - "description": "Allows the app to create, read, update and delete events in all calendars in the organization user has permissions to access. This includes delegate and shared calendars", - "displayName": "Read and write user and shared calendars", - "id": "bbd1ca91-75e0-4814-ad94-9c5dbbae3415", - "Origin": "Delegated (Office 365 Exchange Online)", - "userConsentDescription": "Allows the app to read, update, create and delete events in all calendars in your organization you have permissions to access. This includes delegate and shared calendars", - "userConsentDisplayName": "Read and write to your and shared calendars", - "value": "Calendars.ReadWrite.All" + "description": "Allows the application to read and update printers without a signed-in user. Does not allow creating (registering) or deleting (unregistering) printers.", + "displayName": "Read and update printers", + "id": "f5b3f73d-6247-44df-a74c-866173fddab0", + "origin": "Application (Microsoft Graph)", + "value": "Printer.ReadWrite.All" }, { - "description": "Allows the app to create, read, update, and delete user's mailbox settings. Does not include permission to send mail.", - "displayName": "Read and write user mailbox settings", - "id": "2e83d72d-8895-4b66-9eea-abb43449ab8b", - "Origin": "Delegated (Office 365 Exchange Online)", - "userConsentDescription": "Allows the app to read, update, create, and delete your mailbox settings.", - "userConsentDisplayName": "Read and write to your mailbox settings", - "value": "MailboxSettings.ReadWrite" + "description": "Allows the application to read certificate-based authentication configuration such as all public key infrastructures (PKI) and certificate authorities (CA) configured for the organization, without a signed-in user.", + "displayName": "Read all certificate based authentication configurations", + "id": "214fda0c-514a-4650-b037-b562b1a66124", + "origin": "Application (Microsoft Graph)", + "value": "PublicKeyInfrastructure.Read.All" }, { - "description": "Allows the app to have full control of all site collections on behalf of the signed-in user.", - "displayName": "Manage SharePoint Online", - "id": "56680e0d-d2a3-4ae1-80d8-3c4f2100e3d0", - "Origin": "Delegated (Office 365 SharePoint Online)", - "userConsentDescription": "Have full control of all site collections", - "userConsentDisplayName": "Allows the app to have full control of all site collections on your behalf.", - "value": "AllSites.FullControl" + "description": "Allows the application to perform advanced operations like redirecting a print job to another printer without a signed-in user. Also allows the application to read and update the metadata of print jobs.", + "displayName": "Perform advanced operations on print jobs", + "id": "58a52f47-9e36-4b17-9ebe-ce4ef7f3e6c8", + "origin": "Application (Microsoft Graph)", + "value": "PrintJob.Manage.All" }, { - "description": "Allows to read the LAPS passwords.", - "displayName": "Manage LAPS passwords", - "id": "280b3b69-0437-44b1-bc20-3b2fca1ee3e9", - "Origin": "Delegated", - "userConsentDescription": "Allows to read the LAPS passwords.", - "userConsentDisplayName": "Manage LAPS passwords", - "value": "DeviceLocalCredential.Read.All" + "description": "Allows the application to read the metadata of print jobs without a signed-in user. Does not allow access to print job document content.", + "displayName": "Read basic information for print jobs", + "id": "fbf67eee-e074-4ef7-b965-ab5ce1c1f689", + "origin": "Application (Microsoft Graph)", + "value": "PrintJob.ReadBasic.All" }, { - "description": "Access Microsoft Teams and Skype for Business data as the signed in user", - "displayName": "Access Microsoft Teams and Skype for Business data based on the user's role membership", - "id": "e60370c1-e451-437e-aa6e-d76df38e5f15", - "Origin": "Delegated (Skype and Teams Tenant Admin API)", - "userConsentDescription": "Access Microsoft Teams and Skype for Business data as the signed in user", - "userConsentDisplayName": "Access Microsoft Teams and Skype for Business data based on the user's role membership", - "value": "user_impersonation" + "description": "Allows the application to read and update the metadata and document content of print jobs without a signed-in user.", + "displayName": "Read and write print jobs", + "id": "5114b07b-2898-4de7-a541-53b0004e2e13", + "origin": "Application (Microsoft Graph)", + "value": "PrintJob.ReadWrite.All" }, { - "description": "Read and write all on-premises directory synchronization information", - "displayName": "Read and write all on-premises directory synchronization information", - "id": "c2d95988-7604-4ba1-aaed-38a5f82a51c7", - "Origin": "Delegated", - "userConsentDescription": "Access Microsoft Teams and Skype for Business data as the signed in user", - "userConsentDisplayName": "Access Microsoft Teams and Skype for Business data based on the user's role membership", - "value": "OnPremDirectorySynchronization.ReadWrite.All" + "description": "Allows the application to read and update the metadata of print jobs without a signed-in user. Does not allow access to print job document content.", + "displayName": "Read and write basic information for print jobs", + "id": "57878358-37f4-4d3a-8c20-4816e0d457b1", + "origin": "Application (Microsoft Graph)", + "value": "PrintJob.ReadWriteBasic.All" }, { - "description": "Read and Modify Tenant-Acquired Telephone Number Details", - "displayName": "Read and Modify Tenant-Acquired Telephone Number Details", - "id": "424b07a8-1209-4d17-9fe4-9018a93a1024", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and modify your tenant's acquired telephone number details on behalf of the signed-in admin user. Acquired telephone numbers may include attributes related to assigned object, emergency location, network site, etc.", - "userConsentDisplayName": "Allows the app to read and modify your tenant's acquired telephone number details on behalf of the signed-in admin user. Acquired telephone numbers may include attributes related to assigned object, emergency location, network site, etc.", - "value": "TeamsTelephoneNumber.ReadWrite.All" + "description": "Allows the application to read tenant-wide print settings without a signed-in user.", + "displayName": "Read tenant-wide print settings", + "id": "b5991872-94cf-4652-9765-29535087c6d8", + "origin": "Application (Microsoft Graph)", + "value": "PrintSettings.Read.All" }, { - "description": "Read Teams user configurations", - "displayName": "Read Teams user configurations", - "id": "5c469ce4-dab5-4afd-b9de-14f1ba4004a7", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read your tenant's user configurations on behalf of the signed-in admin user. User configuration may include attributes related to user, such as telephone number, assigned policies, etc.", - "userConsentDisplayName": "Read Teams user configurations", - "value": "TeamsUserConfiguration.Read.All" + "description": "Allows the application to read and update print task definitions without a signed-in user. ", + "displayName": "Read, write and update print task definitions", + "id": "456b71a7-0ee0-4588-9842-c123fcc8f664", + "origin": "Application (Microsoft Graph)", + "value": "PrintTaskDefinition.ReadWrite.All" }, { - "description": "Read and Modify Tenant-Acquired Telephone Number Details", - "displayName": "Read and Modify Tenant-Acquired Telephone Number Details", - "id": "0a42382f-155c-4eb1-9bdc-21548ccaa387", - "Origin": "Application", - "userConsentDescription": "Allows the app to read your tenant's acquired telephone number details, without a signed-in user. Acquired telephone numbers may include attributes related to assigned object, emergency location, network site, etc.", - "userConsentDisplayName": "Allows the app to read your tenant's acquired telephone number details, without a signed-in user. Acquired telephone numbers may include attributes related to assigned object, emergency location, network site, etc.", - "value": "TeamsTelephoneNumber.ReadWrite.All" + "description": "Allows the app to read time-based assignment and just-in-time elevation (including scheduled elevation) of Azure AD built-in and custom administrative roles in your organization, without a signed-in user.", + "displayName": "Read privileged access to Azure AD roles", + "id": "4cdc2547-9148-4295-8d11-be0db1391d6b", + "origin": "Application (Microsoft Graph)", + "value": "PrivilegedAccess.Read.AzureAD" }, { - "description": "Read Teams user configurations", - "displayName": "Read Teams user configurations", - "id": "a91eadaf-2c3c-4362-908b-fb172d208fc6", - "Origin": "Application", - "userConsentDescription": "Allows the app to read your tenant's user configurations, without a signed-in user. User configuration may include attributes related to user, such as telephone number, assigned policies, etc.", - "userConsentDisplayName": "Read Teams user configurations", - "value": "TeamsUserConfiguration.Read.All" + "description": "Allows the app to read time-based assignment and just-in-time elevation (including scheduled elevation) of Azure AD groups in your organization, without a signed-in user.", + "displayName": "Read privileged access to Azure AD groups", + "id": "01e37dc9-c035-40bd-b438-b2879c4870a6", + "origin": "Application (Microsoft Graph)", + "value": "PrivilegedAccess.Read.AzureADGroup" }, { - "description": "Allows the app to read and write Copilot policy settings for the organization, on behalf of the signed-in user.", - "displayName": "Read and write Copilot policy settings", - "id": "e2edbde8-4448-4e49-8ebb-d53ba72df0f3", - "Origin": "Delegated", - "userConsentDescription": "Allows the app to read and write Copilot policy settings for the organization, on your behalf.", - "userConsentDisplayName": "Read and write Copilot policy settings", - "value": "CopilotPolicySettings.ReadWrite" + "description": "Allows the app to read time-based assignment and just-in-time elevation of user privileges to audit Azure resources in your organization, without a signed-in user.", + "displayName": "Read privileged access to Azure resources", + "id": "5df6fe86-1be0-44eb-b916-7bd443a71236", + "origin": "Application (Microsoft Graph)", + "value": "PrivilegedAccess.Read.AzureResources" }, { - "description": "Allows the app to read and write Copilot policy settings for the organization, without a signed-in user.", - "displayName": "Read and write Copilot policy settings", - "id": "cc147c17-b8e8-4d3f-9f94-aa9e279a079a", - "Origin": "Application", - "userConsentDescription": "Allows the app to read and write Copilot policy settings for the organization, without a signed-in user.", - "userConsentDisplayName": "Read and write Copilot policy settings", - "value": "CopilotPolicySettings.ReadWrite" + "description": "Allows the application to read the metadata and document content of print jobs without a signed-in user. ", + "displayName": "Read print jobs", + "id": "ac6f956c-edea-44e4-bd06-64b1b4b9aec9", + "origin": "Application (Microsoft Graph)", + "value": "PrintJob.Read.All" + }, + { + "description": "Access Microsoft Teams and Skype for Business data as the signed in user", + "displayName": "Access Microsoft Teams and Skype for Business data based on the user's role membership", + "id": "e60370c1-e451-437e-aa6e-d76df38e5f15", + "origin": "Delegated (Skype and Teams Tenant Admin API)", + "value": "user_impersonation" } ] diff --git a/Config/SAMManifest.json b/Config/SAMManifest.json index e9d5640e8f554..b4ddb1320508b 100644 --- a/Config/SAMManifest.json +++ b/Config/SAMManifest.json @@ -23,6 +23,10 @@ { "resourceAppId": "00000003-0000-0000-c000-000000000000", "resourceAccess": [ + { + "id": "89b20d8a-76e2-4057-867b-9961f800b9a4", + "type": "Role" + }, { "id": "ed31732f-9495-47ed-ba3b-4ed0948c1c64", "type": "Role" @@ -707,6 +711,10 @@ { "id": "678536fe-1083-478a-9c59-b99265e6b0d3", "type": "Role" + }, + { + "id": "741f803b-c850-494e-b5df-cde7c675a1ca", + "type": "Role" } ] }, diff --git a/Config/openapi-overrides/ListOffboardingProgress.json b/Config/openapi-overrides/ListOffboardingProgress.json new file mode 100644 index 0000000000000..83b9d293f2071 --- /dev/null +++ b/Config/openapi-overrides/ListOffboardingProgress.json @@ -0,0 +1,3 @@ +{ + "method": "get" +} diff --git a/Config/openapi.json b/Config/openapi.json index 34c1d1df1d008..4bc57c7d41509 100644 --- a/Config/openapi.json +++ b/Config/openapi.json @@ -279,6 +279,14 @@ "logbook": { "$ref": "#/components/schemas/LabelValue" }, + "PsaTicketPriority": { + "allOf": [ + { + "$ref": "#/components/schemas/LabelValue" + } + ], + "description": "The audit form posts the raw form values, so an autocomplete selection arrives as a {label, value} object - unwrap it to the bare Halo priority id before storing." + }, "RowKey": { "type": "string" }, @@ -2178,6 +2186,87 @@ "x-cipp-role": "Tenant.Administration.ReadWrite" } }, + "/api/AddEdgeApp": { + "post": { + "summary": "AddEdgeApp", + "operationId": "AddEdgeApp", + "tags": [ + "Endpoint > Applications" + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "AssignTo": { + "type": "string" + }, + "CustomGroup": { + "type": "string" + }, + "displayLanguageLocale": { + "$ref": "#/components/schemas/LabelValue" + }, + "edgeChannel": { + "$ref": "#/components/schemas/LabelValue" + }, + "excludeGroup": { + "type": "string" + }, + "IntuneBody": { + "type": "string" + }, + "selectedTenants": { + "type": "array", + "items": { + "type": "object", + "properties": { + "customerId": { + "type": "string" + } + } + } + } + }, + "additionalProperties": true, + "x-cipp-passthrough": true, + "description": "This endpoint forwards the request body onward rather than reading a fixed set of fields. The properties listed here are the ones it is known to read; others may be accepted." + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardResults" + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Endpoint.Application.ReadWrite", + "x-cipp-any-tenant": true, + "x-cipp-reads-via": [ + "Get-CIPPEdgeAppBody" + ] + } + }, "/api/AddEditTransportRule": { "post": { "summary": "AddEditTransportRule", @@ -3344,7 +3433,7 @@ "Email-Exchange > Transport" ], "requestBody": { - "required": false, + "required": true, "content": { "application/json": { "schema": { @@ -3354,9 +3443,13 @@ "type": "string" }, "name": { - "type": "string" + "type": "string", + "description": "Posted from the row action; without a name the template lists blank." } }, + "required": [ + "name" + ], "additionalProperties": true, "x-cipp-passthrough": true, "description": "This endpoint forwards the request body onward rather than reading a fixed set of fields. The properties listed here are the ones it is known to read; others may be accepted." @@ -4387,6 +4480,64 @@ "x-cipp-role": "Identity.Role.ReadWrite" } }, + "/api/AddJITRoleTemplate": { + "post": { + "summary": "AddJITRoleTemplate", + "operationId": "AddJITRoleTemplate", + "tags": [ + "Identity > Administration > Users" + ], + "description": "Creates a JIT Role Template - a named allow-list of directory roles that can be assigned to a\nCIPP custom role to restrict which roles that role's members may grant via JIT Admin.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "roles": { + "type": "string" + }, + "templateName": { + "type": "string" + } + }, + "required": [ + "templateName" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardResults" + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Identity.Role.ReadWrite" + } + }, "/api/AddMSPApp": { "post": { "summary": "AddMSPApp", @@ -4690,82 +4841,64 @@ ] } }, - "/api/AddPolicy": { + "/api/AddPIMRoleSettingsTemplate": { "post": { - "summary": "AddPolicy", - "operationId": "AddPolicy", + "summary": "Create or update a PIM role settings template.", + "operationId": "AddPIMRoleSettingsTemplate", "tags": [ - "Endpoint > MEM" + "Identity > Administration > Roles" ], + "description": "Saves a Privileged Identity Management role settings template. The settings are validated against CIPP's secure floor (activation must expire within 24 hours and require MFA or an authentication context plus a justification; eligibilities and active assignments must expire within a year; active assignments must require a justification). A template below the floor is rejected with the list of problems rather than silently adjusted. Pass GUID to update an existing template. Pass captureRoleId with a tenantFilter to build the settings from that role's current PIM policy in the tenant instead of supplying them: values below the secure floor are raised to the closest value the floor allows and every raise is reported in the results.", "requestBody": { - "required": true, + "required": false, "content": { "application/json": { "schema": { "type": "object", "properties": { - "assignmentFilter": { - "type": "string" - }, - "AssignmentFilterName": { - "type": "string" - }, - "AssignmentFilterType": { - "type": "string" - }, - "AssignTo": { - "type": "string" - }, - "customGroup": { - "type": "string" - }, - "Description": { - "type": "string" + "captureRoleId": { + "allOf": [ + { + "$ref": "#/components/schemas/LabelValue" + } + ], + "description": "Capture mode: build the settings from a role's current PIM policy in a tenant." }, - "displayName": { + "captureRoleName": { "type": "string" }, - "excludeGroup": { - "type": "string" - }, - "RAWJson": { + "description": { "type": "string" }, - "replacemap": { - "type": "string" + "GUID": { + "type": "string", + "description": "Existing template GUID when editing." }, - "ReusableSettings": { + "roles": { "type": "array", "items": { - "type": "string" - } - }, - "TemplateGuid": { - "type": "string", - "description": "The deploy drawer and wizard send the chosen row's GUID as TemplateList.value, not as TemplateID. Template display names are not unique - re-imports create same-named twins - so resolving by display name below can land on a different row than the one the user picked. The selected RowKey must win whenever the request carries one. String rather than Guid: built-in templates are stored with their filename as RowKey." - }, - "TemplateID": { - "type": "string", - "description": "The deploy drawer and wizard send the chosen row's GUID as TemplateList.value, not as TemplateID. Template display names are not unique - re-imports create same-named twins - so resolving by display name below can land on a different row than the one the user picked. The selected RowKey must win whenever the request carries one. String rather than Guid: built-in templates are stored with their filename as RowKey." + "$ref": "#/components/schemas/LabelValue" + }, + "description": "Roles (label/value pairs of role template ids) when roleScope is Custom." }, - "TemplateList": { + "roleScope": { "allOf": [ { "$ref": "#/components/schemas/LabelValue" } ], - "description": "The deploy drawer and wizard send the chosen row's GUID as TemplateList.value, not as TemplateID. Template display names are not unique - re-imports create same-named twins - so resolving by display name below can land on a different row than the one the user picked. The selected RowKey must win whenever the request carries one. String rather than Guid: built-in templates are stored with their filename as RowKey." + "description": "PrivilegedRoles | AllRoles | Custom" }, - "TemplateType": { + "settings": { + "type": "string" + }, + "templateName": { "type": "string" }, "tenantFilter": { "$ref": "#/components/schemas/LabelValue" } - }, - "required": [ - "tenantFilter" - ] + } } } } @@ -4781,6 +4914,9 @@ } } }, + "400": { + "description": "Bad request - missing required field or invalid input" + }, "401": { "description": "Unauthorized - invalid or missing bearer token" }, @@ -4793,51 +4929,158 @@ "bearerAuth": [] } ], - "x-cipp-role": "Endpoint.MEM.ReadWrite" + "x-cipp-role": "Identity.Role.ReadWrite", + "x-cipp-any-tenant": true } }, - "/api/AddQuarantinePolicy": { + "/api/AddPolicy": { "post": { - "summary": "AddQuarantinePolicy", - "operationId": "AddQuarantinePolicy", + "summary": "AddPolicy", + "operationId": "AddPolicy", "tags": [ - "Email-Exchange > Spamfilter" + "Endpoint > MEM" ], "requestBody": { - "required": false, + "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { - "AllowSender": { + "assignmentFilter": { "type": "string" }, - "BlockSender": { + "AssignmentFilterName": { "type": "string" }, - "Delete": { + "AssignmentFilterType": { "type": "string" }, - "IncludeMessagesFromBlockedSenderAddress": { + "AssignTo": { "type": "string" }, - "Name": { + "customGroup": { "type": "string" }, - "Preview": { + "Description": { "type": "string" }, - "QuarantineNotification": { + "displayName": { "type": "string" }, - "ReleaseActionPreference": { - "$ref": "#/components/schemas/LabelValue" + "excludeGroup": { + "type": "string" }, - "selectedTenants": { + "RAWJson": { + "type": "string" + }, + "replacemap": { + "type": "string" + }, + "ReusableSettings": { + "type": "array", + "items": { + "type": "string" + } + }, + "TemplateGuid": { + "type": "string", + "description": "The deploy drawer and wizard send the chosen row's GUID as TemplateList.value, not as TemplateID. Template display names are not unique - re-imports create same-named twins - so resolving by display name below can land on a different row than the one the user picked. The selected RowKey must win whenever the request carries one. String rather than Guid: built-in templates are stored with their filename as RowKey." + }, + "TemplateID": { + "type": "string", + "description": "The deploy drawer and wizard send the chosen row's GUID as TemplateList.value, not as TemplateID. Template display names are not unique - re-imports create same-named twins - so resolving by display name below can land on a different row than the one the user picked. The selected RowKey must win whenever the request carries one. String rather than Guid: built-in templates are stored with their filename as RowKey." + }, + "TemplateList": { + "allOf": [ + { + "$ref": "#/components/schemas/LabelValue" + } + ], + "description": "The deploy drawer and wizard send the chosen row's GUID as TemplateList.value, not as TemplateID. Template display names are not unique - re-imports create same-named twins - so resolving by display name below can land on a different row than the one the user picked. The selected RowKey must win whenever the request carries one. String rather than Guid: built-in templates are stored with their filename as RowKey." + }, + "TemplateType": { "type": "string" + }, + "tenantFilter": { + "$ref": "#/components/schemas/LabelValue" } - } + }, + "required": [ + "tenantFilter" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardResults" + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Endpoint.MEM.ReadWrite" + } + }, + "/api/AddQuarantinePolicy": { + "post": { + "summary": "AddQuarantinePolicy", + "operationId": "AddQuarantinePolicy", + "tags": [ + "Email-Exchange > Spamfilter" + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "AllowSender": { + "type": "string" + }, + "BlockSender": { + "type": "string" + }, + "Delete": { + "type": "string" + }, + "IncludeMessagesFromBlockedSenderAddress": { + "type": "string" + }, + "Name": { + "type": "string" + }, + "Preview": { + "type": "string" + }, + "QuarantineNotification": { + "type": "string" + }, + "ReleaseActionPreference": { + "$ref": "#/components/schemas/LabelValue" + }, + "selectedTenants": { + "type": "string" + } + } } } } @@ -5223,21 +5466,21 @@ }, "Name": { "type": "string", - "description": "Validate required fields" + "description": "Validate required fields. The \"create template from policy\" row action posts the policy row, which carries Name/PolicyName but no TemplateName." }, "PolicyName": { - "type": "string" + "type": "string", + "description": "Set name and comments - prioritize template-specific fields, falling back to the policy name so a template made from a policy is not listed with a blank name." }, "TemplateDescription": { "type": "string" }, "TemplateName": { "type": "string", - "description": "Set name and comments - prioritize template-specific fields" + "description": "Validate required fields. The \"create template from policy\" row action posts the policy row, which carries Name/PolicyName but no TemplateName." } }, "required": [ - "Name", "PolicyName" ] } @@ -5362,6 +5605,7 @@ "tags": [ "Tenant > Administration > Alerts" ], + "description": "Creates or updates a scripted CIPP alert, stored as a hidden scheduled task.\n\nA selection of two or more tenants or groups is stored verbatim and expanded on every run,\nso tenant group membership is always current.", "requestBody": { "required": true, "content": { @@ -5370,16 +5614,15 @@ "type": "object", "properties": { "excludedTenants": { - "type": "string" - }, - "RowKey": { - "type": "string" + "type": "string", + "description": "Tenants or tenant groups to skip even when they fall within the selection above. Optional." }, "tenantFilter": { "type": "array", "items": { "type": "string" - } + }, + "description": "The tenants, tenant groups or *All Tenants the alert applies to. At least one is required." } }, "required": [ @@ -6822,11 +7065,13 @@ "schema": { "type": "object", "properties": { - "name": { - "type": "string" + "Name": { + "type": "string", + "description": "Posted from the row action; without a name the template lists blank and deploys with no parameters." }, "PowerShellCommand": { - "type": "string" + "type": "string", + "description": "Posted from the row action; without a name the template lists blank and deploys with no parameters." } }, "additionalProperties": true, @@ -6944,6 +7189,9 @@ "password": { "type": "string" }, + "perUserMfa": { + "type": "boolean" + }, "postalCode": { "type": "string" }, @@ -6964,6 +7212,9 @@ "PrimDomain": { "$ref": "#/components/schemas/LabelValue" }, + "PsaTicketId": { + "type": "string" + }, "reference": { "type": "string" }, @@ -7250,6 +7501,9 @@ "password": { "type": "string" }, + "perUserMfa": { + "type": "string" + }, "postalCode": { "type": "string" }, @@ -9122,6 +9376,68 @@ "x-cipp-role": "Identity.Role.ReadWrite" } }, + "/api/EditJITRoleTemplate": { + "post": { + "summary": "EditJITRoleTemplate", + "operationId": "EditJITRoleTemplate", + "tags": [ + "Identity > Administration > Users" + ], + "description": "Updates an existing JIT Role Template.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "GUID": { + "type": "string" + }, + "roles": { + "type": "string" + }, + "templateName": { + "type": "string" + } + }, + "required": [ + "GUID", + "templateName" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardResults" + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Identity.Role.ReadWrite" + } + }, "/api/EditMalwareFilter": { "post": { "summary": "EditMalwareFilter", @@ -11399,9 +11715,23 @@ "GroupId": { "type": "string" }, + "Results": { + "type": "string" + }, "roleDefinitionId": { "type": "string" }, + "RoleMappings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "roleDefinitionId": { + "type": "string" + } + } + } + }, "RoleName": { "type": "string" } @@ -11409,7 +11739,8 @@ } }, "templateId": { - "type": "string" + "type": "string", + "description": "Add-CIPPGDAPRoleTemplate already writes customer-visible logs for the template path" } } } @@ -11681,7 +12012,11 @@ } ], "x-cipp-role": "Tenant.Relationship.ReadWrite", - "x-cipp-any-tenant": true + "x-cipp-any-tenant": true, + "x-cipp-reads-via": [ + "Add-CIPPGDAPRoleTemplate", + "New-CIPPGDAPRoleMapping" + ] } }, "/api/ExecAddMultiTenantApp": { @@ -12016,6 +12351,9 @@ }, "403": { "description": "Forbidden - caller lacks the required RBAC role" + }, + "500": { + "description": "Internal server error" } }, "security": [ @@ -13179,7 +13517,7 @@ "tags": [ "CIPP > Settings" ], - "description": "Drives the super-admin \"Custom Domains\" page. All actions operate on the App Service that\nhosts this CIPP instance (Microsoft.Web/sites/$env:WEBSITE_SITE_NAME) using the managed\nidentity via New-CIPPAzRestRequest — the same resource and auth path the Container\nManagement page uses.\n\nActions (passed as Query.Action or Body.Action):\n List - Site metadata (default hostname, inbound IP) plus every hostname\n binding and any App Service Managed Certificate that matches.\n CheckDns - Live DoH lookup of the alias record a custom domain needs. CIPP no\n longer uses domain-verification TXT records, so a leftover\n asuid. record is detected and flagged for removal rather than\n requested. Powers wizard step 1 + resume.\n AddBinding - Create the hostname binding (wizard step 2). Azure re-validates ownership.\n AddCertificate - Create an App Service Managed Certificate and enable the SNI SSL binding\n (wizard step 3). Safe to re-run — reuses an existing cert if present.\n Remove - Delete a custom hostname binding (and its managed cert, best effort).\n\nEvery action is independently re-runnable so the wizard can resume a half-finished domain or\nretry a failed step without redoing the ones that already succeeded.", + "description": "Drives the super-admin \"Custom Domains\" page. All actions operate on the App Service that\nhosts this CIPP instance (Microsoft.Web/sites/$env:WEBSITE_SITE_NAME) using the managed\nidentity via New-CIPPAzRestRequest — the same resource and auth path the Container\nManagement page uses.\n\nActions (passed as Query.Action or Body.Action):\n List - Site metadata (default hostname, inbound IP) plus every hostname\n binding, any App Service Managed Certificate that matches, and the\n state of a certificate job still running in the background.\n CheckDns - Live DoH lookup of the alias record a custom domain needs. CIPP no\n longer uses domain-verification TXT records, so a leftover\n asuid. record is detected and flagged for removal rather than\n requested. Powers wizard step 1 + resume.\n AddBinding - Create the hostname binding (wizard step 2). Azure validates ownership\n through the alias record.\n AddCertificate - Issue an App Service Managed Certificate and enable the SNI SSL binding\n (wizard step 3) via Invoke-CIPPCustomDomainCertificate. Issuance that\n outlives the request carries on as a hidden scheduled task that retries\n every 15 minutes, a few times, then stops.\n Remove - Delete a custom hostname binding (and its managed cert, best effort).\n\nEvery action is independently re-runnable so the wizard can resume a half-finished domain or\nretry a failed step without redoing the ones that already succeeded.", "requestBody": { "required": true, "content": { @@ -13190,6 +13528,10 @@ "Action": { "type": "string" }, + "DnsRecordType": { + "type": "string", + "description": "Which alias record Azure should validate against: the one CheckDns saw resolve (A or CNAME), else the recommended type for this hostname shape." + }, "Hostname": { "type": "string" } @@ -14192,7 +14534,7 @@ "tags": [ "CIPP > Settings" ], - "description": "Returns Azure portal deep links for the CIPP deployment's own infrastructure (resource group, key vault, function app, static web app) plus its subscription, SKU, hosting mode and timezone.", + "description": "Returns Azure portal deep links for the CIPP deployment's own infrastructure (resource group, key vault, the function app or web app, its App Service plan, static web app) plus its subscription, SKU and timezone. Whether the instance is CyberDrain-hosted or CIPP-NG comes from /api/me.", "responses": { "200": { "description": "Success", @@ -15866,6 +16208,70 @@ "x-cipp-role": "CIPP.Core.ReadWrite" } }, + "/api/ExecCIPPDBCacheAdmin": { + "post": { + "summary": "SuperAdmin browse / remove / empty for CIPPDB (CippReportingDB) cache collections.", + "operationId": "ExecCIPPDBCacheAdmin", + "tags": [ + "CIPP > Core" + ], + "description": "Typed alternative to Table Maintenance for the reporting cache. List returns decoded\ncache objects stamped with CIPPPartitionKey / CIPPRowKey / CIPPETag so the UI can\ndelete by storage key. Empty clears an entire type for a tenant (or AllTenants).", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "Action": { + "type": "string" + }, + "Rows": { + "type": "string" + }, + "TenantFilter": { + "type": "string" + }, + "Type": { + "type": "string" + } + }, + "required": [ + "TenantFilter" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardResults" + } + } + } + }, + "400": { + "description": "Bad request - missing required field or invalid input" + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "CIPP.SuperAdmin.ReadWrite" + } + }, "/api/ExecCippFunction": { "post": { "summary": "Execute a CIPPCore function", @@ -16405,6 +16811,10 @@ "baselineOption": { "type": "string" }, + "certificateAuth": { + "type": "boolean", + "description": "Certificate-auth toggle for an existing install: enabling keeps the client secret as a rollback and switches SAM tokens to the certificate. Idempotent with a certificate-only First Setup." + }, "email": { "type": "string" }, @@ -16464,11 +16874,16 @@ "x-cipp-field-source": "backend" }, "RowKey": { + "type": "string", "x-cipp-field-source": "storage" }, "SASUrl": { "x-cipp-field-source": "storage" }, + "SecretValue": { + "type": "string", + "x-cipp-field-source": "storage" + }, "severity": { "x-cipp-field-source": "backend" }, @@ -16621,50 +17036,50 @@ "type": "string" }, "standardsTemplateId": { - "type": "string", - "description": "Without this the comparison would report the policy as missing while remediation would happily overwrite an existing, similarly named one. Mirrors the candidate selection Set-CIPPIntunePolicy performs at deployment time." - }, - "templateGuid": { "type": "string", "description": "tenantFilter is optional here - supplying it resolves the template the way the standard would for that tenant instead of comparing the stored template verbatim." }, - "tenantFilter": { - "type": "string", - "description": "tenantFilter is optional here - supplying it resolves the template the way the standard would for that tenant instead of comparing the stored template verbatim." - }, - "type": { - "type": "string", - "x-cipp-observed-values": [ - "communityRepo", - "template", - "tenantPolicy", - "tenantPolicyByTemplate" - ] - }, - "urlName": { - "type": "string" - } - } - }, - "sourceB": { - "type": "object", - "properties": { - "branch": { - "type": "string" - }, - "fullName": { - "type": "string" - }, - "path": { - "type": "string" - }, - "policyId": { - "type": "string" - }, - "standardsTemplateId": { - "type": "string", - "description": "Without this the comparison would report the policy as missing while remediation would happily overwrite an existing, similarly named one. Mirrors the candidate selection Set-CIPPIntunePolicy performs at deployment time." - }, + "templateGuid": { + "type": "string", + "description": "tenantFilter is optional here - supplying it resolves the template the way the standard would for that tenant instead of comparing the stored template verbatim." + }, + "tenantFilter": { + "type": "string", + "description": "tenantFilter is optional here - supplying it resolves the template the way the standard would for that tenant instead of comparing the stored template verbatim." + }, + "type": { + "type": "string", + "x-cipp-observed-values": [ + "communityRepo", + "template", + "tenantPolicy", + "tenantPolicyByTemplate" + ] + }, + "urlName": { + "type": "string" + } + } + }, + "sourceB": { + "type": "object", + "properties": { + "branch": { + "type": "string" + }, + "fullName": { + "type": "string" + }, + "path": { + "type": "string" + }, + "policyId": { + "type": "string" + }, + "standardsTemplateId": { + "type": "string", + "description": "tenantFilter is optional here - supplying it resolves the template the way the standard would for that tenant instead of comparing the stored template verbatim." + }, "templateGuid": { "type": "string", "description": "tenantFilter is optional here - supplying it resolves the template the way the standard would for that tenant instead of comparing the stored template verbatim." @@ -17474,6 +17889,10 @@ "access_token": { "type": "string", "description": "Find Existing app registration" + }, + "certificateOnly": { + "type": "boolean", + "description": "A certificate-only setup provisions no client secret. Determined up front so every app management policy call in this flow leaves the password-addition block in force." } } } @@ -17915,6 +18334,9 @@ "Action": { "type": "string" }, + "AllowedRolesTemplate": { + "type": "string" + }, "AllowedTenants": { "type": "string" }, @@ -20087,6 +20509,69 @@ "x-cipp-role": "Exchange.Mailbox.ReadWrite" } }, + "/api/ExecEmptySiteRecycleBin": { + "post": { + "summary": "ExecEmptySiteRecycleBin", + "operationId": "ExecEmptySiteRecycleBin", + "tags": [ + "Teams-Sharepoint" + ], + "description": "Permanently empty a site recycle bin (first stage, second stage, or both).\nItem ids are used only server-side; the response never includes file names.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "SiteUrl": { + "type": "string" + }, + "Stage": { + "type": "string" + }, + "tenantFilter": { + "type": "string" + } + }, + "required": [ + "SiteUrl", + "tenantFilter" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides." + } + } + } + }, + "400": { + "description": "Bad request - missing required field or invalid input" + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Sharepoint.SiteRecycleBin.ReadWrite" + } + }, "/api/ExecEnableArchive": { "post": { "summary": "ExecEnableArchive", @@ -20721,7 +21206,7 @@ { "name": "TicketType", "in": "query", - "description": "Outcomes and priorities are scoped to a ticket type. The settings page sends the ticket type currently selected in the form so the lists follow the dropdown; without it both fall back to whatever ticket type was last saved.", + "description": "Outcomes and priorities are scoped to a ticket type. The settings page sends the ticket type currently selected in the form so the lists follow the dropdown; without it both fall back to whatever ticket type was last saved. @() on each: PowerShell unrolls single-element output, so a ticket type with one outcome (or a lookup that answers with a single explanatory row) would otherwise serialise as a bare object and break callers that expect a list.", "required": false, "schema": { "type": "string" @@ -20891,6 +21376,9 @@ ] } }, + { + "$ref": "#/components/parameters/tenantFilter" + }, { "name": "TenantID", "in": "query", @@ -21561,6 +22049,9 @@ }, "403": { "description": "Forbidden - caller lacks the required RBAC role" + }, + "500": { + "description": "Internal server error" } }, "security": [ @@ -21737,9 +22228,15 @@ "schema": { "type": "object", "properties": { + "CustomSuffix": { + "type": "string" + }, "GroupId": { "type": "string" }, + "NewRoles": { + "type": "string" + }, "OriginalTemplateId": { "type": "string", "description": "Use OriginalTemplateId if provided (for rename), otherwise use TemplateId" @@ -21749,6 +22246,12 @@ "items": { "type": "object", "properties": { + "GroupId": { + "type": "array", + "items": { + "type": "string" + } + }, "roleDefinitionId": { "type": "string" } @@ -21757,7 +22260,7 @@ }, "TemplateId": { "type": "string", - "description": "Use OriginalTemplateId if provided (for rename), otherwise use TemplateId" + "description": "Template-first save: creates any group mappings the editor asked for, then writes the full mapping set to the template in one shot." } } } @@ -21774,7 +22277,8 @@ "enum": [ "Add", "Delete", - "Edit" + "Edit", + "Save" ] } }, @@ -21800,12 +22304,18 @@ "type": "string", "x-cipp-field-source": "storage" }, + "GroupMappings": { + "x-cipp-field-source": "frontend" + }, "PartitionKey": { "x-cipp-field-source": "storage" }, "RoleMappings": { "type": "string", - "x-cipp-field-source": "storage,backend,frontend" + "x-cipp-field-source": "storage,backend" + }, + "Roles": { + "x-cipp-field-source": "frontend" }, "RowKey": { "x-cipp-field-source": "storage" @@ -22995,6 +23505,96 @@ "x-cipp-role": "Exchange.Mailbox.ReadWrite" } }, + "/api/ExecHistoricalSearch": { + "post": { + "summary": "ExecHistoricalSearch", + "operationId": "ExecHistoricalSearch", + "tags": [ + "Email-Exchange > Tools" + ], + "description": "Starts or cancels an Exchange Online historical search. Historical searches cover up to 90 days,\ndeliver results as CSV (max 100,000 rows) and are limited to 250 submissions per day per tenant.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "Action": { + "type": "string" + }, + "deliveryStatus": { + "$ref": "#/components/schemas/LabelValue" + }, + "direction": { + "$ref": "#/components/schemas/LabelValue" + }, + "jobId": { + "type": "string" + }, + "messageId": { + "type": "string" + }, + "notifyAddress": { + "type": "string" + }, + "originalClientIP": { + "type": "string" + }, + "recipientAddress": { + "type": "string" + }, + "reportTitle": { + "type": "string" + }, + "reportType": { + "$ref": "#/components/schemas/LabelValue" + }, + "senderAddress": { + "type": "string" + }, + "tenantFilter": { + "type": "string" + } + }, + "required": [ + "jobId", + "tenantFilter" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides." + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Exchange.Mailbox.ReadWrite" + } + }, "/api/ExecHVEUser": { "post": { "summary": "ExecHVEUser", @@ -23367,7 +23967,11 @@ "type": "object", "properties": { "AdminRoles": { - "$ref": "#/components/schemas/LabelValue" + "type": "array", + "items": { + "$ref": "#/components/schemas/LabelValue" + }, + "description": "Enforce the caller's allowed JIT roles (from the JIT Role Template on their custom role). Get-CIPPJITAdminAllowedRoles is authoritative and fails closed for restricted callers, so we trust its result rather than swallowing errors here." }, "Domain": { "$ref": "#/components/schemas/LabelValue" @@ -23626,29 +24230,51 @@ "x-cipp-any-tenant": true } }, - "/api/ExecLicenseSearch": { + "/api/ExecLicensePricing": { "post": { - "summary": "ExecLicenseSearch", - "operationId": "ExecLicenseSearch", + "summary": "ExecLicensePricing", + "operationId": "ExecLicensePricing", "tags": [ - "CIPP > Core" + "Tenant > Reports" ], - "description": "Finds which tenants hold the given licence SKUs, searching the cached licence overview rather than querying each tenant live. Takes an array of skuIds in the body.", + "description": "Manage MSP-global license price overrides used by the license optimization report.\nSetPrice upserts a per-SKU monthly price; RemovePrice deletes an override so the SKU falls\nback to the shipped MSRP estimate.", "requestBody": { - "required": false, + "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { - "skuIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Get skuIds from POST body" + "Action": { + "type": "string", + "enum": [ + "RemovePrice", + "SetPrice" + ], + "description": "SetPrice or RemovePrice" + }, + "Currency": { + "type": "string", + "description": "Overrides are currency-scoped: one row per (skuId, currency), so an AUD override and a USD override for the same SKU coexist. RowKey = \"{skuId}-{currency}\"." + }, + "MonthlyPrice": { + "type": "number", + "description": "Monthly price per seat, in the given currency" + }, + "Product_Display_Name": { + "type": "string" + }, + "skuId": { + "type": "string", + "description": "The SKU GUID (skuId) the price applies to" + }, + "skuPartNumber": { + "type": "string" } - } + }, + "required": [ + "Action" + ] } } } @@ -23660,7 +24286,102 @@ "application/json": { "schema": { "type": "object", - "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides." + "description": "Derived from the fields written into the storage table it reads. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", + "properties": { + "Currency": { + "x-cipp-field-source": "storage" + }, + "ETag": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "MonthlyPrice": { + "type": "number", + "x-cipp-field-source": "storage" + }, + "PartitionKey": { + "x-cipp-field-source": "storage" + }, + "Product_Display_Name": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "RowKey": { + "x-cipp-field-source": "storage" + }, + "skuId": { + "x-cipp-field-source": "storage" + }, + "skuPartNumber": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "Timestamp": { + "type": "string", + "x-cipp-field-source": "storage" + } + } + } + } + } + }, + "400": { + "description": "Bad request - missing required field or invalid input" + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Tenant.Directory.ReadWrite", + "x-cipp-any-tenant": true + } + }, + "/api/ExecLicenseSearch": { + "post": { + "summary": "ExecLicenseSearch", + "operationId": "ExecLicenseSearch", + "tags": [ + "CIPP > Core" + ], + "description": "Finds which tenants hold the given licence SKUs, searching the cached licence overview rather than querying each tenant live. Takes an array of skuIds in the body.", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "skuIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Get skuIds from POST body" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides." } } } @@ -23870,6 +24591,7 @@ "x-cipp-field-source": "graph-entity" }, "RowKey": { + "type": "string", "x-cipp-field-source": "storage" }, "samlMetadataUrl": { @@ -23879,6 +24601,10 @@ "SASUrl": { "x-cipp-field-source": "storage" }, + "SecretValue": { + "type": "string", + "x-cipp-field-source": "storage" + }, "serviceManagementReference": { "type": "string", "x-cipp-field-source": "graph-entity" @@ -24182,12 +24908,35 @@ "Email-Exchange > Administration" ], "requestBody": { - "required": false, + "required": true, "content": { "application/json": { "schema": { "type": "object", - "properties": {} + "properties": { + "Delete": { + "type": "string" + }, + "deviceid": { + "type": "string" + }, + "guid": { + "type": "string" + }, + "Quarantine": { + "type": "string" + }, + "tenantFilter": { + "type": "string" + }, + "Userid": { + "type": "string", + "description": "Interact with query parameters or the body of the request. This is a state-changing action, so the frontend dispatches it as a POST; keep the query fallback for backwards compatibility." + } + }, + "required": [ + "tenantFilter" + ] } } } @@ -24231,6 +24980,7 @@ { "name": "Userid", "in": "query", + "description": "Interact with query parameters or the body of the request. This is a state-changing action, so the frontend dispatches it as a POST; keep the query fallback for backwards compatibility.", "required": false, "schema": { "type": "string" @@ -25103,7 +25853,7 @@ "tags": [ "Security > Incidents" ], - "description": "Lists Microsoft Defender for Office 365 alerts for a tenant, filtered to that service source. tenantFilter=AllTenants reads the cached alert table rather than querying each tenant live.", + "description": "Lists Microsoft Defender for Office 365 and Defender for Endpoint alerts for a tenant, filtered to those service sources. tenantFilter=AllTenants reads the cached alert table rather than querying each tenant live.", "parameters": [ { "$ref": "#/components/parameters/tenantFilter" @@ -25264,7 +26014,7 @@ }, "serviceSource": { "type": "object", - "x-cipp-field-source": "graph-entity" + "x-cipp-field-source": "graph-entity,frontend" }, "severity": { "type": "object", @@ -27014,6 +27764,9 @@ } } }, + "400": { + "description": "Bad request - missing required field or invalid input" + }, "401": { "description": "Unauthorized - invalid or missing bearer token" }, @@ -27223,6 +27976,7 @@ "tags": [ "Identity > Administration > Users" ], + "description": "Runs the offboarding wizard: one scheduled offboarding job per user, immediately or at the\nscheduled time, reporting live progress under one job id. Action=Rerun queues an existing\noffboarding task again; Action=RerunStep queues one step of it, reported to the same progress row.", "requestBody": { "required": true, "content": { @@ -27230,6 +27984,9 @@ "schema": { "type": "object", "properties": { + "Action": { + "type": "string" + }, "forward": { "$ref": "#/components/schemas/LabelValue" }, @@ -27250,6 +28007,9 @@ } } }, + "PsaTicketId": { + "type": "string" + }, "reference": { "type": "string" }, @@ -27266,6 +28026,18 @@ }, "description": "Scheduled: when enabled, date must be a valid Unix timestamp" }, + "StepIndex": { + "type": "integer", + "description": "Zero-based index of the step, as listed in the progress row, to run again" + }, + "StepTitle": { + "type": "string", + "description": "Title of that step, used to name the re-run task" + }, + "TaskId": { + "type": "string", + "description": "RowKey of the offboarding task to run again" + }, "tenantFilter": { "allOf": [ { @@ -27291,19 +28063,23 @@ } } }, + "parameters": [ + { + "name": "Action", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { - "type": "object", - "description": "Derived from the fields the endpoint selects onto each record. The response may carry more; these are the ones known to exist.", - "properties": { - "Results": { - "x-cipp-field-source": "backend" - } - } + "$ref": "#/components/schemas/StandardResults" } } } @@ -27424,7 +28200,7 @@ "type": "boolean" }, "standardsExcludeAllTenants": { - "type": "string" + "type": "boolean" } } } @@ -27470,6 +28246,9 @@ "type": "string", "x-cipp-field-source": "storage,backend" }, + "StandardsExcludeAllTenants": { + "x-cipp-field-source": "storage,backend" + }, "Status": { "x-cipp-field-source": "storage,backend" }, @@ -27715,7 +28494,8 @@ "type": "object", "properties": { "enabled": { - "type": "boolean" + "type": "boolean", + "description": "Subscription create/update is logged by New-CIPPGraphSubscription; log the onboarding config write here." }, "EventType": { "$ref": "#/components/schemas/LabelValue" @@ -28230,6 +29010,129 @@ "x-cipp-role": "Identity.User.ReadWrite" } }, + "/api/ExecPIMRoleAssignment": { + "post": { + "summary": "Change a directory role assignment through PIM in the secure direction only.", + "operationId": "ExecPIMRoleAssignment", + "tags": [ + "Identity > Administration > Roles" + ], + "description": "Converts a permanent assignment to eligible, grants a time-bound active assignment, extends or renews a time-bound assignment or eligibility, or removes an assignment. Every request must carry an expiration (a duration or an end date); permanent / no-expiration assignments are refused, as are changes to group-inherited rows, the CIPP-SAM application and the last active Global Administrator. Requires Entra ID P2.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "Action": { + "allOf": [ + { + "$ref": "#/components/schemas/LabelValue" + } + ], + "description": "ConvertToEligible | GrantActive | Extend | Renew | Remove" + }, + "AssignmentType": { + "allOf": [ + { + "$ref": "#/components/schemas/LabelValue" + } + ], + "description": "The row's current assignment type: Permanent | Active | ActivatedFromEligible | Eligible" + }, + "DirectoryScopeId": { + "allOf": [ + { + "$ref": "#/components/schemas/LabelValue" + } + ], + "description": "'/' for the whole directory or '/administrativeUnits/{id}'." + }, + "Duration": { + "allOf": [ + { + "$ref": "#/components/schemas/LabelValue" + } + ], + "description": "ISO 8601 lifetime such as PT4H or P1Y. Use either Duration or EndDateTime, not both." + }, + "EndDateTime": { + "allOf": [ + { + "$ref": "#/components/schemas/LabelValue" + } + ], + "description": "Absolute end (unix seconds or ISO 8601). Use either Duration or EndDateTime, not both." + }, + "Justification": { + "type": "string", + "description": "Reason recorded on the PIM request and in the CIPP logbook." + }, + "PrincipalId": { + "allOf": [ + { + "$ref": "#/components/schemas/LabelValue" + } + ], + "description": "Object id of the user, group or service principal." + }, + "RoleDefinitionId": { + "allOf": [ + { + "$ref": "#/components/schemas/LabelValue" + } + ], + "description": "Role template id (roleDefinitionId as PIM reports it)." + }, + "tenantFilter": { + "$ref": "#/components/schemas/LabelValue" + }, + "TimeZone": { + "allOf": [ + { + "$ref": "#/components/schemas/LabelValue" + } + ], + "description": "IANA time zone of the browser (e.g. Australia/Perth); only used to word the end time in the result." + } + }, + "required": [ + "tenantFilter" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardResults" + } + } + } + }, + "400": { + "description": "Bad request - missing required field or invalid input" + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Identity.Role.ReadWrite" + } + }, "/api/ExecQuarantineManagement": { "post": { "summary": "ExecQuarantineManagement", @@ -28308,6 +29211,75 @@ "x-cipp-role": "Exchange.SpamFilter.ReadWrite" } }, + "/api/ExecReactivateSite": { + "post": { + "summary": "Reactivate an archived SharePoint or OneDrive site.", + "operationId": "ExecReactivateSite", + "tags": [ + "Teams-Sharepoint" + ], + "description": "Reactivates (unarchives) a Microsoft 365 Archive site through the Graph beta\nsite: unarchive endpoint (POST /beta/sites/{site-id}/unarchive). Primarily used to\nreactivate archived OneDrive accounts before granting permissions to them.\nReactivation is asynchronous (can take up to 24 hours) and, for fully-archived\naccounts, may incur Microsoft 365 Archive charges and require Unlicensed OneDrive\nbilling to be enabled on the tenant.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "SiteId": { + "type": "string", + "description": "Site-collection GUID (the row's siteId / sharepointIds.siteId). Used to build the Graph composite site id without touching the locked, archived site." + }, + "SiteUrl": { + "type": "string", + "description": "Full web URL of the archived site / OneDrive (the row's webUrl)." + }, + "tenantFilter": { + "type": "string", + "description": "Tenant the archived site belongs to." + }, + "WebId": { + "type": "string", + "description": "Web GUID (the row's webId / sharepointIds.webId)." + } + }, + "required": [ + "SiteUrl", + "tenantFilter" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardResults" + } + } + } + }, + "400": { + "description": "Bad request - missing required field or invalid input" + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Sharepoint.Site.ReadWrite" + } + }, "/api/ExecRefreshMyAccess": { "get": { "summary": "Re-check the caller's Entra group membership and refresh their CIPP roles", @@ -31403,7 +32375,7 @@ "tags": [ "Identity > Administration > Users" ], - "description": "Sends a test MFA push notification to a user's authenticator app and reports whether it was approved. Used to confirm a user's MFA registration works. This causes a real prompt on the user's device.", + "description": "Sends a test MFA push notification to a user's authenticator app and reports whether it was approved, or - when an OTP code is supplied - verifies that typed code without sending a push. Used to confirm a user's MFA registration works. The push path causes a real prompt on the user's device.", "requestBody": { "required": true, "content": { @@ -31411,6 +32383,10 @@ "schema": { "type": "object", "properties": { + "OTP": { + "type": "string", + "description": "When an OTP code is supplied we verify that code instead of sending a push notification." + }, "TenantFilter": { "type": "string" }, @@ -31432,10 +32408,366 @@ "application/json": { "schema": { "type": "object", - "description": "Derived from the fields the endpoint selects onto each record. The response may carry more; these are the ones known to exist.", + "description": "Derived from the Microsoft Graph entity it queries, and the fields the endpoint selects onto each record. This endpoint returns the Graph response as-is without selecting fields, so these are the properties the entity CAN carry (x-cipp-field-source: graph-entity) rather than a proven projection - Graph returns a default subset unless asked otherwise.", "properties": { + "aboutMe": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "accountEnabled": { + "type": "boolean", + "x-cipp-field-source": "graph-entity" + }, + "ageGroup": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "assignedLicenses": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "assignedPlans": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "authorizationInfo": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "birthday": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "businessPhones": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "city": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "cloudLicensing": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "cloudRealtimeCommunicationInfo": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "companyName": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "consentProvidedForMinor": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "country": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "createdDateTime": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "creationType": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "customSecurityAttributes": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "deletedDateTime": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "department": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "deviceEnrollmentLimit": { + "type": "integer", + "x-cipp-field-source": "graph-entity" + }, + "deviceKeys": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "displayName": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "employeeHireDate": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "employeeId": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "employeeLeaveDateTime": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "employeeOrgData": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "employeeType": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "externalUserState": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "externalUserStateChangeDateTime": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "faxNumber": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "givenName": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "hireDate": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "id": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "identities": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "identityGovernance": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "identityParentId": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "imAddresses": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "infoCatalogs": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "interests": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "isLicenseReconciliationNeeded": { + "type": "boolean", + "x-cipp-field-source": "graph-entity" + }, + "isManagementRestricted": { + "type": "boolean", + "x-cipp-field-source": "graph-entity" + }, + "isResourceAccount": { + "type": "boolean", + "x-cipp-field-source": "graph-entity" + }, + "jobTitle": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "lastPasswordChangeDateTime": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "legalAgeGroupClassification": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "licenseAssignmentStates": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "mail": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "mailboxSettings": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "mailNickname": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "mobilePhone": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "mySite": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "officeLocation": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "onPremisesDistinguishedName": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "onPremisesDomainName": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "onPremisesExtensionAttributes": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "onPremisesImmutableId": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "onPremisesLastSyncDateTime": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "onPremisesProvisioningErrors": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "onPremisesSamAccountName": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "onPremisesSecurityIdentifier": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "onPremisesSipInfo": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "onPremisesSyncEnabled": { + "type": "boolean", + "x-cipp-field-source": "graph-entity" + }, + "onPremisesUserPrincipalName": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "otherMails": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "passwordPolicies": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "passwordProfile": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "pastProjects": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "postalCode": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "preferredDataLocation": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "preferredLanguage": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "preferredName": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "print": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "provisionedPlans": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "proxyAddresses": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "refreshTokensValidFromDateTime": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "responsibilities": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, "Results": { "x-cipp-field-source": "backend" + }, + "schools": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "securityIdentifier": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "serviceProvisioningErrors": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "showInAddressList": { + "type": "boolean", + "x-cipp-field-source": "graph-entity" + }, + "signInActivity": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "signInSessionsValidFromDateTime": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "skills": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "state": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "streetAddress": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "surname": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "usageLocation": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "userPrincipalName": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "userType": { + "type": "string", + "x-cipp-field-source": "graph-entity" } } } @@ -34044,6 +35376,108 @@ "x-cipp-role": "Sharepoint.Site.ReadWrite" } }, + "/api/ExecSiteBrowserLibraryCopy": { + "post": { + "summary": "ExecSiteBrowserLibraryCopy", + "operationId": "ExecSiteBrowserLibraryCopy", + "tags": [ + "Teams-Sharepoint" + ], + "description": "Starts or preflights a SharePoint document library content copy (CreateCopyJobs + MoveButKeepSource).\nActions: PreflightLibraryCopy, StartLibraryCopy.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "Action": { + "type": "string" + }, + "DestLibraryName": { + "type": "string" + }, + "DestListId": { + "type": "string" + }, + "DestSiteId": { + "type": "string" + }, + "DestSiteName": { + "type": "string" + }, + "DestSiteUrl": { + "type": "string" + }, + "NameConflictBehavior": { + "type": "string" + }, + "SourceLibraryName": { + "type": "string" + }, + "SourceListId": { + "type": "string" + }, + "SourceSiteId": { + "type": "string" + }, + "SourceSiteName": { + "type": "string" + }, + "SourceSiteUrl": { + "type": "string" + }, + "tenantFilter": { + "type": "string" + } + }, + "required": [ + "Action", + "tenantFilter" + ] + } + } + } + }, + "parameters": [ + { + "name": "Action", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardResults" + } + } + } + }, + "400": { + "description": "Bad request - missing required field or invalid input" + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Sharepoint.Site.ReadWrite" + } + }, "/api/ExecSiteBrowserPermissions": { "post": { "summary": "ExecSiteBrowserPermissions", @@ -35378,11 +36812,16 @@ "x-cipp-field-source": "storage" }, "RowKey": { + "type": "string", "x-cipp-field-source": "storage" }, "SASUrl": { "x-cipp-field-source": "storage" }, + "SecretValue": { + "type": "string", + "x-cipp-field-source": "storage" + }, "TenantId": { "x-cipp-field-source": "storage" }, @@ -35661,6 +37100,7 @@ }, "status": { "type": "string", + "description": "The status is written at the end of this block, after the action it implies succeeds.", "x-cipp-observed-values": [ "deniedDelete", "DeniedRemediate" @@ -35805,11 +37245,16 @@ "x-cipp-field-source": "backend" }, "RowKey": { + "type": "string", "x-cipp-field-source": "storage" }, "SASUrl": { "x-cipp-field-source": "storage" }, + "SecretValue": { + "type": "string", + "x-cipp-field-source": "storage" + }, "TenantId": { "x-cipp-field-source": "storage" }, @@ -35973,7 +37418,8 @@ "bearerAuth": [] } ], - "x-cipp-role": "CIPP.Core.ReadWrite" + "x-cipp-role": "CIPP.Core.ReadWrite", + "x-cipp-any-tenant": true } }, "/api/ExecUserSettings": { @@ -38287,6 +39733,59 @@ "x-cipp-any-tenant": true } }, + "/api/ListAsyncDeployment": { + "get": { + "summary": "Get the live progress of a background job", + "operationId": "ListAsyncDeployment", + "tags": [ + "CIPP > Scheduler" + ], + "description": "Returns the status rows of a background job that reports progress while it runs, such as a\nuser offboarding started from the wizard: one row per target (the user) with its overall\nstatus and the status and message of every step.", + "parameters": [ + { + "name": "DeploymentId", + "in": "query", + "description": "The job id handed back when the work was queued (e.g. DeploymentId from ExecOffboardUser)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides." + } + } + } + } + }, + "400": { + "description": "Bad request - missing required field or invalid input" + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "CIPP.Scheduler.Read", + "x-cipp-any-tenant": true + } + }, "/api/ListAuditLogCoverage": { "get": { "summary": "ListAuditLogCoverage", @@ -39821,7 +41320,7 @@ "label": { "x-cipp-field-source": "backend" }, - "package": { + "Package": { "x-cipp-field-source": "storage,frontend" }, "PartitionKey": { @@ -39837,8 +41336,8 @@ "SHA": { "x-cipp-field-source": "storage" }, - "source": { - "x-cipp-field-source": "storage" + "Source": { + "x-cipp-field-source": "storage,frontend" }, "templateCount": { "x-cipp-field-source": "backend" @@ -40512,8 +42011,34 @@ "tags": [ "Tenant > Conditional" ], - "description": "Lists Conditional Access policies for a tenant with resolved display names for users, groups, applications, and locations.", + "description": "Lists Conditional Access policies for a tenant with resolved display names for users, groups, applications, and locations. When manualPagination is set on an AllTenants read, one page is returned per request with a continuation token in Metadata.nextLink.", "parameters": [ + { + "name": "manualPagination", + "in": "query", + "description": "Return one page per request with a continuation token in Metadata.nextLink; AllTenants reads only.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "nextLink", + "in": "query", + "description": "Continuation token from the previous page's Metadata.nextLink; opaque to callers.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "PageSize", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, { "$ref": "#/components/parameters/tenantFilter" } @@ -44563,7 +46088,7 @@ "x-cipp-field-source": "storage" }, "ExecutedTime": { - "x-cipp-field-source": "frontend" + "x-cipp-field-source": "storage,frontend" }, "Hidden": { "type": "boolean", @@ -44585,6 +46110,14 @@ "type": "string", "x-cipp-field-source": "storage" }, + "PsaTicketId": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "PsaTicketPriority": { + "type": "string", + "x-cipp-field-source": "storage" + }, "PsaTicketStrategy": { "type": "string", "x-cipp-field-source": "storage" @@ -45344,7 +46877,18 @@ "tags": [ "Tenant > GDAP" ], - "description": "Lists the configured GDAP role-to-security-group mappings used for delegated admin access.", + "description": "Lists the configured GDAP role-to-security-group mappings used for delegated admin access.\nPass ?validate=true to annotate each mapping with the state of its partner tenant group.", + "parameters": [ + { + "name": "validate", + "in": "query", + "description": "Opt-in only: other consumers of this endpoint depend on the unannotated shape.", + "required": false, + "schema": { + "type": "boolean" + } + } + ], "responses": { "200": { "description": "Success", @@ -45366,6 +46910,12 @@ "GroupName": { "x-cipp-field-source": "storage,backend,frontend" }, + "GroupStatus": { + "x-cipp-field-source": "backend,frontend" + }, + "GroupStatusMessage": { + "x-cipp-field-source": "backend" + }, "PartitionKey": { "x-cipp-field-source": "storage" }, @@ -45387,6 +46937,9 @@ "Timestamp": { "type": "string", "x-cipp-field-source": "storage" + }, + "UsedInTemplates": { + "x-cipp-field-source": "frontend" } } } @@ -46080,6 +47633,15 @@ "type": "string" } }, + { + "name": "maxPageBytes", + "in": "query", + "description": "Paged AllTenants cache reads only: target page size in bytes of raw JSON, clamped between 262144 and 8388608 (default 4000000). Pages always hold at least one whole tenant.", + "required": false, + "schema": { + "type": "integer" + } + }, { "name": "nextLink", "in": "query", @@ -46270,7 +47832,7 @@ "tags": [ "Identity > Administration > Groups" ], - "description": "Lists Entra ID groups for a tenant, including group members and owners. Supports UseReportDB=true query parameter to retrieve cached data from the reporting database for significantly better performance, especially when querying AllTenants.", + "description": "Lists Entra ID groups for a tenant, including group members and owners. Supports UseReportDB=true query parameter to retrieve cached data from the reporting database for significantly better performance, especially when querying AllTenants. When manualPagination is also set on a cached read, one page is returned per request as { Results, Metadata } with a continuation token in Metadata.nextLink.", "parameters": [ { "name": "expandMembers", @@ -46307,6 +47869,15 @@ "type": "string" } }, + { + "name": "manualPagination", + "in": "query", + "description": "Return one page per request as { Results, Metadata } with a continuation token in Metadata.nextLink; cached reads only.", + "required": false, + "schema": { + "type": "string" + } + }, { "name": "members", "in": "query", @@ -46316,6 +47887,15 @@ "type": "boolean" } }, + { + "name": "nextLink", + "in": "query", + "description": "Continuation token from the previous page's Metadata.nextLink; opaque to callers. Stream the cached blobs as raw JSON so member arrays are never re-parsed here.", + "required": false, + "schema": { + "type": "string" + } + }, { "name": "owners", "in": "query", @@ -46325,6 +47905,14 @@ "type": "boolean" } }, + { + "name": "PageSize", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, { "$ref": "#/components/parameters/tenantFilter" }, @@ -46419,9 +48007,6 @@ "members": { "x-cipp-field-source": "backend" }, - "membersCsv": { - "x-cipp-field-source": "backend" - }, "membershipRule": { "type": "string", "x-cipp-field-source": "graph" @@ -46441,9 +48026,6 @@ "owners": { "x-cipp-field-source": "backend" }, - "ownersCsv": { - "x-cipp-field-source": "backend" - }, "primDomain": { "x-cipp-field-source": "backend" }, @@ -46681,6 +48263,78 @@ "x-cipp-any-tenant": true } }, + "/api/ListGroupUsage": { + "get": { + "summary": "ListGroupUsage", + "operationId": "ListGroupUsage", + "tags": [ + "Identity > Reports" + ], + "description": "Compiles where each Entra group is used (Conditional Access, Intune assignments, group-based\nlicensing, Teams, nested groups, Entra roles, enterprise applications, Exchange transport\nrules) from the CIPP reporting database cache. Always served from cache — no live Graph calls.", + "parameters": [ + { + "$ref": "#/components/parameters/tenantFilter" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Derived from the columns the CIPP UI renders. The response may carry more; these are the ones known to exist.", + "properties": { + "displayName": { + "x-cipp-field-source": "frontend" + }, + "groupType": { + "x-cipp-field-source": "frontend" + }, + "isUsed": { + "x-cipp-field-source": "frontend" + }, + "mail": { + "x-cipp-field-source": "frontend" + }, + "Tenant": { + "x-cipp-field-source": "frontend" + }, + "usageCount": { + "x-cipp-field-source": "frontend" + }, + "usedIn": { + "x-cipp-field-source": "frontend" + }, + "usedLocations": { + "x-cipp-field-source": "frontend" + } + } + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Identity.Group.Read" + } + }, "/api/ListGuestUsers": { "get": { "summary": "List guest users with lifecycle status", @@ -46688,8 +48342,34 @@ "tags": [ "Identity > Administration > Users" ], - "description": "Lists all guest accounts in a tenant with a computed lifecycle status (Active, Pending Acceptance, Stale, Never Signed In or Disabled) based on the invitation state and sign-in activity. Supports UseReportDB=true to serve cached data from the reporting database; AllTenants always uses the cache.", + "description": "Lists all guest accounts in a tenant with a computed lifecycle status (Active, Pending Acceptance, Stale, Never Signed In or Disabled) based on the invitation state and sign-in activity. Supports UseReportDB=true to serve cached data from the reporting database; AllTenants always uses the cache. When manualPagination is set on a cached read, one page is returned per request as { Results, Metadata } with a continuation token in Metadata.nextLink.", "parameters": [ + { + "name": "manualPagination", + "in": "query", + "description": "Return one page per request as { Results, Metadata } with a continuation token in Metadata.nextLink; cached reads only.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "nextLink", + "in": "query", + "description": "Continuation token from the previous page's Metadata.nextLink; opaque to callers.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "PageSize", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, { "name": "staleDays", "in": "query", @@ -46864,6 +48544,86 @@ "x-cipp-role": "CIPP.Extension.Read" } }, + "/api/ListHistoricalSearches": { + "get": { + "summary": "ListHistoricalSearches", + "operationId": "ListHistoricalSearches", + "tags": [ + "Email-Exchange > Tools" + ], + "description": "Lists Exchange Online historical searches (async message trace/report jobs) submitted in the last 10 days.", + "parameters": [ + { + "name": "jobId", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/tenantFilter" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Derived from the columns the CIPP UI renders. The response may carry more; these are the ones known to exist.", + "properties": { + "EndDate": { + "x-cipp-field-source": "frontend" + }, + "JobProgress": { + "x-cipp-field-source": "frontend" + }, + "ReportTitle": { + "x-cipp-field-source": "frontend" + }, + "ReportType": { + "x-cipp-field-source": "frontend" + }, + "Rows": { + "x-cipp-field-source": "frontend" + }, + "StartDate": { + "x-cipp-field-source": "frontend" + }, + "Status": { + "x-cipp-field-source": "frontend" + }, + "SubmitDate": { + "x-cipp-field-source": "frontend" + } + } + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Exchange.Mailbox.Read" + } + }, "/api/ListHVEAccounts": { "get": { "summary": "ListHVEAccounts", @@ -47017,6 +48777,9 @@ "type": "object", "description": "Derived from the columns the CIPP UI renders. The response may carry more; these are the ones known to exist.", "properties": { + "accountEnabled": { + "x-cipp-field-source": "frontend" + }, "daysSinceLastSignIn": { "x-cipp-field-source": "frontend" }, @@ -47623,8 +49386,8 @@ "SHA": { "x-cipp-field-source": "storage" }, - "source": { - "x-cipp-field-source": "storage,backend" + "Source": { + "x-cipp-field-source": "storage,backend,frontend" }, "templateCount": { "x-cipp-field-source": "backend" @@ -47959,14 +49722,14 @@ "x-cipp-any-tenant": true } }, - "/api/ListKnownIPDb": { + "/api/ListJITAllowedRoles": { "get": { - "summary": "ListKnownIPDb", - "operationId": "ListKnownIPDb", + "summary": "ListJITAllowedRoles", + "operationId": "ListJITAllowedRoles", "tags": [ - "CIPP > Core" + "Identity > Administration > Users" ], - "description": "Lists known IP address entries from the CIPP IP database, optionally filtered by tenant.", + "description": "Returns the directory roles the calling user is permitted to assign via JIT Admin, based on the\nJIT Role Template(s) attached to their CIPP custom role(s). When the caller is unrestricted the\nfull role catalog is available (Restricted = false).", "responses": { "200": { "description": "Success", @@ -47994,29 +49757,27 @@ "bearerAuth": [] } ], - "x-cipp-role": "CIPP.Core.Read", + "x-cipp-role": "Identity.Role.Read", "x-cipp-any-tenant": true } }, - "/api/ListLicenses": { + "/api/ListJITRoleTemplates": { "get": { - "summary": "ListLicenses", - "operationId": "ListLicenses", + "summary": "ListJITRoleTemplates", + "operationId": "ListJITRoleTemplates", "tags": [ - "Tenant > Reports" + "Identity > Administration > Users" ], - "description": "Lists Microsoft 365 license SKUs and their assigned/available counts for a tenant. For AllTenants queries, consider using ListDBCache for better performance.", + "description": "Lists JIT Role Templates - named allow-lists of directory roles used to restrict which roles a\nCIPP custom role may assign via JIT Admin.", "parameters": [ { - "name": "IncludeExcluded", + "name": "GUID", "in": "query", + "description": "If a specific GUID is requested, filter to that template", "required": false, "schema": { - "type": "boolean" + "type": "string" } - }, - { - "$ref": "#/components/parameters/tenantFilter" } ], "responses": { @@ -48028,28 +49789,55 @@ "type": "array", "items": { "type": "object", - "description": "Derived from the fields written into the storage table it reads, and the fields the endpoint selects onto each record. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", + "description": "Derived from the fields written into the storage table it reads, and the columns the CIPP UI renders. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", "properties": { + "createdBy": { + "x-cipp-field-source": "frontend" + }, + "createdDate": { + "x-cipp-field-source": "frontend" + }, "ETag": { "type": "string", "x-cipp-field-source": "storage" }, - "License": { - "type": "string", - "x-cipp-field-source": "storage,backend" + "GUID": { + "x-cipp-field-source": "storage" + }, + "JSON": { + "x-cipp-field-source": "storage" + }, + "Package": { + "x-cipp-field-source": "storage" }, "PartitionKey": { "x-cipp-field-source": "storage" }, + "Permissions": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "roles": { + "x-cipp-field-source": "frontend" + }, "RowKey": { "x-cipp-field-source": "storage" }, - "Tenant": { - "x-cipp-field-source": "backend" + "SHA": { + "x-cipp-field-source": "storage" + }, + "Source": { + "x-cipp-field-source": "storage" + }, + "templateName": { + "x-cipp-field-source": "storage,frontend" }, "Timestamp": { "type": "string", "x-cipp-field-source": "storage" + }, + "UpdatedBy": { + "x-cipp-field-source": "storage" } } } @@ -48069,28 +49857,84 @@ "bearerAuth": [] } ], - "x-cipp-role": "Tenant.Directory.Read" + "x-cipp-role": "Identity.Role.Read", + "x-cipp-any-tenant": true } }, - "/api/ListLicensesReport": { + "/api/ListKnownIPDb": { "get": { - "summary": "ListLicensesReport", - "operationId": "ListLicensesReport", + "summary": "ListKnownIPDb", + "operationId": "ListKnownIPDb", + "tags": [ + "CIPP > Core" + ], + "description": "Lists known IP address entries from the CIPP IP database, optionally filtered by tenant.", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides." + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "CIPP.Core.Read", + "x-cipp-any-tenant": true + } + }, + "/api/ListLicenseOptimization": { + "get": { + "summary": "ListLicenseOptimization", + "operationId": "ListLicenseOptimization", "tags": [ "Tenant > Reports" ], - "description": "Lists a detailed license overview across all tenants or a single tenant, including SKU breakdowns, costs, and availability.", + "description": "License cost-optimization report for a tenant: a monetary summary plus reclaim\nopportunities across five waste tiers (unassigned seats, disabled and inactive licensed\naccounts, mailbox-only downgrade candidates, and redundant overlapping SKUs). Computed from\nthe reporting-DB cache. For tenantFilter=AllTenants it returns a per-tenant summary money\nmap (ranked by reclaimable spend) instead of the full opportunity detail.", "parameters": [ { - "name": "QueueId", + "name": "currency", "in": "query", + "description": "Currency the money figures are resolved in (ISO code); defaults to USD", "required": false, "schema": { "type": "string" } }, { - "$ref": "#/components/parameters/tenantFilter" + "name": "inactiveDays", + "in": "query", + "description": "Sign-in age in days past which an enabled licensed user counts as inactive (default 90)", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "tenantFilter", + "in": "query", + "description": "The tenant to report on, or AllTenants for the cross-tenant summary money map", + "required": true, + "schema": { + "type": "string" + } } ], "responses": { @@ -48102,52 +49946,251 @@ "type": "array", "items": { "type": "object", - "description": "Derived from the fields written into the storage table it reads, and the fields the endpoint selects onto each record, and the columns the CIPP UI renders. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", + "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides." + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Tenant.Directory.Read", + "x-cipp-any-tenant": true + } + }, + "/api/ListLicensePricing": { + "get": { + "summary": "ListLicensePricing", + "operationId": "ListLicensePricing", + "tags": [ + "Tenant > Reports" + ], + "description": "Lists the resolved monthly price for every known license SKU: MSP price overrides merged\nover the shipped MSRP estimates. Consumed by the license optimization report and its\nprice-management UI. Each row carries a Source of Override, Estimate, or Unknown.\n\nPrices are resolved in the requested currency (?currency=, default USD). The response also\ncarries the list of currencies present in the price data so the UI can offer a selector.", + "parameters": [ + { + "name": "currency", + "in": "query", + "description": "Currency to resolve prices in (ISO code); defaults to USD", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Derived from the columns the CIPP UI renders. The response may carry more; these are the ones known to exist.", "properties": { - "AssignedGroups": { + "Currency": { "x-cipp-field-source": "frontend" }, - "AssignedUsers": { + "MonthlyPrice": { "x-cipp-field-source": "frontend" }, - "CountAvailable": { + "Product_Display_Name": { "x-cipp-field-source": "frontend" }, - "CountUsed": { + "skuId": { + "x-cipp-field-source": "frontend" + }, + "skuPartNumber": { "x-cipp-field-source": "frontend" }, + "Source": { + "x-cipp-field-source": "frontend" + } + } + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Tenant.Directory.Read", + "x-cipp-any-tenant": true + } + }, + "/api/ListLicenses": { + "get": { + "summary": "ListLicenses", + "operationId": "ListLicenses", + "tags": [ + "Tenant > Reports" + ], + "description": "Lists Microsoft 365 license SKUs and their assigned/available counts for a tenant. For AllTenants queries, consider using ListDBCache for better performance.", + "parameters": [ + { + "name": "IncludeExcluded", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "$ref": "#/components/parameters/tenantFilter" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Derived from the fields written into the storage table it reads, and the fields the endpoint selects onto each record. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", + "properties": { "ETag": { "type": "string", "x-cipp-field-source": "storage" }, "License": { "type": "string", - "x-cipp-field-source": "storage,frontend" - }, - "Metadata": { - "x-cipp-field-source": "backend" + "x-cipp-field-source": "storage,backend" }, "PartitionKey": { "x-cipp-field-source": "storage" }, - "Results": { - "x-cipp-field-source": "backend" - }, "RowKey": { "x-cipp-field-source": "storage" }, "Tenant": { - "x-cipp-field-source": "frontend" - }, - "TermInfo": { - "x-cipp-field-source": "frontend" + "x-cipp-field-source": "backend" }, "Timestamp": { "type": "string", "x-cipp-field-source": "storage" - }, - "TotalLicenses": { - "x-cipp-field-source": "frontend" + } + } + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Tenant.Directory.Read" + } + }, + "/api/ListLicensesReport": { + "get": { + "summary": "ListLicensesReport", + "operationId": "ListLicensesReport", + "tags": [ + "Tenant > Reports" + ], + "description": "Lists a detailed license overview across all tenants or a single tenant, including SKU breakdowns, costs, and availability.", + "parameters": [ + { + "name": "QueueId", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/tenantFilter" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Derived from the fields written into the storage table it reads, and the fields the endpoint selects onto each record, and the columns the CIPP UI renders. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", + "properties": { + "AssignedGroups": { + "x-cipp-field-source": "frontend" + }, + "AssignedUsers": { + "x-cipp-field-source": "frontend" + }, + "CountAvailable": { + "x-cipp-field-source": "frontend" + }, + "CountUsed": { + "x-cipp-field-source": "frontend" + }, + "ETag": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "License": { + "type": "string", + "x-cipp-field-source": "storage,frontend" + }, + "Metadata": { + "x-cipp-field-source": "backend" + }, + "PartitionKey": { + "x-cipp-field-source": "storage" + }, + "Results": { + "x-cipp-field-source": "backend" + }, + "RowKey": { + "x-cipp-field-source": "storage" + }, + "Tenant": { + "x-cipp-field-source": "frontend" + }, + "TermInfo": { + "x-cipp-field-source": "frontend" + }, + "Timestamp": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "TotalLicenses": { + "x-cipp-field-source": "frontend" } } } @@ -48177,7 +50220,7 @@ "tags": [ "CIPP > Core" ], - "description": "Lists CIPP platform audit logs with filtering by severity, date range, tenant, and user. Supports listing available log categories.", + "description": "Lists CIPP platform audit logs with filtering by severity, date range, tenant, and user. Supports listing available log categories, fetching a single entry, and server-side pagination via manualPagination/nextLink.", "parameters": [ { "name": "API", @@ -48203,6 +50246,15 @@ "type": "string" } }, + { + "name": "Days", + "in": "query", + "description": "Days=N widens a filtered query to the last N calendar days (in the instance timezone), so the scoped drawers still show a run that finished last night. Ignored when dates are given.", + "required": false, + "schema": { + "type": "integer" + } + }, { "name": "EndDate", "in": "query", @@ -48214,6 +50266,7 @@ { "name": "Filter", "in": "query", + "description": "When true, the Severity/User/Tenant/API/StartDate/EndDate query filters are applied; otherwise the current day is returned unfiltered.", "required": false, "schema": { "type": "boolean" @@ -48230,11 +50283,39 @@ { "name": "logentryid", "in": "query", + "description": "Return single log entry by RowKey. RowKeys are either legacy GUIDs or the inverted-ticks format Write-LogMessage writes; both use only hex digits and hyphens.", "required": false, "schema": { "type": "string" } }, + { + "name": "manualPagination", + "in": "query", + "description": "Return one page per request plus a continuation token in Metadata.nextLink, which the frontend passes back as nextLink to fetch the next page. Pages walk the requested date range newest-day-first and, within a day, in RowKey order (newest-first for entries written with the inverted-ticks RowKey scheme).", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "nextLink", + "in": "query", + "description": "Continuation token from the previous page's Metadata.nextLink; opaque to callers.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "PageSize", + "in": "query", + "description": "Rows to return per page, clamped between 50 and 1000. Defaults to 400.", + "required": false, + "schema": { + "type": "integer" + } + }, { "name": "ScheduledTaskId", "in": "query", @@ -48480,8 +50561,43 @@ "tags": [ "Email-Exchange > Administration" ], - "description": "Lists Exchange Online mailboxes for a tenant. Supports UseReportDB=true query parameter to retrieve cached data from the reporting database for significantly better performance, especially when querying AllTenants.", + "description": "Lists Exchange Online mailboxes for a tenant. Supports UseReportDB=true query parameter to retrieve cached data from the reporting database for significantly better performance, especially when querying AllTenants. When manualPagination is also set, one page is returned per request as { Results, Metadata } with a continuation token in Metadata.nextLink.", "parameters": [ + { + "name": "manualPagination", + "in": "query", + "description": "Return one page per request as { Results, Metadata } with a continuation token in Metadata.nextLink; cached reads only.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "Minimal", + "in": "query", + "description": "Picker mode: address autocompletes only need the address + name, so skip the heavy field set, the per-mailbox computed properties, and the extra Get-OrganizationConfig call.", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "name": "nextLink", + "in": "query", + "description": "Continuation token from the previous page's Metadata.nextLink; opaque to callers.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "PageSize", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, { "$ref": "#/components/parameters/tenantFilter" }, @@ -49059,6 +51175,100 @@ "x-cipp-role": "Exchange.Mailbox.Read" } }, + "/api/ListMailFlowReports": { + "get": { + "summary": "ListMailFlowReports", + "operationId": "ListMailFlowReports", + "tags": [ + "Email-Exchange > Reports" + ], + "description": "Returns Exchange Online mail flow reports: disposition counts by day (Get-MailFlowStatusReport)\nand top sender/recipient summaries (Get-MailTrafficSummaryReport). Both support up to 90 days.", + "parameters": [ + { + "name": "category", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "days", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "reportType", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "MailFlowStatus", + "TrafficSummary" + ] + } + }, + { + "$ref": "#/components/parameters/tenantFilter" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Derived from the fields the endpoint selects onto each record. The response may carry more; these are the ones known to exist.", + "properties": { + "Count": { + "x-cipp-field-source": "backend" + }, + "Date": { + "x-cipp-field-source": "backend" + }, + "Direction": { + "x-cipp-field-source": "backend" + }, + "EventType": { + "x-cipp-field-source": "backend" + }, + "Extra": { + "x-cipp-field-source": "backend" + }, + "Name": { + "x-cipp-field-source": "backend" + } + } + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Exchange.Mailbox.Read" + } + }, "/api/ListMailQuarantine": { "get": { "summary": "ListMailQuarantine", @@ -49606,7 +51816,7 @@ "tags": [ "Email-Exchange > Tools" ], - "description": "Traces email message delivery in Exchange Online, searchable by message ID, sender, recipient, and date range.", + "description": "Traces email delivery in Exchange Online via the Graph message trace API\n(/beta/admin/exchange/tracing/messageTraces), searchable by sender, recipient, subject,\nstatus, IP, message ID and date range. Graph works over GDAP/app-only where the legacy\nreporting endpoints do not. Requires the \"Transport Data Platform\" service principal\n(8bd644d1-64a1-4d4b-ae52-2e0cbf64e373) in the tenant; it is provisioned on demand. Until\nthat SP activates (which can take hours), or where the Graph permission is not yet\nconsented, the request falls back to Get-MessageTraceV2 so results are returned immediately.", "requestBody": { "required": true, "content": { @@ -49615,7 +51825,8 @@ "type": "object", "properties": { "days": { - "type": "string" + "type": "number", + "description": "Parse the shared search inputs." }, "endDate": { "type": "integer" @@ -49626,19 +51837,28 @@ "ID": { "type": "string" }, - "MessageId": { + "messageId": { + "type": "string" + }, + "messageTraceId": { "type": "string" }, "recipient": { "$ref": "#/components/schemas/LabelValue" }, "sender": { - "$ref": "#/components/schemas/LabelValue" + "type": "string" }, "startDate": { "type": "integer" }, "status": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "subjectFilterType": { "$ref": "#/components/schemas/LabelValue" }, "tenantFilter": { @@ -49667,43 +51887,25 @@ "type": "array", "items": { "type": "object", - "description": "Derived from the fields the endpoint selects onto each record, and the columns the CIPP UI renders. The response may carry more; these are the ones known to exist.", + "description": "Derived from the columns the CIPP UI renders. The response may carry more; these are the ones known to exist.", "properties": { - "Action": { - "x-cipp-field-source": "backend" - }, - "Date": { - "x-cipp-field-source": "backend" - }, - "Detail": { - "x-cipp-field-source": "backend" - }, - "Event": { - "x-cipp-field-source": "backend" - }, - "FromIP": { - "x-cipp-field-source": "backend" - }, - "MessageTraceId": { - "x-cipp-field-source": "backend" - }, "Received": { - "x-cipp-field-source": "backend,frontend" + "x-cipp-field-source": "frontend" }, "RecipientAddress": { - "x-cipp-field-source": "backend,frontend" + "x-cipp-field-source": "frontend" }, "SenderAddress": { - "x-cipp-field-source": "backend,frontend" + "x-cipp-field-source": "frontend" + }, + "Size": { + "x-cipp-field-source": "frontend" }, "Status": { - "x-cipp-field-source": "backend,frontend" + "x-cipp-field-source": "frontend" }, "Subject": { - "x-cipp-field-source": "backend,frontend" - }, - "ToIP": { - "x-cipp-field-source": "backend" + "x-cipp-field-source": "frontend" } } } @@ -49736,8 +51938,34 @@ "tags": [ "Identity > Reports" ], - "description": "Lists users and their MFA registration status for a tenant. Supports UseReportDB=true query parameter to retrieve cached data from the reporting database for significantly better performance, especially when querying AllTenants.", + "description": "Lists users and their MFA registration status for a tenant. Supports UseReportDB=true query parameter to retrieve cached data from the reporting database for significantly better performance, especially when querying AllTenants. When manualPagination is also set, one page is returned per request as { Results, Metadata } with a continuation token in Metadata.nextLink.", "parameters": [ + { + "name": "manualPagination", + "in": "query", + "description": "Return one page per request as { Results, Metadata } with a continuation token in Metadata.nextLink; cached reads only.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "nextLink", + "in": "query", + "description": "Continuation token from the previous page's Metadata.nextLink; opaque to callers.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "PageSize", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, { "$ref": "#/components/parameters/tenantFilter" }, @@ -50073,6 +52301,15 @@ "PartitionKey": { "x-cipp-field-source": "storage" }, + "PsaTicketId": { + "x-cipp-field-source": "storage" + }, + "PsaTicketPriority": { + "x-cipp-field-source": "storage" + }, + "Reference": { + "x-cipp-field-source": "storage" + }, "RowKey": { "x-cipp-field-source": "storage" }, @@ -50340,6 +52577,59 @@ "x-cipp-role": "Identity.AuditLog.Read" } }, + "/api/ListOffboardingProgress": { + "get": { + "summary": "Get the live progress of an offboarding job", + "operationId": "ListOffboardingProgress", + "tags": [ + "Identity > Administration > Users" + ], + "description": "Returns the progress rows of an offboarding job started from the wizard: one row per user with\nits overall status and the status and message of every step. Same rows as ListAsyncDeployment.\nThis is a read-only GET, so it carries a read role; an offboarding operator (who holds the\nbroader user write role) can still follow and re-run their jobs.", + "parameters": [ + { + "name": "DeploymentId", + "in": "query", + "description": "The DeploymentId handed back by ExecOffboardUser, also stored on each offboarding task", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides." + } + } + } + } + }, + "400": { + "description": "Bad request - missing required field or invalid input" + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Identity.User.Read", + "x-cipp-any-tenant": true + } + }, "/api/ListOffboardTenants": { "post": { "summary": "ListOffboardTenants", @@ -50802,93 +53092,35 @@ "x-cipp-role": "Identity.User.Read" } }, - "/api/ListPotentialApps": { - "post": { - "summary": "ListPotentialApps", - "operationId": "ListPotentialApps", - "tags": [ - "Endpoint > Applications" - ], - "description": "Searches application repositories (WinGet, Chocolatey) for available applications matching a search string.", - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "SearchString": { - "type": "string" - }, - "type": { - "type": "string", - "x-cipp-observed-values": [ - "Choco", - "WinGet" - ] - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "description": "Derived from the fields the endpoint selects onto each record. The response may carry more; these are the ones known to exist.", - "properties": { - "applicationName": { - "x-cipp-field-source": "backend" - }, - "packagename": { - "x-cipp-field-source": "backend" - } - } - } - } - } - } - }, - "401": { - "description": "Unauthorized - invalid or missing bearer token" - }, - "403": { - "description": "Forbidden - caller lacks the required RBAC role" - } - }, - "security": [ - { - "bearerAuth": [] - } - ], - "x-cipp-role": "Endpoint.Application.Read" - } - }, - "/api/ListQuarantinePolicy": { + "/api/ListPIMRoles": { "get": { - "summary": "ListQuarantinePolicy", - "operationId": "ListQuarantinePolicy", + "summary": "List Entra directory roles grouped with their PIM assignment breakdown.", + "operationId": "ListPIMRoles", "tags": [ - "Email-Exchange > Spamfilter" + "Identity > Administration > Roles" ], - "description": "Lists quarantine policies configured in Exchange Online Protection, controlling end-user access to quarantined messages.", + "description": "Returns one row per role (per tenant when AllTenants is selected) with the role's definition details, how many principals hold it permanently, eligibly or with a time-bound active assignment, the role's PIM policy summary, a slim Members list and the full assignment rows for drill-in. Roles nobody holds are included for a single tenant so the result is also the role catalogue. Powers the Roles & PIM page; ListRoles keeps its original per-definition shape and ListRoleAssignments stays one flat row per assignment.", "parameters": [ { - "$ref": "#/components/parameters/tenantFilter" + "name": "principalId", + "in": "query", + "description": "Restrict to the roles one principal (object id) holds.", + "required": false, + "schema": { + "type": "string" + } }, { - "name": "Type", + "name": "roleTemplateId", "in": "query", + "description": "Restrict to one role template id, e.g. from an alert link.", "required": false, "schema": { "type": "string" } + }, + { + "$ref": "#/components/parameters/tenantFilter" } ], "responses": { @@ -50900,131 +53132,427 @@ "type": "array", "items": { "type": "object", - "description": "Derived from the fields the endpoint selects onto each record, and the columns the CIPP UI renders. The response may carry more; these are the ones known to exist.", + "description": "Derived from the columns the CIPP UI renders. The response may carry more; these are the ones known to exist.", "properties": { - "AllowSender": { + "ActiveCount": { "x-cipp-field-source": "frontend" }, - "BlockSender": { + "AssignmentType": { "x-cipp-field-source": "frontend" }, - "Builtin": { - "x-cipp-field-source": "backend" - }, - "Delete": { + "EligibleCount": { "x-cipp-field-source": "frontend" }, - "IncludeMessagesFromBlockedSenderAddress": { + "EndDateTime": { "x-cipp-field-source": "frontend" }, - "Name": { + "IsPrivilegedRole": { "x-cipp-field-source": "frontend" }, - "Preview": { + "Members": { "x-cipp-field-source": "frontend" }, - "QuarantineNotification": { - "x-cipp-field-source": "backend,frontend" - }, - "ReleaseActionPreference": { - "x-cipp-field-source": "backend,frontend" - }, - "WhenChanged": { + "MemberType": { "x-cipp-field-source": "frontend" }, - "WhenCreated": { - "x-cipp-field-source": "frontend" - } - } - } - } - } - } - }, - "401": { - "description": "Unauthorized - invalid or missing bearer token" - }, - "403": { - "description": "Forbidden - caller lacks the required RBAC role" - } - }, - "security": [ - { - "bearerAuth": [] - } - ], - "x-cipp-role": "Exchange.SpamFilter.Read" - } - }, - "/api/ListReportBuilderTemplates": { - "get": { - "summary": "ListReportBuilderTemplates", - "operationId": "ListReportBuilderTemplates", - "tags": [ - "Tools > Report-Builder" - ], - "description": "Lists saved Report Builder templates that define custom report configurations with data blocks and formatting.", - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "description": "Derived from the fields written into the storage table it reads, and the columns the CIPP UI renders. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", - "properties": { - "CustomCount": { + "PermanentCount": { "x-cipp-field-source": "frontend" }, - "ETag": { - "type": "string", - "x-cipp-field-source": "storage" - }, - "GUID": { - "x-cipp-field-source": "storage" - }, - "JSON": { - "x-cipp-field-source": "storage" - }, - "Name": { + "PolicySummary": { "x-cipp-field-source": "frontend" }, - "Package": { - "x-cipp-field-source": "storage" - }, - "PartitionKey": { - "x-cipp-field-source": "storage" - }, - "Permissions": { - "type": "string", - "x-cipp-field-source": "storage" - }, - "RowKey": { - "x-cipp-field-source": "storage" - }, - "Sections": { + "PrincipalDisplayName": { "x-cipp-field-source": "frontend" }, - "SHA": { - "x-cipp-field-source": "storage" - }, - "Source": { - "x-cipp-field-source": "storage" + "PrincipalType": { + "x-cipp-field-source": "frontend" }, - "TemplateName": { - "x-cipp-field-source": "storage" + "PrincipalUserPrincipalName": { + "x-cipp-field-source": "frontend" }, - "TestCount": { + "RoleDisplayName": { "x-cipp-field-source": "frontend" }, - "Timestamp": { - "type": "string", - "x-cipp-field-source": "storage" + "Scope": { + "x-cipp-field-source": "frontend" }, - "UpdatedBy": { - "x-cipp-field-source": "storage" + "Tenant": { + "x-cipp-field-source": "frontend" + } + } + } + } + } + } + }, + "400": { + "description": "Bad request - missing required field or invalid input" + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Identity.Role.Read" + } + }, + "/api/ListPIMRoleSettingsTemplates": { + "get": { + "summary": "List PIM role settings templates.", + "operationId": "ListPIMRoleSettingsTemplates", + "tags": [ + "Identity > Administration > Roles" + ], + "description": "Lists saved Privileged Identity Management role settings templates. A template names a set of roles and the activation, eligibility, assignment, approval and notification rules to enforce on them; the PIMRoleSettings standard deploys a template to tenants.", + "parameters": [ + { + "name": "GUID", + "in": "query", + "description": "Return only the template with this GUID.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "id", + "in": "query", + "description": "Return only the template with this GUID.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Derived from the fields written into the storage table it reads, and the columns the CIPP UI renders. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", + "properties": { + "ETag": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "GUID": { + "x-cipp-field-source": "storage" + }, + "JSON": { + "x-cipp-field-source": "storage" + }, + "meetsSecureFloor": { + "x-cipp-field-source": "frontend" + }, + "Package": { + "x-cipp-field-source": "storage" + }, + "PartitionKey": { + "x-cipp-field-source": "storage" + }, + "Permissions": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "roleCount": { + "x-cipp-field-source": "frontend" + }, + "roleScope": { + "x-cipp-field-source": "frontend" + }, + "RowKey": { + "x-cipp-field-source": "storage" + }, + "settings.activationMaxDuration": { + "x-cipp-field-source": "frontend" + }, + "settings.activationRequires": { + "x-cipp-field-source": "frontend" + }, + "settings.activationRequiresApproval": { + "x-cipp-field-source": "frontend" + }, + "settings.activeAssignmentMaxDuration": { + "x-cipp-field-source": "frontend" + }, + "settings.eligibilityMaxDuration": { + "x-cipp-field-source": "frontend" + }, + "SHA": { + "x-cipp-field-source": "storage" + }, + "Source": { + "x-cipp-field-source": "storage" + }, + "templateName": { + "x-cipp-field-source": "storage,frontend" + }, + "Timestamp": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "UpdatedBy": { + "x-cipp-field-source": "storage,frontend" + }, + "updatedDate": { + "x-cipp-field-source": "frontend" + } + } + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Identity.Role.Read", + "x-cipp-any-tenant": true + } + }, + "/api/ListPotentialApps": { + "post": { + "summary": "ListPotentialApps", + "operationId": "ListPotentialApps", + "tags": [ + "Endpoint > Applications" + ], + "description": "Searches application repositories (WinGet, Chocolatey) for available applications matching a search string.", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "SearchString": { + "type": "string" + }, + "type": { + "type": "string", + "x-cipp-observed-values": [ + "Choco", + "WinGet" + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Derived from the fields the endpoint selects onto each record. The response may carry more; these are the ones known to exist.", + "properties": { + "applicationName": { + "x-cipp-field-source": "backend" + }, + "packagename": { + "x-cipp-field-source": "backend" + } + } + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Endpoint.Application.Read" + } + }, + "/api/ListQuarantinePolicy": { + "get": { + "summary": "ListQuarantinePolicy", + "operationId": "ListQuarantinePolicy", + "tags": [ + "Email-Exchange > Spamfilter" + ], + "description": "Lists quarantine policies configured in Exchange Online Protection, controlling end-user access to quarantined messages.", + "parameters": [ + { + "$ref": "#/components/parameters/tenantFilter" + }, + { + "name": "Type", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Derived from the fields the endpoint selects onto each record, and the columns the CIPP UI renders. The response may carry more; these are the ones known to exist.", + "properties": { + "AllowSender": { + "x-cipp-field-source": "frontend" + }, + "BlockSender": { + "x-cipp-field-source": "frontend" + }, + "Builtin": { + "x-cipp-field-source": "backend" + }, + "Delete": { + "x-cipp-field-source": "frontend" + }, + "IncludeMessagesFromBlockedSenderAddress": { + "x-cipp-field-source": "frontend" + }, + "Name": { + "x-cipp-field-source": "frontend" + }, + "Preview": { + "x-cipp-field-source": "frontend" + }, + "QuarantineNotification": { + "x-cipp-field-source": "backend,frontend" + }, + "ReleaseActionPreference": { + "x-cipp-field-source": "backend,frontend" + }, + "WhenChanged": { + "x-cipp-field-source": "frontend" + }, + "WhenCreated": { + "x-cipp-field-source": "frontend" + } + } + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Exchange.SpamFilter.Read" + } + }, + "/api/ListReportBuilderTemplates": { + "get": { + "summary": "ListReportBuilderTemplates", + "operationId": "ListReportBuilderTemplates", + "tags": [ + "Tools > Report-Builder" + ], + "description": "Lists saved Report Builder templates that define custom report configurations with data blocks and formatting.", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Derived from the fields written into the storage table it reads, and the columns the CIPP UI renders. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", + "properties": { + "CustomCount": { + "x-cipp-field-source": "frontend" + }, + "ETag": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "GUID": { + "x-cipp-field-source": "storage" + }, + "JSON": { + "x-cipp-field-source": "storage" + }, + "Name": { + "x-cipp-field-source": "frontend" + }, + "Package": { + "x-cipp-field-source": "storage" + }, + "PartitionKey": { + "x-cipp-field-source": "storage" + }, + "Permissions": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "RowKey": { + "x-cipp-field-source": "storage" + }, + "Sections": { + "x-cipp-field-source": "frontend" + }, + "SHA": { + "x-cipp-field-source": "storage" + }, + "Source": { + "x-cipp-field-source": "storage" + }, + "TemplateName": { + "x-cipp-field-source": "storage" + }, + "TestCount": { + "x-cipp-field-source": "frontend" + }, + "Timestamp": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "UpdatedBy": { + "x-cipp-field-source": "storage" } } } @@ -51197,16 +53725,19 @@ "RestrictiveRetention": { "x-cipp-field-source": "frontend" }, - "RuleCount": { + "RetentionAction": { "x-cipp-field-source": "backend,frontend" }, - "WhenChangedUTC": { - "x-cipp-field-source": "frontend" + "RetentionDuration": { + "x-cipp-field-source": "backend,frontend" }, - "WhenCreatedUTC": { - "x-cipp-field-source": "frontend" + "RuleCount": { + "x-cipp-field-source": "backend,frontend" }, - "Workload": { + "ScopedLocations": { + "x-cipp-field-source": "backend,frontend" + }, + "WhenChangedUTC": { "x-cipp-field-source": "frontend" } } @@ -51332,6 +53863,88 @@ "x-cipp-any-tenant": true } }, + "/api/ListRoleAssignments": { + "get": { + "summary": "List Entra directory role assignments with their PIM assignment type.", + "operationId": "ListRoleAssignments", + "tags": [ + "Identity > Administration > Roles" + ], + "description": "Returns one row per principal, role and scope showing whether the assignment is Permanent (active with no end date), Active (time-bound), ActivatedFromEligible or Eligible, whether it is held directly or through a role-assignable group, the scope (directory or administrative unit), the principal type, the role's description and built-in flag, and the role's PIM policy summary. For a single tenant roles that nobody holds are included as Unassigned rows so the result is also the role catalogue. A single tenant is read live from Graph (Entra ID P2 tenants via PIM, others via unified RBAC where every assignment is permanent); AllTenants is served from the reporting cache.", + "parameters": [ + { + "name": "includeUnassigned", + "in": "query", + "description": "Single tenant: also list roles that nobody holds (AssignmentType 'Unassigned'), so the result is the full role catalogue. Default true; set to false for assignments only.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "permanentOnly", + "in": "query", + "description": "Only return permanent (active, no end date) assignments.", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "name": "principalId", + "in": "query", + "description": "Restrict to one principal (object id), e.g. for the user view page.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "roleTemplateId", + "in": "query", + "description": "Restrict to one role template id.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/tenantFilter" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides." + } + } + } + } + }, + "400": { + "description": "Bad request - missing required field or invalid input" + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Identity.Role.Read" + } + }, "/api/ListRoles": { "get": { "summary": "ListRoles", @@ -52264,6 +54877,9 @@ "type": "string", "x-cipp-field-source": "storage" }, + "ExecutedTime": { + "x-cipp-field-source": "storage" + }, "Hidden": { "type": "boolean", "x-cipp-field-source": "storage" @@ -52287,6 +54903,14 @@ "type": "string", "x-cipp-field-source": "storage" }, + "PsaTicketId": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "PsaTicketPriority": { + "type": "string", + "x-cipp-field-source": "storage" + }, "PsaTicketStrategy": { "type": "string", "x-cipp-field-source": "storage" @@ -54619,6 +57243,56 @@ "x-cipp-role": "Sharepoint.Site.Read" } }, + "/api/ListSiteBrowserLibraryCopy": { + "get": { + "summary": "ListSiteBrowserLibraryCopy", + "operationId": "ListSiteBrowserLibraryCopy", + "tags": [ + "Teams-Sharepoint" + ], + "description": "Returns sanitized aggregate status for a SharePoint library copy operation (OperationId).", + "parameters": [ + { + "name": "OperationId", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/tenantFilter" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardResults" + } + } + } + }, + "400": { + "description": "Bad request - missing required field or invalid input" + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Sharepoint.Site.Read" + } + }, "/api/ListSiteBrowserPermissions": { "get": { "summary": "ListSiteBrowserPermissions", @@ -54964,6 +57638,64 @@ "x-cipp-role": "Sharepoint.SiteRecycleBin.Read" } }, + "/api/ListSiteRecycleBinSummary": { + "get": { + "summary": "ListSiteRecycleBinSummary", + "operationId": "ListSiteRecycleBinSummary", + "tags": [ + "Teams-Sharepoint" + ], + "description": "Aggregate recycle bin sizes for a site (counts + bytes by stage). Never returns\nitem titles, leaf names, or paths — storage-report privacy ceiling.", + "parameters": [ + { + "name": "MaxItems", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "SiteUrl", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/tenantFilter" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardResults" + } + } + } + }, + "400": { + "description": "Bad request - missing required field or invalid input" + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Sharepoint.SiteRecycleBin.Read" + } + }, "/api/ListSiteRoleDefinitions": { "get": { "summary": "ListSiteRoleDefinitions", @@ -55029,7 +57761,7 @@ "tags": [ "Teams-Sharepoint" ], - "description": "Lists SharePoint sites or OneDrive usage for a tenant. Requires a Type parameter (SharePointSiteUsage or OneDriveUsageAccount). Supports UseReportDB=true query parameter to retrieve cached data from the reporting database for significantly better performance, especially when querying AllTenants.", + "description": "Lists SharePoint sites or OneDrive usage for a tenant. Requires a Type parameter (SharePointSiteUsage or OneDriveUsageAccount). SharePoint live data uses SPO admin RLD plus Graph enrichment; OneDrive live data uses Graph usage reports. Supports UseReportDB=true query parameter to retrieve cached data from the reporting database for significantly better performance, especially when querying AllTenants.", "parameters": [ { "$ref": "#/components/parameters/tenantFilter" @@ -55146,6 +57878,56 @@ "x-cipp-role": "Sharepoint.Site.Read" } }, + "/api/ListSiteStorageComposition": { + "get": { + "summary": "ListSiteStorageComposition", + "operationId": "ListSiteStorageComposition", + "tags": [ + "Teams-Sharepoint" + ], + "description": "Site-level storage composition at library ceiling: tip / previous-version estimate /\nrecycle estimate from root web StorageMetrics + site StorageUsed. No file names.", + "parameters": [ + { + "name": "SiteUrl", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/tenantFilter" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardResults" + } + } + } + }, + "400": { + "description": "Bad request - missing required field or invalid input" + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Sharepoint.Site.Read" + } + }, "/api/ListSiteUserAccess": { "get": { "summary": "ListSiteUserAccess", @@ -55868,7 +58650,7 @@ "SHA": { "x-cipp-field-source": "storage" }, - "source": { + "Source": { "x-cipp-field-source": "storage" }, "standards": { @@ -55918,6 +58700,78 @@ "x-cipp-any-tenant": true } }, + "/api/ListStorageCleanupScan": { + "get": { + "summary": "ListStorageCleanupScan", + "operationId": "ListStorageCleanupScan", + "tags": [ + "Teams-Sharepoint" + ], + "description": "Reads the hold-only StorageCleanupScan CIPPDB cache and rebuilds the scans map expected by\nthe storage report cleanup opportunity helpers. No live enumeration — refresh via\nExecCIPPDBCache Name=StorageCleanupScan. Report-private; not used by other List APIs.", + "parameters": [ + { + "$ref": "#/components/parameters/tenantFilter" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Derived from the fields the endpoint selects onto each record. The response may carry more; these are the ones known to exist.", + "properties": { + "cleanupSynced": { + "x-cipp-field-source": "backend" + }, + "lastDataRefresh": { + "x-cipp-field-source": "backend" + }, + "librariesScanned": { + "x-cipp-field-source": "backend" + }, + "scans": { + "x-cipp-field-source": "backend" + }, + "sitesScanned": { + "x-cipp-field-source": "backend" + }, + "sitesSkipped": { + "x-cipp-field-source": "backend" + }, + "sitesWithRecycle": { + "x-cipp-field-source": "backend" + }, + "summary": { + "x-cipp-field-source": "backend" + } + } + } + } + } + } + }, + "400": { + "description": "Bad request - missing required field or invalid input" + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Sharepoint.Site.Read" + } + }, "/api/ListTeams": { "get": { "summary": "ListTeams", @@ -57303,6 +60157,9 @@ "type": "string", "x-cipp-field-source": "storage" }, + "StandardsExcludeAllTenants": { + "x-cipp-field-source": "storage" + }, "Status": { "x-cipp-field-source": "storage,frontend" }, @@ -58226,7 +61083,7 @@ "type": "array", "items": { "type": "object", - "description": "Derived from the fields the endpoint selects onto each record. The response may carry more; these are the ones known to exist.", + "description": "Derived from the fields written into the storage table it reads, and the fields the endpoint selects onto each record. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", "properties": { "AdditionalEmailAddresses": { "x-cipp-field-source": "backend" @@ -58237,12 +61094,19 @@ "ComplianceTagHoldApplied": { "x-cipp-field-source": "backend" }, + "Data": { + "type": "string", + "x-cipp-field-source": "storage" + }, "DeliverToMailboxAndForward": { "x-cipp-field-source": "backend" }, "displayName": { "x-cipp-field-source": "backend" }, + "ETag": { + "x-cipp-field-source": "storage" + }, "ExchangeGuid": { "x-cipp-field-source": "backend" }, @@ -58285,6 +61149,9 @@ "MessageCopyForSentAsEnabled": { "x-cipp-field-source": "backend" }, + "PartitionKey": { + "x-cipp-field-source": "storage" + }, "primarySmtpAddress": { "x-cipp-field-source": "backend" }, @@ -58297,6 +61164,16 @@ "RetentionHoldEnabled": { "x-cipp-field-source": "backend" }, + "RowKey": { + "x-cipp-field-source": "storage" + }, + "Timestamp": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "Type": { + "x-cipp-field-source": "storage" + }, "UPN": { "x-cipp-field-source": "backend" }, @@ -58444,25 +61321,25 @@ "x-cipp-any-tenant": true } }, - "/api/ListUsers": { + "/api/ListUserReportedMessage": { "get": { - "summary": "ListUsers", - "operationId": "ListUsers", + "summary": "ListUserReportedMessage", + "operationId": "ListUserReportedMessage", "tags": [ - "Identity > Administration > Users" + "Email-Exchange > Spamfilter" ], - "description": "Lists Entra ID users for a tenant with license and sign-in details, or retrieves a specific user by ID. For AllTenants or cached data, consider using ListDBCache with type=Users for significantly better performance.", + "description": "Retrieves the raw EML content of a user reported message by its Internet Message ID. Tries the quarantine store first (Export-QuarantineMessage), then falls back to reading the message from the recipient's or reporter's mailbox via Graph.", "parameters": [ { - "name": "graphFilter", + "name": "InternetMessageId", "in": "query", - "required": false, + "required": true, "schema": { "type": "string" } }, { - "name": "IncludeLogonDetails", + "name": "RecipientEmail", "in": "query", "required": false, "schema": { @@ -58470,15 +61347,15 @@ } }, { - "$ref": "#/components/parameters/tenantFilter" - }, - { - "name": "UserID", + "name": "ReporterEmail", "in": "query", "required": false, "schema": { "type": "string" } + }, + { + "$ref": "#/components/parameters/tenantFilter" } ], "responses": { @@ -58490,7 +61367,546 @@ "type": "array", "items": { "type": "object", - "description": "Derived from the Microsoft Graph entity it queries, and the fields the endpoint selects onto each record. This endpoint returns the Graph response as-is without selecting fields, so these are the properties the entity CAN carry (x-cipp-field-source: graph-entity) rather than a proven projection - Graph returns a default subset unless asked otherwise.", + "description": "Derived from the Microsoft Graph entity it queries. This endpoint returns the Graph response as-is without selecting fields, so these are the properties the entity CAN carry (x-cipp-field-source: graph-entity) rather than a proven projection - Graph returns a default subset unless asked otherwise.", + "properties": { + "aboutMe": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "accountEnabled": { + "type": "boolean", + "x-cipp-field-source": "graph-entity" + }, + "ageGroup": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "assignedLicenses": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "assignedPlans": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "authorizationInfo": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "birthday": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "businessPhones": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "city": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "companyName": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "consentProvidedForMinor": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "country": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "createdDateTime": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "creationType": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "customSecurityAttributes": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "deletedDateTime": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "department": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "deviceEnrollmentLimit": { + "type": "integer", + "x-cipp-field-source": "graph-entity" + }, + "displayName": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "employeeHireDate": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "employeeId": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "employeeLeaveDateTime": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "employeeOrgData": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "employeeType": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "externalUserState": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "externalUserStateChangeDateTime": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "faxNumber": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "givenName": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "hireDate": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "id": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "identities": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "identityParentId": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "imAddresses": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "interests": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "isManagementRestricted": { + "type": "boolean", + "x-cipp-field-source": "graph-entity" + }, + "isResourceAccount": { + "type": "boolean", + "x-cipp-field-source": "graph-entity" + }, + "jobTitle": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "lastPasswordChangeDateTime": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "legalAgeGroupClassification": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "licenseAssignmentStates": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "mail": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "mailboxSettings": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "mailNickname": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "mobilePhone": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "mySite": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "officeLocation": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "onPremisesDistinguishedName": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "onPremisesDomainName": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "onPremisesExtensionAttributes": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "onPremisesImmutableId": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "onPremisesLastSyncDateTime": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "onPremisesProvisioningErrors": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "onPremisesSamAccountName": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "onPremisesSecurityIdentifier": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "onPremisesSyncEnabled": { + "type": "boolean", + "x-cipp-field-source": "graph-entity" + }, + "onPremisesUserPrincipalName": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "otherMails": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "passwordPolicies": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "passwordProfile": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "pastProjects": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "postalCode": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "preferredDataLocation": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "preferredLanguage": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "preferredName": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "print": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "provisionedPlans": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "proxyAddresses": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "responsibilities": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "schools": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "securityIdentifier": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "serviceProvisioningErrors": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "showInAddressList": { + "type": "boolean", + "x-cipp-field-source": "graph-entity" + }, + "signInActivity": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "signInSessionsValidFromDateTime": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "skills": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "state": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "streetAddress": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "surname": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "usageLocation": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "userPrincipalName": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "userType": { + "type": "string", + "x-cipp-field-source": "graph-entity" + } + } + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Exchange.SpamFilter.Read" + } + }, + "/api/ListUserReportedMessages": { + "get": { + "summary": "ListUserReportedMessages", + "operationId": "ListUserReportedMessages", + "tags": [ + "Email-Exchange > Spamfilter" + ], + "description": "Lists user reported email threat submissions (Defender Submissions with source 'user') for a tenant.", + "parameters": [ + { + "$ref": "#/components/parameters/tenantFilter" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Derived from the Microsoft Graph entity it queries, and the fields the endpoint selects onto each record, and the columns the CIPP UI renders. This endpoint returns the Graph response as-is without selecting fields, so these are the properties the entity CAN carry (x-cipp-field-source: graph-entity) rather than a proven projection - Graph returns a default subset unless asked otherwise.", + "properties": { + "adminReview": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "attackSimulationInfo": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "Category": { + "type": "object", + "x-cipp-field-source": "graph-entity,frontend" + }, + "clientSource": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "contentType": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "createdBy": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "createdDateTime": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "id": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "internetMessageId": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "Metadata": { + "x-cipp-field-source": "backend" + }, + "originalCategory": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "receivedDateTime": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "RecipientEmail": { + "x-cipp-field-source": "frontend" + }, + "recipientEmailAddress": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "ReportedBy": { + "x-cipp-field-source": "frontend" + }, + "ReportedDateTime": { + "x-cipp-field-source": "frontend" + }, + "result": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "ResultCategory": { + "x-cipp-field-source": "frontend" + }, + "Results": { + "x-cipp-field-source": "backend" + }, + "Sender": { + "type": "string", + "x-cipp-field-source": "graph-entity,frontend" + }, + "senderIP": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "source": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "Status": { + "type": "object", + "x-cipp-field-source": "graph-entity,frontend" + }, + "Subject": { + "type": "string", + "x-cipp-field-source": "graph-entity,frontend" + }, + "Tenant": { + "x-cipp-field-source": "frontend" + }, + "tenantAllowOrBlockListAction": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "tenantId": { + "type": "string", + "x-cipp-field-source": "graph-entity" + } + } + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Exchange.SpamFilter.Read" + } + }, + "/api/ListUsers": { + "get": { + "summary": "ListUsers", + "operationId": "ListUsers", + "tags": [ + "Identity > Administration > Users" + ], + "description": "Lists Entra ID users for a tenant with license and sign-in details, or retrieves a specific user by ID. For AllTenants or cached data, consider using ListDBCache with type=Users for significantly better performance.", + "parameters": [ + { + "name": "graphFilter", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "IncludeLogonDetails", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/tenantFilter" + }, + { + "name": "UserID", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Derived from the Microsoft Graph entity it queries, and the fields the endpoint selects onto each record. This endpoint returns the Graph response as-is without selecting fields, so these are the properties the entity CAN carry (x-cipp-field-source: graph-entity) rather than a proven projection - Graph returns a default subset unless asked otherwise.", "properties": { "aboutMe": { "type": "string", @@ -59468,6 +62884,15 @@ "PartitionKey": { "x-cipp-field-source": "storage" }, + "PsaTicketId": { + "x-cipp-field-source": "storage" + }, + "PsaTicketPriority": { + "x-cipp-field-source": "storage" + }, + "Reference": { + "x-cipp-field-source": "storage" + }, "RowKey": { "x-cipp-field-source": "storage" }, @@ -61426,41 +64851,312 @@ "bearerAuth": [] } ], - "x-cipp-role": "Exchange.Connector.ReadWrite" + "x-cipp-role": "Exchange.Connector.ReadWrite" + } + }, + "/api/RemoveExConnectorTemplate": { + "post": { + "summary": "RemoveExConnectorTemplate", + "operationId": "RemoveExConnectorTemplate", + "tags": [ + "Email-Exchange > Transport" + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ID": { + "type": "string" + } + } + } + } + } + }, + "parameters": [ + { + "name": "ID", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardResults" + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Exchange.Connector.ReadWrite", + "x-cipp-any-tenant": true + } + }, + "/api/RemoveGroupTemplate": { + "post": { + "summary": "RemoveGroupTemplate", + "operationId": "RemoveGroupTemplate", + "tags": [ + "Identity > Administration > Groups" + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ID": { + "type": "string" + } + } + } + } + } + }, + "parameters": [ + { + "name": "ID", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardResults" + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Identity.Group.ReadWrite", + "x-cipp-any-tenant": true + } + }, + "/api/RemoveIntuneReusableSetting": { + "post": { + "summary": "RemoveIntuneReusableSetting", + "operationId": "RemoveIntuneReusableSetting", + "tags": [ + "Endpoint > MEM" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "DisplayName": { + "type": "string" + }, + "ID": { + "type": "string" + }, + "tenantFilter": { + "type": "string" + } + }, + "required": [ + "ID", + "tenantFilter" + ] + } + } + } + }, + "parameters": [ + { + "name": "DisplayName", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "ID", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/tenantFilter" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardResults" + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Endpoint.MEM.ReadWrite" + } + }, + "/api/RemoveIntuneReusableSettingTemplate": { + "post": { + "summary": "RemoveIntuneReusableSettingTemplate", + "operationId": "RemoveIntuneReusableSettingTemplate", + "tags": [ + "Endpoint > MEM" + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ID": { + "type": "string" + } + } + } + } + } + }, + "parameters": [ + { + "name": "ID", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardResults" + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Endpoint.MEM.ReadWrite", + "x-cipp-any-tenant": true } }, - "/api/RemoveExConnectorTemplate": { + "/api/RemoveIntuneScript": { "post": { - "summary": "RemoveExConnectorTemplate", - "operationId": "RemoveExConnectorTemplate", + "summary": "RemoveIntuneScript", + "operationId": "RemoveIntuneScript", "tags": [ - "Email-Exchange > Transport" + "Endpoint > MEM" ], "requestBody": { - "required": false, + "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { + "DisplayName": { + "type": "string" + }, "ID": { "type": "string" + }, + "ScriptType": { + "type": "string", + "enum": [ + "Linux", + "MacOS", + "Remediation", + "Windows" + ] + }, + "TenantFilter": { + "type": "string", + "description": "Interact with query parameters or the body of the request." } - } + }, + "required": [ + "TenantFilter" + ] } } } }, - "parameters": [ - { - "name": "ID", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - } - ], "responses": { "200": { "description": "Success", @@ -61477,9 +65173,6 @@ }, "403": { "description": "Forbidden - caller lacks the required RBAC role" - }, - "500": { - "description": "Internal server error" } }, "security": [ @@ -61487,16 +65180,15 @@ "bearerAuth": [] } ], - "x-cipp-role": "Exchange.Connector.ReadWrite", - "x-cipp-any-tenant": true + "x-cipp-role": "Endpoint.MEM.ReadWrite" } }, - "/api/RemoveGroupTemplate": { + "/api/RemoveIntuneTemplate": { "post": { - "summary": "RemoveGroupTemplate", - "operationId": "RemoveGroupTemplate", + "summary": "RemoveIntuneTemplate", + "operationId": "RemoveIntuneTemplate", "tags": [ - "Identity > Administration > Groups" + "Endpoint > MEM" ], "requestBody": { "required": false, @@ -61549,95 +65241,15 @@ "bearerAuth": [] } ], - "x-cipp-role": "Identity.Group.ReadWrite", - "x-cipp-any-tenant": true - } - }, - "/api/RemoveIntuneReusableSetting": { - "post": { - "summary": "RemoveIntuneReusableSetting", - "operationId": "RemoveIntuneReusableSetting", - "tags": [ - "Endpoint > MEM" - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "DisplayName": { - "type": "string" - }, - "ID": { - "type": "string" - }, - "tenantFilter": { - "type": "string" - } - }, - "required": [ - "ID", - "tenantFilter" - ] - } - } - } - }, - "parameters": [ - { - "name": "DisplayName", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "ID", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "$ref": "#/components/parameters/tenantFilter" - } - ], - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StandardResults" - } - } - } - }, - "401": { - "description": "Unauthorized - invalid or missing bearer token" - }, - "403": { - "description": "Forbidden - caller lacks the required RBAC role" - } - }, - "security": [ - { - "bearerAuth": [] - } - ], "x-cipp-role": "Endpoint.MEM.ReadWrite" } }, - "/api/RemoveIntuneReusableSettingTemplate": { + "/api/RemoveJITAdminTemplate": { "post": { - "summary": "RemoveIntuneReusableSettingTemplate", - "operationId": "RemoveIntuneReusableSettingTemplate", + "summary": "RemoveJITAdminTemplate", + "operationId": "RemoveJITAdminTemplate", "tags": [ - "Endpoint > MEM" + "Identity > Administration > Users" ], "requestBody": { "required": false, @@ -61680,74 +65292,12 @@ }, "403": { "description": "Forbidden - caller lacks the required RBAC role" - } - }, - "security": [ - { - "bearerAuth": [] - } - ], - "x-cipp-role": "Endpoint.MEM.ReadWrite", - "x-cipp-any-tenant": true - } - }, - "/api/RemoveIntuneScript": { - "post": { - "summary": "RemoveIntuneScript", - "operationId": "RemoveIntuneScript", - "tags": [ - "Endpoint > MEM" - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "DisplayName": { - "type": "string" - }, - "ID": { - "type": "string" - }, - "ScriptType": { - "type": "string", - "enum": [ - "Linux", - "MacOS", - "Remediation", - "Windows" - ] - }, - "TenantFilter": { - "type": "string", - "description": "Interact with query parameters or the body of the request." - } - }, - "required": [ - "TenantFilter" - ] - } - } - } - }, - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StandardResults" - } - } - } }, - "401": { - "description": "Unauthorized - invalid or missing bearer token" + "404": { + "description": "Not found" }, - "403": { - "description": "Forbidden - caller lacks the required RBAC role" + "500": { + "description": "Internal server error" } }, "security": [ @@ -61755,16 +65305,17 @@ "bearerAuth": [] } ], - "x-cipp-role": "Endpoint.MEM.ReadWrite" + "x-cipp-role": "Identity.Role.ReadWrite" } }, - "/api/RemoveIntuneTemplate": { + "/api/RemoveJITRoleTemplate": { "post": { - "summary": "RemoveIntuneTemplate", - "operationId": "RemoveIntuneTemplate", + "summary": "RemoveJITRoleTemplate", + "operationId": "RemoveJITRoleTemplate", "tags": [ - "Endpoint > MEM" + "Identity > Administration > Users" ], + "description": "Deletes a JIT Role Template.", "requestBody": { "required": false, "content": { @@ -61784,7 +65335,7 @@ { "name": "ID", "in": "query", - "required": false, + "required": true, "schema": { "type": "string" } @@ -61807,6 +65358,9 @@ "403": { "description": "Forbidden - caller lacks the required RBAC role" }, + "404": { + "description": "Not found" + }, "500": { "description": "Internal server error" } @@ -61816,16 +65370,17 @@ "bearerAuth": [] } ], - "x-cipp-role": "Endpoint.MEM.ReadWrite" + "x-cipp-role": "Identity.Role.ReadWrite" } }, - "/api/RemoveJITAdminTemplate": { + "/api/RemovePIMRoleSettingsTemplate": { "post": { - "summary": "RemoveJITAdminTemplate", - "operationId": "RemoveJITAdminTemplate", + "summary": "Delete a PIM role settings template.", + "operationId": "RemovePIMRoleSettingsTemplate", "tags": [ - "Identity > Administration > Users" + "Identity > Administration > Roles" ], + "description": "Deletes a saved Privileged Identity Management role settings template by GUID. Tenants already configured from the template keep their settings.", "requestBody": { "required": false, "content": { @@ -61833,8 +65388,13 @@ "schema": { "type": "object", "properties": { + "GUID": { + "type": "string", + "description": "GUID of the template to delete." + }, "ID": { - "type": "string" + "type": "string", + "description": "GUID of the template to delete." } } } @@ -61845,7 +65405,8 @@ { "name": "ID", "in": "query", - "required": true, + "description": "GUID of the template to delete.", + "required": false, "schema": { "type": "string" } @@ -61880,7 +65441,8 @@ "bearerAuth": [] } ], - "x-cipp-role": "Identity.Role.ReadWrite" + "x-cipp-role": "Identity.Role.ReadWrite", + "x-cipp-any-tenant": true } }, "/api/RemovePolicy": { @@ -63044,12 +66606,20 @@ "Tenant > Administration > Tenant" ], "requestBody": { - "required": false, + "required": true, "content": { "application/json": { "schema": { "type": "object", - "properties": {} + "properties": { + "defaultDomainName": { + "type": "string", + "description": "Get the tenant identifier from the request body (POST) or query (legacy GET)." + } + }, + "required": [ + "defaultDomainName" + ] } } } @@ -63058,8 +66628,8 @@ { "name": "defaultDomainName", "in": "query", - "description": "Get the tenant identifier from query parameters", - "required": true, + "description": "Get the tenant identifier from the request body (POST) or query (legacy GET).", + "required": false, "schema": { "type": "string" } @@ -63527,6 +67097,15 @@ "PartitionKey": { "x-cipp-field-source": "storage" }, + "PsaTicketId": { + "x-cipp-field-source": "storage" + }, + "PsaTicketPriority": { + "x-cipp-field-source": "storage" + }, + "Reference": { + "x-cipp-field-source": "storage" + }, "Results": { "x-cipp-field-source": "backend" }, diff --git a/Config/standards.json b/Config/standards.json index 57c45550d2fb6..5e79d23e0fcca 100644 --- a/Config/standards.json +++ b/Config/standards.json @@ -582,15 +582,23 @@ "name": "standards.AuthenticationMethods", "cat": "Entra (AAD) Standards", "tag": [], - "helpText": "Configures all authentication methods for the tenant including Microsoft Authenticator, FIDO2, SMS, Voice, Email OTP, Temporary Access Pass, Software OATH, Hardware OATH, Certificate-based, and QR Code Pin. Enable or disable each method and optionally target specific groups.", - "docsDescription": "Unified standard to configure all authentication method policies in a single place. Each method can be independently enabled or disabled, targeted to all users or specific groups using group name wildcards, and configured with method-specific settings such as TAP lifetime, QR code pin length, and Authenticator software OTP.", + "helpText": "Configures all authentication methods for the tenant including Microsoft Authenticator, FIDO2, SMS, Voice, Email OTP, Temporary Access Pass, Software OATH, Hardware OATH, Certificate-based, and QR Code Pin. Set each method to Enabled, Disabled or Not Configured and optionally target specific groups. Methods set to Not Configured (or left blank) keep the tenant's current setting.", + "docsDescription": "Unified standard to configure all authentication method policies in a single place. Each method can be independently set to Enabled, Disabled or Not Configured (leaving the tenant's current configuration untouched), targeted to all users or specific groups using group name wildcards, and configured with method-specific settings such as TAP lifetime, QR code pin length, and Authenticator software OTP.", "executiveText": "Provides centralized control over all tenant authentication methods from a single standard. Administrators can enable phishing-resistant methods like FIDO2 and Microsoft Authenticator while disabling less secure options like SMS and Voice. Each method supports group-level targeting using wildcard group names, allowing staged rollouts and granular control.", "addedComponent": [ { - "type": "switch", + "type": "autoComplete", + "multiple": false, + "creatable": false, + "required": false, "name": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", "label": "Microsoft Authenticator", - "defaultValue": false + "helperText": "Not Configured or blank leaves the tenant's current setting untouched.", + "options": [ + { "label": "Enabled", "value": true }, + { "label": "Disabled", "value": false }, + { "label": "Not Configured", "value": "notConfigured" } + ] }, { "type": "textField", @@ -599,7 +607,7 @@ "required": false, "condition": { "field": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, @@ -610,7 +618,7 @@ "defaultValue": false, "condition": { "field": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, @@ -627,7 +635,7 @@ ], "condition": { "field": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, @@ -644,7 +652,7 @@ ], "condition": { "field": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, @@ -661,7 +669,7 @@ ], "condition": { "field": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, @@ -678,15 +686,23 @@ ], "condition": { "field": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, { - "type": "switch", + "type": "autoComplete", + "multiple": false, + "creatable": false, + "required": false, "name": "standards.AuthenticationMethods.FIDO2Enabled", "label": "FIDO2 Security Keys", - "defaultValue": false + "helperText": "Not Configured or blank leaves the tenant's current setting untouched.", + "options": [ + { "label": "Enabled", "value": true }, + { "label": "Disabled", "value": false }, + { "label": "Not Configured", "value": "notConfigured" } + ] }, { "type": "textField", @@ -695,15 +711,23 @@ "required": false, "condition": { "field": "standards.AuthenticationMethods.FIDO2Enabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, { - "type": "switch", + "type": "autoComplete", + "multiple": false, + "creatable": false, + "required": false, "name": "standards.AuthenticationMethods.TAPEnabled", "label": "Temporary Access Pass", - "defaultValue": false + "helperText": "Not Configured or blank leaves the tenant's current setting untouched.", + "options": [ + { "label": "Enabled", "value": true }, + { "label": "Disabled", "value": false }, + { "label": "Not Configured", "value": "notConfigured" } + ] }, { "type": "textField", @@ -712,7 +736,7 @@ "required": false, "condition": { "field": "standards.AuthenticationMethods.TAPEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, @@ -728,7 +752,7 @@ ], "condition": { "field": "standards.AuthenticationMethods.TAPEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, @@ -739,7 +763,7 @@ "defaultValue": 60, "condition": { "field": "standards.AuthenticationMethods.TAPEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, @@ -750,7 +774,7 @@ "defaultValue": 60, "condition": { "field": "standards.AuthenticationMethods.TAPEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, @@ -761,7 +785,7 @@ "defaultValue": 480, "condition": { "field": "standards.AuthenticationMethods.TAPEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, @@ -772,15 +796,23 @@ "defaultValue": 8, "condition": { "field": "standards.AuthenticationMethods.TAPEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, { - "type": "switch", + "type": "autoComplete", + "multiple": false, + "creatable": false, + "required": false, "name": "standards.AuthenticationMethods.SoftwareOathEnabled", "label": "Third-Party Software OATH Tokens", - "defaultValue": false + "helperText": "Not Configured or blank leaves the tenant's current setting untouched.", + "options": [ + { "label": "Enabled", "value": true }, + { "label": "Disabled", "value": false }, + { "label": "Not Configured", "value": "notConfigured" } + ] }, { "type": "textField", @@ -789,15 +821,23 @@ "required": false, "condition": { "field": "standards.AuthenticationMethods.SoftwareOathEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, { - "type": "switch", + "type": "autoComplete", + "multiple": false, + "creatable": false, + "required": false, "name": "standards.AuthenticationMethods.HardwareOathEnabled", "label": "Hardware OATH Tokens", - "defaultValue": false + "helperText": "Not Configured or blank leaves the tenant's current setting untouched.", + "options": [ + { "label": "Enabled", "value": true }, + { "label": "Disabled", "value": false }, + { "label": "Not Configured", "value": "notConfigured" } + ] }, { "type": "textField", @@ -806,15 +846,23 @@ "required": false, "condition": { "field": "standards.AuthenticationMethods.HardwareOathEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, { - "type": "switch", + "type": "autoComplete", + "multiple": false, + "creatable": false, + "required": false, "name": "standards.AuthenticationMethods.SMSEnabled", "label": "SMS", - "defaultValue": false + "helperText": "Not Configured or blank leaves the tenant's current setting untouched.", + "options": [ + { "label": "Enabled", "value": true }, + { "label": "Disabled", "value": false }, + { "label": "Not Configured", "value": "notConfigured" } + ] }, { "type": "textField", @@ -823,15 +871,23 @@ "required": false, "condition": { "field": "standards.AuthenticationMethods.SMSEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, { - "type": "switch", + "type": "autoComplete", + "multiple": false, + "creatable": false, + "required": false, "name": "standards.AuthenticationMethods.VoiceEnabled", "label": "Voice Call", - "defaultValue": false + "helperText": "Not Configured or blank leaves the tenant's current setting untouched.", + "options": [ + { "label": "Enabled", "value": true }, + { "label": "Disabled", "value": false }, + { "label": "Not Configured", "value": "notConfigured" } + ] }, { "type": "textField", @@ -840,15 +896,23 @@ "required": false, "condition": { "field": "standards.AuthenticationMethods.VoiceEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, { - "type": "switch", + "type": "autoComplete", + "multiple": false, + "creatable": false, + "required": false, "name": "standards.AuthenticationMethods.EmailEnabled", "label": "Email OTP", - "defaultValue": false + "helperText": "Not Configured or blank leaves the tenant's current setting untouched.", + "options": [ + { "label": "Enabled", "value": true }, + { "label": "Disabled", "value": false }, + { "label": "Not Configured", "value": "notConfigured" } + ] }, { "type": "textField", @@ -857,15 +921,23 @@ "required": false, "condition": { "field": "standards.AuthenticationMethods.EmailEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, { - "type": "switch", + "type": "autoComplete", + "multiple": false, + "creatable": false, + "required": false, "name": "standards.AuthenticationMethods.x509CertificateEnabled", "label": "Certificate-Based Authentication", - "defaultValue": false + "helperText": "Not Configured or blank leaves the tenant's current setting untouched.", + "options": [ + { "label": "Enabled", "value": true }, + { "label": "Disabled", "value": false }, + { "label": "Not Configured", "value": "notConfigured" } + ] }, { "type": "textField", @@ -874,15 +946,23 @@ "required": false, "condition": { "field": "standards.AuthenticationMethods.x509CertificateEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, { - "type": "switch", + "type": "autoComplete", + "multiple": false, + "creatable": false, + "required": false, "name": "standards.AuthenticationMethods.QRCodePinEnabled", "label": "QR Code Pin", - "defaultValue": false + "helperText": "Not Configured or blank leaves the tenant's current setting untouched.", + "options": [ + { "label": "Enabled", "value": true }, + { "label": "Disabled", "value": false }, + { "label": "Not Configured", "value": "notConfigured" } + ] }, { "type": "textField", @@ -891,7 +971,7 @@ "required": false, "condition": { "field": "standards.AuthenticationMethods.QRCodePinEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, @@ -902,7 +982,7 @@ "defaultValue": 365, "condition": { "field": "standards.AuthenticationMethods.QRCodePinEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, @@ -913,7 +993,7 @@ "defaultValue": 8, "condition": { "field": "standards.AuthenticationMethods.QRCodePinEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } } @@ -999,7 +1079,8 @@ "labelField": "TemplateName", "valueField": "TemplateId", "queryKey": "StdAppApprovalTemplateList", - "addedField": { "AppId": "AppId" } + "addedField": { "AppId": "AppId" }, + "templateView": { "title": "App Approval Template" } }, "condition": { "field": "standards.AppDeploy.mode", @@ -1353,6 +1434,32 @@ "powershellEquivalent": "Update-MgBetaPolicyCrossTenantAccessPolicyDefault", "recommendedBy": [] }, + { + "name": "standards.ExternalComplianceTrusted", + "cat": "Entra (AAD) Standards", + "tag": [], + "helpText": "Sets the state of the Cross-tenant access setting to trust external compliant devices. This allows guest users to use a compliant device from their home tenant to access your tenant.", + "executiveText": "Allows external partners and vendors to use compliant devices from their own organization when accessing company resources, streamlining collaboration while maintaining security standards. This reduces friction for external users while ensuring their devices still meet compliance requirements.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Select value", + "name": "standards.ExternalComplianceTrusted.state", + "options": [ + { "label": "Enabled", "value": "true" }, + { "label": "Disabled", "value": "false" } + ] + } + ], + "label": "Sets the Cross-tenant access setting to trust external compliant devices", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2026-08-25", + "powershellEquivalent": "Update-MgBetaPolicyCrossTenantAccessPolicyDefault", + "recommendedBy": [] + }, { "name": "standards.DisableTenantCreation", "cat": "Entra (AAD) Standards", @@ -1661,7 +1768,7 @@ "cat": "Entra (AAD) Standards", "tag": ["SMB1001 (2.8)"], "appliesToTest": ["SMB1001_2_8", "ZTNA21858"], - "helpText": "Blocks login for guest users that have not logged in for a number of days", + "helpText": "Blocks login for guest users whose most recent sign-in attempt, interactive or non-interactive, is older than the number of days. Guests that have never signed in are only included when 'Disable accounts that have not yet signed in' is enabled. Accounts an administrator re-enabled in the last 7 days are left alone.", "executiveText": "Automatically disables external guest accounts that haven't been used for a number of days, reducing security risks from dormant accounts while maintaining access for active external collaborators. This helps maintain a clean user directory and reduces potential attack vectors.", "addedComponent": [ { @@ -1670,6 +1777,12 @@ "required": true, "defaultValue": 90, "label": "Days of inactivity" + }, + { + "type": "switch", + "name": "standards.DisableGuests.IncludeNeverSignedIn", + "label": "Disable accounts that have not yet signed in", + "defaultValue": false } ], "label": "Disable Guest accounts that have not logged on for a number of days", @@ -2128,6 +2241,16 @@ "type": "textField", "name": "standards.OutBoundSpamAlert.OutboundSpamContact", "label": "Outbound spam contact" + }, + { + "type": "switch", + "name": "standards.OutBoundSpamAlert.BccSuspiciousOutboundMail", + "label": "BCC suspicious outbound mail to a mailbox" + }, + { + "type": "textField", + "name": "standards.OutBoundSpamAlert.BccSuspiciousOutboundContact", + "label": "BCC recipient for suspicious outbound mail" } ], "label": "Set Outbound Spam Alert e-mail", @@ -3322,7 +3445,7 @@ "tag": ["CIS M365 7.0.0 (8.6.1)"], "appliesToTest": ["CIS_8_6_1"], "helpText": "Set the state of the spam submission button in Outlook", - "docsDescription": "Set the state of the built-in Report button in Outlook. This gives the users the ability to report emails as spam or phish.", + "docsDescription": "Set the state of the built-in Report button in Outlook. This gives the users the ability to report emails as spam or phish. When a destination email address is set, the 'Send reported items to' setting controls whether reported messages go to Microsoft and the reporting mailbox, or to the reporting mailbox only (for third-party phishing report services).", "executiveText": "Enables employees to easily report suspicious emails directly from Outlook, helping improve the organization's spam and phishing detection systems. This crowdsourced approach to security allows users to contribute to threat detection while providing valuable feedback to enhance email security filters.", "addedComponent": [ { @@ -3340,6 +3463,16 @@ "name": "standards.UserSubmissions.email", "required": false, "label": "Destination email address" + }, + { + "type": "autoComplete", + "multiple": false, + "label": "Send reported items to (when a destination email address is set)", + "name": "standards.UserSubmissions.reportDestination", + "options": [ + { "label": "Microsoft and my reporting mailbox", "value": "Both" }, + { "label": "My reporting mailbox only", "value": "Mailbox" } + ] } ], "label": "Set the state of the built-in Report button in Outlook", @@ -4309,6 +4442,18 @@ } ] }, + { + "type": "autoComplete", + "required": false, + "multiple": false, + "creatable": false, + "label": "Bulk moves enabled (deliver bulk mail below the threshold to the Promotions folder - Preview)", + "name": "standards.SpamFilterPolicy.BulkMovesEnabled", + "options": [ + { "label": "On", "value": "On" }, + { "label": "Off", "value": "Off" } + ] + }, { "type": "autoComplete", "required": true, @@ -4779,7 +4924,7 @@ "cat": "Intune Standards", "tag": ["CIS M365 7.0.0 (4.2)", "CISA (MS.AAD.19.1v1)"], "appliesToTest": ["CIS_4_2"], - "helpText": "Sets the default platform restrictions for enrolling devices into Intune. Note: Do not block personally owned if platform is blocked.", + "helpText": "Sets the default platform restrictions for enrolling devices into Intune, including optional minimum and maximum OS version limits per platform (Android Enterprise, Android, iOS/iPadOS and Windows). Note: Do not block personally owned if platform is blocked.", "executiveText": "Controls which types of devices (iOS, Android, Windows, macOS) and ownership models (corporate vs. personal) can be enrolled in the company's device management system. This helps maintain security standards while supporting necessary business device types and usage scenarios.", "addedComponent": [ { @@ -4794,6 +4939,20 @@ "label": "Block personally owned Android Enterprise (work profile)", "default": false }, + { + "type": "textField", + "name": "standards.DefaultPlatformRestrictions.osMinimumVersionAndroidForWork", + "label": "Android Enterprise (work profile) minimum OS version", + "helperText": "Example: 11.0. Leave blank to not enforce a minimum.", + "required": false + }, + { + "type": "textField", + "name": "standards.DefaultPlatformRestrictions.osMaximumVersionAndroidForWork", + "label": "Android Enterprise (work profile) maximum OS version", + "helperText": "Example: 14.0. Leave blank to not enforce a maximum.", + "required": false + }, { "type": "switch", "name": "standards.DefaultPlatformRestrictions.platformAndroidBlocked", @@ -4806,6 +4965,20 @@ "label": "Block personally owned Android", "default": false }, + { + "type": "textField", + "name": "standards.DefaultPlatformRestrictions.osMinimumVersionAndroid", + "label": "Android minimum OS version", + "helperText": "Example: 10.0. Leave blank to not enforce a minimum.", + "required": false + }, + { + "type": "textField", + "name": "standards.DefaultPlatformRestrictions.osMaximumVersionAndroid", + "label": "Android maximum OS version", + "helperText": "Example: 13.0. Leave blank to not enforce a maximum.", + "required": false + }, { "type": "switch", "name": "standards.DefaultPlatformRestrictions.platformiOSBlocked", @@ -4818,6 +4991,20 @@ "label": "Block personally owned iOS", "default": false }, + { + "type": "textField", + "name": "standards.DefaultPlatformRestrictions.osMinimumVersioniOS", + "label": "iOS/iPadOS minimum OS version", + "helperText": "Example: 16.1. Leave blank to not enforce a minimum.", + "required": false + }, + { + "type": "textField", + "name": "standards.DefaultPlatformRestrictions.osMaximumVersioniOS", + "label": "iOS/iPadOS maximum OS version", + "helperText": "Example: 18.0. Leave blank to not enforce a maximum.", + "required": false + }, { "type": "switch", "name": "standards.DefaultPlatformRestrictions.platformMacOSBlocked", @@ -4841,6 +5028,20 @@ "name": "standards.DefaultPlatformRestrictions.personalWindowsBlocked", "label": "Block personally owned Windows", "default": false + }, + { + "type": "textField", + "name": "standards.DefaultPlatformRestrictions.osMinimumVersionWindows", + "label": "Windows minimum OS version", + "helperText": "Example: 10.0.19045.0. Leave blank to not enforce a minimum.", + "required": false + }, + { + "type": "textField", + "name": "standards.DefaultPlatformRestrictions.osMaximumVersionWindows", + "label": "Windows maximum OS version", + "helperText": "Example: 10.0.22631.0. Leave blank to not enforce a maximum.", + "required": false } ], "label": "Device enrollment restrictions", @@ -5261,6 +5462,40 @@ "ONEDRIVE_ENTERPRISE" ] }, + { + "name": "standards.SPGuestPeoplePicker", + "cat": "SharePoint Standards", + "tag": [], + "helpText": "Controls whether guest (external) users already in the tenant appear as suggestions in the SharePoint and OneDrive People Picker. Enforces the wanted state on BOTH the tenant default and every existing site collection - they are set independently, so changing the tenant default does not update existing sites. The per-site picture is read from the SharePoint reporting cache (refreshed daily), so a large tenant is never enumerated live during a run; a 24h rerun guard stops the write sweep from repeating before that cache refreshes and re-evaluates the result.", + "executiveText": "Makes existing external collaborators discoverable (or hidden) when sharing SharePoint and OneDrive content, consistently across the tenant default and every existing site. This keeps the sharing experience predictable and prevents individual sites from drifting away from the agreed collaboration posture.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Guest People Picker suggestions", + "name": "standards.SPGuestPeoplePicker.state", + "options": [ + { "label": "Show guests in the People Picker", "value": "true" }, + { "label": "Hide guests in the People Picker", "value": "false" } + ] + } + ], + "label": "Show guest users in the SharePoint People Picker", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2026-09-03", + "powershellEquivalent": "Set-SPOTenant / Set-SPOSite -ShowPeoplePickerSuggestionsForGuestUsers $true or $false", + "recommendedBy": ["CIPP"], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] + }, { "name": "standards.SPAzureB2B", "cat": "SharePoint Standards", @@ -5801,7 +6036,7 @@ "CIS_8_5_8", "CIS_8_5_9" ], - "helpText": "Defines the CIS recommended global meeting policy for Teams. This includes AllowAnonymousUsersToJoinMeeting, AllowAnonymousUsersToStartMeeting, AutoAdmittedUsers, AllowPSTNUsersToBypassLobby, MeetingChatEnabledType, DesignatedPresenterRoleMode, AllowExternalParticipantGiveRequestControl, AllowParticipantGiveRequestControl", + "helpText": "Defines the CIS recommended global meeting policy for Teams. This includes AllowAnonymousUsersToJoinMeeting, AllowAnonymousUsersToStartMeeting, AutoAdmittedUsers, AllowPSTNUsersToBypassLobby, MeetingChatEnabledType, DesignatedPresenterRoleMode, AllowExternalParticipantGiveRequestControl, AllowParticipantGiveRequestControl, AllowExternalNonTrustedMeetingChat, AllowCloudRecording", "executiveText": "Establishes security-focused default settings for Teams meetings, controlling who can join meetings, present content, and participate in chats. These policies balance collaboration needs with security requirements, ensuring meetings remain productive while protecting against unauthorized access and disruption.", "addedComponent": [ { @@ -5876,13 +6111,39 @@ "type": "switch", "name": "standards.TeamsGlobalMeetingPolicy.AllowExternalParticipantGiveRequestControl", "label": "External participants can give or request control" + }, + { + "type": "autoComplete", + "required": false, + "multiple": false, + "creatable": false, + "name": "standards.TeamsGlobalMeetingPolicy.AllowExternalNonTrustedMeetingChat", + "label": "External meeting chat", + "helperText": "CIS 8.5.8 recommends Off. Leave blank to keep the tenant's current value.", + "options": [ + { "label": "Off (CIS recommended)", "value": false }, + { "label": "On", "value": true } + ] + }, + { + "type": "autoComplete", + "required": false, + "multiple": false, + "creatable": false, + "name": "standards.TeamsGlobalMeetingPolicy.AllowCloudRecording", + "label": "Meeting cloud recording", + "helperText": "CIS 8.5.9 recommends Off. Leave blank to keep the tenant's current value.", + "options": [ + { "label": "Off (CIS recommended)", "value": false }, + { "label": "On", "value": true } + ] } ], "label": "Define Global Meeting Policy for Teams", "impact": "Low Impact", "impactColour": "info", "addedDate": "2024-11-12", - "powershellEquivalent": "Set-CsTeamsMeetingPolicy -AllowAnonymousUsersToJoinMeeting $false -AllowAnonymousUsersToStartMeeting $false -AutoAdmittedUsers $AutoAdmittedUsers -AllowPSTNUsersToBypassLobby $false -MeetingChatEnabledType EnabledExceptAnonymous -DesignatedPresenterRoleMode $DesignatedPresenterRoleMode -AllowExternalParticipantGiveRequestControl $false -AllowParticipantGiveRequestControl $false", + "powershellEquivalent": "Set-CsTeamsMeetingPolicy -AllowAnonymousUsersToJoinMeeting $false -AllowAnonymousUsersToStartMeeting $false -AutoAdmittedUsers $AutoAdmittedUsers -AllowPSTNUsersToBypassLobby $false -MeetingChatEnabledType EnabledExceptAnonymous -DesignatedPresenterRoleMode $DesignatedPresenterRoleMode -AllowExternalParticipantGiveRequestControl $false -AllowParticipantGiveRequestControl $false -AllowExternalNonTrustedMeetingChat $false -AllowCloudRecording $false", "recommendedBy": ["CIS"], "requiredCapabilities": ["MCOSTANDARD", "MCOEV", "MCOIMP", "TEAMS1", "Teams_Room_Standard"] }, @@ -8092,7 +8353,8 @@ "url": "/api/ListAppTemplates", "labelField": "displayName", "valueField": "GUID", - "queryKey": "StdIntuneAppTemplateList" + "queryKey": "StdIntuneAppTemplateList", + "templateView": { "title": "Application Template" } } } ], @@ -8493,5 +8755,50 @@ "warn": false, "remediate": false } + }, + { + "name": "standards.PIMRoleSettings", + "label": "PIM Role Settings Template", + "cat": "Templates", + "multiple": true, + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "impact": "High Impact", + "impactColour": "danger", + "addedDate": "2026-08-23", + "tag": [], + "helpText": "Deploys a Privileged Identity Management role settings template to the tenant: activation limits, MFA or authentication context, justification, approval, eligibility and active-assignment expiry and notification rules for the roles the template covers. Templates cannot weaken settings below CIPP's secure floor.", + "docsDescription": "Deploys a Privileged Identity Management role settings template to the tenant. The template defines, for a set of roles, the maximum activation duration, whether activation requires MFA or an authentication context, justification, ticket and approval requirements, the maximum lifetime of eligible and active assignments, and additional notification recipients. Templates are validated against CIPP's secure floor (activation within 24 hours with MFA or an authentication context and a justification; eligible and active assignments must expire within a year; active assignments require a justification) and are refused, not adjusted, when they fall below it. Requires Entra ID P2.", + "executiveText": "Enforces consistent Privileged Identity Management settings so that administrator roles can only be used for a limited time, after strong authentication and with a recorded reason. This keeps standing administrative access to a minimum and makes every use of privilege visible and accountable.", + "addedComponent": [ + { + "type": "autoComplete", + "name": "TemplateList", + "multiple": false, + "required": true, + "creatable": false, + "label": "Select PIM Role Settings Template", + "api": { + "url": "/api/ListPIMRoleSettingsTemplates", + "labelField": "templateName", + "valueField": "GUID", + "queryKey": "ListPIMRoleSettingsTemplates", + "showRefresh": true, + "templateView": { + "title": "PIM Role Settings Template" + } + } + } + ], + "powershellEquivalent": "Update-MgBetaPolicyRoleManagementPolicyRule", + "recommendedBy": [ + "CIPP" + ], + "requiredCapabilities": [ + "AAD_PREMIUM_P2" + ] } ] diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 index d4f9a8b2f814c..2fda2e5351d42 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 @@ -456,7 +456,25 @@ if (-not $IntuneResponse) { $IntuneDevicesError = 'Intune device query did not return a response' } elseif ([int]$IntuneResponse.status -ge 400) { - $IntuneDevicesError = $IntuneResponse.body.error.message + # Graph proxies this call to Intune's DeviceFE service, which returns its own JSON + # error blob as the Graph error message. Unwrap it so the report shows a readable + # sentence instead of raw JSON, keeping the Activity ID for Microsoft support cases. + $RawIntuneError = $IntuneResponse.body.error.message + $IntuneDevicesError = $RawIntuneError + if ($RawIntuneError -match '^\s*\{') { + try { + $ParsedIntuneError = $RawIntuneError | ConvertFrom-Json -ErrorAction Stop + if (-not [string]::IsNullOrWhiteSpace($ParsedIntuneError.Message)) { + $IntuneDevicesError = $ParsedIntuneError.Message + } + } catch { + # Not valid JSON after all - keep the raw message + } + } + if ($IntuneDevicesError -like 'An error has occurred*') { + $ActivityId = [regex]::Match($IntuneDevicesError, 'Activity ID: ([0-9a-fA-F-]{36})').Groups[1].Value + $IntuneDevicesError = "Intune returned an unexpected error (HTTP $($IntuneResponse.status)). This is a failure inside the Intune service itself - usually transient, or the tenant does not have Intune provisioned. Rerun the check to retry.$(if ($ActivityId) { " Microsoft support reference (Activity ID): $ActivityId" })" + } if ([string]::IsNullOrWhiteSpace($IntuneDevicesError)) { $IntuneDevicesError = "Intune device query failed with status $($IntuneResponse.status)" } @@ -518,15 +536,19 @@ foreach ($Row in (@($RuleChangesLog) + @($SafelistChanges) + @($SharingChanges))) { $Geo = & $GetGeo $Row.ClientIP - $Row | Add-Member -NotePropertyName 'Country' -NotePropertyValue $Geo.CountryOrRegion -Force - $Row | Add-Member -NotePropertyName 'City' -NotePropertyValue $Geo.City -Force - $Row | Add-Member -NotePropertyName 'ForeignLocation' -NotePropertyValue (& $TestForeign $Geo.CountryOrRegion) -Force + $Row | Add-Member -NotePropertyMembers ([ordered]@{ + Country = $Geo.CountryOrRegion + City = $Geo.City + ForeignLocation = (& $TestForeign $Geo.CountryOrRegion) + }) -Force } foreach ($Row in @($SentMessages)) { $Geo = & $GetGeo $Row.FromIP - $Row | Add-Member -NotePropertyName 'Country' -NotePropertyValue $Geo.CountryOrRegion -Force - $Row | Add-Member -NotePropertyName 'City' -NotePropertyValue $Geo.City -Force - $Row | Add-Member -NotePropertyName 'ForeignLocation' -NotePropertyValue (& $TestForeign $Geo.CountryOrRegion) -Force + $Row | Add-Member -NotePropertyMembers ([ordered]@{ + Country = $Geo.CountryOrRegion + City = $Geo.City + ForeignLocation = (& $TestForeign $Geo.CountryOrRegion) + }) -Force } foreach ($Row in @($SuspectUserSignIns)) { $Row | Add-Member -NotePropertyName 'ForeignLocation' -NotePropertyValue (& $TestForeign $Row.Country) -Force diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Domain Analyser/Push-DomainAnalyserTenant.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Domain Analyser/Push-DomainAnalyserTenant.ps1 index 24d9c31535dfb..20bba052712a5 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Domain Analyser/Push-DomainAnalyserTenant.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Domain Analyser/Push-DomainAnalyserTenant.ps1 @@ -109,8 +109,10 @@ function Push-DomainAnalyserTenant { $Domain.MailProviders = $OldDomain.MailProviders } # Fix tenant info in the event of a default domain name change in a tenant - $Domain | Add-Member -MemberType NoteProperty -Name 'TenantId' -Value $TenantDomain.Tenant -Force - $Domain | Add-Member -MemberType NoteProperty -Name 'TenantGUID' -Value $TenantDomain.TenantGUID -Force + $Domain | Add-Member -NotePropertyMembers ([ordered]@{ + TenantId = $TenantDomain.Tenant + TenantGUID = $TenantDomain.TenantGUID + }) -Force } # Return domain object to list $TenantDomainObjects.Add($Domain) diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Mailbox Permissions/Push-GetCalendarPermissionsBatch.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Mailbox Permissions/Push-GetCalendarPermissionsBatch.ps1 index 66424361f4b18..d95878b545a93 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Mailbox Permissions/Push-GetCalendarPermissionsBatch.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Mailbox Permissions/Push-GetCalendarPermissionsBatch.ps1 @@ -29,6 +29,10 @@ function Push-GetCalendarPermissionsBatch { try { $CacheEntries = Get-CIPPAzDataTableEntity @FolderCacheTable -Filter "PartitionKey eq '$TenantFilter'" foreach ($Entry in $CacheEntries) { + # Entries predating the FolderType fix can name a subfolder instead of the root, + # and the name alone cannot say which. Anything unstamped is a miss: Phase 1 + # rediscovers it and overwrites the row, so a poisoned cache self-heals. + if ($Entry.FolderType -ne 'Calendar') { continue } $CachedFolders[$Entry.RowKey] = $Entry.FolderName } Write-Information "CAL Cached Folders count is $($CachedFolders.Count)" @@ -70,23 +74,37 @@ function Push-GetCalendarPermissionsBatch { } Write-Information "Phase 1: Bulk Get-MailboxFolderStatistics for $($CacheMissMailboxes.Count) mailboxes" - $FolderStatsResults = New-ExoBulkRequest -tenantid $TenantFilter -cmdletArray @($FolderStatsRequests) - + $FolderStatsResults = New-ExoBulkRequest -tenantid $TenantFilter -cmdletArray @($FolderStatsRequests) -Select 'Name,FolderType' + + # One call returns EVERY calendar folder flattened under one OperationGuid, so + # last-wins cached whatever the mailbox listed last - 'United States holidays' for + # over half a tenant, after which Phase 2 queried that folder and got nothing. + # FolderType stays English in any mailbox language, same reason + # Invoke-ListCalendarPermissions uses it. No fallback on purpose: a guess here + # poisons a cache that never expires. foreach ($Result in $FolderStatsResults) { if ($Result.error) { Write-Information "Failed to get folder stats for $($Result.OperationGuid): $($Result.error)" continue } $MailboxUPN = $Result.OperationGuid - $FolderName = $Result.name - if ($MailboxUPN -and $FolderName) { - $FolderNameMap[$MailboxUPN] = $FolderName - $NewCacheEntries.Add(@{ - PartitionKey = $TenantFilter - RowKey = $MailboxUPN - FolderName = $FolderName - }) - } + if (-not $MailboxUPN -or -not $Result.Name -or $Result.FolderType -ne 'Calendar') { continue } + if ($FolderNameMap.ContainsKey($MailboxUPN)) { continue } + + $FolderNameMap[$MailboxUPN] = $Result.Name + $NewCacheEntries.Add(@{ + PartitionKey = $TenantFilter + RowKey = $MailboxUPN + FolderName = $Result.Name + FolderType = 'Calendar' + }) + } + + # Loud on purpose: if the API stops returning FolderType, every mailbox fails the + # filter above and the whole type silently collects nothing. + $NoRootCalendar = $CacheMissMailboxes.Count - $NewCacheEntries.Count + if ($NoRootCalendar -gt 0) { + Write-Information "No root calendar folder (FolderType 'Calendar') found for $NoRootCalendar of $($CacheMissMailboxes.Count) cache-miss mailboxes" } # Persist newly discovered folder names to cache diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/OneDrive Long Paths/Push-DBCacheOneDriveLongPaths.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/OneDrive Long Paths/Push-DBCacheOneDriveLongPaths.ps1 new file mode 100644 index 0000000000000..0c5ab5b01badc --- /dev/null +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/OneDrive Long Paths/Push-DBCacheOneDriveLongPaths.ps1 @@ -0,0 +1,225 @@ +function Push-DBCacheOneDriveLongPaths { + <# + .SYNOPSIS + Full-recount OneDrive path-length counts for one personal site (resumable). + + .DESCRIPTION + Walks the site default drive via Graph root/delta (no item webUrl). Measures decoded + cloud path length and inferred Windows sync full-path length in memory, increments + counts, discards path strings. Writes one anonymized OneDriveLongPaths row: + ownerPrincipalName, countOver260, countOver400. + + Checkpoints CurrentUri + running counts for timebox/throttle requeue. Does not use + deltaLink incremental count math — each run recounts from scratch (resume continues + the same full walk). + + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param($Item) + + $TenantFilter = [string]$Item.TenantFilter + $SiteId = [string]$Item.SiteId + $OwnerPrincipalName = [string]($Item.OwnerPrincipalName ?? '') + $ScanId = [string]$Item.ScanId + $RequeueCount = [int]($Item.RequeueCount ?? 0) + + $CacheType = 'OneDriveLongPaths' + $StateTable = Get-CippTable -tablename 'CippOneDriveLongPathsState' + $SafeTenant = ConvertTo-CIPPODataFilterValue -Value $TenantFilter -Type String + + # Superseded scan — overlapping ExecCIPPDBCache runs must not write. + $CurrentScan = Get-CIPPAzDataTableEntity @StateTable -Filter "PartitionKey eq '$SafeTenant' and RowKey eq 'scan'" + if (-not $CurrentScan -or [string]$CurrentScan.ScanId -ne $ScanId) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "OneDrive long-paths: skipping superseded scan $ScanId" -sev Debug + return @() + } + + $SiteKeySegment = ($SiteId -replace '[/\\#?]', '_') -replace '[\u0000-\u001F\u007F-\u009F]', '' + $CheckpointRowKey = "chk-$SiteKeySegment" + + $TimeboxSeconds = 540 + if ($env:CIPPNG -eq 'true') { $TimeboxSeconds = 1100 } + if ($null -ne $env:CIPP_ONEDRIVE_LONGPATHS_TIMEBOX_SECONDS -and "$($env:CIPP_ONEDRIVE_LONGPATHS_TIMEBOX_SECONDS)" -ne '') { + $Parsed = 0 + if ([int]::TryParse("$($env:CIPP_ONEDRIVE_LONGPATHS_TIMEBOX_SECONDS)", [ref]$Parsed) -and $Parsed -ge 0) { + $TimeboxSeconds = $Parsed + } + } + + # Fixed length (C:\Users\ + \OneDrive - {org}\) computed once per tenant at fan-out; add UPN local-part after owner is known. + $FixedLength = [int]($Item.InferredLocalRootFixedLength ?? 0) + if ($FixedLength -le 0) { + $OrgDisplayName = [string]($Item.OrgDisplayName ?? 'Organization') + if ([string]::IsNullOrWhiteSpace($OrgDisplayName)) { $OrgDisplayName = 'Organization' } + $FixedLength = ('C:\Users\').Length + ("\OneDrive - $OrgDisplayName\").Length + } + $Stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + + function Get-LongPathsCheckpoint { + $Row = Get-CIPPAzDataTableEntity @StateTable -Filter "PartitionKey eq '$SafeTenant' and RowKey eq '$CheckpointRowKey'" + if (-not $Row -or [string]$Row.ScanId -ne $ScanId) { return $null } + try { ($Row.StateJson | ConvertFrom-Json -ErrorAction Stop) } catch { $null } + } + + function Save-LongPathsCheckpoint { + param($State) + Add-CIPPAzDataTableEntity @StateTable -Entity @{ + PartitionKey = $TenantFilter + RowKey = $CheckpointRowKey + ScanId = $ScanId + StateJson = [string]($State | ConvertTo-Json -Depth 5 -Compress) + } -Force + } + + function Remove-LongPathsCheckpoint { + $Row = Get-CIPPAzDataTableEntity @StateTable -Filter "PartitionKey eq '$SafeTenant' and RowKey eq '$CheckpointRowKey'" + if ($Row) { Remove-CIPPAzDataTableEntity @StateTable -Entity $Row -Force } + } + + function Invoke-LongPathsRequeue { + param([int]$NextRequeueCount = $RequeueCount) + $ResumeItem = [PSCustomObject]@{} + foreach ($Property in $Item.PSObject.Properties) { + $ResumeItem | Add-Member -NotePropertyName $Property.Name -NotePropertyValue $Property.Value -Force + } + $ResumeItem | Add-Member -NotePropertyName 'RequeueCount' -NotePropertyValue $NextRequeueCount -Force + $null = Start-CIPPOrchestrator -InputObject ([PSCustomObject]@{ + Batch = @($ResumeItem) + OrchestratorName = "OneDriveLongPathsResume_$($TenantFilter)_$([guid]::NewGuid().ToString('N').Substring(0, 8))" + SkipLog = $true + }) + } + + function Invoke-LongPathsTimeboxRequeue { + param($State) + if ($Stopwatch.Elapsed.TotalSeconds -lt $TimeboxSeconds) { return $false } + Save-LongPathsCheckpoint -State $State + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "OneDrive long-paths: timebox reached for $OwnerPrincipalName; requeueing" -sev Debug + Invoke-LongPathsRequeue + return $true + } + + function Invoke-LongPathsThrottleRequeue { + param([string]$ErrorMessage, $State) + if ($ErrorMessage -notmatch 'throttl|too many requests|429') { return $false } + if ($RequeueCount -ge 6) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "OneDrive long-paths: still throttled after $RequeueCount resumes for $OwnerPrincipalName; giving up this scan" -sev Warning + return $false + } + Save-LongPathsCheckpoint -State $State + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "OneDrive long-paths: throttled for $OwnerPrincipalName; requeueing (attempt $($RequeueCount + 1))" -sev Info + Invoke-LongPathsRequeue -NextRequeueCount ($RequeueCount + 1) + return $true + } + + try { + $Drive = $null + try { + $Drive = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/sites/$SiteId/drive?`$select=id,name,driveType,owner" -tenantid $TenantFilter -asapp $true + } catch { + if ($_.Exception.Message -match 'Access to this site has been blocked') { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'OneDrive long-paths: skipping locked OneDrive site' -sev Info + return @() + } + throw + } + + if (-not $Drive.id) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'OneDrive long-paths: no default drive for site' -sev Debug + return @() + } + + if ([string]::IsNullOrWhiteSpace($OwnerPrincipalName)) { + $OwnerPrincipalName = [string]($Drive.owner.user.email ?? $Drive.owner.user.userPrincipalName ?? '') + } + if ([string]::IsNullOrWhiteSpace($OwnerPrincipalName)) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'OneDrive long-paths: skipping site with unresolved owner UPN' -sev Debug + return @() + } + + $LocalPart = $OwnerPrincipalName + $At = $OwnerPrincipalName.IndexOf('@') + if ($At -gt 0) { $LocalPart = $OwnerPrincipalName.Substring(0, $At) } + $LocalRootLength = $FixedLength + $LocalPart.Length + + $DeltaSelect = 'id,name,parentReference,folder,file,deleted' + $FullDeltaUri = "https://graph.microsoft.com/beta/drives/$($Drive.id)/root/delta?`$select=$DeltaSelect&`$top=999" + + $CountOver260 = 0 + $CountOver400 = 0 + $Uri = $FullDeltaUri + + $Checkpoint = Get-LongPathsCheckpoint + if ($Checkpoint -and $Checkpoint.CurrentUri) { + $Uri = [string]$Checkpoint.CurrentUri + $CountOver260 = [int]($Checkpoint.CountOver260 ?? 0) + $CountOver400 = [int]($Checkpoint.CountOver400 ?? 0) + } + + while ($Uri) { + try { + $Page = New-GraphGetRequest -uri $Uri -tenantid $TenantFilter -asapp $true -noPagination $true -SkipValueExtraction + } catch { + $ErrorMessage = $_.Exception.Message + $State = @{ + CurrentUri = $Uri + CountOver260 = $CountOver260 + CountOver400 = $CountOver400 + } + if (Invoke-LongPathsThrottleRequeue -ErrorMessage $ErrorMessage -State $State) { return @() } + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "OneDrive long-paths: delta failed for ${OwnerPrincipalName}: $ErrorMessage" -sev Warning + return @() + } + + $Values = @($Page.value) + foreach ($PageItem in $Values) { + if ($PageItem.deleted) { continue } + if (-not $PageItem.file -and -not $PageItem.folder) { continue } + + $CloudLength = Get-CIPPDriveItemCloudPathLength -ParentPath ([string]$PageItem.parentReference.path) -Name ([string]$PageItem.name) + if ($CloudLength -le 0) { continue } + + if ($CloudLength -gt 400) { $CountOver400++ } + $InferredLocal = $LocalRootLength + $CloudLength + if ($InferredLocal -gt 260) { $CountOver260++ } + } + + $Next = $Page.'@odata.nextLink' + $DeltaDone = $Page.'@odata.deltaLink' + if ($Next) { + $Uri = [string]$Next + } elseif ($DeltaDone) { + # Full recount complete — do not store deltaLink for incremental counts. + $Uri = $null + } else { + $Uri = $null + } + + if ($Uri) { + $State = @{ + CurrentUri = $Uri + CountOver260 = $CountOver260 + CountOver400 = $CountOver400 + } + Save-LongPathsCheckpoint -State $State + if (Invoke-LongPathsTimeboxRequeue -State $State) { return @() } + } + } + + $Row = [PSCustomObject]@{ + id = $OwnerPrincipalName + ownerPrincipalName = $OwnerPrincipalName + countOver260 = [int]$CountOver260 + countOver400 = [int]$CountOver400 + } + Add-CIPPDbItem -TenantFilter $TenantFilter -Type $CacheType -Data @($Row) -Append -RunId $ScanId + Remove-LongPathsCheckpoint + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "OneDrive long-paths: cached counts for $OwnerPrincipalName (260=$CountOver260, 400=$CountOver400)" -sev Debug + return @() + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "OneDrive long-paths: site task failed: $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) + return @() + } +} diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/OneDrive Root Permissions/Push-DBCacheOneDriveRootPermissionsBatch.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/OneDrive Root Permissions/Push-DBCacheOneDriveRootPermissionsBatch.ps1 index 47e9f69b92c17..bbdf94bafe8a9 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/OneDrive Root Permissions/Push-DBCacheOneDriveRootPermissionsBatch.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/OneDrive Root Permissions/Push-DBCacheOneDriveRootPermissionsBatch.ps1 @@ -362,10 +362,10 @@ function Push-DBCacheOneDriveRootPermissionsBatch { $AssociatedGroupTitles = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) try { - $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter - $Scope = "$($SharePointInfo.SharePointUrl)/.default" - $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } - $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + $RestContext = Resolve-CIPPSharePointRestContext -TenantFilter $TenantFilter -SiteUrl $SiteUrl + $Scope = $RestContext.Scope + $JsonAccept = $RestContext.Headers + $BaseUri = $RestContext.BaseUri $AssociatedEndpoints = [ordered]@{ 'Owners' = 'associatedownergroup' diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 index a0bb9f7693082..188eecb36f3ae 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 @@ -67,10 +67,18 @@ function Push-CIPPDBCacheData { $DefenderCapable = $false try { - $DefenderCapable = Test-CIPPStandardLicense -StandardName Compliance'DefenderLicenseCheck' -TenantFilter $TenantFilter -RequiredCapabilities @('MDE_SMB', 'WIN_DEF_ATP', 'DEFENDER_ENDPOINT_P1') -SkipLog + $DefenderCapable = Test-CIPPStandardLicense -StandardName 'DefenderLicenseCheck' -TenantFilter $TenantFilter -RequiredCapabilities @('MDE_SMB', 'WIN_DEF_ATP', 'DEFENDER_ENDPOINT_P1') -SkipLog } catch { $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Compliance license check failed: $($_.Exception.Message)" -sev Warning -LogData $ErrorMessage + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Defender license check failed: $($_.Exception.Message)" -sev Warning -LogData $ErrorMessage + } + + $MdoCapable = $false + try { + $MdoCapable = Test-CIPPStandardLicense -StandardName 'DefenderForOffice365LicenseCheck' -TenantFilter $TenantFilter -Preset DefenderForOffice365 -SkipLog + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Defender for Office 365 license check failed: $($_.Exception.Message)" -sev Warning -LogData $ErrorMessage } $SharePointCapable = $false @@ -89,7 +97,7 @@ function Push-CIPPDBCacheData { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Teams license check failed: $($_.Exception.Message)" -sev Warning -LogData $ErrorMessage } - Write-Information "License capabilities for $TenantFilter - Intune: $IntuneCapable, CA: $ConditionalAccessCapable, P2: $AzureADPremiumP2Capable, Exchange: $ExchangeCapable, Compliance: $ComplianceCapable, SharePoint: $SharePointCapable, Teams: $TeamsCapable" + Write-Information "License capabilities for $TenantFilter - Intune: $IntuneCapable, CA: $ConditionalAccessCapable, P2: $AzureADPremiumP2Capable, Exchange: $ExchangeCapable, Compliance: $ComplianceCapable, SharePoint: $SharePointCapable, Teams: $TeamsCapable, MDO: $MdoCapable" # Build grouped collection tasks — one activity per license category instead of one per cache type $Tasks = [System.Collections.Generic.List[object]]::new() @@ -211,6 +219,18 @@ function Push-CIPPDBCacheData { Write-Host "Skipping Defender data collection for $TenantFilter - no required license" } + if ($MdoCapable) { + $Tasks.Add(@{ + FunctionName = 'ExecCIPPDBCache' + CollectionType = 'DefenderForOffice365' + TenantFilter = $TenantFilter + QueueId = $QueueId + QueueName = "DB Cache DefenderForOffice365 - $TenantFilter" + }) + } else { + Write-Host "Skipping Defender for Office 365 data collection for $TenantFilter - no required license" + } + if ($SharePointCapable) { $Tasks.Add(@{ FunctionName = 'ExecCIPPDBCache' @@ -219,7 +239,7 @@ function Push-CIPPDBCacheData { QueueId = $QueueId QueueName = "DB Cache SharePoint - $TenantFilter" }) - # SharePointSharingLinks runs adhoc since it can take a long time to enumerate all sharing links for large tenants + # SharePointSharingLinks and OneDriveLongPaths run adhoc — full drive walks are too slow for nightly } else { Write-Host "Skipping SharePoint data collection for $TenantFilter - no required license" } diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPOffboardingComplete.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPOffboardingComplete.ps1 index c848c0b147238..9ce7262e3494a 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPOffboardingComplete.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPOffboardingComplete.ps1 @@ -105,17 +105,68 @@ function Push-CIPPOffboardingComplete { Write-LogMessage -API 'Offboarding' -tenant $TenantFilter -message "Offboarding completed successfully for $Username" -sev Info -headers $Headers - # Send post-execution alerts if configured + # Send post-execution alerts if configured, and keep each delivery outcome with the task and + # on the progress row, so a failed webhook, email or PSA note is as visible as a failed step. if ($TaskInfo.PostExecution -and $ProcessedResults) { - Send-CIPPScheduledTaskAlert -Results $ProcessedResults -TaskInfo $TaskInfo -TenantFilter $TenantFilter -TaskType 'User Offboarding' + $DeploymentId = $Item.Parameters.DeploymentId + # The notification steps have been on the row since the job started; show them running + # while the deliveries are made, then fill each one in by title. + $NotifyIndexes = @{} + if ($DeploymentId) { + $Row = Get-CIPPAsyncDeployment -JobId $DeploymentId | Where-Object { $_.Name -eq $Username } + $RowSteps = @($Row.Steps) + for ($i = 0; $i -lt $RowSteps.Count; $i++) { + if ($RowSteps[$i].Kind -eq 'notify') { + $NotifyIndexes[[string]$RowSteps[$i].Title] = $i + Set-CIPPAsyncDeploymentStep -JobId $DeploymentId -Name $Username -StepIndex $i -StepStatus 'running' -Message 'Sending' + } + } + } + + $PostExecutionResults = @(Send-CIPPScheduledTaskAlert -Results $ProcessedResults -TaskInfo $TaskInfo -TenantFilter $TenantFilter -TaskType 'User Offboarding') + $null = Update-AzDataTableEntity -Force @Table -Entity @{ + PartitionKey = $TaskInfo.PartitionKey + RowKey = $TaskInfo.RowKey + PostExecutionResults = [string](ConvertTo-Json -Compress -Depth 5 -InputObject $PostExecutionResults) + } + + if ($DeploymentId) { + # One step per channel; the PSA channel can make several deliveries (per-user tickets). + $Covered = @{} + foreach ($Group in ($PostExecutionResults | Group-Object -Property Channel)) { + $Title = "Notify via $($Group.Name)" + $Message = @($Group.Group | ForEach-Object { [string]$_.Result }) -join "`n" + # Skipped = asked for and not delivered, so it fails the step. + $NotifyStatus = if (@($Group.Group | Where-Object { [string]$_.Result -match '^(Error|Could not|Failed|Skipped)' }).Count -gt 0) { 'failed' } else { 'succeeded' } + if ($NotifyIndexes.ContainsKey($Title)) { + $Covered[$Title] = $true + Set-CIPPAsyncDeploymentStep -JobId $DeploymentId -Name $Username -StepIndex $NotifyIndexes[$Title] -StepStatus $NotifyStatus -Message $Message + } else { + Add-CIPPAsyncDeploymentStep -JobId $DeploymentId -Name $Username -Title $Title -StepStatus $NotifyStatus -Message $Message -Kind 'notify' + } + } + # A channel that produced no delivery at all (the sender gave up before reaching it) + foreach ($Title in @($NotifyIndexes.Keys | Where-Object { -not $Covered.ContainsKey($_) })) { + Set-CIPPAsyncDeploymentStep -JobId $DeploymentId -Name $Username -StepIndex $NotifyIndexes[$Title] -StepStatus 'failed' -Message 'No delivery was attempted' + } + } } } + if ($Item.Parameters.DeploymentId) { + # Close the live-progress row: failed when any step failed, otherwise succeeded. + $Row = Get-CIPPAsyncDeployment -JobId $Item.Parameters.DeploymentId | Where-Object { $_.Name -eq $Username } + $FinalStatus = if (@($Row.Steps | Where-Object { $_.Status -eq 'failed' }).Count -gt 0) { 'failed' } else { 'succeeded' } + Set-CIPPAsyncDeploymentStatus -JobId $Item.Parameters.DeploymentId -Name $Username -Status $FinalStatus -Logs $StoredResults + } Write-LogMessage -API 'Offboarding' -tenant $TenantFilter -message "Offboarding completed for $Username" -sev Info -headers $Headers return "Offboarding completed for $Username" } catch { $ErrorMsg = "Failed to complete offboarding for $Username : $($_.Exception.Message)" Write-LogMessage -API 'Offboarding' -tenant $TenantFilter -message $ErrorMsg -sev Error -headers $Headers -LogData (Get-CippException -Exception $_) + if ($Item.Parameters.DeploymentId) { + Set-CIPPAsyncDeploymentStatus -JobId $Item.Parameters.DeploymentId -Name $Username -Status 'failed' -Logs $ErrorMsg + } throw $ErrorMsg } } diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPOffboardingTask.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPOffboardingTask.ps1 index eacb976bf70ed..9b92fc0fd3df1 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPOffboardingTask.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPOffboardingTask.ps1 @@ -4,7 +4,8 @@ function Push-CIPPOffboardingTask { Generic wrapper to execute individual offboarding task cmdlets .DESCRIPTION - Executes the specified cmdlet with the provided parameters as part of user offboarding + Executes the specified cmdlet with the provided parameters as part of user offboarding and, + when the job tracks live progress, reports the outcome to the step it was stamped with .FUNCTIONALITY Entrypoint @@ -14,9 +15,12 @@ function Push-CIPPOffboardingTask { $Cmdlet = $Item.Cmdlet $Parameters = $Item.Parameters | ConvertTo-Json -Depth 5 | ConvertFrom-Json -AsHashtable + # Live progress (optional): the job stamps each task with the status row and step it reports to + $Step = if ($Item.DeploymentId) { @{ JobId = $Item.DeploymentId; Name = $Item.DeploymentName; StepIndex = [int]$Item.StepIndex } } try { Write-Information "Executing offboarding cmdlet: $Cmdlet" + if ($Step) { Set-CIPPAsyncDeploymentStep @Step -StepStatus 'running' -Message 'In progress' } # Check if cmdlet exists $CmdletInfo = Get-Command -Name $Cmdlet -Module CIPPCore -ErrorAction SilentlyContinue @@ -28,11 +32,19 @@ function Push-CIPPOffboardingTask { $Result = & $Cmdlet @Parameters Write-Information "Completed $Cmdlet successfully" + if ($Step) { + # Most cmdlets report per-item problems as returned 'Error: ...' lines rather than throwing + # (group removal, for one), so a returned error line counts as a failed step. + $Lines = @($Result | ForEach-Object { [string]($_.resultText ?? $_) }) + $StepStatus = if (@($Lines | Where-Object { $_ -match '^\s*(Error|Failed)\b' }).Count -gt 0) { 'failed' } else { 'succeeded' } + Set-CIPPAsyncDeploymentStep @Step -StepStatus $StepStatus -Message ($Lines -join "`n") + } return $Result } catch { $ErrorMsg = "Failed to execute $Cmdlet : $($_.Exception.Message)" Write-Information $ErrorMsg + if ($Step) { Set-CIPPAsyncDeploymentStep @Step -StepStatus 'failed' -Message $ErrorMsg } return $ErrorMsg } } diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecJITAdminListAllTenants.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecJITAdminListAllTenants.ps1 index 2a4ec142805dc..79ffdb7bd551c 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecJITAdminListAllTenants.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecJITAdminListAllTenants.ps1 @@ -31,7 +31,7 @@ function Push-ExecJITAdminListAllTenants { $BulkRequests.Add(@{ id = $User.id method = 'GET' - url = "users/$($User.id)/memberOf/microsoft.graph.directoryRole/?`$select=id,displayName" + url = "users/$($User.id)/memberOf/microsoft.graph.directoryRole/?`$select=id,displayName,roleTemplateId" }) } # Ensure $BulkRequests is not empty or null before making the bulk request @@ -45,7 +45,7 @@ function Push-ExecJITAdminListAllTenants { if ($RoleResults) { $userRoleResult = $RoleResults | Where-Object -Property id -EQ $currentUser.id if ($userRoleResult -and $userRoleResult.body -and $userRoleResult.body.value) { - $MemberOf = $userRoleResult.body.value | Select-Object displayName, id + $MemberOf = $userRoleResult.body.value | Select-Object displayName, id, roleTemplateId } } @@ -61,6 +61,7 @@ function Push-ExecJITAdminListAllTenants { jitAdminEnabled = $jitAdminEnabled jitAdminExpiration = $jitAdminExpiration memberOf = ($MemberOf | ConvertTo-Json -Depth 5 -Compress) + roleTemplateIds = @($MemberOf.roleTemplateId | Where-Object { $_ }) } } diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecMdoAlertsListAllTenants.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecMdoAlertsListAllTenants.ps1 index 15cab5352f97a..7485073537ca7 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecMdoAlertsListAllTenants.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecMdoAlertsListAllTenants.ps1 @@ -10,8 +10,8 @@ function Push-ExecMdoAlertsListAllTenants { $Table = Get-CIPPTable -TableName 'cachealertsandincidents' try { - # Get MDO alerts using the specific endpoint and filter - $Alerts = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/security/alerts_v2?`$filter=serviceSource eq 'microsoftDefenderForOffice365'" -tenantid $domainName + # Get MDO and MDE alerts using the specific endpoint and filter + $Alerts = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/security/alerts_v2?`$filter=serviceSource eq 'microsoftDefenderForOffice365' or serviceSource eq 'microsoftDefenderForEndpoint'" -tenantid $domainName foreach ($Alert in $Alerts) { $GUID = (New-Guid).Guid diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecOnboardTenantQueue.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecOnboardTenantQueue.ps1 index 8b4dddb20c1ee..b1f17b64e77a5 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecOnboardTenantQueue.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecOnboardTenantQueue.ps1 @@ -13,6 +13,20 @@ function Push-ExecOnboardTenantQueue { $OnboardTable = Get-CIPPTable -TableName 'TenantOnboarding' $TenantOnboarding = Get-CIPPAzDataTableEntity @OnboardTable -Filter "RowKey eq '$Id'" + # Prefer Item flag; fall back to persisted onboarding row (poll/retry may omit it from Item) + $StandardsExcludeAllTenants = if ($Item.StandardsExcludeAllTenants -eq $true) { + $true + } else { + [bool]$TenantOnboarding.StandardsExcludeAllTenants + } + if ($StandardsExcludeAllTenants -eq $true) { + if ($TenantOnboarding.PSObject.Properties.Name -notcontains 'StandardsExcludeAllTenants') { + $TenantOnboarding | Add-Member -NotePropertyName 'StandardsExcludeAllTenants' -NotePropertyValue $true -Force + } else { + $TenantOnboarding.StandardsExcludeAllTenants = $true + } + } + $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = "Starting onboarding for relationship $Id" }) $OnboardingSteps = $TenantOnboarding.OnboardingSteps | ConvertFrom-Json $OnboardingSteps.Step1.Status = 'running' @@ -358,7 +372,7 @@ function Push-ExecOnboardTenantQueue { AutoMapRoles = $Item.AutoMapRoles IgnoreMissingRoles = $Item.IgnoreMissingRoles AddMissingGroups = $Item.AddMissingGroups - StandardsExcludeAllTenants = $Item.StandardsExcludeAllTenants + StandardsExcludeAllTenants = $StandardsExcludeAllTenants } } $RetryTask = [PSCustomObject]@{ @@ -487,7 +501,7 @@ function Push-ExecOnboardTenantQueue { } if ($OnboardingSteps.Step4.Status -eq 'succeeded') { - if ($Item.StandardsExcludeAllTenants -eq $true) { + if ($StandardsExcludeAllTenants -eq $true) { $GroupTable = Get-CIPPTable -tablename 'TenantGroups' $MembersTable = Get-CIPPTable -tablename 'TenantGroupMembers' $ExclusionGroupName = 'Excluded onboarded tenants' @@ -542,14 +556,10 @@ function Push-ExecOnboardTenantQueue { } $NewExcludedTenants.Add($GroupExclusionObj) $object.excludedTenants = $NewExcludedTenants - $JSON = ConvertTo-Json -InputObject $object -Compress -Depth 10 + # Depth 100 like every other writer; write back the row read so its other columns survive. + $AllTenantsTemplate.JSON = ConvertTo-Json -InputObject $object -Compress -Depth 100 $TemplatesTable.Force = $true - Add-CIPPAzDataTableEntity @TemplatesTable -Entity @{ - JSON = "$JSON" - RowKey = $AllTenantsTemplate.RowKey - GUID = $AllTenantsTemplate.GUID - PartitionKey = 'StandardsTemplateV2' - } + Add-CIPPAzDataTableEntity @TemplatesTable -Entity $AllTenantsTemplate } } diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 index 9862a22073f6c..76642ac56c520 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 @@ -227,6 +227,24 @@ function Push-ExecScheduledCommand { Write-Information "Failed to remove parameters: $($_.Exception.Message)" } + # Stored parameters are user input: the command's tenant parameter is forced to the authorized + # task tenant so a stored value can never target another tenant. When a command declares more + # than one tenant-identifying name, only the most specific one is injected and the others are + # dropped so the command resolves them itself. + $DeclaredTenantParams = [array](@('TenantFilter', 'Tenant', 'TenantId') | Where-Object { $Function.Parameters.ContainsKey($_) }) + foreach ($TenantParamName in $DeclaredTenantParams) { + $StoredTenantValue = $commandParameters[$TenantParamName] + $StoredTenantString = [string]($StoredTenantValue.value ?? $StoredTenantValue) + if (![string]::IsNullOrWhiteSpace($StoredTenantString) -and $StoredTenantString -ne [string]$Tenant) { + Write-LogMessage -API 'Scheduler_UserTasks' -tenant $Tenant -tenantid $TenantInfo.customerId -message "Task $($task.Name): stored parameter -$TenantParamName value '$StoredTenantString' does not match the authorized tenant '$Tenant' and was overridden." -sev Error + } + if ($TenantParamName -eq $DeclaredTenantParams[0]) { + $commandParameters[$TenantParamName] = $Tenant + } else { + $commandParameters.Remove($TenantParamName) + } + } + if ($IsTriggerTask -eq $true -and $Trigger.ExecutePerResource -ne $true) { # iterate through paramters looking for %variables% and replace them with matched data from the delta query # examples would be %id% to be the id of the result @@ -404,7 +422,18 @@ function Push-ExecScheduledCommand { if ($TaskAttachments) { $AlertParams.Attachments = $TaskAttachments } - Send-CIPPScheduledTaskAlert @AlertParams + $PostExecutionResults = @(Send-CIPPScheduledTaskAlert @AlertParams) + # Keep the delivery outcomes with the task, so a failed webhook, email or PSA note shows on the task page. + try { + $TaskTable = Get-CippTable -tablename 'ScheduledTasks' + $null = Update-AzDataTableEntity -Force @TaskTable -Entity @{ + PartitionKey = $task.PartitionKey + RowKey = $task.RowKey + PostExecutionResults = [string](ConvertTo-Json -Compress -Depth 5 -InputObject $PostExecutionResults) + } + } catch { + Write-Information "Could not store the post-execution results: $($_.Exception.Message)" + } } try { diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-GetMailboxRulesBatch.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-GetMailboxRulesBatch.ps1 index 8ba597b4e1dbd..cac14c52d3e19 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-GetMailboxRulesBatch.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-GetMailboxRulesBatch.ps1 @@ -38,8 +38,10 @@ function Push-GetMailboxRulesBatch { # Add metadata and return for aggregation if (($Rules | Measure-Object).Count -gt 0) { $RulesWithMetadata = foreach ($Rule in $Rules) { - $Rule | Add-Member -NotePropertyName 'Tenant' -NotePropertyValue $TenantFilter -Force - $Rule | Add-Member -NotePropertyName 'UserPrincipalName' -NotePropertyValue $Rule.OperationGuid -Force + $Rule | Add-Member -NotePropertyMembers ([ordered]@{ + Tenant = $TenantFilter + UserPrincipalName = $Rule.OperationGuid + }) -Force $Rule } return , $RulesWithMetadata diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ListConditionalAccessPoliciesAllTenants.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ListConditionalAccessPoliciesAllTenants.ps1 index 6654ae719c800..daf01e43ec2fb 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ListConditionalAccessPoliciesAllTenants.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ListConditionalAccessPoliciesAllTenants.ps1 @@ -103,7 +103,8 @@ $AllServicePrincipals = ($BulkResults | Where-Object { $_.id -eq 'servicePrincipals' }).body.value foreach ($cap in $ConditionalAccessPolicyOutput) { - $GUID = (New-Guid).Guid + # Deterministic key so overlapping fan-outs upsert instead of appending duplicates. + $RowKey = ('{0}-{1}' -f $domainName, $cap.id) -replace '[\\/#?]', '_' -replace '[\x00-\x1F\x7F]', '' $PolicyData = @{ id = $cap.id displayName = $cap.displayName @@ -136,7 +137,7 @@ $Entity = @{ Policy = [string]($PolicyData | ConvertTo-Json -Depth 10 -Compress) - RowKey = [string]$GUID + RowKey = [string]$RowKey PartitionKey = 'CAPolicy' Tenant = [string]$domainName } @@ -144,7 +145,6 @@ } } catch { - $GUID = (New-Guid).Guid $ErrorPolicy = ConvertTo-Json -InputObject @{ Tenant = $domainName displayName = "Could not connect to Tenant: $($_.Exception.Message)" @@ -156,7 +156,7 @@ } -Compress $Entity = @{ Policy = [string]$ErrorPolicy - RowKey = [string]$GUID + RowKey = [string]('{0}-Error' -f $domainName) -replace '[\\/#?]', '_' PartitionKey = 'CAPolicy' Tenant = [string]$domainName } diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Storage Cleanup/Push-DBCacheStorageCleanupScanBatch.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Storage Cleanup/Push-DBCacheStorageCleanupScanBatch.ps1 new file mode 100644 index 0000000000000..89cc02c93d7a7 --- /dev/null +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Storage Cleanup/Push-DBCacheStorageCleanupScanBatch.ps1 @@ -0,0 +1,248 @@ +function Push-DBCacheStorageCleanupScanBatch { + <# + .SYNOPSIS + Collects library version estimates and recycle-bin totals for a batch of SharePoint sites. + + .DESCRIPTION + Processes up to 20 site seeds per activity. Each site is wrapped in its own try/catch so a + batch of N sites always returns exactly N site results - Push-StoreStorageCleanupScan + relies on that to verify completeness before it replaces the cache. + + Per site (mirrors ListSiteBrowser library drill-in + ListSiteRecycleBinSummary): + - Graph lists for documentLibrary / webPageLibrary + - SPO StorageMetrics for versionEstimateBytes + - Aggregate recycle bin sizes (no item titles or paths) + + Two row types are emitted, discriminated by rowType: + - Site one per scanned site (Full or Skipped); carries recycle aggregates + - Library one per visible document/page library when collection succeeded + + collectionStatus: + - Full libraries were collected; recycle fields may still be null if recycle failed + - Skipped site-level collection failed; no Library rows + + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param($Item) + + $TenantFilter = $Item.TenantFilter + $BatchNumber = $Item.BatchNumber + $SiteSeeds = @($Item.Sites) + + function New-CleanupSiteRow { + param( + $SiteSeed, + [string]$Status, + [string]$ErrorMessage, + [int]$LibrariesScanned, + [string]$CollectedAt, + $Recycle + ) + [PSCustomObject]@{ + rowType = 'Site' + id = "$($SiteSeed.id)_site" + siteId = $SiteSeed.id + displayName = $SiteSeed.displayName + siteUrl = $SiteSeed.webUrl + collectionStatus = $Status + collectionError = $ErrorMessage + librariesScanned = $LibrariesScanned + recycleTotalBytes = $Recycle.totalBytes + recycleItemCount = $Recycle.itemCount + recycleFirstStageBytes = $Recycle.firstStageBytes + recycleFirstStageCount = $Recycle.firstStageCount + recycleSecondStageBytes = $Recycle.secondStageBytes + recycleSecondStageCount = $Recycle.secondStageCount + recycleCapped = $Recycle.capped + recycleScannedItems = $Recycle.scannedItems + collectedAt = $CollectedAt + } + } + + function Get-CIPPRecycleBinSummary { + param( + [string]$TenantFilter, + [string]$SiteUrl, + [string]$Scope, + $JsonAccept, + [int]$MaxItems = 5000 + ) + $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + $FirstCount = [int64]0 + $FirstBytes = [int64]0 + $SecondCount = [int64]0 + $SecondBytes = [int64]0 + $Seen = 0 + $Capped = $false + $NextUri = "$BaseUri/site/RecycleBin?`$select=Id,Size,ItemState&`$top=500&`$orderby=DeletedDate desc" + + while ($NextUri) { + $Page = New-GraphGetRequest -uri $NextUri -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true -noPagination $true -SkipValueExtraction + $Items = @() + $NextLink = $null + if ($null -ne $Page.value) { + $Items = @($Page.value) + $NextLink = $Page.'@odata.nextLink' + } elseif ($Page -is [System.Array]) { + $Items = @($Page) + } elseif ($Page.PSObject.Properties.Name -contains 'Id') { + $Items = @($Page) + } + + foreach ($BinItem in $Items) { + if ($Seen -ge $MaxItems) { + $Capped = $true + break + } + $Seen++ + $Size = 0 + try { $Size = [int64][double]$BinItem.Size } catch { $Size = 0 } + $State = 0 + try { $State = [int]$BinItem.ItemState } catch { $State = 0 } + if ($State -eq 2) { + $SecondCount++ + $SecondBytes += $Size + } else { + $FirstCount++ + $FirstBytes += $Size + } + } + + if ($Capped -or [string]::IsNullOrWhiteSpace($NextLink)) { break } + $NextUri = $NextLink + } + + return [PSCustomObject]@{ + siteUrl = $SiteUrl.TrimEnd('/') + itemCount = $FirstCount + $SecondCount + totalBytes = $FirstBytes + $SecondBytes + firstStageCount = $FirstCount + firstStageBytes = $FirstBytes + secondStageCount = $SecondCount + secondStageBytes = $SecondBytes + capped = $Capped + scannedItems = $Seen + } + } + + $SiteResults = [System.Collections.Generic.List[object]]::new() + + try { + Write-Information "Processing StorageCleanupScan batch $BatchNumber for tenant $TenantFilter with $($SiteSeeds.Count) sites" + + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $SpoScope = "$($SharePointInfo.SharePointUrl)/.default" + $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } + + foreach ($SiteSeed in $SiteSeeds) { + $CollectedAt = (Get-Date).ToUniversalTime().ToString('o') + $LibraryRows = [System.Collections.Generic.List[object]]::new() + try { + $SiteId = $SiteSeed.id + $SiteUrl = $SiteSeed.webUrl + if ([string]::IsNullOrWhiteSpace($SiteUrl)) { + throw 'Site webUrl is required' + } + + $SiteSegment = $SiteId + if ([string]::IsNullOrWhiteSpace($SiteSegment)) { + $ParsedUrl = [System.Uri]$SiteUrl + $SiteSegment = if ($ParsedUrl.AbsolutePath -in @('', '/')) { + $ParsedUrl.Host + } else { + "$($ParsedUrl.Host):$($ParsedUrl.AbsolutePath):" + } + } + + $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + $Lists = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/sites/$SiteSegment/lists?`$select=id,displayName,name,webUrl,list,createdDateTime" -tenantid $TenantFilter -asapp $true + $Libraries = @($Lists | Where-Object { $_.list.hidden -ne $true -and $_.list.template -in @('documentLibrary', 'webPageLibrary') }) + + foreach ($List in $Libraries) { + $StorageUsed = $null + $FileCount = $null + $FileStreamSize = $null + $MetadataSize = $null + $VersionEstimate = $null + try { + $Metrics = New-GraphGetRequest -uri "$BaseUri/web/lists(guid'$($List.id)')/RootFolder?`$select=StorageMetrics&`$expand=StorageMetrics" -tenantid $TenantFilter -scope $SpoScope -extraHeaders $JsonAccept -UseCertificate -AsApp $true + $TotalSize = ConvertTo-SPOAdminListInt64 -Raw $Metrics.StorageMetrics.TotalSize + $FileStreamSize = ConvertTo-SPOAdminListInt64 -Raw $Metrics.StorageMetrics.TotalFileStreamSize + $MetadataSize = ConvertTo-SPOAdminListInt64 -Raw $Metrics.StorageMetrics.MetadataSize + $FileCount = ConvertTo-SPOAdminListInt64 -Raw $Metrics.StorageMetrics.TotalFileCount + $StorageUsed = $TotalSize + if ($null -ne $TotalSize) { + $Tip = if ($null -ne $FileStreamSize) { $FileStreamSize } else { [int64]0 } + $Meta = if ($null -ne $MetadataSize) { $MetadataSize } else { [int64]0 } + $VersionEstimate = [Math]::Max([int64]0, $TotalSize - $Tip - $Meta) + } + } catch { + $StorageUsed = $null + $FileCount = $null + $FileStreamSize = $null + $MetadataSize = $null + $VersionEstimate = $null + } + + $LibraryRows.Add([PSCustomObject]@{ + rowType = 'Library' + id = "$($SiteId)_$($List.id)" + siteId = $SiteId + siteUrl = $SiteUrl + libraryId = $List.id + libraryName = $List.name + libraryDisplayName = $List.displayName + storageUsedInBytes = $StorageUsed + versionEstimateBytes = $VersionEstimate + fileStreamSizeInBytes = $FileStreamSize + metadataSizeInBytes = $MetadataSize + fileCount = $FileCount + template = $List.list.template + webUrl = $List.webUrl + collectedAt = $CollectedAt + }) + } + + $Recycle = $null + try { + $Recycle = Get-CIPPRecycleBinSummary -TenantFilter $TenantFilter -SiteUrl $SiteUrl -Scope $SpoScope -JsonAccept $JsonAccept + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "StorageCleanupScan: recycle summary failed for '$SiteUrl': $($_.Exception.Message)" -sev Warning + $Recycle = $null + } + + $SiteResults.Add([PSCustomObject]@{ + SiteId = $SiteId + CollectionStatus = 'Full' + SiteRow = (New-CleanupSiteRow -SiteSeed $SiteSeed -Status 'Full' -ErrorMessage $null -LibrariesScanned $LibraryRows.Count -CollectedAt $CollectedAt -Recycle $Recycle) + Rows = @($LibraryRows) + }) + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "StorageCleanupScan: collection failed for '$($SiteSeed.webUrl)': $($_.Exception.Message)" -sev Warning + $SiteResults.Add([PSCustomObject]@{ + SiteId = $SiteSeed.id + CollectionStatus = 'Skipped' + SiteRow = (New-CleanupSiteRow -SiteSeed $SiteSeed -Status 'Skipped' -ErrorMessage $_.Exception.Message -LibrariesScanned 0 -CollectedAt $CollectedAt -Recycle $null) + Rows = @() + }) + } + } + + if ($SiteResults.Count -ne $SiteSeeds.Count) { + throw "Batch $BatchNumber invariant violated: expected $($SiteSeeds.Count) site results, got $($SiteResults.Count)" + } + + return [PSCustomObject]@{ + BatchNumber = $BatchNumber + Sites = @($SiteResults) + } + + } catch { + $ErrorMsg = "Failed StorageCleanupScan batch $BatchNumber for tenant $TenantFilter : $($_.Exception.Message)" + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message $ErrorMsg -sev Error -LogData (Get-CippException -Exception $_) + throw + } +} diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Storage Cleanup/Push-StoreStorageCleanupScan.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Storage Cleanup/Push-StoreStorageCleanupScan.ps1 new file mode 100644 index 0000000000000..167e2df42b355 --- /dev/null +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Storage Cleanup/Push-StoreStorageCleanupScan.ps1 @@ -0,0 +1,89 @@ +function Push-StoreStorageCleanupScan { + <# + .SYNOPSIS + Post-execution function that aggregates per-batch storage cleanup rows and writes the cache. + + .DESCRIPTION + Collects the Sites arrays returned by every Push-DBCacheStorageCleanupScanBatch activity, + flattens their Site and Library rows into a single row set, and writes StorageCleanupScan + once via Add-CIPPDbItem. + + Completeness guard: if the number of site results does not match ExpectedSiteCount the + function throws without writing. The cache is written in replace mode, so writing a partial + set would silently discard every site the failed batches were responsible for. + + Merge-on-Skip: when a site returns Skipped, its Library rows are restored from the existing + cache (matched on siteId) so a transient SPO failure does not erase cleanup signals that + were collected successfully on an earlier run. + + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param($Item) + + $TenantFilter = $Item.Parameters.TenantFilter + $ExpectedSiteCount = [int]$Item.Parameters.ExpectedSiteCount + + try { + $SiteResults = [System.Collections.Generic.List[object]]::new() + foreach ($BatchResult in @($Item.Results)) { + foreach ($SiteResult in @($BatchResult.Sites)) { + if ($SiteResult) { $SiteResults.Add($SiteResult) } + } + } + + $ActualCount = $SiteResults.Count + if ($ActualCount -ne $ExpectedSiteCount) { + throw "StorageCleanupScan completeness check failed for $TenantFilter : expected $ExpectedSiteCount site results, got $ActualCount" + } + + $SkippedResults = @($SiteResults | Where-Object { $_.CollectionStatus -eq 'Skipped' }) + $MergedCount = 0 + $PriorRowsBySiteId = @{} + if ($SkippedResults.Count -gt 0) { + foreach ($Existing in @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'StorageCleanupScan')) { + if ($Existing.rowType -ne 'Library') { continue } + $Key = [string]$Existing.siteId + if (-not $Key) { continue } + if (-not $PriorRowsBySiteId.ContainsKey($Key)) { + $PriorRowsBySiteId[$Key] = [System.Collections.Generic.List[object]]::new() + } + $PriorRowsBySiteId[$Key].Add($Existing) + } + } + + $AllRows = [System.Collections.Generic.List[object]]::new() + foreach ($SiteResult in $SiteResults) { + if ($SiteResult.SiteRow) { $AllRows.Add($SiteResult.SiteRow) } + + if ($SiteResult.CollectionStatus -eq 'Skipped') { + $Key = [string]$SiteResult.SiteId + if ($Key -and $PriorRowsBySiteId.ContainsKey($Key)) { + foreach ($Row in $PriorRowsBySiteId[$Key]) { $AllRows.Add($Row) } + $MergedCount++ + } + continue + } + + foreach ($Row in @($SiteResult.Rows)) { + if ($Row) { $AllRows.Add($Row) } + } + } + + if ($SkippedResults.Count -gt 0) { + $RemainingSkipped = $SkippedResults.Count - $MergedCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "StorageCleanupScan: $($SkippedResults.Count) of $ActualCount sites returned Skipped from collection; restored $MergedCount from prior cache; $RemainingSkipped have no library rows" -sev Warning + } + + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'StorageCleanupScan' -Data @($AllRows) -AddCount + + $LibraryCount = @($AllRows | Where-Object { $_.rowType -eq 'Library' }).Count + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $LibraryCount StorageCleanupScan libraries across $ActualCount sites ($MergedCount merge-on-Skip) from $(@($Item.Results).Count) batches" -sev Info + return + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to store StorageCleanupScan: $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) + throw + } +} diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertApnCertExpiry.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertApnCertExpiry.ps1 index 0b288e964f31d..dfc1459d293c8 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertApnCertExpiry.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertApnCertExpiry.ps1 @@ -12,8 +12,18 @@ function Get-CIPPAlertApnCertExpiry { ) try { + $expiryDays = 30 + if ($InputValue -is [hashtable] -or $InputValue -is [pscustomobject]) { + if ($null -ne $InputValue.DaysUntilExpiry -and $InputValue.DaysUntilExpiry -ne '') { + $parsedDays = 0 + if ([int]::TryParse($InputValue.DaysUntilExpiry.ToString(), [ref]$parsedDays) -and $parsedDays -gt 0) { + $expiryDays = $parsedDays + } + } + } + $Apn = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/applePushNotificationCertificate' -tenantid $TenantFilter - $AlertData = if ($Apn.expirationDateTime -lt (Get-Date).AddDays(30) -and $Apn.expirationDateTime -gt (Get-Date).AddDays(-7)) { + $AlertData = if ($Apn.expirationDateTime -lt (Get-Date).AddDays($expiryDays) -and $Apn.expirationDateTime -gt (Get-Date).AddDays(-7)) { $Apn | Select-Object -Property appleIdentifier, expirationDateTime } if ($AlertData) { diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertAppSecretExpiry.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertAppSecretExpiry.ps1 index 20041ca7845c1..7e741af1e5d2b 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertAppSecretExpiry.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertAppSecretExpiry.ps1 @@ -15,6 +15,8 @@ function Get-CIPPAlertAppSecretExpiry { Write-Host "Checking app expire for $($TenantFilter)" $appList = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/applications?`$select=appId,displayName,passwordCredentials" -tenantid $TenantFilter } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Application secret expiry alert: unable to list applications: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage return } diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertArchiveQuota.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertArchiveQuota.ps1 new file mode 100644 index 0000000000000..8c9499d49a53f --- /dev/null +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertArchiveQuota.ps1 @@ -0,0 +1,111 @@ +function Get-CIPPAlertArchiveQuota { + <# + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $false)] + [Alias('input')] + $InputValue, + [Parameter(Mandatory)] + $TenantFilter + ) + + $Threshold = if ($InputValue.ArchiveQuotaThreshold) { [int]$InputValue.ArchiveQuotaThreshold } else { 90 } + $ExcludedRaw = Get-CIPPTextReplacement -TenantFilter $TenantFilter -Text ([string]$InputValue.ArchiveQuotaExcludedMailboxes) + $Excluded = @($ExcludedRaw -split ',' | ForEach-Object { $_.Trim().ToLower() } | Where-Object { $_ }) + + # Prefer the reporting DB: Set-CIPPDBCacheMailboxes already stores archive size and quota (both in + # bytes) per mailbox, so a warm cache answers this without any Exchange Online call. The archive + # figures change slowly and this alert runs weekly, so day-old cache data is well within tolerance. + # Fall back to live Exchange only when the tenant has no cached mailbox data (or a cache written + # before ArchiveQuota was added), so the alert never silently no-ops. + $ArchiveUsage = $null + try { + $Cached = @(Get-CIPPMailboxesReport -TenantFilter $TenantFilter -ErrorAction Stop) + } catch { + $Cached = @() + } + $CacheHasQuota = $Cached.Count -gt 0 -and ($Cached[0].PSObject.Properties.Name -contains 'ArchiveQuota') + + if ($CacheHasQuota) { + $ArchiveUsage = foreach ($Mailbox in $Cached) { + if ($Mailbox.ArchiveEnabled -ne $true -or -not $Mailbox.UPN) { continue } + [PSCustomObject]@{ + UPN = $Mailbox.UPN + RecipientType = $Mailbox.recipientTypeDetails + UsedBytes = [int64]($Mailbox.ArchiveSize ?? 0) + QuotaBytes = [int64]($Mailbox.ArchiveQuota ?? 0) + } + } + } else { + try { + # -Archive limits the result to mailboxes that actually have an online archive, and the + # quota fields ride along so only the per-mailbox usage needs a second lookup. + $ArchiveMailboxes = @(New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-Mailbox' -cmdParams @{ Archive = $true } -Select 'UserPrincipalName,RecipientTypeDetails,ArchiveQuota,ArchiveGuid' -useSystemMailbox $true | Where-Object { $_.UserPrincipalName }) + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Archive quota Alert: Unable to get archive mailboxes: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + return + } + if ($ArchiveMailboxes.Count -eq 0) { return } + + # Archive size only comes from Get-MailboxStatistics. Batch it with an operation guid per + # mailbox so each result maps back to its mailbox, the same pattern the reporting-DB cache uses. + $MailboxByRequestId = @{} + $StatsRequests = @(foreach ($Mailbox in $ArchiveMailboxes) { + $OperationGuid = [Guid]::NewGuid().ToString() + $MailboxByRequestId[$OperationGuid] = $Mailbox + @{ + CmdletInput = @{ + CmdletName = 'Get-MailboxStatistics' + Parameters = @{ Identity = $Mailbox.UserPrincipalName; Archive = $true } + } + OperationGuid = $OperationGuid + } + }) + + try { + $StatsResults = New-ExoBulkRequest -tenantid $TenantFilter -cmdletArray $StatsRequests -useSystemMailbox $true + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Archive quota Alert: Unable to get archive statistics: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + return + } + + $ArchiveUsage = foreach ($Stat in @($StatsResults)) { + if (-not $Stat.OperationGuid -or -not $MailboxByRequestId.ContainsKey($Stat.OperationGuid) -or $Stat.error) { continue } + $Mailbox = $MailboxByRequestId[$Stat.OperationGuid] + [PSCustomObject]@{ + UPN = $Mailbox.UserPrincipalName + RecipientType = $Mailbox.RecipientTypeDetails + UsedBytes = Get-ExoOnlineStringBytes -SizeString ([string]$Stat.TotalItemSize) + QuotaBytes = Get-ExoOnlineStringBytes -SizeString ([string]$Mailbox.ArchiveQuota) + } + } + } + + $OverQuota = foreach ($Item in @($ArchiveUsage)) { + if (-not $Item.UPN) { continue } + if ($Excluded -contains $Item.UPN.ToLower()) { continue } + # An archive with no quota reports 0 bytes here (e.g. 'Unlimited'); skip rather than divide by zero. + if ($Item.QuotaBytes -le 0) { continue } + $UsagePercent = [math]::Round(($Item.UsedBytes / $Item.QuotaBytes) * 100) + if ($UsagePercent -ge $Threshold) { + [PSCustomObject]@{ + Message = "$($Item.UPN): Online archive is more than $($Threshold)% full. Archive is $UsagePercent% full" + Owner = $Item.UPN + RecipientType = $Item.RecipientType + UsagePercent = $UsagePercent + ArchiveUsedBytes = $Item.UsedBytes + ArchiveQuotaBytes = $Item.QuotaBytes + Tenant = $TenantFilter + } + } + } + + if ($OverQuota) { + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $OverQuota + } +} diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertDepTokenExpiry.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertDepTokenExpiry.ps1 index 63436166abda8..10320d3bf030c 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertDepTokenExpiry.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertDepTokenExpiry.ps1 @@ -13,10 +13,20 @@ function Get-CIPPAlertDepTokenExpiry { try { try { + $expiryDays = 30 + if ($InputValue -is [hashtable] -or $InputValue -is [pscustomobject]) { + if ($null -ne $InputValue.DaysUntilExpiry -and $InputValue.DaysUntilExpiry -ne '') { + $parsedDays = 0 + if ([int]::TryParse($InputValue.DaysUntilExpiry.ToString(), [ref]$parsedDays) -and $parsedDays -gt 0) { + $expiryDays = $parsedDays + } + } + } + $DepTokens = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/depOnboardingSettings' -tenantid $TenantFilter $AlertData = foreach ($Dep in $DepTokens) { - if ($Dep.tokenExpirationDateTime -lt (Get-Date).AddDays(30) -and $Dep.tokenExpirationDateTime -gt (Get-Date).AddDays(-7)) { - $Message = 'Apple Device Enrollment Program token expiring on {0}' -f $Dep.tokenExpirationDateTime + if ($Dep.tokenExpirationDateTime -lt (Get-Date).AddDays($expiryDays) -and $Dep.tokenExpirationDateTime -gt (Get-Date).AddDays(-7)) { + $Message = 'Apple Device Enrollment Program token expiring on {0}' -f ([datetime]$Dep.tokenExpirationDateTime).ToString('yyyy-MM-dd') $Dep | Select-Object -Property tokenName, @{Name = 'Message'; Expression = { $Message } } } } diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertDeviceComplianceGracePeriod.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertDeviceComplianceGracePeriod.ps1 new file mode 100644 index 0000000000000..02acf09a1fd12 --- /dev/null +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertDeviceComplianceGracePeriod.ps1 @@ -0,0 +1,55 @@ +function Get-CIPPAlertDeviceComplianceGracePeriod { + <# + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] + [Alias('input')] + $InputValue, + $TenantFilter + ) + try { + $ExpiresWithinDays = 0 + if ($null -ne $InputValue.ExpiresWithinDays -and $InputValue.ExpiresWithinDays -ne '') { + $parsedDays = 0 + if ([int]::TryParse($InputValue.ExpiresWithinDays.ToString(), [ref]$parsedDays) -and $parsedDays -gt 0) { + $ExpiresWithinDays = $parsedDays + } + } + + $GraphRequest = New-GraphGETRequest -uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=complianceState eq 'inGracePeriod'&`$select=id,deviceName,userPrincipalName,operatingSystem,managedDeviceOwnerType,complianceState,complianceGracePeriodExpirationDateTime,lastSyncDateTime&`$top=999" -tenantid $TenantFilter + $AlertData = foreach ($Device in $GraphRequest) { + $Expiration = $Device.complianceGracePeriodExpirationDateTime + $DaysRemaining = if ($Expiration) { [Math]::Ceiling(([DateTime]$Expiration - (Get-Date).ToUniversalTime()).TotalDays) } else { $null } + if ($ExpiresWithinDays -gt 0 -and $null -ne $DaysRemaining -and $DaysRemaining -gt $ExpiresWithinDays) { continue } + + $Message = if ($null -ne $DaysRemaining) { + 'Device {0} is in the compliance grace period and will be marked noncompliant on {1} ({2} days remaining)' -f $Device.deviceName, ([datetime]$Expiration).ToString('yyyy-MM-dd'), $DaysRemaining + } else { + 'Device {0} is in the compliance grace period' -f $Device.deviceName + } + + [PSCustomObject]@{ + DeviceName = $Device.deviceName + Id = $Device.id + UserPrincipalName = $Device.userPrincipalName + OperatingSystem = $Device.operatingSystem + OwnerType = $Device.managedDeviceOwnerType + GracePeriodExpiration = $Expiration + DaysRemaining = $DaysRemaining + LastSync = $Device.lastSyncDateTime + Message = $Message + Tenant = $TenantFilter + } + } + + if ($AlertData) { + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Could not get compliance grace period state for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + } +} diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertExpiringLicenses.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertExpiringLicenses.ps1 index 15fcfdf3009b2..200adf97719ba 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertExpiringLicenses.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertExpiringLicenses.ps1 @@ -16,10 +16,12 @@ function Get-CIPPAlertExpiringLicenses { # Support both old format (direct value) and new format (object with properties) if ($InputValue -is [hashtable] -or $InputValue -is [PSCustomObject]) { $DaysThreshold = if ($InputValue.ExpiringLicensesDays) { [int]$InputValue.ExpiringLicensesDays } else { 30 } + $MinDaysThreshold = if ($InputValue.ExpiringLicensesMinDays) { [int]$InputValue.ExpiringLicensesMinDays } else { 0 } $UnassignedOnly = if ($null -ne $InputValue.ExpiringLicensesUnassignedOnly) { [bool]$InputValue.ExpiringLicensesUnassignedOnly } else { $false } } else { # Backward compatibility: if InputValue is a simple value, treat it as days threshold $DaysThreshold = if ($InputValue) { [int]$InputValue } else { 30 } + $MinDaysThreshold = 0 $UnassignedOnly = $false } @@ -39,7 +41,9 @@ function Get-CIPPAlertExpiringLicenses { foreach ($Term in $TermData) { $DaysUntilRenew = [int]$Term.DaysUntilRenew - if ($DaysUntilRenew -lt $DaysThreshold -and $DaysUntilRenew -gt 0) { + # Graph does not expose the actual commitment term (P1M/P1Y), so the minimum + # threshold is the only reliable way to skip monthly auto-renewing subscriptions + if ($DaysUntilRenew -lt $DaysThreshold -and $DaysUntilRenew -gt 0 -and $DaysUntilRenew -ge $MinDaysThreshold) { $Message = if ($UnassignedOnly) { "$($_.License) has $UnassignedCount unassigned license(s) expiring in $DaysUntilRenew days. The estimated term is $($Term.Term)" diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertIntuneApprovalRequests.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertIntuneApprovalRequests.ps1 index 9978c2fb377a9..4c2fe0844787a 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertIntuneApprovalRequests.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertIntuneApprovalRequests.ps1 @@ -24,7 +24,7 @@ function Get-CIPPAlertIntuneApprovalRequests { # left null even for requests raised through Graph, so there is no 'who' to report. $Operation = ($ApprovalRequest.payloadOperation ?? 'change').ToLower() $Target = $ApprovalRequest.payloadName ?? (@($ApprovalRequest.requiredOperationApprovalPolicyTypes) -join ', ') - $Message = 'Intune {0} of "{1}" is waiting for multi-admin approval and expires {2}' -f $Operation, $Target, $ApprovalRequest.expirationDateTime + $Message = 'Intune {0} of "{1}" is waiting for multi-admin approval and expires {2}' -f $Operation, $Target, ([datetime]$ApprovalRequest.expirationDateTime).ToString('yyyy-MM-dd') $ApprovalRequest | Select-Object -Property id, status, requestDateTime, expirationDateTime, requestJustification, @{Name = 'operation'; Expression = { $ApprovalRequest.payloadOperation } }, diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertMXRecordChanged.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertMXRecordChanged.ps1 index 80a676b5ff37e..056928e9c2afa 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertMXRecordChanged.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertMXRecordChanged.ps1 @@ -23,6 +23,11 @@ function Get-CIPPAlertMXRecordChanged { $ChangedDomains = foreach ($Domain in $DomainData) { try { $PreviousDomain = $PreviousResults | Where-Object { $_.Domain -eq $Domain.Domain } + if (-not $PreviousDomain) { + Write-Information "Initializing MX record baseline for domain $($Domain.Domain): $($Domain.ActualMXRecords.Hostname -join ', ')" + continue + } + $PreviousRecords = if ($PreviousDomain.ActualMXRecords) { @($PreviousDomain.ActualMXRecords -split ',' | Sort-Object) } else { @() } $CurrentRecords = if ($Domain.ActualMXRecords.Hostname) { @($Domain.ActualMXRecords.Hostname | Sort-Object) } else { @() } diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNewAppApproval.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNewAppApproval.ps1 index ac6aa1bef28e5..b8e0252a39396 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNewAppApproval.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNewAppApproval.ps1 @@ -24,7 +24,11 @@ function Get-CIPPAlertNewAppApproval { $userConsentRequests = New-GraphGetRequest -Uri "https://graph.microsoft.com/v1.0/identityGovernance/appConsent/appConsentRequests/$($App.id)/userConsentRequests" -tenantid $TenantFilter $userConsentRequests | ForEach-Object { - if ($_.status -eq 'Expired') { + # Only alert on pending (InProgress) requests. The top-level appConsentRequests + # filter matches an app when ANY of its userConsentRequests is InProgress, but + # this per-app list returns ALL of them - including Completed, Denied and Expired + # - so without this guard already-resolved requests were being alerted on. + if ($_.status -ne 'InProgress') { return } $consentUrl = if ($App.consentType -eq 'Static') { diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertOneDriveLongPaths.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertOneDriveLongPaths.ps1 new file mode 100644 index 0000000000000..de865c1a9015f --- /dev/null +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertOneDriveLongPaths.ps1 @@ -0,0 +1,76 @@ +function Get-CIPPAlertOneDriveLongPaths { + <# + .FUNCTIONALITY + Entrypoint + .SYNOPSIS + Alert on OneDrive accounts with over-long path counts from the Report DB cache. + + .DESCRIPTION + Reads OneDriveLongPaths cache only (no live crawl). Requires a prior + ExecCIPPDBCache?Name=OneDriveLongPaths run. Alert text includes owner UPN and + counts only — never file or folder names. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $false)] + [Alias('input')] + $InputValue, + $TenantFilter + ) + + $HasSharePoint = Test-CIPPStandardLicense -StandardName 'OneDriveLongPaths' -TenantFilter $TenantFilter -Preset SharePoint + if (-not $HasSharePoint) { + return + } + + try { + $MinCount = 1 + if ($InputValue -is [hashtable] -or $InputValue -is [PSCustomObject]) { + $Raw = $InputValue.OneDriveLongPaths ?? $InputValue.MinCount ?? $InputValue + if ($null -ne $Raw -and "$Raw" -ne '') { + $Parsed = 0 + if ([int]::TryParse("$Raw", [ref]$Parsed) -and $Parsed -gt 0) { + $MinCount = $Parsed + } + } + } elseif ($null -ne $InputValue -and "$InputValue" -ne '') { + $Parsed = 0 + if ([int]::TryParse("$InputValue", [ref]$Parsed) -and $Parsed -gt 0) { + $MinCount = $Parsed + } + } + + $Rows = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'OneDriveLongPaths') + if (-not $Rows) { + return + } + + $AlertData = foreach ($Row in $Rows) { + $Upn = [string]($Row.ownerPrincipalName ?? $Row.id) + if ([string]::IsNullOrWhiteSpace($Upn)) { continue } + + $Count260 = 0 + $Count400 = 0 + if ($null -ne $Row.countOver260) { [void][int]::TryParse("$($Row.countOver260)", [ref]$Count260) } + if ($null -ne $Row.countOver400) { [void][int]::TryParse("$($Row.countOver400)", [ref]$Count400) } + + if ($Count260 -lt $MinCount) { continue } + + [PSCustomObject]@{ + Message = "${Upn}: $Count260 OneDrive paths may exceed Windows 260-character path limit when synced ($Count400 over cloud 400 limit)." + Id = $Upn + ownerPrincipalName = $Upn + countOver260 = $Count260 + countOver400 = $Count400 + Tenant = $TenantFilter + } + } + + if ($AlertData) { + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-AlertMessage -message "OneDrive long paths alert failed: $($ErrorMessage.NormalizedError)" -tenant $TenantFilter -LogData $ErrorMessage + } +} diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertPermanentActiveAdminAssigned.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertPermanentActiveAdminAssigned.ps1 new file mode 100644 index 0000000000000..6cad3c9cdaa81 --- /dev/null +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertPermanentActiveAdminAssigned.ps1 @@ -0,0 +1,94 @@ +function Get-CIPPAlertPermanentActiveAdminAssigned { + <# + .FUNCTIONALITY + Entrypoint + .SYNOPSIS + Alerts when a principal gains a permanent (no end date) active admin role assignment. + .DESCRIPTION + Compares the tenant's current permanent active role assignments with the set seen on the + previous run (DeltaCompare table) and alerts on new ones that are not in the approved + allow list. Entra ID P2 tenants are read through PIM (roleAssignmentScheduleInstances, so + time-bound and activated assignments are excluded); other tenants through unified RBAC, + where every assignment is permanent. + + Inputs: ApprovedAdmins - comma separated UPN prefixes, UPNs or display names that may hold + permanent assignments (break-glass accounts); PrivilegedRolesOnly - limit to CIPP's + privileged role list (default on). + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $false)] + [Alias('input')] + $InputValue, + $TenantFilter + ) + try { + $Approved = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $PrivilegedRolesOnly = $true + $ApprovedRaw = $null + if ($InputValue -is [System.Collections.IDictionary] -or $InputValue -is [pscustomobject]) { + $ApprovedRaw = $InputValue.ApprovedAdmins + if ($null -ne $InputValue.PrivilegedRolesOnly) { $PrivilegedRolesOnly = [bool]($InputValue.PrivilegedRolesOnly -eq $true -or "$($InputValue.PrivilegedRolesOnly)" -eq 'true') } + } elseif ($null -ne $InputValue) { + $ApprovedRaw = $InputValue + } + foreach ($Entry in @("$ApprovedRaw" -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ })) { $null = $Approved.Add($Entry) } + + $Rows = @(Get-CIPPPIMRoleAssignments -TenantFilter $TenantFilter | Where-Object { $_.AssignmentType -eq 'Permanent' -and $_.MemberType -ne 'Group' }) + if ($PrivilegedRolesOnly) { + $Rows = @($Rows | Where-Object { $_.IsPrivilegedRole }) + } + + $Current = @($Rows | ForEach-Object { + [PSCustomObject]@{ + Key = "$($_.PrincipalId)|$($_.RoleDefinitionId)|$($_.DirectoryScopeId)" + PrincipalId = $_.PrincipalId + DisplayName = $_.PrincipalDisplayName + UserPrincipalName = $_.PrincipalUserPrincipalName + PrincipalType = $_.PrincipalType + Role = $_.RoleDisplayName + Scope = $_.Scope + } + }) + + $DeltaTable = Get-CIPPTable -Table DeltaCompare + $Filter = "PartitionKey eq 'PermanentAdminDelta' and RowKey eq '{0}'" -f $TenantFilter + $Previous = (Get-CIPPAzDataTableEntity @DeltaTable -Filter $Filter).delta | ConvertFrom-Json -ErrorAction SilentlyContinue + $PreviousKeys = [System.Collections.Generic.HashSet[string]]::new([string[]]@($Previous.Key | Where-Object { $_ }), [System.StringComparer]::OrdinalIgnoreCase) + + Add-CIPPAzDataTableEntity @DeltaTable -Entity @{ + PartitionKey = 'PermanentAdminDelta' + RowKey = [string]$TenantFilter + delta = "$(ConvertTo-Json -InputObject @($Current) -Depth 5 -Compress)" + } -Force + + # First run only seeds the baseline; alerting on everything that already existed would be noise. + if ($null -eq $Previous) { return } + + $AlertData = foreach ($Item in $Current) { + if ($PreviousKeys.Contains($Item.Key)) { continue } + $Candidates = @($Item.PrincipalId, $Item.DisplayName, $Item.UserPrincipalName) + if ($Item.UserPrincipalName) { $Candidates += ($Item.UserPrincipalName -split '@')[0] } + $IsApproved = $false + foreach ($Candidate in $Candidates) { if ($Candidate -and $Approved.Contains("$Candidate")) { $IsApproved = $true; break } } + if ($IsApproved) { continue } + $Label = @($Item.UserPrincipalName, $Item.DisplayName, $Item.PrincipalId) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -First 1 + [PSCustomObject]@{ + Message = "$Label was given a permanent (no end date) active assignment to the $($Item.Role) role." + UserPrincipalName = $Item.UserPrincipalName + DisplayName = $Item.DisplayName + PrincipalType = $Item.PrincipalType + Role = $Item.Role + Scope = $Item.Scope + Tenant = $TenantFilter + } + } + + if ($AlertData) { + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Could not check permanent admin assignments for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + } +} diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertQuarantineReleaseRequests.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertQuarantineReleaseRequests.ps1 index e53db602ca355..b9607981ecdcd 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertQuarantineReleaseRequests.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertQuarantineReleaseRequests.ps1 @@ -18,11 +18,15 @@ } try { + # The received-date window has to be wide enough to catch a release request raised some time after + # the message was quarantined. The old 6-hour window missed most of them; a one-day window suits an + # hourly-scheduled alert. (The Quarantine page applies no received-date filter, which is why the + # request is visible there while no webhook or email is ever sent.) $cmdParams = @{ PageSize = 1000 ReleaseStatus = 'Requested' - StartReceivedDate = (Get-Date).AddHours(-6) - EndReceivedDate = (Get-Date).AddHours(0) + StartReceivedDate = (Get-Date).AddDays(-1) + EndReceivedDate = (Get-Date) } $RequestedReleases = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-QuarantineMessage' -cmdParams $cmdParams -ErrorAction Stop | Select-Object -ExcludeProperty *data.type* | Sort-Object -Property ReceivedTime diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertQuotaUsed.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertQuotaUsed.ps1 index 3d803dafa0d99..91691e65b6c6c 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertQuotaUsed.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertQuotaUsed.ps1 @@ -26,7 +26,8 @@ function Get-CIPPAlertQuotaUsed { } $OverQuota = $AlertData | ForEach-Object { - if (!$_.StorageUsedInBytes -or !$_.prohibitSendReceiveQuotaInBytes) { return } + if (!$_.storageUsedInBytes -or !$_.prohibitSendReceiveQuotaInBytes) { return } + if ("$($_.isDeleted)" -eq 'True') { return } if ($Excluded -contains $_.userPrincipalName.ToLower()) { return } # Report returns 'User'/'Shared' or 'UserMailbox'/'SharedMailbox' depending on tenant; normalize before matching if ($MailboxTypes.Count -gt 0 -and ($_.recipientType -replace 'Mailbox$') -notin $MailboxTypes) { return } diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertUnlicensedOneDriveData.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertUnlicensedOneDriveData.ps1 index aee0951583e30..751440ba7682f 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertUnlicensedOneDriveData.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertUnlicensedOneDriveData.ps1 @@ -128,17 +128,8 @@ function Get-CIPPAlertUnlicensedOneDriveData { } $StorageUsedGB = $null - $StorageBytes = 0.0 - $ParsedStorage = $false - if ($Row.PSObject.Properties.Name -contains 'StorageUsed.') { - $ParsedStorage = [double]::TryParse("$($Row.'StorageUsed.')", [ref]$StorageBytes) - } - if (-not $ParsedStorage -and $Row.StorageUsed) { - $UsedRaw = "$($Row.StorageUsed)" -replace '[^\d.]', '' - $ParsedStorage = [double]::TryParse($UsedRaw, [ref]$StorageBytes) - } - if ($ParsedStorage) { - $StorageUsedGB = [math]::Round($StorageBytes / 1GB, 2) + if ($null -ne $Row.StorageUsed) { + $StorageUsedGB = [math]::Round([double]$Row.StorageUsed / 1GB, 2) } $Title = [string]$Row.Title diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertVppTokenExpiry.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertVppTokenExpiry.ps1 index 775fa7cec263d..c2d228ad4b8bf 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertVppTokenExpiry.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertVppTokenExpiry.ps1 @@ -12,13 +12,23 @@ function Get-CIPPAlertVppTokenExpiry { ) try { try { + $expiryDays = 30 + if ($InputValue -is [hashtable] -or $InputValue -is [pscustomobject]) { + if ($null -ne $InputValue.DaysUntilExpiry -and $InputValue.DaysUntilExpiry -ne '') { + $parsedDays = 0 + if ([int]::TryParse($InputValue.DaysUntilExpiry.ToString(), [ref]$parsedDays) -and $parsedDays -gt 0) { + $expiryDays = $parsedDays + } + } + } + $VppTokens = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceAppManagement/vppTokens' -tenantid $TenantFilter $AlertData = foreach ($Vpp in $VppTokens) { if ($Vpp.state -ne 'valid') { $Message = 'Apple Volume Purchase Program Token is not valid, new token required' $Vpp | Select-Object -Property organizationName, appleId, vppTokenAccountType, @{Name = 'Message'; Expression = { $Message } }, @{Name = 'Tenant'; Expression = { $TenantFilter } } - } elseif ($Vpp.expirationDateTime -lt (Get-Date).AddDays(30).ToUniversalTime() -and $Vpp.expirationDateTime -gt (Get-Date).AddDays(-7).ToUniversalTime()) { - $Message = 'Apple Volume Purchase Program token expiring on {0}' -f $Vpp.expirationDateTime + } elseif ($Vpp.expirationDateTime -lt (Get-Date).AddDays($expiryDays).ToUniversalTime() -and $Vpp.expirationDateTime -gt (Get-Date).AddDays(-7).ToUniversalTime()) { + $Message = 'Apple Volume Purchase Program token expiring on {0}' -f ([datetime]$Vpp.expirationDateTime).ToString('yyyy-MM-dd') $Vpp | Select-Object -Property organizationName, appleId, vppTokenAccountType, @{Name = 'Message'; Expression = { $Message } }, @{Name = 'Tenant'; Expression = { $TenantFilter } } } } diff --git a/Modules/CIPPCore/Public/Add-CIPPApplicationPermission.ps1 b/Modules/CIPPCore/Public/Add-CIPPApplicationPermission.ps1 index 263a2a0fbfcd9..f8a0b90f97c8d 100644 --- a/Modules/CIPPCore/Public/Add-CIPPApplicationPermission.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPApplicationPermission.ps1 @@ -165,6 +165,11 @@ function Add-CIPPApplicationPermission { # App-only scopes changed; a cached client_credentials token still carries the old # roles, so drop it rather than wait out its TTL. $null = Clear-CippTokenCache -TenantFilter $TenantFilter + Write-LogMessage -API 'Add-CIPPApplicationPermission' -tenant $TenantFilter -message "Added $counter application permission(s) to $($ourSVCPrincipal.displayName)" -Sev 'Info' + } + $Failures = @($Results | Where-Object { $_ -match '^Failed to' }) + if ($Failures.Count -gt 0) { + Write-LogMessage -API 'Add-CIPPApplicationPermission' -tenant $TenantFilter -message "Failed during application permission update for $($ourSVCPrincipal.displayName): $($Failures.Count) error(s)" -Sev 'Warning' -LogData @{ Failures = $Failures } } "Added $counter Application permissions to $($ourSVCPrincipal.displayName)" return $Results diff --git a/Modules/CIPPCore/Public/Add-CIPPDbItem.ps1 b/Modules/CIPPCore/Public/Add-CIPPDbItem.ps1 index 963619ad3bbe0..597a479adb257 100644 --- a/Modules/CIPPCore/Public/Add-CIPPDbItem.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPDbItem.ps1 @@ -130,7 +130,11 @@ function Add-CIPPDbItem { $Filter += " and Timestamp lt datetime'{0}'" -f $RunStartUtc.UtcDateTime.ToString('yyyy-MM-ddTHH:mm:ss.fffffffZ') } - $Existing = Get-CIPPAzDataTableEntity @Table -Filter $Filter -Property PartitionKey, RowKey, ETag, OriginalEntityId, RunId + # Project all row-level split markers (OriginalEntityId, PartIndex, PartCount) so split + # entities reassemble; a subset makes the module drop them. Reassembly is what keeps this + # sound - each logical row carries its RunId. Raw rows (no markers) would be wrong: part + # rows lack RunId and would look like foreign-run orphans. + $Existing = Get-CIPPAzDataTableEntity @Table -Filter $Filter -Property PartitionKey, RowKey, ETag, OriginalEntityId, RunId, PartIndex, PartCount if ($Existing) { $Orphans = foreach ($Row in @($Existing)) { if ($Row.RowKey -eq "$Type-Count") { continue } diff --git a/Modules/CIPPCore/Public/Add-CIPPDelegatedPermission.ps1 b/Modules/CIPPCore/Public/Add-CIPPDelegatedPermission.ps1 index 8543acab6b84d..9f5fbf9459e1e 100644 --- a/Modules/CIPPCore/Public/Add-CIPPDelegatedPermission.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPDelegatedPermission.ps1 @@ -82,6 +82,7 @@ function Add-CIPPDelegatedPermission { } $CurrentDelegatedScopes = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals/$($ourSVCPrincipal.id)/oauth2PermissionGrants" -skipTokenCache $true -tenantid $TenantFilter -NoAuthCheck $true + $ChangedResources = [System.Collections.Generic.List[string]]::new() foreach ($App in $RequiredResourceAccess) { if (!$App) { @@ -134,6 +135,7 @@ function Add-CIPPDelegatedPermission { } | ConvertTo-Json -Compress $CreateRequest = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/v1.0/oauth2PermissionGrants' -tenantid $TenantFilter -body $Createbody -type POST -NoAuthCheck $true $Results.add("Successfully added permissions for $($svcPrincipalId.displayName)") + $ChangedResources.Add("$($svcPrincipalId.displayName) (added)") } catch { $Results.add("Failed to add permissions for $($svcPrincipalId.displayName): $(Get-NormalizedError -message $_.Exception.Message)") continue @@ -180,9 +182,20 @@ function Add-CIPPDelegatedPermission { # Added permissions $Added = ($Compare | Where-Object { $_.SideIndicator -eq '=>' }).InputObject -join ' ' $Removed = ($Compare | Where-Object { $_.SideIndicator -eq '<=' }).InputObject -join ' ' + $AddedCount = @(($Compare | Where-Object { $_.SideIndicator -eq '=>' })).Count + $RemovedCount = @(($Compare | Where-Object { $_.SideIndicator -eq '<=' })).Count $Results.add("Successfully updated permissions for $($svcPrincipalId.displayName). $(if ($Added) { "Added: $Added"}) $(if ($Removed) { "Removed: $Removed"})") + $ChangedResources.Add("$($svcPrincipalId.displayName) (updated: +$AddedCount/-$RemovedCount)") } } + if ($ChangedResources.Count -gt 0) { + Write-LogMessage -API 'Add-CIPPDelegatedPermission' -tenant $TenantFilter -message "Updated delegated permissions for $($ourSVCPrincipal.displayName): $($ChangedResources -join '; ')" -Sev 'Info' + } + $Failures = @($Results | Where-Object { $_ -match '^Failed to' }) + if ($Failures.Count -gt 0) { + Write-LogMessage -API 'Add-CIPPDelegatedPermission' -tenant $TenantFilter -message "Failed during delegated permission update for $($ourSVCPrincipal.displayName): $($Failures.Count) error(s)" -Sev 'Warning' -LogData @{ Failures = $Failures } + } + return $Results } diff --git a/Modules/CIPPCore/Public/Add-CIPPGDAPRoleTemplate.ps1 b/Modules/CIPPCore/Public/Add-CIPPGDAPRoleTemplate.ps1 index 4c01b85cb78c8..c8b9cad39c180 100644 --- a/Modules/CIPPCore/Public/Add-CIPPGDAPRoleTemplate.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPGDAPRoleTemplate.ps1 @@ -10,38 +10,53 @@ function Add-CIPPGDAPRoleTemplate { param( $TemplateId, $RoleMappings, - [switch]$Overwrite + [switch]$Overwrite, + $Headers, + $APIName = 'AddGDAPRoleTemplate' ) - $Table = Get-CIPPTable -TableName 'GDAPRoleTemplates' - $Templates = Get-CIPPAzDataTableEntity @Table - if ($Templates.RowKey -contains $TemplateId -and !$Overwrite.IsPresent) { - $ExistingTemplate = $Templates | Where-Object -Property RowKey -EQ $RowKey - try { - $ExistingRoleMappings = $ExistingTemplate.RoleMappings | ConvertFrom-Json - } catch { - $ExistingRoleMappings = @() - } - $NewRoleMappings = [System.Collections.Generic.List[object]]@() + try { + $Table = Get-CIPPTable -TableName 'GDAPRoleTemplates' + $Templates = Get-CIPPAzDataTableEntity @Table + if ($Templates.RowKey -contains $TemplateId -and !$Overwrite.IsPresent) { + $ExistingTemplate = $Templates | Where-Object -Property RowKey -EQ $TemplateId + try { + $ExistingRoleMappings = $ExistingTemplate.RoleMappings | ConvertFrom-Json + } catch { + $ExistingRoleMappings = @() + } + $NewRoleMappings = [System.Collections.Generic.List[object]]@() - $ExistingRoleMappings | ForEach-Object { - $NewRoleMappings.Add($_) - } - # Merge the new role mappings with the existing role mappings, exclude ones that have a duplicate roleDefinitionId - $RoleMappings | ForEach-Object { - if ($_.roleDefinitionId -notin $ExistingRoleMappings.roleDefinitionId) { + $ExistingRoleMappings | ForEach-Object { $NewRoleMappings.Add($_) } + # Merge the new role mappings with the existing role mappings, exclude ones that have a duplicate roleDefinitionId + $RoleMappings | ForEach-Object { + if ($_.roleDefinitionId -notin $ExistingRoleMappings.roleDefinitionId) { + $NewRoleMappings.Add($_) + } + } + $NewRoleMappings = @($NewRoleMappings | Sort-Object -Property GroupName) | ConvertTo-Json -Compress + $ExistingTemplate.RoleMappings = [string]$NewRoleMappings + $Template = $ExistingTemplate + Add-CIPPAzDataTableEntity @Table -Entity $Template -Force + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "Updated GDAP role template '$TemplateId'" -Sev 'Info' + } else { + $Template = [PSCustomObject]@{ + PartitionKey = 'RoleTemplate' + RowKey = $TemplateId + RoleMappings = [string](@($RoleMappings | Sort-Object -Property GroupName) | ConvertTo-Json -Compress) + } + Add-CIPPAzDataTableEntity @Table -Entity $Template -Force + if ($Overwrite.IsPresent) { + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "Overwrote GDAP role template '$TemplateId'" -Sev 'Info' + } else { + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "Created GDAP role template '$TemplateId'" -Sev 'Info' + } } - $NewRoleMappings = @($NewRoleMappings | Sort-Object -Property GroupName) | ConvertTo-Json -Compress - $ExistingTemplate.RoleMappings = [string]$NewRoleMappings - $Template = $ExistingTemplate - } else { - $Template = [PSCustomObject]@{ - PartitionKey = 'RoleTemplate' - RowKey = $TemplateId - RoleMappings = [string](@($RoleMappings | Sort-Object -Property GroupName) | ConvertTo-Json -Compress) - } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "Failed to save GDAP role template '$TemplateId': $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + throw } - Add-CIPPAzDataTableEntity @Table -Entity $Template -Force } diff --git a/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 b/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 index 1eaaa5f840663..f94ec6b7675e7 100644 --- a/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 @@ -38,7 +38,8 @@ function Add-CIPPScheduledTask { $ExistingTask.TaskState = 'Planned' Add-CIPPAzDataTableEntity @Table -Entity $ExistingTask -Force Write-LogMessage -headers $Headers -API 'RunNow' -message "Task $($ExistingTask.Name) scheduled to run now" -Sev 'Info' -Tenant $ExistingTask.Tenant - Add-CippQueueMessage -Cmdlet 'Start-UserTasksOrchestrator' -Parameters @{ + # Add-CippQueueMessage returns $true; without discarding it the caller's Results array shows a bare 'true' + $null = Add-CippQueueMessage -Cmdlet 'Start-UserTasksOrchestrator' -Parameters @{ TaskId = $RowKey } return "Task $($ExistingTask.Name) scheduled to run now" @@ -58,7 +59,7 @@ function Add-CIPPScheduledTask { $Filter = "PartitionKey eq 'ScheduledTask' and Name eq '$($Task.Name)' and TaskState ne 'Completed' and TaskState ne 'Failed'" $ExistingTask = (Get-CIPPAzDataTableEntity @Table -Filter $Filter) if ($ExistingTask) { - return "Task with name $($Task.Name) already exists" + return "Error - A scheduled task named '$($Task.Name)' already exists and was not created again." } } @@ -147,7 +148,6 @@ function Add-CIPPScheduledTask { $Parameters.Headers = $Headers | Select-Object -Property 'x-forwarded-for', 'x-ms-client-principal', 'x-ms-client-principal-idp', 'x-ms-client-principal-name' } - $Parameters = ($Parameters | ConvertTo-Json -Depth 10 -Compress) $AdditionalProperties = [System.Collections.Hashtable]@{} foreach ($Prop in $task.AdditionalProperties) { if ($null -eq $Prop.Value -or $Prop.Value -eq '' -or ($Prop.Value | Measure-Object).Count -eq 0) { @@ -156,7 +156,6 @@ function Add-CIPPScheduledTask { $AdditionalProperties[$Prop.Key] = $Prop.Value } $AdditionalProperties = ([PSCustomObject]$AdditionalProperties | ConvertTo-Json -Compress) - if ($Parameters -eq 'null') { $Parameters = '' } $Recurrence = if ([string]::IsNullOrEmpty($task.Recurrence.value)) { @@ -223,6 +222,22 @@ function Add-CIPPScheduledTask { } } + # Stored parameters are user input: strip any tenant-identifying parameter so the + # authorized task tenant is injected at execution instead of a stored value, and log + # when the stored value pointed somewhere other than the picked tenant. + foreach ($TenantParamName in @('TenantFilter', 'Tenant', 'TenantId')) { + if (-not $Parameters.ContainsKey($TenantParamName)) { continue } + $StoredTenantValue = $Parameters[$TenantParamName] + $StoredTenantString = [string]($StoredTenantValue.value ?? $StoredTenantValue) + if (![string]::IsNullOrWhiteSpace($StoredTenantString) -and $StoredTenantString -ne [string]$tenantFilter) { + Write-LogMessage -headers $Headers -API 'ScheduledTask' -message "Task $($task.Name): parameter -$TenantParamName value '$StoredTenantString' does not match the selected tenant '$tenantFilter' and was removed; the task runs against the selected tenant." -Sev 'Error' -Tenant $tenantFilter + } + $Parameters.Remove($TenantParamName) + } + + $Parameters = ($Parameters | ConvertTo-Json -Depth 10 -Compress) + if ($Parameters -eq 'null') { $Parameters = '' } + $entity = @{ PartitionKey = [string]'ScheduledTask' TaskState = [string]'Planned' @@ -243,6 +258,8 @@ function Add-CIPPScheduledTask { AlertComment = [string]$task.AlertComment CustomSubject = [string]$task.CustomSubject PsaTicketStrategy = [string]($task.PsaTicketStrategy.value ?? $task.PsaTicketStrategy) + PsaTicketPriority = [string]($task.PsaTicketPriority.value ?? $task.PsaTicketPriority) + PsaTicketId = [string]($task.PsaTicketId.value ?? $task.PsaTicketId) } @@ -278,6 +295,13 @@ function Add-CIPPScheduledTask { } } + # Stored verbatim so the orchestrator expands groups at run time. The version marker tells + # it excludedTenants holds only the operator's picks, not a snapshot of unselected tenants. + if ($task.Tenants) { + $entity['Tenants'] = $task.Tenants -is [string] ? [string]$task.Tenants : [string]($task.Tenants | ConvertTo-Json -Compress -Depth 10) + $entity['TenantSelectionVersion'] = 2 + } + if ($task.Trigger) { $entity.Trigger = [string]($task.Trigger | ConvertTo-Json -Compress) $TriggerType = $task.Trigger.Type.value ?? $task.Trigger.Type @@ -346,7 +370,8 @@ function Add-CIPPScheduledTask { } if ($RunNow.IsPresent) { - Add-CippQueueMessage -Cmdlet 'Start-UserTasksOrchestrator' -Parameters @{ + # Add-CippQueueMessage returns $true; without discarding it the caller's Results array shows a bare 'true' + $null = Add-CippQueueMessage -Cmdlet 'Start-UserTasksOrchestrator' -Parameters @{ TaskId = $RowKey } return "Task $($entity.Name) scheduled to run now" diff --git a/Modules/CIPPCore/Public/AsyncDeployment/Add-CIPPAsyncDeploymentStep.ps1 b/Modules/CIPPCore/Public/AsyncDeployment/Add-CIPPAsyncDeploymentStep.ps1 new file mode 100644 index 0000000000000..08ff6f00ff9ea --- /dev/null +++ b/Modules/CIPPCore/Public/AsyncDeployment/Add-CIPPAsyncDeploymentStep.ps1 @@ -0,0 +1,66 @@ +function Add-CIPPAsyncDeploymentStep { + <# + .SYNOPSIS + Append a step to an async deployment row + + .DESCRIPTION + Adds a step, with its final status, to the end of a CacheAsyncDeployments row created by + New-CIPPAsyncDeployment. Used for work that only exists once the job has finished, such as the + post-execution notifications of an offboarding. Meant to be called after the parallel step + workers are done; failures to persist are swallowed so reporting never breaks the job. + + .PARAMETER JobId + The deployment job id + + .PARAMETER Name + The row name (the user for offboarding, the tenant for tenant-keyed jobs) + + .PARAMETER Title + Step title shown to the user + + .PARAMETER StepStatus + pending, running, succeeded or failed + + .PARAMETER Message + Progress message shown under the step title + + .PARAMETER Kind + What kind of step this is, e.g. 'notify'. The UI offers a step re-run only for plain task steps. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$JobId, + + [Parameter(Mandatory = $true)] + [string]$Name, + + [Parameter(Mandatory = $true)] + [string]$Title, + + [ValidateSet('pending', 'running', 'succeeded', 'failed')] + [string]$StepStatus = 'succeeded', + + [string]$Message = '', + + [string]$Kind = '' + ) + + try { + if ($Message.Length -gt 2000) { $Message = $Message.Substring(0, 2000) + '...' } + $Table = Get-CIPPTable -TableName 'CacheAsyncDeployments' + $SafeJobId = $JobId -replace "'", "''" + $SafeName = $Name -replace "'", "''" + $Row = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq '$SafeJobId' and RowKey eq '$SafeName'" + if (-not $Row) { return } + + $Steps = @( + @($Row.Steps | ConvertFrom-Json) + [pscustomobject]@{ Title = $Title; Status = $StepStatus; Message = $Message; Kind = $Kind } + ) + $Row.Steps = [string](ConvertTo-Json -InputObject @($Steps) -Compress -Depth 5) + Update-CIPPAzDataTableEntity @Table -Entity $Row -Force + } catch { + Write-Verbose "Failed to append async deployment step: $($_.Exception.Message)" + } +} diff --git a/Modules/CIPPCore/Public/AsyncDeployment/Get-CIPPAsyncDeployment.ps1 b/Modules/CIPPCore/Public/AsyncDeployment/Get-CIPPAsyncDeployment.ps1 index d91caacbe8a52..4162a966d2d4e 100644 --- a/Modules/CIPPCore/Public/AsyncDeployment/Get-CIPPAsyncDeployment.ps1 +++ b/Modules/CIPPCore/Public/AsyncDeployment/Get-CIPPAsyncDeployment.ps1 @@ -26,6 +26,9 @@ function Get-CIPPAsyncDeployment { Name = $_.RowKey Source = $_.Source Status = $_.Status + TaskId = $_.TaskId + # Offboarding rows are users, so the tenant rides alongside; tenant-keyed jobs leave it empty + TenantFilter = $_.TenantFilter Steps = @($_.Steps | ConvertFrom-Json) Logs = $_.Logs } diff --git a/Modules/CIPPCore/Public/AsyncDeployment/New-CIPPAsyncDeployment.ps1 b/Modules/CIPPCore/Public/AsyncDeployment/New-CIPPAsyncDeployment.ps1 index 68eb4eb19bfe1..e59d666c5614b 100644 --- a/Modules/CIPPCore/Public/AsyncDeployment/New-CIPPAsyncDeployment.ps1 +++ b/Modules/CIPPCore/Public/AsyncDeployment/New-CIPPAsyncDeployment.ps1 @@ -17,10 +17,21 @@ function New-CIPPAsyncDeployment { One row is created per name — typically the target tenants. .PARAMETER StepTitles - Ordered step titles shown to the user (e.g. one per site template) + Ordered steps shown to the user (e.g. one per site template): a title string, or an object with + Title plus an optional Kind (e.g. 'notify', which the UI does not offer for step re-run) and an + initial Message. Optional, so a row can be created as soon as work is queued and given its steps + later by calling this again with the same JobId and Name. .PARAMETER Source Which feature created this job (e.g. SharePointTemplate) + + .PARAMETER TenantFilter + The tenant the rows belong to, when the names are not tenants themselves (offboarding rows are + users). Stored on the row so restricted callers only see rows for tenants in their scope. + + .PARAMETER TaskId + The ScheduledTasks RowKey behind the row(s), when the work runs as a scheduled task. Stored on + the row so the UI can offer a re-run through the scheduler. #> [CmdletBinding()] param( @@ -29,19 +40,31 @@ function New-CIPPAsyncDeployment { [Parameter(Mandatory = $true)] [string[]]$Names, - [Parameter(Mandatory = $true)] - [string[]]$StepTitles, + [object[]]$StepTitles = @(), + + [string]$Source = 'CIPP', + + [string]$TaskId, - [string]$Source = 'CIPP' + [string]$TenantFilter ) $Table = Get-CIPPTable -TableName 'CacheAsyncDeployments' $InitialSteps = [string](ConvertTo-Json -Compress -Depth 5 -InputObject @( $StepTitles | ForEach-Object { - @{ - Title = [string]$_ - Status = 'pending' - Message = 'Waiting for deployment to start' + if ($_ -is [string]) { + @{ + Title = [string]$_ + Status = 'pending' + Message = 'Waiting to start' + } + } else { + @{ + Title = [string]$_.Title + Status = 'pending' + Message = [string]($_.Message ?? 'Waiting to start') + Kind = [string]$_.Kind + } } } )) @@ -53,6 +76,8 @@ function New-CIPPAsyncDeployment { Source = [string]$Source Status = 'queued' Steps = $InitialSteps + TaskId = [string]$TaskId + TenantFilter = [string]$TenantFilter Logs = '' } -Force } diff --git a/Modules/CIPPCore/Public/AsyncDeployment/Set-CIPPAsyncDeploymentStep.ps1 b/Modules/CIPPCore/Public/AsyncDeployment/Set-CIPPAsyncDeploymentStep.ps1 index 84943120f71c4..b95305fac8ea5 100644 --- a/Modules/CIPPCore/Public/AsyncDeployment/Set-CIPPAsyncDeploymentStep.ps1 +++ b/Modules/CIPPCore/Public/AsyncDeployment/Set-CIPPAsyncDeploymentStep.ps1 @@ -5,8 +5,9 @@ function Set-CIPPAsyncDeploymentStep { .DESCRIPTION Sets the status and message of a single step on a CacheAsyncDeployments row created by - New-CIPPAsyncDeployment. Safe to call from queue workers; failures to persist are - swallowed so status reporting never breaks the actual deployment. + New-CIPPAsyncDeployment. Safe to call from queue workers, including several at once for + different steps of the same row; failures to persist are swallowed so status reporting + never breaks the actual work. .PARAMETER JobId The deployment job id @@ -41,20 +42,31 @@ function Set-CIPPAsyncDeploymentStep { [string]$Message = '' ) - try { - $Table = Get-CIPPTable -TableName 'CacheAsyncDeployments' - $SafeJobId = $JobId -replace "'", "''" - $SafeName = $Name -replace "'", "''" - $Row = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq '$SafeJobId' and RowKey eq '$SafeName'" - if (-not $Row) { return } - - $Steps = @($Row.Steps | ConvertFrom-Json) - if ($StepIndex -lt 0 -or $StepIndex -ge $Steps.Count) { return } - $Steps[$StepIndex].Status = $StepStatus - $Steps[$StepIndex].Message = $Message - $Row.Steps = [string](ConvertTo-Json -InputObject @($Steps) -Compress -Depth 5) - Add-CIPPAzDataTableEntity @Table -Entity $Row -Force - } catch { - Write-Verbose "Failed to update async deployment step: $($_.Exception.Message)" + # Keep the row well inside the 64 KB property limit: a cmdlet that lists hundreds of groups + # would otherwise make the whole write fail and freeze the progress view. + if ($Message.Length -gt 2000) { $Message = $Message.Substring(0, 2000) + '...' } + + # All steps of a row live in one JSON property, and steps can run on different workers at the + # same time. The write is ETag-checked (no -Force) so a step finishing between our read and + # write is not overwritten; a rejected write re-reads the row and tries again. + for ($Attempt = 1; $Attempt -le 5; $Attempt++) { + try { + $Table = Get-CIPPTable -TableName 'CacheAsyncDeployments' + $SafeJobId = $JobId -replace "'", "''" + $SafeName = $Name -replace "'", "''" + $Row = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq '$SafeJobId' and RowKey eq '$SafeName'" + if (-not $Row) { return } + + $Steps = @($Row.Steps | ConvertFrom-Json) + if ($StepIndex -lt 0 -or $StepIndex -ge $Steps.Count) { return } + $Steps[$StepIndex].Status = $StepStatus + $Steps[$StepIndex].Message = $Message + $Row.Steps = [string](ConvertTo-Json -InputObject @($Steps) -Compress -Depth 5) + Update-CIPPAzDataTableEntity @Table -Entity $Row + return + } catch { + Write-Verbose "Failed to update async deployment step (attempt $Attempt): $($_.Exception.Message)" + Start-Sleep -Milliseconds (Get-Random -Minimum 50 -Maximum 250) + } } } diff --git a/Modules/CIPPCore/Public/Authentication/Get-CIPPHttpFunctions.ps1 b/Modules/CIPPCore/Public/Authentication/Get-CIPPHttpFunctions.ps1 index 6074f4c09bd01..e68eeb19fee48 100644 --- a/Modules/CIPPCore/Public/Authentication/Get-CIPPHttpFunctions.ps1 +++ b/Modules/CIPPCore/Public/Authentication/Get-CIPPHttpFunctions.ps1 @@ -68,6 +68,7 @@ function Get-CIPPHttpFunctions { } $Results } catch { - "Function Error $($_.Exception.Message): $($_.InvocationInfo.PositionMessage)" + # A failed enumeration must fail, or the caller caches the error text as the universe. + throw "Failed to enumerate HTTP function permissions: $($_.Exception.Message) $($_.InvocationInfo.PositionMessage)" } } diff --git a/Modules/CIPPCore/Public/Authentication/Get-CippApiClient.ps1 b/Modules/CIPPCore/Public/Authentication/Get-CippApiClient.ps1 index 0bdd119a85753..ca0d3399e4723 100644 --- a/Modules/CIPPCore/Public/Authentication/Get-CippApiClient.ps1 +++ b/Modules/CIPPCore/Public/Authentication/Get-CippApiClient.ps1 @@ -29,8 +29,11 @@ function Get-CippApiClient { if ($Client.IPRange) { try { $IPRange = @($Client.IPRange | ConvertFrom-Json -ErrorAction Stop) - if (($IPRange | Measure-Object).Count -eq 0) { @('Any') } - $Client.IPRange = $IPRange + if (($IPRange | Measure-Object).Count -eq 0) { + $Client.IPRange = @('Any') + } else { + $Client.IPRange = $IPRange + } } catch { $Client.IPRange = @('Any') } diff --git a/Modules/CIPPCore/Public/Authentication/Get-CippHttpPermissions.ps1 b/Modules/CIPPCore/Public/Authentication/Get-CippHttpPermissions.ps1 index 599ce78189de4..6b5f573d5aa42 100644 --- a/Modules/CIPPCore/Public/Authentication/Get-CippHttpPermissions.ps1 +++ b/Modules/CIPPCore/Public/Authentication/Get-CippHttpPermissions.ps1 @@ -31,20 +31,35 @@ function Get-CippHttpPermissions { $AllPermissionCacheTable = Get-CIPPTable -tablename 'cachehttppermissions' $AllPermissionsRow = Get-CIPPAzDataTableEntity @AllPermissionCacheTable -Filter "PartitionKey eq 'HttpFunctions' and RowKey eq 'HttpFunctions' and Version eq '$($Version)'" - if (-not $AllPermissionsRow.Permissions) { - $AllPermissions = Get-CIPPHttpFunctions -ByRole | Select-Object -ExpandProperty Permission - $Entity = @{ - PartitionKey = 'HttpFunctions' - RowKey = 'HttpFunctions' - Version = [string]$Version - Permissions = [string]($AllPermissions | ConvertTo-Json -Compress) - } - Add-CIPPAzDataTableEntity @AllPermissionCacheTable -Entity $Entity -Force + # A universe written by an out-of-memory worker is short or garbage and was never recomputed; validate on read and write. + $Cached = if ($AllPermissionsRow.Permissions) { + try { @($AllPermissionsRow.Permissions | ConvertFrom-Json -ErrorAction Stop) } catch { @() } + } else { @() } + + if (Test-CippHttpPermissionUniverse -Permissions $Cached) { + $AllPermissions = $Cached } else { - $AllPermissions = $AllPermissionsRow.Permissions | ConvertFrom-Json + if ($AllPermissionsRow.Permissions) { + Write-Warning "The cached HTTP permission universe for version $Version is not usable ($($Cached.Count) entries); recomputing it." + } + $AllPermissions = @(Get-CIPPHttpFunctions -ByRole | Select-Object -ExpandProperty Permission) + if (Test-CippHttpPermissionUniverse -Permissions $AllPermissions) { + $Entity = @{ + PartitionKey = 'HttpFunctions' + RowKey = 'HttpFunctions' + Version = [string]$Version + Permissions = [string]($AllPermissions | ConvertTo-Json -Compress) + } + Add-CIPPAzDataTableEntity @AllPermissionCacheTable -Entity $Entity -Force + } else { + # Serve it uncached so the next request retries. + Write-Warning "HTTP permission enumeration returned $($AllPermissions.Count) entries, which is not a complete universe; not caching it." + return @($AllPermissions) + } } $script:CippHttpPermissions = @($AllPermissions) $script:CippHttpPermissionsVersion = $Version return $script:CippHttpPermissions } + diff --git a/Modules/CIPPCore/Public/Authentication/Set-CIPPAccessRole.ps1 b/Modules/CIPPCore/Public/Authentication/Set-CIPPAccessRole.ps1 index 623a318b472c8..2d539283a7c67 100644 --- a/Modules/CIPPCore/Public/Authentication/Set-CIPPAccessRole.ps1 +++ b/Modules/CIPPCore/Public/Authentication/Set-CIPPAccessRole.ps1 @@ -22,7 +22,9 @@ function Set-CIPPAccessRole { [Parameter(Mandatory = $true)] [string]$Role, [Parameter(Mandatory = $true)] - $Group + $Group, + $Headers, + $APIName = 'Set-CIPPAccessRole' ) $BlacklistedRoles = @('authenticated', 'anonymous') @@ -56,5 +58,7 @@ function Set-CIPPAccessRole { Clear-CippAccessUserCache try { Start-UserSyncTimer } catch {} try { [Craft.Services.AuthBridge]::InvalidateUsers() } catch {} + + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "Mapped Entra group '$($Group.displayName)' to CIPP access role '$Role'" -Sev 'Info' } } diff --git a/Modules/CIPPCore/Public/Authentication/Test-CIPPAccess.ps1 b/Modules/CIPPCore/Public/Authentication/Test-CIPPAccess.ps1 index 890251be393d2..876542e4f229c 100644 --- a/Modules/CIPPCore/Public/Authentication/Test-CIPPAccess.ps1 +++ b/Modules/CIPPCore/Public/Authentication/Test-CIPPAccess.ps1 @@ -272,6 +272,13 @@ function Test-CIPPAccess { if ($env:cipp_hosted_failed_payments) { $MeResponse['hostedFailedPayments'] = $true } + # CyberDrain-hosted instance (CIPP_HOSTED is set by the hosted deployment templates). + # Lets the frontend point at the management portal for anything the instance's own + # identity cannot do, such as custom domains on the shared App Service plan. + $MeResponse['hosted'] = $env:CIPP_HOSTED -eq 'true' + # CIPP-NG (container web app on an App Service plan) versus a legacy function app plus + # static web app - the backend page shows different resources for each. + $MeResponse['ng'] = $env:CIPPNG -eq 'true' $CanManageAppSettings = $Permissions -contains 'CIPP.AppSettings.ReadWrite' $HasAnyPermission = ($Permissions | Measure-Object).Count -gt 0 @@ -469,78 +476,49 @@ function Test-CIPPAccess { if ($PermissionsFound) { # Tenant list and group list requests have already returned above, from the # cached scope rules. Everything from here is the per-endpoint access decision. + # Resolve the target from the request only. Do not fall back to $env:TenantID — + # that is the partner/home tenant, not a customer. Missing/unmapped filters are + # unresolved: Test-CippRoleTenantScope returns $true (block fail-closed / allow quirk). + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter.value ?? $Request.Body.tenantFilter ?? $Request.Query.tenantId ?? $Request.Body.tenantId.value ?? $Request.Body.tenantId $TenantAllowed = $false $APIAllowed = $false $swPermissionEval = [System.Diagnostics.Stopwatch]::StartNew() + + # Block pass: deny wins, but only when the blocking role also grants the + # permission and its tenant scope covers the target. Test-CippRoleTenantScope + # returns $true for missing/unmapped tenants — here that means fail closed (apply block). foreach ($Role in $PermissionSet) { + $RoleGrantsPermission = $false foreach ($Perm in $Role.Permissions) { if ($Perm -match $APIRole) { - if ($Role.BlockedEndpoints -contains $Request.Params.CIPPEndpoint) { - throw "Access to this CIPP API endpoint is not allowed, the custom role '$($Role.Role)' has blocked this endpoint: $($Request.Params.CIPPEndpoint)" - } - $APIAllowed = $true + $RoleGrantsPermission = $true break } } + if (-not $RoleGrantsPermission) { continue } + if ($Role.BlockedEndpoints -notcontains $Request.Params.CIPPEndpoint) { continue } - if ($APIAllowed) { - $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter.value ?? $Request.Body.tenantFilter ?? $Request.Query.tenantId ?? $Request.Body.tenantId.value ?? $Request.Body.tenantId ?? $env:TenantID - # Check tenant level access - if (($Role.BlockedTenants | Measure-Object).Count -eq 0 -and $Role.AllowedTenants -contains 'AllTenants') { - $TenantAllowed = $true - } elseif ($TenantFilter -eq 'AllTenants' -and $ApiRole -match 'Write$') { - $TenantAllowed = $false - } elseif ($TenantFilter -eq 'AllTenants' -and $ApiRole -match 'Read$') { - $TenantAllowed = $true - } else { - $Tenant = ($Tenants | Where-Object { $TenantFilter -eq $_.customerId -or $TenantFilter -eq $_.defaultDomainName }).customerId - - # Expand allowed tenant groups to individual tenant IDs - $ExpandedAllowedTenants = foreach ($AllowedItem in $Role.AllowedTenants) { - if ($AllowedItem -is [PSCustomObject] -and $AllowedItem.type -eq 'Group') { - try { - $GroupMembers = Expand-CIPPTenantGroups -TenantFilter @($AllowedItem) - $GroupMembers | ForEach-Object { $_.addedFields.customerId } - } catch { - Write-Warning "Failed to expand allowed tenant group '$($AllowedItem.label)': $($_.Exception.Message)" - @() - } - } else { - $AllowedItem - } - } - - # Expand blocked tenant groups to individual tenant IDs - $ExpandedBlockedTenants = foreach ($BlockedItem in $Role.BlockedTenants) { - if ($BlockedItem -is [PSCustomObject] -and $BlockedItem.type -eq 'Group') { - try { - $GroupMembers = Expand-CIPPTenantGroups -TenantFilter @($BlockedItem) - $GroupMembers | ForEach-Object { $_.addedFields.customerId } - } catch { - Write-Warning "Failed to expand blocked tenant group '$($BlockedItem.label)': $($_.Exception.Message)" - @() - } - } else { - $BlockedItem - } - } - - if ($ExpandedAllowedTenants -contains 'AllTenants') { - $AllowedTenants = $Tenants.customerId - } else { - $AllowedTenants = $ExpandedAllowedTenants - } + $BlockInScope = Test-CippRoleTenantScope -Role $Role -TenantFilter $TenantFilter -Tenants $Tenants -Request $Request -ApiRole $APIRole + if ($BlockInScope) { + throw "Access to this CIPP API endpoint is not allowed, the custom role '$($Role.Role)' has blocked this endpoint: $($Request.Params.CIPPEndpoint)" + } + } - if ($Tenant) { - $TenantAllowed = $AllowedTenants -contains $Tenant -and $ExpandedBlockedTenants -notcontains $Tenant - if (!$TenantAllowed) { continue } - break - } else { - $TenantAllowed = $true - break - } + # Allow pass: sticky $APIAllowed preserved (permission from one role + tenant + # from another can still succeed). BlockedEndpoints already handled above. + foreach ($Role in $PermissionSet) { + foreach ($Perm in $Role.Permissions) { + if ($Perm -match $APIRole) { + $APIAllowed = $true + break } } + + if ($APIAllowed) { + $TenantAllowed = Test-CippRoleTenantScope -Role $Role -TenantFilter $TenantFilter -Tenants $Tenants -Request $Request -ApiRole $APIRole + if (!$TenantAllowed) { continue } + break + } } $swPermissionEval.Stop() $AccessTimings['EvaluatePermissions'] = $swPermissionEval.Elapsed.TotalMilliseconds diff --git a/Modules/CIPPCore/Public/Authentication/Test-CippHttpPermissionUniverse.ps1 b/Modules/CIPPCore/Public/Authentication/Test-CippHttpPermissionUniverse.ps1 new file mode 100644 index 0000000000000..ec3d122c618cc --- /dev/null +++ b/Modules/CIPPCore/Public/Authentication/Test-CippHttpPermissionUniverse.ps1 @@ -0,0 +1,27 @@ +function Test-CippHttpPermissionUniverse { + <# + .SYNOPSIS + Decides whether a list of permission names can be the full HTTP permission universe. + .DESCRIPTION + The universe is every distinct .ROLE across the HTTP entrypoints - well over a hundred + names shaped Area.Object.Read/ReadWrite, always including the core read permission the + dashboard needs. A list that is short, contains non-permission text, or lacks the core + permission came from a failed or truncated enumeration. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [AllowNull()] + [AllowEmptyCollection()] + [object[]]$Permissions + ) + + $Names = @($Permissions | ForEach-Object { [string]$_ } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + if ($Names.Count -lt 50) { return $false } + if ($Names -notcontains 'CIPP.Core.Read') { return $false } + foreach ($Name in $Names) { + if ($Name -ne 'None' -and $Name -notmatch '^[A-Za-z0-9]+\.[A-Za-z0-9]+\.[A-Za-z]+$') { return $false } + } + return $true +} diff --git a/Modules/CIPPCore/Public/Authentication/Test-CippRoleTenantScope.ps1 b/Modules/CIPPCore/Public/Authentication/Test-CippRoleTenantScope.ps1 new file mode 100644 index 0000000000000..c72d3abe1ca41 --- /dev/null +++ b/Modules/CIPPCore/Public/Authentication/Test-CippRoleTenantScope.ps1 @@ -0,0 +1,132 @@ +function Test-CippRoleTenantScope { + <# + .SYNOPSIS + Whether a custom role's tenant scope covers the request's target tenant (or group). + + .DESCRIPTION + Extracted from the per-endpoint allow path in Test-CIPPAccess. Same rules: + AllTenants with no blocked list, AllTenants Write/Read request special-cases, + group-shaped body authorized by group identity (no member expand), then + allowed-minus-blocked after expanding tenant groups. + + Unknown / missing / unmapped tenant filters return $true. Callers interpret that + differently: the allow path treats it as allow (legacy quirk); the + BlockedEndpoints pass treats it as in-scope so the deny still applies + (fail closed). Do not fall back to $env:TenantID — that is the partner + home tenant, not a customer. Do not "align" those call sites without an + explicit decision. + + .PARAMETER Role + Role permission object from Get-CIPPRolePermissions (AllowedTenants, BlockedTenants, ...). + + .PARAMETER TenantFilter + Resolved tenant filter string (customerId, domain, AllTenants, or group value when body is Group-shaped). + + .PARAMETER Tenants + Tenant list from Get-Tenants -IncludeErrors (used to resolve filter and expand AllTenants). + + .PARAMETER Request + HTTP request; Body.tenantFilter.type -eq 'Group' selects group-identity authorization. + + .PARAMETER ApiRole + Endpoint permission string; used for AllTenants Write$ / Read$ branches. + + .OUTPUTS + [bool] $true if the role's scope covers the target. + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + $Role, + + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [AllowNull()] + $TenantFilter, + + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [AllowNull()] + $Tenants, + + [Parameter(Mandatory = $true)] + $Request, + + [Parameter(Mandatory = $true)] + [string]$ApiRole + ) + + $Tenants = @($Tenants) + + if (($Role.BlockedTenants | Measure-Object).Count -eq 0 -and $Role.AllowedTenants -contains 'AllTenants') { + return $true + } + + if ($TenantFilter -eq 'AllTenants' -and $ApiRole -match 'Write$') { + return $false + } + + if ($TenantFilter -eq 'AllTenants' -and $ApiRole -match 'Read$') { + return $true + } + + # A requested tenant GROUP arrives as a complex body object + # {type:'Group', value:}. Authorize it by group identity against the + # role's granted groups - never by expanding members - so it can't fall + # through to the unknown-tenant allow below. Query-string filters are plain + # strings and cannot carry a group, so only the body object is a group request. + if ($Request.Body.tenantFilter.type -eq 'Group') { + $RequestedGroup = $Request.Body.tenantFilter.value + $AllowedGroupIds = @(foreach ($AllowedItem in $Role.AllowedTenants) { + if ($AllowedItem -is [PSCustomObject] -and $AllowedItem.type -eq 'Group') { $AllowedItem.value } + }) + return ($AllowedGroupIds -contains $RequestedGroup) + } + + $Tenant = ($Tenants | Where-Object { $TenantFilter -eq $_.customerId -or $TenantFilter -eq $_.defaultDomainName }).customerId + + $ExpandedAllowedTenants = foreach ($AllowedItem in $Role.AllowedTenants) { + if ($AllowedItem -is [PSCustomObject] -and $AllowedItem.type -eq 'Group') { + try { + $GroupMembers = Expand-CIPPTenantGroups -TenantFilter @($AllowedItem) + $GroupMembers | ForEach-Object { $_.addedFields.customerId } + } catch { + Write-Warning "Failed to expand allowed tenant group '$($AllowedItem.label)': $($_.Exception.Message)" + @() + } + } else { + $AllowedItem + } + } + + $ExpandedBlockedTenants = foreach ($BlockedItem in $Role.BlockedTenants) { + if ($BlockedItem -is [PSCustomObject] -and $BlockedItem.type -eq 'Group') { + try { + $GroupMembers = Expand-CIPPTenantGroups -TenantFilter @($BlockedItem) + $GroupMembers | ForEach-Object { $_.addedFields.customerId } + } catch { + Write-Warning "Failed to expand blocked tenant group '$($BlockedItem.label)': $($_.Exception.Message)" + @() + } + } else { + $BlockedItem + } + } + + if ($ExpandedAllowedTenants -contains 'AllTenants') { + $AllowedTenants = $Tenants.customerId + } else { + $AllowedTenants = $ExpandedAllowedTenants + } + + if ($Tenant) { + return ($AllowedTenants -contains $Tenant -and $ExpandedBlockedTenants -notcontains $Tenant) + } + + # Unmapped tenant filter: true for both call sites (allow quirk / block fail-closed). + return $true +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAntiPhishPolicyState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAntiPhishPolicyState.ps1 index af3c853f8d341..f18a8b63ad75e 100644 --- a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAntiPhishPolicyState.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAntiPhishPolicyState.ps1 @@ -43,6 +43,11 @@ function Get-CIPPBaselineAntiPhishPolicyState { $Rule = @($Rules | Where-Object { "$($_.Name)" -eq $RuleName }) | Select-Object -First 1 $V = $Item.Variables + # Get-AntiPhishPolicy only populates Enabled for the built-in default policy; on a custom policy + # the active state lives on the rule's State (see Invoke-ListAntiPhishingFilters). Fall back to + # the policy value so the built-in default policy stays correct. + $PolicyEnabled = if ($null -ne $Rule.State) { $Rule.State -eq 'Enabled' } else { [bool]$Policy.Enabled } + # The eight properties every tenant has. $Expected = [PSCustomObject]@{ name = $PolicyName @@ -56,7 +61,7 @@ function Get-CIPPBaselineAntiPhishPolicyState { } $Current = [PSCustomObject]@{ name = "$($Policy.Name)" - enabled = [bool]$Policy.Enabled + enabled = $PolicyEnabled enableSpoofIntelligence = [bool]$Policy.EnableSpoofIntelligence enableFirstContactSafetyTips = [bool]$Policy.EnableFirstContactSafetyTips enableUnauthenticatedSender = [bool]$Policy.EnableUnauthenticatedSender diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAuthenticationMethodsState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAuthenticationMethodsState.ps1 index ffa1e606bccf2..892e5f334da42 100644 --- a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAuthenticationMethodsState.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAuthenticationMethodsState.ps1 @@ -46,11 +46,16 @@ function Get-CIPPBaselineAuthenticationMethodsState { ) $Configured = @(foreach ($Method in $AuthMethods) { + # Formerly a switch (raw boolean), now a three-state autoComplete whose option + # wrapper the pipeline already unwrapped to $true/$false/'notConfigured'. Legacy + # booleans and the new values both land here; 'notConfigured' skips the method + # exactly like an absent variable - the tenant's current setting is never graded. $Enabled = $V."$($Method.Key)Enabled" - if ($null -eq $Enabled -or "$Enabled" -eq '') { continue } + $Enabled = $Enabled.value ?? $Enabled + if ($null -eq $Enabled -or "$Enabled" -eq '' -or "$Enabled" -eq 'notConfigured') { continue } [PSCustomObject]@{ Id = $Method.Id; RemediationId = $Method.RemediationId; Key = $Method.Key; Label = $Method.Label - Enabled = [bool]($Enabled -eq $true -or "$Enabled" -eq 'True') + Enabled = [bool]($Enabled -eq $true -or "$Enabled" -eq 'True' -or "$Enabled" -eq 'enabled') GroupName = "$($V."$($Method.Key)Group")" ExcludeGroupName = "$($V."$($Method.Key)ExcludeGroup")" } diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDefaultPlatformRestrictionsState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDefaultPlatformRestrictionsState.ps1 index d6f3d9d9d22df..c4c4e993705de 100644 --- a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDefaultPlatformRestrictionsState.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDefaultPlatformRestrictionsState.ps1 @@ -48,6 +48,28 @@ function Get-CIPPBaselineDefaultPlatformRestrictionsState { $Expected | Add-Member -NotePropertyName $Entry.e -NotePropertyValue ([bool]($V.($Entry.e) -eq $true)) $Current | Add-Member -NotePropertyName $Entry.e -NotePropertyValue ([bool]$Config.($Entry.c).($Entry.p)) } + + # Minimum/maximum OS version per platform. macOS is intentionally absent - the Intune + # enrollment restriction for macOS carries no version limit. Each is graded ONLY when the + # operator supplied it (like WHfB's newer fields): a blank field means 'no opinion', so it + # stays out of the compare here and omitWhenBlank prunes it from the remediation body. + # osMinimumVersion/osMaximumVersion are free-form strings on Graph, compared as strings. + $VersionMap = @( + @{ e = 'osMinimumVersionAndroidForWork'; c = 'androidForWorkRestriction'; p = 'osMinimumVersion' } + @{ e = 'osMaximumVersionAndroidForWork'; c = 'androidForWorkRestriction'; p = 'osMaximumVersion' } + @{ e = 'osMinimumVersionAndroid'; c = 'androidRestriction'; p = 'osMinimumVersion' } + @{ e = 'osMaximumVersionAndroid'; c = 'androidRestriction'; p = 'osMaximumVersion' } + @{ e = 'osMinimumVersioniOS'; c = 'iosRestriction'; p = 'osMinimumVersion' } + @{ e = 'osMaximumVersioniOS'; c = 'iosRestriction'; p = 'osMaximumVersion' } + @{ e = 'osMinimumVersionWindows'; c = 'windowsRestriction'; p = 'osMinimumVersion' } + @{ e = 'osMaximumVersionWindows'; c = 'windowsRestriction'; p = 'osMaximumVersion' } + ) + foreach ($Entry in $VersionMap) { + if ([string]::IsNullOrWhiteSpace("$($V.($Entry.e))")) { continue } + $Expected | Add-Member -NotePropertyName $Entry.e -NotePropertyValue "$($V.($Entry.e))" + $Current | Add-Member -NotePropertyName $Entry.e -NotePropertyValue "$($Config.($Entry.c).($Entry.p))" + } + $Current | Add-Member -NotePropertyName 'configurationId' -NotePropertyValue "$($Config.id)" @{ Expected = $Expected; Current = $Current } diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDeployContactTemplatesState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDeployContactTemplatesState.ps1 index 923f73a95440b..bf66ff8d66e5a 100644 --- a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDeployContactTemplatesState.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDeployContactTemplatesState.ps1 @@ -82,7 +82,7 @@ function Get-CIPPBaselineDeployContactTemplatesState { @{ Template = 'jobTitle'; Current = "$($Existing.Title)" } @{ Template = 'city'; Current = "$($Existing.City)" } @{ Template = 'postalCode'; Current = "$($Existing.PostalCode)" } - @{ Template = 'country'; Current = "$($Existing.CountryOrRegion)" } + @{ Template = 'country'; Current = "$($Existing.CountryOrRegion)"; IsCountry = $true } @{ Template = 'mobilePhone'; Current = "$($Existing.MobilePhone)" } ) $Differences = [System.Collections.Generic.List[string]]::new() @@ -93,7 +93,11 @@ function Get-CIPPBaselineDeployContactTemplatesState { continue } if ([string]::IsNullOrWhiteSpace("$TemplateValue")) { continue } - $Mismatch = if ($Field.IsEmail) { + # country: template stores an ISO code ('US'), Exchange returns the full name + # ('United States'); normalise both to a code before comparing. + $Mismatch = if ($Field.IsCountry) { + [string]::IsNullOrWhiteSpace($Field.Current) -or (ConvertTo-CIPPCountryCode "$TemplateValue") -ne (ConvertTo-CIPPCountryCode $Field.Current) + } elseif ($Field.IsEmail) { [string]::IsNullOrWhiteSpace($Field.Current) -or -not "$TemplateValue".Equals($Field.Current, [System.StringComparison]::OrdinalIgnoreCase) } else { [string]::IsNullOrWhiteSpace($Field.Current) -or "$TemplateValue" -ne $Field.Current diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableGuestsState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableGuestsState.ps1 index a014a5bfc03f6..2408a4bc0cc53 100644 --- a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableGuestsState.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableGuestsState.ps1 @@ -1,16 +1,17 @@ function Get-CIPPBaselineDisableGuestsState { <# .SYNOPSIS - Prepare hook for DisableGuests: enabled guests that are stale or never accepted their - invitation. + Prepare hook for DisableGuests: enabled guests with no sign-in attempt inside the window. .DESCRIPTION - Read live rather than from the Guests cache: that collector expands sponsors but - selects neither signInActivity nor externalUserState, and both decide the verdict here. - Extending it would let this move to cache like the other user sweeps. + Read live rather than from the Guests cache: that collector expands sponsors but does not + select signInActivity, which decides the verdict here. Extending it would let this move to + cache like the other user sweeps. - A guest counts when it has not signed in within the window, OR when it is still - PendingAcceptance - an invitation nobody ever took up is exactly the account this is - meant to close. Accounts an admin re-enabled in the last 7 days are left alone. + A guest counts when the newest of its interactive, non-interactive and successful sign-in + timestamps is older than the window - the same view the Entra portal and the inactive-guest + alert give. Guests with no sign-in on record (typically invitations nobody redeemed) only + count when IncludeNeverSignedIn is on; it is off by default and off when the template + predates it. Accounts an admin re-enabled in the last 7 days are left alone. .FUNCTIONALITY Internal #> @@ -21,17 +22,15 @@ function Get-CIPPBaselineDisableGuestsState { ) $CheckDays = if ([string]::IsNullOrWhiteSpace("$($Item.Variables.days)")) { 90 } else { [int]$Item.Variables.days } + $IncludeNeverSignedIn = $Item.Variables.IncludeNeverSignedIn -eq $true $Cutoff = (Get-Date).AddDays(-$CheckDays).ToUniversalTime() $Lookup = $Cutoff.ToString('o') - $Guests = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users?`$filter=createdDateTime le $Lookup and userType eq 'Guest' and accountEnabled eq true&`$select=id,userPrincipalName,signInActivity,mail,userType,accountEnabled,createdDateTime,externalUserState" -scope 'https://graph.microsoft.com/.default' -tenantid $TenantFilter) + $Guests = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users?`$filter=createdDateTime le $Lookup and userType eq 'Guest' and accountEnabled eq true&`$select=id,userPrincipalName,signInActivity,mail,userType,accountEnabled,createdDateTime" -scope 'https://graph.microsoft.com/.default' -tenantid $TenantFilter) $Stale = @($Guests | Where-Object { - if ($_.signInActivity -and $_.signInActivity.lastSuccessfulSignInDateTime) { - ([datetime]$_.signInActivity.lastSuccessfulSignInDateTime).ToUniversalTime() -le $Cutoff - } else { - $_.externalUserState -eq 'PendingAcceptance' - } + $LastSignIn = Get-CIPPLastSignInDateTime -SignInActivity $_.signInActivity + if ($LastSignIn) { $LastSignIn -le $Cutoff } else { $IncludeNeverSignedIn } }) if ($Stale.Count -gt 0) { diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableSharedMailboxState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableSharedMailboxState.ps1 index 0dfea4da2f76f..f47f7f3c69485 100644 --- a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableSharedMailboxState.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableSharedMailboxState.ps1 @@ -8,12 +8,12 @@ function Get-CIPPBaselineDisableSharedMailboxState { standard read the adminapi Mailbox endpoint live; the cache carries recipientTypeDetails and ExternalDirectoryObjectId, so no live call is needed. - NOTE - a deliberate behaviour change. The classic filter read + NOTE - the classic filter read RecipientTypeDetails -eq 'SharedMailbox' -or RecipientTypeDetails -eq 'SchedulingMailbox' -and UserPrincipalName -in $UserList - and -and binds tighter than -or, so the enabled/cloud-only test only ever applied to - SchedulingMailbox. Every shared mailbox was swept regardless, including ones whose - account was already disabled or directory-synced. The join here applies to both types, - which is what the standard's own description says it does. + which looks like it only joins SchedulingMailbox against the user list, but does not: + PowerShell gives -and and -or the same precedence and associates them left to right, so + it means '(shared or scheduling) and still enabled'. Same set as the join here; the only + real difference is that this one keys on ExternalDirectoryObjectId instead of the UPN. .FUNCTIONALITY Internal #> diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineExternalComplianceTrustedState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineExternalComplianceTrustedState.ps1 new file mode 100644 index 0000000000000..a9e1642109b08 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineExternalComplianceTrustedState.ps1 @@ -0,0 +1,26 @@ +function Get-CIPPBaselineExternalComplianceTrustedState { + <# + .SYNOPSIS + Prepare hook for ExternalComplianceTrusted: does the tenant trust device compliance from external tenants. + .DESCRIPTION + One graded boolean, read from the default cross-tenant access policy's inboundTrust. + A hook rather than a declarative expected because the operator's switch has to grade + in BOTH directions - trusting external device compliance and deliberately not trusting it + are both valid postures. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Policy = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'CrossTenantAccessPolicy') | Select-Object -First 1 + if (-not $Policy) { return @{ Current = $null } } + + @{ + Expected = [PSCustomObject]@{ isCompliantDeviceAccepted = [bool]($Item.Variables.state -eq $true) } + Current = [PSCustomObject]@{ isCompliantDeviceAccepted = [bool]$Policy.inboundTrust.isCompliantDeviceAccepted } + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineFIDO2PasskeyProfilesState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineFIDO2PasskeyProfilesState.ps1 index 042dc95eb3ce9..e75cdd1f5757a 100644 --- a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineFIDO2PasskeyProfilesState.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineFIDO2PasskeyProfilesState.ps1 @@ -31,6 +31,9 @@ function Get-CIPPBaselineFIDO2PasskeyProfilesState { $EnforcementType = "$($V.EnforcementType.value ?? $V.EnforcementType)" if ([string]::IsNullOrWhiteSpace($EnforcementType)) { $EnforcementType = 'allow' } $AAGUIDs = @("$($V.AAGUIDs)" -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ } | Sort-Object) + # Supplying AAGUIDs implies enforcement - the list only takes effect while isEnforced = $true, so + # the expected state must enforce whenever AAGUIDs are present to match what the executor writes. + if ($AAGUIDs.Count -gt 0) { $EnforceRestrictions = $true } if ($EnforceRestrictions -and $AAGUIDs.Count -eq 0) { return @{ Current = $null } } $DefaultProfile = @($Config.passkeyProfiles) | Where-Object { "$($_.id)" -eq "$($Config.defaultPasskeyProfile)" } | Select-Object -First 1 diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineMailboxRecipientLimitsState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineMailboxRecipientLimitsState.ps1 index 042cc16981df8..079acaee98db4 100644 --- a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineMailboxRecipientLimitsState.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineMailboxRecipientLimitsState.ps1 @@ -44,7 +44,8 @@ function Get-CIPPBaselineMailboxRecipientLimitsState { if ($UPN -like 'DiscoverySearchMailbox*' -or $UPN -like 'SystemMailbox*') { continue } $Plan = $PlanCap["$($Mailbox.MailboxPlanId)"] - $Cap = if ($Plan) { [int]"$($Plan.MaxRecipientsPerMessage)" } else { 0 } + # A plan without a stored limit (null on some tenants) means no cap - [int]'' throws. + $Cap = if ($Plan -and "$($Plan.MaxRecipientsPerMessage)" -match '^\d+$') { [int]"$($Plan.MaxRecipientsPerMessage)" } else { 0 } if ($Plan -and $Cap -gt 0 -and $Limit -gt $Cap) { $PlanIssues.Add("$UPN (plan $($Plan.DisplayName) caps at $Cap)") continue diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineMalwareFilterPolicyState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineMalwareFilterPolicyState.ps1 index e633388a38f17..948e76deaf86a 100644 --- a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineMalwareFilterPolicyState.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineMalwareFilterPolicyState.ps1 @@ -10,7 +10,9 @@ function Get-CIPPBaselineMalwareFilterPolicyState { FileTypes is the 55-entry default list plus whatever the operator adds as a comma-separated string, compared as a set - the classic used Compare-Object, which is - order-insensitive, so both sides are sorted here. + order-insensitive, case-insensitive AND collapses duplicates, so both sides are + lowercased and unique-sorted here (tenants exist whose policy stores the list doubled, + and Exchange keeps whatever casing it was last sent). The two admin-notification addresses are graded only when supplied, matching the classic '($null -eq $Settings.X) -or ...' tests. @@ -45,8 +47,8 @@ function Get-CIPPBaselineMalwareFilterPolicyState { $Rule = @($Rules | Where-Object { "$($_.Name)" -eq $RuleName }) | Select-Object -First 1 $DefaultFileTypes = @('ace', 'ani', 'apk', 'app', 'appx', 'arj', 'bat', 'cab', 'cmd', 'com', 'deb', 'dex', 'dll', 'docm', 'elf', 'exe', 'hta', 'img', 'iso', 'jar', 'jnlp', 'kext', 'lha', 'lib', 'library', 'lnk', 'lzh', 'macho', 'msc', 'msi', 'msix', 'msp', 'mst', 'pif', 'ppa', 'ppam', 'reg', 'rev', 'scf', 'scr', 'sct', 'sys', 'uif', 'vb', 'vbe', 'vbs', 'vxd', 'wsc', 'wsf', 'wsh', 'xll', 'xz', 'z') - $Optional = @("$($Item.Variables.OptionalFileTypes)" -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) - $ExpectedFileTypes = @(($DefaultFileTypes + $Optional) | Sort-Object) + $Optional = @("$($Item.Variables.OptionalFileTypes)" -split ',' | ForEach-Object { $_.Trim().ToLowerInvariant() } | Where-Object { $_ }) + $ExpectedFileTypes = @(($DefaultFileTypes + $Optional) | Sort-Object -Unique) $Expected = [PSCustomObject]@{ name = $PolicyName @@ -68,7 +70,7 @@ function Get-CIPPBaselineMalwareFilterPolicyState { name = "$($Policy.Name)" enableFileFilter = [bool]$Policy.EnableFileFilter fileTypeAction = "$($Policy.FileTypeAction)" - fileTypes = @(@($Policy.FileTypes) | Where-Object { $_ } | Sort-Object) + fileTypes = @(@($Policy.FileTypes) | Where-Object { $_ } | ForEach-Object { "$_".ToLowerInvariant() } | Sort-Object -Unique) zapEnabled = [bool]$Policy.ZapEnabled quarantineTag = "$($Policy.QuarantineTag)" enableInternalSenderAdminNotifications = [bool]$Policy.EnableInternalSenderAdminNotifications @@ -88,6 +90,10 @@ function Get-CIPPBaselineMalwareFilterPolicyState { } } + # The executor merges these over the spec's static policyParams. FileTypes is graded + # above, so it must also be written: a policy created or left without the list would + # otherwise drift on fileTypes forever with nothing ever sending them. + $Current | Add-Member -NotePropertyName 'extraPolicyParams' -NotePropertyValue ([PSCustomObject]@{ FileTypes = @($ExpectedFileTypes) }) $Current | Add-Member -NotePropertyName 'policyName' -NotePropertyValue $PolicyName $Current | Add-Member -NotePropertyName 'ruleName' -NotePropertyValue $RuleName $Current | Add-Member -NotePropertyName 'policyExists' -NotePropertyValue ([bool]$Policy) diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinePhishProtectionState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinePhishProtectionState.ps1 index 2081cb12fcce4..8ba46ddc3ed51 100644 --- a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinePhishProtectionState.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinePhishProtectionState.ps1 @@ -34,9 +34,14 @@ function Get-CIPPBaselinePhishProtectionState { background-image: url(https://clone.cipp.app/api/PublicPhishingCheck?Tenantid=$($TenantFilter)&URL=https://$($CIPPUrl)); } "@ + # The here-string inherits the source file's line endings, but the branding CSS round-trips + # through Graph as LF. Normalise both sides so a CRLF checkout (or CSS stored with different + # endings) still grades a re-read as a match rather than a silent drift. + $CSS = $CSS -replace "`r`n", "`n" + $NormalizedBody = "$CurrentBody" -replace "`r`n", "`n" $Current = [PSCustomObject]@{ - phishingCSSEnabled = [bool]("$CurrentBody" -like "*$CSS*") + phishingCSSEnabled = [bool]($NormalizedBody -like "*$CSS*") } # Carried for the executor. $Current | Add-Member -NotePropertyName 'currentBody' -NotePropertyValue "$CurrentBody" diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSPGuestPeoplePickerState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSPGuestPeoplePickerState.ps1 new file mode 100644 index 0000000000000..e7cebdb9accf1 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSPGuestPeoplePickerState.ps1 @@ -0,0 +1,54 @@ +function Get-CIPPBaselineSPGuestPeoplePickerState { + <# + .SYNOPSIS + Prepare hook for SPGuestPeoplePicker: the tenant default and every cached site collection whose + People Picker guest visibility differs from the wanted state. + .DESCRIPTION + Decides offenders from cache, not the live enumeration. Which sites differ comes from the + SPOSites reporting cache (refreshed by the daily SharePoint CIPPDB run); the tenant default is + read through Get-CIPPSPOTenant's own 1h cache (which works app-only with the certificate even + where the delegated SPOTenant reporting collector cannot). Reading cache keeps a large tenant + from being enumerated live on every run and lets the baseline engine's optimistic post-write + model verify on the next daily cache read. + + Detection always runs (the read is cheap and idempotent against the daily cache); the 24h + rerun guard lives in the executor so it throttles only the write sweep, never drift reporting. + + Returns the offenders/targets pair the sweep model expects: offenders are display strings + graded against [] ('Tenant default' plus offending site URLs); targets carry the Scope and the + value to write. Current is $null (No Data) only when the tenant configuration cannot be read. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Wanted = ($Item.Variables.showGuests -eq $true) -or ("$($Item.Variables.showGuests)" -eq 'true') + + try { + $Tenant = Get-CIPPSPOTenant -TenantFilter $TenantFilter -UseCertificate | Select-Object -First 1 + } catch { + return @{ Current = $null; NoDataReason = "Could not read the SharePoint tenant configuration: $($_.Exception.Message)" } + } + if (-not $Tenant) { return @{ Current = $null; NoDataReason = 'Could not read the SharePoint tenant configuration.' } } + + $Sites = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'SPOSites' | Where-Object { $_ -and $_.Url }) + + $Offenders = [System.Collections.Generic.List[string]]::new() + $Targets = [System.Collections.Generic.List[object]]::new() + + if ([bool]$Tenant.ShowPeoplePickerSuggestionsForGuestUsers -ne $Wanted) { + $Offenders.Add('Tenant default') + $Targets.Add([PSCustomObject]@{ Scope = 'tenant'; SiteUrl = $null; Wanted = $Wanted }) + } + + foreach ($Site in ($Sites | Where-Object { [bool]$_.ShowPeoplePickerSuggestionsForGuestUsers -ne $Wanted } | Sort-Object Url)) { + $Offenders.Add("$($Site.Url)") + $Targets.Add([PSCustomObject]@{ Scope = 'site'; SiteUrl = "$($Site.Url)"; Wanted = $Wanted }) + } + + @{ Current = [PSCustomObject]@{ offenders = @($Offenders); targets = @($Targets) } } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSendReceiveLimitTenantState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSendReceiveLimitTenantState.ps1 index 9bde6b58400ff..ecca826004fbd 100644 --- a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSendReceiveLimitTenantState.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSendReceiveLimitTenantState.ps1 @@ -3,11 +3,12 @@ function Get-CIPPBaselineSendReceiveLimitTenantState { .SYNOPSIS Prepare hook for SendReceiveLimitTenant: mailbox plan send/receive limits. .DESCRIPTION - Grades which mailbox PLANS are off the configured limits - Exchange reports sizes as - display strings ('35 MB (36,700,160 bytes)'), so the byte count is parsed out the - way the classic parsed it, and 'Unlimited' always counts as an offender. New - mailboxes inherit their plan, which is why the plan is the graded object rather than - any mailbox. + Grades which mailbox PLANS are off the configured limits. The DBCache collector + normalizes MaxSendSize/MaxReceiveSize to whole MB ($null = Unlimited), so the compare + is MB against MB; rows written before that normalization still carry Exchange's + display strings ('35 MB (36,700,160 bytes)') and are parsed down to MB the same way. + 'Unlimited' always counts as an offender. New mailboxes inherit their plan, which is + why the plan is the graded object rather than any mailbox. .FUNCTIONALITY Internal #> @@ -25,18 +26,25 @@ function Get-CIPPBaselineSendReceiveLimitTenantState { $SendLimit = [int]"$($Item.Variables.SendLimit)" $ReceiveLimit = [int]"$($Item.Variables.ReceiveLimit)" if ($SendLimit -lt 1 -or $SendLimit -gt 150 -or $ReceiveLimit -lt 1 -or $ReceiveLimit -gt 150) { return @{ Current = $null } } - $MaxSendBytes = [int64]$SendLimit * 1MB - $MaxReceiveBytes = [int64]$ReceiveLimit * 1MB + + # The collector stores whole MB ($null = Unlimited); pre-normalization rows still hold + # display strings with a byte suffix, which reduce to the same MB value. + $ConvertSizeToMB = { + param($Value) + if ([string]::IsNullOrWhiteSpace("$Value") -or "$Value" -match 'Unlimited') { return $null } + if ("$Value" -match '\(([\d,]+)') { return [int][math]::Round([int64]($Matches[1] -replace ',', '') / 1MB) } + try { return [int]$Value } catch { return $null } + } $Offenders = [System.Collections.Generic.List[object]]::new() foreach ($Plan in $Plans) { - if ("$($Plan.MaxSendSize)" -match 'Unlimited' -or "$($Plan.MaxReceiveSize)" -match 'Unlimited') { + $PlanSend = & $ConvertSizeToMB $Plan.MaxSendSize + $PlanReceive = & $ConvertSizeToMB $Plan.MaxReceiveSize + if ($null -eq $PlanSend -or $null -eq $PlanReceive) { $Offenders.Add($Plan) continue } - $PlanSend = [int64]("$($Plan.MaxSendSize)" -replace '.*\(([\d,]+).*', '$1' -replace ',', '') - $PlanReceive = [int64]("$($Plan.MaxReceiveSize)" -replace '.*\(([\d,]+).*', '$1' -replace ',', '') - if ($PlanSend -ne $MaxSendBytes -or $PlanReceive -ne $MaxReceiveBytes) { $Offenders.Add($Plan) } + if ($PlanSend -ne $SendLimit -or $PlanReceive -ne $ReceiveLimit) { $Offenders.Add($Plan) } } $Current = [PSCustomObject]@{ plansOffLimits = @($Offenders | ForEach-Object { "$($_.DisplayName)" } | Sort-Object) } diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSpamFilterPolicyState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSpamFilterPolicyState.ps1 index dcf830c6698d7..15a092b4de3a4 100644 --- a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSpamFilterPolicyState.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSpamFilterPolicyState.ps1 @@ -140,6 +140,16 @@ function Get-CIPPBaselineSpamFilterPolicyState { $Current | Add-Member -NotePropertyName 'regionBlockList' -NotePropertyValue @(@($Policy.RegionBlockList) | Where-Object { $_ } | ForEach-Object { "$_".ToUpper() } | Sort-Object) } + # BulkMovesEnabled (bulk mail to the Promotions folder) is in Preview and not available in + # every organization, so it is only graded - and written, via extraPolicyParams below - when + # explicitly configured On or Off. 'Do not configure' never sends the parameter to a tenant + # that may reject it. + $BulkMovesEnabled = "$($V.BulkMovesEnabled.value ?? $V.BulkMovesEnabled)" + if ($BulkMovesEnabled -in @('On', 'Off')) { + $Expected | Add-Member -NotePropertyName 'bulkMovesEnabled' -NotePropertyValue $BulkMovesEnabled + $Current | Add-Member -NotePropertyName 'bulkMovesEnabled' -NotePropertyValue "$($Policy.BulkMovesEnabled)" + } + # The built-in Default policy cannot carry a rule. if (-not $IsDefaultPolicy) { $Expected | Add-Member -NotePropertyName 'rule' -NotePropertyValue ([PSCustomObject]@{ @@ -189,6 +199,9 @@ function Get-CIPPBaselineSpamFilterPolicyState { } else { $ExtraPolicyParams['EnableRegionBlockList'] = $false } + if ($BulkMovesEnabled -in @('On', 'Off')) { + $ExtraPolicyParams['BulkMovesEnabled'] = $BulkMovesEnabled + } $Current | Add-Member -NotePropertyName 'extraPolicyParams' -NotePropertyValue ([PSCustomObject]$ExtraPolicyParams) @{ Expected = $Expected; Current = $Current } diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineUserSubmissionsState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineUserSubmissionsState.ps1 index edb4e1ea2b776..0a3b60789ef50 100644 --- a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineUserSubmissionsState.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineUserSubmissionsState.ps1 @@ -36,8 +36,13 @@ function Get-CIPPBaselineUserSubmissionsState { if ($Email -notmatch '@') { return @{ Current = $null } } } + # 'Send reported items to' only applies when an address is configured; blank or missing + # keeps the original posture (Microsoft as well as the reporting mailbox). + $Destination = "$($Item.Variables.reportDestination.value ?? $Item.Variables.reportDestination)" + $ReportToMicrosoft = [string]::IsNullOrWhiteSpace($Email) -or $Destination -ne 'Mailbox' + if ($State -eq 'enable' -and -not [string]::IsNullOrWhiteSpace($Email)) { - $Expected = [PSCustomObject]@{ reportToMicrosoft = $true; customAddressCorrect = $true; ruleCorrect = $true } + $Expected = [PSCustomObject]@{ reportToMicrosoft = $ReportToMicrosoft; customAddressCorrect = $true; ruleCorrect = $true } $Current = [PSCustomObject]@{ reportToMicrosoft = [bool]$Policy.EnableReportToMicrosoft customAddressCorrect = [bool]($Policy.ReportJunkToCustomizedAddress -eq $true -and @($Policy.ReportJunkAddresses) -eq $Email -and @@ -69,6 +74,7 @@ function Get-CIPPBaselineUserSubmissionsState { $Current | Add-Member -NotePropertyName 'ruleExists' -NotePropertyValue ([bool]$Rule) $Current | Add-Member -NotePropertyName 'ruleEnabled' -NotePropertyValue ([bool]($Rule -and "$($Rule.State)" -eq 'Enabled')) $Current | Add-Member -NotePropertyName 'resolvedEmail' -NotePropertyValue $Email + $Current | Add-Member -NotePropertyName 'reportDestination' -NotePropertyValue $Destination @{ Expected = $Expected; Current = $Current } } diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineWorkItems.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineWorkItems.ps1 index 1dda1b5b15935..9d96137d4aec3 100644 --- a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineWorkItems.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineWorkItems.ps1 @@ -17,6 +17,14 @@ function Get-CIPPBaselineWorkItems { instance key, except for definitions declaring instanceIdentity (e.g. the CA template id): there the SELECTED TEMPLATE is the identity, so the same template applied twice at one level collides while different templates coexist. + - DIFFERENT standards writing the SAME tenant object (definition writeTarget) with + remediation on and opposing desired values are the same class of conflict, caught + per tenant after resolution: each definition's writeTargetProperties render to + its claims on the shared object, and overlapping claims with different values + mark every involved item Conflicted - proven live: the umbrella + AuthenticationMethods rewrote per-method states, and the OauthConsent trio are + mutually opposing consent policies. Compare-only items never conflict here - + detection reads don't fight. - Excluded tenants get nothing from that baseline. Each item carries the configured variable values, action posture, inheritance tiers for the UI, and the baseline's alert destinations. @@ -263,10 +271,13 @@ function Get-CIPPBaselineWorkItems { $Effective[$Key] = @{ Rank = 3; Candidates = $OverrideCandidates } } + # Collected instead of streamed: the write-target conflict pass below needs the whole + # tenant's resolved set in hand before anything is emitted. + $ResolvedItems = [System.Collections.Generic.List[object]]::new() foreach ($Entry in $Effective.Values) { $Candidates = @($Entry.Candidates | Sort-Object -Property UpdatedAt -Descending) if ($Candidates.Count -le 1) { - $Candidates[0].Item + $ResolvedItems.Add($Candidates[0].Item) continue } # Same rank, same identity, different settings: even the expected value is @@ -295,9 +306,123 @@ function Get-CIPPBaselineWorkItems { $ConflictItem.Tiers = @($ConflictTiers) # Attribute the row to every colliding baseline, not just its own source. $ConflictItem.SourceTemplate = ($ConflictNames -join ', ') - $ConflictItem | Add-Member -NotePropertyName Conflicted -NotePropertyValue $true -Force - $ConflictItem | Add-Member -NotePropertyName ConflictWith -NotePropertyValue $ConflictNames -Force - $ConflictItem + $ConflictItem | Add-Member -NotePropertyMembers ([ordered]@{ + Conflicted = $true + ConflictWith = $ConflictNames + }) -Force + $ResolvedItems.Add($ConflictItem) } } + + # ---- write-target coordination ----------------------------------------------------- + # Definitions naming a shared tenant object (writeTarget) stamp it onto their items so + # the orchestrator can serialize same-object writes; parallel activities racing one + # object were last-writer-wins on live tenants (a Graph 409 in the worst case). + foreach ($ResolvedItem in $ResolvedItems) { + $ResolvedItem | Add-Member -NotePropertyName WriteTarget -NotePropertyValue "$($DefinitionsByName[$ResolvedItem.BaseName].writeTarget)" -Force + } + + # Claim values are canonicalized before compare because the families mix vocabularies + # for the same write: the umbrella stores a method's desired state as $true/$false + # where the per-method standards store 'enabled'/'disabled' - both mean the same PATCH. + # Blank, 'notConfigured' and unresolved %tokens% are 'no opinion' and never conflict. + $CanonicalClaimValue = { + param($Value) + if ($null -eq $Value) { return $null } + $Value = $Value.value ?? $Value + if ($Value -is [array]) { + $Parts = @($Value | ForEach-Object { & $CanonicalClaimValue $_ } | Where-Object { $null -ne $_ } | Sort-Object) + if ($Parts.Count -eq 0) { return $null } + return ('[{0}]' -f ($Parts -join ',')) + } + if ($Value -is [bool]) { return $(if ($Value) { 'enabled' } else { 'disabled' }) } + $Text = "$Value".Trim() + if ($Text -eq '' -or $Text -eq 'notConfigured' -or $Text -match '^%[A-Za-z0-9_]+%$') { return $null } + if ($Text -match '^(?i)true$') { return 'enabled' } + if ($Text -match '^(?i)false$') { return 'disabled' } + if ($Text -match '^-?\d+(\.\d+)?$') { return "$([double]$Text)" } + $Text.ToLowerInvariant() + } + + # Renders a definition's writeTargetProperties with the item's variables into + # path -> canonical value. Defaults apply exactly as the engine applies them at run + # time (locked always, blank takes default/recommended) - without this a blank + # DisableSMS state would claim nothing while the run enforces 'disabled'. + $RenderWriteClaims = { + param($Definition, $Variables) + if ($null -eq $Definition.writeTargetProperties) { return $null } + $Values = @{} + foreach ($ConfiguredVariable in (($Variables ?? [PSCustomObject]@{}).PSObject.Properties)) { + # The UI's pickers save option WRAPPERS ({label, value}); claims need the value. + $Values[$ConfiguredVariable.Name] = if ($ConfiguredVariable.Value -is [array]) { + @($ConfiguredVariable.Value | ForEach-Object { $_.value ?? $_ }) + } else { + $ConfiguredVariable.Value.value ?? $ConfiguredVariable.Value + } + } + foreach ($Declared in (($Definition.variables ?? [PSCustomObject]@{}).PSObject.Properties)) { + $Fallback = $Declared.Value.default ?? $Declared.Value.recommended + if ($null -eq $Fallback) { continue } + $IsBlank = [string]::IsNullOrEmpty("$($Values[$Declared.Name])") + if ($Declared.Value.locked -eq $true -or ($IsBlank -and $Declared.Value.omitWhenBlank -ne $true)) { + $Values[$Declared.Name] = $Fallback + } + } + $Claims = @{} + foreach ($ClaimProperty in $Definition.writeTargetProperties.PSObject.Properties) { + $ClaimValue = $ClaimProperty.Value + if ($ClaimValue -is [string] -and $ClaimValue -match '^%([A-Za-z0-9_]+)%$') { + $ClaimValue = $Values[$Matches[1]] + } + $Canonical = & $CanonicalClaimValue $ClaimValue + # Hashtable keys compare case-insensitively, which absorbs the id-casing drift + # between definitions ('SoftwareOath' filter vs 'softwareOath' Graph id). + if ($null -ne $Canonical) { $Claims[$ClaimProperty.Name] = $Canonical } + } + $Claims + } + + # Two REMEDIATING standards claiming the same property of one shared object with + # different values can only scramble the tenant (each write undoes the other), so the + # whole set parks at Conflict through the same Conflicted plumbing as the + # same-standard collision above. Items already Conflicted never write and are skipped. + foreach ($TargetGroup in ($ResolvedItems | Where-Object { $_.WriteTarget } | Group-Object -Property { '{0}|{1}' -f $_.TenantFilter, $_.WriteTarget })) { + $Claimants = @($TargetGroup.Group | Where-Object { $_.RemediateEnabled -and $_.Conflicted -ne $true }) + if ($Claimants.Count -lt 2) { continue } + # Index-aligned with $Claimants; a List keeps $null entries (definition without + # writeTargetProperties) in place where a pipeline would drop them. + $ClaimMaps = [System.Collections.Generic.List[object]]::new() + foreach ($Claimant in $Claimants) { + $ClaimMaps.Add((& $RenderWriteClaims $DefinitionsByName[$Claimant.BaseName] $Claimant.Variables)) + } + # claimant index -> its opponents; every pairwise opposition marks BOTH sides. + $Opponents = @{} + for ($First = 0; $First -lt $Claimants.Count - 1; $First++) { + for ($Second = $First + 1; $Second -lt $Claimants.Count; $Second++) { + $MapA = $ClaimMaps[$First] + $MapB = $ClaimMaps[$Second] + if (-not $MapA -or -not $MapB) { continue } + $Opposed = @($MapA.Keys | Where-Object { $MapB.ContainsKey($_) -and $MapA[$_] -ne $MapB[$_] }) + if ($Opposed.Count -eq 0) { continue } + foreach ($Pair in @(@($First, $Second), @($Second, $First))) { + $Theirs = $Claimants[$Pair[1]] + if (-not $Opponents.ContainsKey($Pair[0])) { $Opponents[$Pair[0]] = [System.Collections.Generic.List[string]]::new() } + $Opponents[$Pair[0]].Add(('{0} ({1})' -f $Theirs.Standard, $Theirs.TemplateName)) + } + } + } + if ($Opponents.Count -eq 0) { continue } + $GroupAlert = @($Opponents.Keys | Where-Object { $Claimants[$_].AlertEnabled }).Count -gt 0 + foreach ($Index in $Opponents.Keys) { + $Member = $Claimants[$Index] + $Member.RemediateEnabled = $false + $Member.AlertEnabled = $GroupAlert + $Member | Add-Member -NotePropertyMembers ([ordered]@{ + Conflicted = $true + ConflictWith = @($Opponents[$Index] | Select-Object -Unique) + }) -Force + } + } + + foreach ($ResolvedItem in $ResolvedItems) { $ResolvedItem } } diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinecalDefaultState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinecalDefaultState.ps1 index 59a2456e6d8b3..eeac363d8bd1d 100644 --- a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinecalDefaultState.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinecalDefaultState.ps1 @@ -13,6 +13,11 @@ function Get-CIPPBaselinecalDefaultState { Only the 'Default' principal is graded; named delegates are somebody's deliberate grant and are none of this standard's business. AccessRights arrives as an array on some rows and a string on others, so it is joined before comparing. + + Coverage is deliberately NOT graded here. 'offenders' is the only channel the compare + keeps, and an entry there leaves 'targets' empty - ExoBulkSweep then returns at its + `if ($Attempted -eq 0)` guard, before refreshCache, while the engine records Remediated. + Invoke-CIPPStandardcalDefault carries the coverage check instead. .FUNCTIONALITY Internal #> diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineAppDeploy.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineAppDeploy.ps1 index 58a1d2ab441b7..b3b3f80b24df0 100644 --- a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineAppDeploy.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineAppDeploy.ps1 @@ -19,21 +19,37 @@ function Invoke-CIPPBaselineAppDeploy { $Current ) - $ServicePrincipals = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'ServicePrincipals') + try { + $ServicePrincipals = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'ServicePrincipals') + } catch { + # Without this, a storage hiccup here surfaces as a bare transport error with no hint + # of what AppDeploy was doing. + throw "AppDeploy: reading the ServicePrincipals cache for $TenantFilter failed: $($_.Exception.Message)" + } $Mode = [string]($Remediate.mode.value ?? $Remediate.mode ?? 'copy') if ($Mode -eq 'copy') { - foreach ($App in @("$($Remediate.appids)" -split ',')) { - $App = $App.Trim() - if (-not $App) { continue } + $AppIds = @("$($Remediate.appids)" -split ',' | ForEach-Object { "$_".Trim() } | Where-Object { $_ }) + $FailedApps = [System.Collections.Generic.List[string]]::new() + $LastError = $null + foreach ($App in $AppIds) { $Application = $ServicePrincipals | Where-Object -Property appId -EQ $App try { New-CIPPApplicationCopy -App $App -Tenant $TenantFilter Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Added application $($Application.displayName) ($App) and updated its permissions." -Sev 'Info' } catch { - Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Failed to add app $($Application.displayName) ($App): $($_.Exception.Message)" -Sev 'Error' + $FailedApps.Add($App) + $LastError = "$($_.Exception.Message)" + # The log write itself can fail on the same storage hiccup that broke the + # deploy; the end-of-loop throw still names the app either way. + try { Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Failed to add app $($Application.displayName) ($App): $($_.Exception.Message)" -Sev 'Error' } catch { $null = $_ } } } + # Partial failure keeps the classic's log-and-continue; when nothing deployed at all, + # surface WHICH app id(s) failed instead of grading the run remediated. + if ($AppIds.Count -gt 0 -and $FailedApps.Count -eq $AppIds.Count) { + throw "AppDeploy: deploying application id(s) $($FailedApps -join ', ') failed. Last error: $LastError" + } return } diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExternalComplianceTrusted.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExternalComplianceTrusted.ps1 new file mode 100644 index 0000000000000..905acb3dedbf9 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExternalComplianceTrusted.ps1 @@ -0,0 +1,27 @@ +function Invoke-CIPPBaselineExternalComplianceTrusted { + <# + .SYNOPSIS + ExternalComplianceTrusted executor: sets inbound device compliance trust on the default + cross-tenant access policy. + .DESCRIPTION + Reads the policy LIVE and patches the merged inboundTrust object, never the single + flag: Graph replaces the whole complex value on PATCH, so a bare + isCompliantDeviceAccepted body would silently reset the MFA and hybrid-join trust flags. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $Policy = New-GraphGetRequest -uri 'https://graph.microsoft.com/v1.0/policies/crossTenantAccessPolicy/default?$select=inboundTrust' -tenantid $TenantFilter + if (-not $Policy.inboundTrust) { throw 'Could not read the default cross-tenant access policy - refusing a blind write.' } + + $Policy.inboundTrust.isCompliantDeviceAccepted = [bool]($Remediate.trusted -eq $true -or "$($Remediate.trusted)" -eq 'True') + $Body = ConvertTo-Json -Compress -Depth 10 -InputObject ([PSCustomObject]@{ inboundTrust = $Policy.inboundTrust }) + $null = New-GraphPostRequest -tenantid $TenantFilter -uri 'https://graph.microsoft.com/v1.0/policies/crossTenantAccessPolicy/default' -type PATCH -body $Body + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Set external device compliance trust to $($Policy.inboundTrust.isCompliantDeviceAccepted)." -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineFIDO2PasskeyProfiles.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineFIDO2PasskeyProfiles.ps1 index 5cc412d74194c..d2c563000832b 100644 --- a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineFIDO2PasskeyProfiles.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineFIDO2PasskeyProfiles.ps1 @@ -26,6 +26,9 @@ function Invoke-CIPPBaselineFIDO2PasskeyProfiles { $EnforcementType = "$($Remediate.enforcementType)" if ([string]::IsNullOrWhiteSpace($EnforcementType)) { $EnforcementType = 'allow' } $AAGUIDs = @("$($Remediate.aaGuids)" -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + # A profile's AAGUID list only takes effect while key restrictions are enforced; sent with + # isEnforced = $false the list is stored but never applied. Supplying AAGUIDs implies enforcement. + if ($AAGUIDs.Count -gt 0) { $Enforce = $true } $UpdatedProfiles = @(@($Current.allProfiles) | ForEach-Object { if ("$($_.id)" -eq $DefaultId) { diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineIntuneAppTemplateDeploy.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineIntuneAppTemplateDeploy.ps1 index d9010d1dfc136..edb9593ac058c 100644 --- a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineIntuneAppTemplateDeploy.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineIntuneAppTemplateDeploy.ps1 @@ -33,10 +33,12 @@ function Invoke-CIPPBaselineIntuneAppTemplateDeploy { default { "$($App.AppType)" } } $DeployConfig = $App.Config | ConvertTo-Json -Depth 100 | ConvertFrom-Json -Depth 100 - $DeployConfig | Add-Member -NotePropertyName 'type' -NotePropertyValue $QueueType -Force - $DeployConfig | Add-Member -NotePropertyName 'Applicationname' -NotePropertyValue "$($App.AppName)" -Force $AppAssignTo = if ("$($DeployConfig.AssignTo)" -eq 'customGroup') { $DeployConfig.CustomGroup } else { $DeployConfig.AssignTo } - $DeployConfig | Add-Member -NotePropertyName 'assignTo' -NotePropertyValue $AppAssignTo -Force + $DeployConfig | Add-Member -NotePropertyMembers ([ordered]@{ + type = $QueueType + Applicationname = "$($App.AppName)" + assignTo = $AppAssignTo + }) -Force $null = New-CIPPIntuneAppDeployment -AppConfig $DeployConfig -TenantFilter $TenantFilter -APIName 'Baselines' Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Queued the Intune app '$($App.AppName)' ($($App.AppType)) from template '$($App.TemplateName)'." -Sev 'Info' diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSPGuestPeoplePicker.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSPGuestPeoplePicker.ps1 new file mode 100644 index 0000000000000..83b7ce806223b --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSPGuestPeoplePicker.ps1 @@ -0,0 +1,95 @@ +function Invoke-CIPPBaselineSPGuestPeoplePicker { + <# + .SYNOPSIS + SPGuestPeoplePicker executor: applies People Picker guest visibility to the tenant default + and each offending site collection, once per 24h per tenant. + .DESCRIPTION + Each target from the prepare hook carries a Scope ('tenant' or 'site') and the value in + 'Wanted'. Tenant targets write through Set-CIPPSPOTenant, site targets through the concurrent + Set-CIPPSPOSiteBulk fan-out. It does NOT re-read after writing: the eventually-consistent site + enumeration lags a just-applied write, and the baseline engine already verifies optimistically + on the next daily cache read. + + A 24h rerun guard (Test-CIPPRerun, shared with the classic standard via the 'SPGuestPeoplePicker' + key) gates the write sweep: the prepare's offender set is derived from the SPOSites cache, which + only refreshes daily, so without the guard a sub-daily baseline schedule would re-issue the same + writes against a stale picture and throttle SharePoint. Detection and drift reporting are not + gated - only the sweep. + + Partial failure does NOT throw - sites that wrote stay written, failures are logged, and once + the cache reflects the successes the next run's prepare re-derives only the still-drifted set. + A run where every write failed throws (a permission/endpoint problem, not drift). + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $Targets = @($Current.targets | Where-Object { $_ }) + if ($Targets.Count -eq 0) { return } + + # Once-per-24h per tenant. Test-CIPPRerun records the run as it allows it, and the classic standard + # shares this key, so whichever system sweeps first blocks the other until the next daily cache run + # reflects the change. Only reached when there is drift to write (the engine calls the executor only + # for a non-compliant, remediate-enabled item), so detection/alerting are never gated by it. + if (Test-CIPPRerun -Tenant $TenantFilter -API 'SPGuestPeoplePicker' -Interval 86400) { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message 'SPGuestPeoplePicker: write sweep already ran within the last 24h - skipping it until the next daily cache run re-evaluates the result.' -Sev 'Info' + return + } + + $AuthSplat = @{} + if ($Remediate.useCertificate) { $AuthSplat['UseCertificate'] = $true } + $Property = 'ShowPeoplePickerSuggestionsForGuestUsers' + + $Attempted = 0 + $Failed = 0 + $FailureDetail = [System.Collections.Generic.List[string]]::new() + + # Tenant default (at most one target): a fresh Get-CIPPSPOTenant supplies the write handle, then + # one Set-CIPPSPOTenant. No re-read - the next daily cache run confirms it. + foreach ($Target in @($Targets | Where-Object { $_.Scope -eq 'tenant' })) { + $Attempted++ + try { + $SPOTenant = Get-CIPPSPOTenant -TenantFilter $TenantFilter @AuthSplat | Select-Object -First 1 + if (-not $SPOTenant) { throw "Could not resolve the SharePoint tenant object for $TenantFilter." } + $null = $SPOTenant | Set-CIPPSPOTenant -Properties @{ $Property = [bool]$Target.Wanted } @AuthSplat + } catch { + $Failed++ + $FailureDetail.Add("Tenant default -> $($_.Exception.Message)") + } + } + + # Existing sites: one concurrent bulk write. Per-site success/failure comes straight from the bulk + # result (CSOM reports per-site errors in the response body); no separate verification read. + $SiteTargets = @($Targets | Where-Object { $_.Scope -eq 'site' -and $_.SiteUrl }) + if ($SiteTargets.Count -gt 0) { + $BulkSites = @($SiteTargets | ForEach-Object { @{ SiteUrl = $_.SiteUrl; Properties = @{ $Property = [bool]$_.Wanted } } }) + try { + $Results = @(Set-CIPPSPOSiteBulk -TenantFilter $TenantFilter -Sites $BulkSites @AuthSplat) + $ResultByUrl = @{} + foreach ($Result in $Results) { $ResultByUrl["$($Result.SiteUrl)"] = $Result } + foreach ($SiteTarget in $SiteTargets) { + $Attempted++ + $Result = $ResultByUrl["$($SiteTarget.SiteUrl)"] + if (-not $Result -or -not $Result.Success) { + $Failed++ + $FailureDetail.Add("$($SiteTarget.SiteUrl) -> $(if ($Result.Error) { $Result.Error } else { 'no result returned' })") + } + } + } catch { + foreach ($SiteTarget in $SiteTargets) { $Attempted++; $Failed++ } + $FailureDetail.Add("Site batch -> $($_.Exception.Message)") + } + } + + if ($FailureDetail.Count -gt 0) { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Guest People Picker: $Failed of $Attempted writes failed. $($FailureDetail -join ' | ')" -Sev 'Warning' + } + if ($Attempted -gt 0 -and $Failed -eq $Attempted) { + throw "SPGuestPeoplePicker: all $Attempted writes failed. $($FailureDetail | Select-Object -First 1)" + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSPOVersionControl.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSPOVersionControl.ps1 index ebdc7dbd658c2..c4af3384b22ad 100644 --- a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSPOVersionControl.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSPOVersionControl.ps1 @@ -24,7 +24,8 @@ function Invoke-CIPPBaselineSPOVersionControl { $ExpireDays = [int]"$($Remediate.expireVersionsAfterDays ?? 0)" if (-not $AutoTrim -and $ExpireDays -ne 0 -and ($ExpireDays -lt 30 -or $ExpireDays -gt 36500)) { return } - $State = Get-CIPPSPOTenant -TenantFilter $TenantFilter | Select-Object -Property _ObjectIdentity_, TenantFilter + # SharePoint app-only requires the SAM certificate; delegated is not available on every tenant. + $State = Get-CIPPSPOTenant -TenantFilter $TenantFilter -UseCertificate | Select-Object -Property _ObjectIdentity_, TenantFilter if (-not $State) { throw 'Could not read the SPO tenant configuration - refusing a blind write.' } $MethodParams = if ($AutoTrim) { @@ -32,7 +33,7 @@ function Invoke-CIPPBaselineSPOVersionControl { } else { @(@{ Type = 'Boolean'; Value = $false }, @{ Type = 'Int32'; Value = $MajorLimit }, @{ Type = 'Int32'; Value = $ExpireDays }) } - $State | Set-CIPPSPOTenant -MethodName 'SetFileVersionPolicy' -MethodParameters $MethodParams + $State | Set-CIPPSPOTenant -MethodName 'SetFileVersionPolicy' -MethodParameters $MethodParams -UseCertificate Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Set the file version policy (autoTrim=$AutoTrim$(if (-not $AutoTrim) { ", limit=$MajorLimit, expire=${ExpireDays}d" }))." -Sev 'Info' if ($Remediate.applyToExistingSites -eq $true -or "$($Remediate.applyToExistingSites)" -eq 'True') { @@ -47,15 +48,13 @@ function Invoke-CIPPBaselineSPOVersionControl { $SiteProperties.MajorVersionLimit = $MajorLimit $SiteProperties.ExpireVersionsAfterDays = $ExpireDays } - $Failures = 0 - foreach ($Site in $Sites) { - try { - Set-CIPPSPOSite -TenantFilter $TenantFilter -SiteUrl $Site.webUrl -Properties $SiteProperties - } catch { - $Failures++ - Write-Information "Baselines: version policy on $($Site.webUrl) continued past: $($_.Exception.Message)" - } + # One concurrent batch (Set-CIPPSPOSiteBulk fans out in .NET) instead of ~2s per site. + $BulkSites = @($Sites | ForEach-Object { @{ SiteUrl = $_.webUrl; Properties = $SiteProperties } }) + $BulkResults = @(Set-CIPPSPOSiteBulk -TenantFilter $TenantFilter -Sites $BulkSites -UseCertificate) + $Failures = @($BulkResults | Where-Object { -not $_.Success }) + foreach ($FailedSite in $Failures) { + Write-Information "Baselines: version policy on $($FailedSite.SiteUrl) continued past: $($FailedSite.Error)" } - Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Applied the version policy to $(@($Sites).Count - $Failures) of $(@($Sites).Count) existing site(s)." -Sev 'Info' + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Applied the version policy to $(@($Sites).Count - $Failures.Count) of $(@($Sites).Count) existing site(s)." -Sev 'Info' } } diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 index 1b6123c0a2b93..800f3419e9214 100644 --- a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 @@ -108,6 +108,24 @@ function Invoke-CIPPBaselineStandard { if ($Definition.package) { throw "Package standard $($Item.BaseName) must be expanded by the resolver and never executes directly." } $Label = $Definition.label ?? $Item.Standard + # A disabled definition is not ready for use: the catalog hides it, and anything + # still configured from before skips here - never compared, never written - with + # the reason visible instead of a silent absence. + if ($Definition.disabled -eq $true) { + if ($GradeOnly) { return $null } + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "`"$Label`" is disabled (not ready for use) - skipped without comparing or changing anything. - Run $RunId" -Sev 'Info' + $Disabled = [PSCustomObject]@{ + Item = $Item; Mode = $Mode; TriggeredBy = $TriggeredBy + ExpectedValue = $null; CurrentValue = $null; Compliant = $false + PendingVerification = $false; LicenseAvailable = $true + Status = 'No Data'; Remediated = $false; Outcome = 'Skipped-Disabled' + Diff = $null; RowDiff = @(); Manual = $null; Inheritance = @($Item.Tiers) + AlertEvent = $null; CacheType = $null + } + Set-CIPPBaselineResult -Result $Disabled -Prior $null -RunId $RunId -Detail 'This standard is disabled - it is not ready for use and was skipped.' + return $Disabled + } + # Definition-aware variable pass: declared defaults apply at RUN time, not just as # editor seeds. A blank variable would otherwise splice its raw '%token%' into the # expected value and grade as permanent fake drift ('%enabled%' vs true). Rules: diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineUserSubmissions.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineUserSubmissions.ps1 index f4eeb03e810bb..6ac12bd73e678 100644 --- a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineUserSubmissions.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineUserSubmissions.ps1 @@ -23,8 +23,10 @@ function Invoke-CIPPBaselineUserSubmissions { $Email = "$($Current.resolvedEmail)" if ($State -eq 'enable' -and -not [string]::IsNullOrWhiteSpace($Email)) { + # 'Mailbox' routes reports to the reporting mailbox only (third-party phishing + # services); anything else keeps the original Microsoft-as-well posture. $PolicyParams = @{ - EnableReportToMicrosoft = $true + EnableReportToMicrosoft = "$($Current.reportDestination)" -ne 'Mailbox' ReportJunkToCustomizedAddress = $true; ReportJunkAddresses = $Email ReportNotJunkToCustomizedAddress = $true; ReportNotJunkAddresses = $Email ReportPhishToCustomizedAddress = $true; ReportPhishAddresses = $Email diff --git a/Modules/CIPPCore/Public/Clear-CIPPDbCache.ps1 b/Modules/CIPPCore/Public/Clear-CIPPDbCache.ps1 new file mode 100644 index 0000000000000..3071bf503c023 --- /dev/null +++ b/Modules/CIPPCore/Public/Clear-CIPPDbCache.ps1 @@ -0,0 +1,107 @@ +function Clear-CIPPDbCache { + <# + .SYNOPSIS + Remove every CippReportingDB row for a cache type and reset the Count row to 0. + + .DESCRIPTION + SuperAdmin empty path. + + Rows are read and deleted physically rather than as reassembled large entities, so any + part rows a split entity left behind go with it. + + The delete is re-read afterwards and a row that survived is an error. Table deletes + report success for a row the service says does not exist - the SDK returns 404 as a + response rather than throwing, and AzBobbyTables' large-entity remove swallows it in + its per-row fallback - so an empty that only trusted the delete call could report + clearing rows it never touched. + + .PARAMETER TenantFilter + Tenant domain, GUID, or AllTenants. + + .PARAMETER Type + Cache collection name (e.g. Users, Groups, Mailboxes). + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $true)] + [string]$Type + ) + + try { + $Table = Get-CippTable -tablename 'CippReportingDB' + $IsAllTenants = $TenantFilter -eq 'AllTenants' -or $TenantFilter -eq 'allTenants' + $TypeName = [string]$Type + $CountRowKey = "$TypeName-Count" + + if ($IsAllTenants) { + $Filter = "RowKey ge '$TypeName-' and RowKey lt '${TypeName}0'" + $ResultTenant = 'AllTenants' + $DbTenant = $null + } else { + $Tenant = Get-Tenants -TenantFilter $TenantFilter + if (-not $Tenant) { + throw "Tenant '$TenantFilter' not found" + } + $DbTenant = [string]$Tenant.defaultDomainName + if ([string]::IsNullOrWhiteSpace($DbTenant)) { + throw "Tenant '$TenantFilter' has no defaultDomainName" + } + $ResultTenant = $DbTenant + $Filter = "PartitionKey eq '$DbTenant' and RowKey ge '$TypeName-' and RowKey lt '${TypeName}0'" + } + + $Rows = @(Get-AzDataTableEntity @Table -Filter $Filter -Property PartitionKey, RowKey -ErrorAction Stop) + + $TouchedPartitions = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $Entities = [System.Collections.Generic.List[object]]::new() + foreach ($Row in $Rows) { + $Pk = [string]$Row.PartitionKey + $Rk = [string]$Row.RowKey + if ([string]::IsNullOrWhiteSpace($Pk) -or [string]::IsNullOrWhiteSpace($Rk)) { continue } + [void]$TouchedPartitions.Add($Pk) + if ($Rk -eq $CountRowKey) { continue } + $Entities.Add([pscustomobject]@{ PartitionKey = $Pk; RowKey = $Rk }) + } + + $RemovedCount = 0 + if ($Entities.Count -gt 0) { + # One call: the module splits the entities into transactions per partition key and + # per the service's 100-operation limit, so an AllTenants clear needs no grouping here. + Remove-AzDataTableEntity @Table -Entity $Entities.ToArray() -Force -ErrorAction Stop + $RemovedCount = $Entities.Count + } + + if (-not $IsAllTenants) { + [void]$TouchedPartitions.Add($DbTenant) + } + + foreach ($Partition in $TouchedPartitions) { + $null = Add-CIPPAzDataTableEntity @Table -Entity @{ + PartitionKey = $Partition + RowKey = $CountRowKey + DataCount = 0 + Type = $TypeName + } -Force + } + + $StillThere = @(Get-AzDataTableEntity @Table -Filter $Filter -Property PartitionKey, RowKey -ErrorAction Stop | + Where-Object { [string]$_.RowKey -ne $CountRowKey }) + if ($StillThere.Count -gt 0) { + $Sample = ($StillThere | Select-Object -First 3 | ForEach-Object { "$($_.PartitionKey)/$($_.RowKey)" }) -join ', ' + throw "Deleted $RemovedCount $TypeName row(s) for $TenantFilter but $($StillThere.Count) still exist (e.g. $Sample)" + } + + Write-LogMessage -API 'CIPPDbItem' -tenant $TenantFilter -message "Cleared $RemovedCount $TypeName cache row(s) for $TenantFilter" -sev Warning + return [PSCustomObject]@{ + RemovedCount = $RemovedCount + Tenant = $ResultTenant + Type = $TypeName + } + } catch { + Write-LogMessage -API 'CIPPDbItem' -tenant $TenantFilter -message "Failed to clear $Type cache: $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) + throw + } +} diff --git a/Modules/CIPPCore/Public/Clear-CIPPMobileDevice.ps1 b/Modules/CIPPCore/Public/Clear-CIPPMobileDevice.ps1 new file mode 100644 index 0000000000000..9e9da733c2d4d --- /dev/null +++ b/Modules/CIPPCore/Public/Clear-CIPPMobileDevice.ps1 @@ -0,0 +1,42 @@ +function Clear-CIPPMobileDevice { + [CmdletBinding()] + param( + $UserId, + $TenantFilter, + $Username, + $APIName = 'Wipe Mobile', + $Headers + ) + + try { + $WipedDevices = [System.Collections.Generic.List[string]]::new() + $ErrorDevices = [System.Collections.Generic.List[string]]::new() + # AccountOnly wipes the Exchange account data from the device, never the full device. + # Requires EAS v16.1+; older clients fail the call rather than falling back to a device wipe. + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-MobileDevice' -Anchor $Username -cmdParams @{mailbox = $Username } | ForEach-Object { + try { + $MobileDevice = $_ + # FriendlyName is usually empty; fall back like the ActiveSync device list. + $DeviceName = @($MobileDevice.FriendlyName, $MobileDevice.DeviceModel, $MobileDevice.DeviceOS, $MobileDevice.DeviceId) | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -First 1 + if (-not $DeviceName) { $DeviceName = 'Unknown device' } + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Clear-MobileDevice' -Anchor $Username -cmdParams @{Identity = $MobileDevice.Identity; AccountOnly = $true } + $WipedDevices.Add([string]$DeviceName) + } catch { + $ErrorDevices.Add([string]$DeviceName) + } + } + if ($ErrorDevices.Count -eq 0) { + $Message = "Successfully issued an account-only wipe for $($WipedDevices.Count) mobile devices for $($Username): $($WipedDevices -join '; '). The wipe is performed when the device next connects to Exchange." + } else { + $Message = "Failed to wipe all mobile devices for $($Username). Successfully issued an account-only wipe for $($WipedDevices.Count) mobile devices: $($WipedDevices -join '; '). Failed to wipe $($ErrorDevices.Count) mobile devices: $($ErrorDevices -join '; ')" + Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev 'Error' -tenant $TenantFilter + } + return $Message + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Message = "Failed to wipe mobile devices for $($Username). Error: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev 'Error' -tenant $TenantFilter -LogData $ErrorMessage + throw $Message + } +} diff --git a/Modules/CIPPCore/Public/ConvertTo-CIPPCountryCode.ps1 b/Modules/CIPPCore/Public/ConvertTo-CIPPCountryCode.ps1 new file mode 100644 index 0000000000000..4f655459fa59b --- /dev/null +++ b/Modules/CIPPCore/Public/ConvertTo-CIPPCountryCode.ps1 @@ -0,0 +1,64 @@ +function ConvertTo-CIPPCountryCode { + <# + .SYNOPSIS + Normalises a country value (ISO 3166-1 alpha-2 code or country name) to its code. + + .DESCRIPTION + Contact templates store the country as a two-letter ISO code - the value field of the + frontend country picker - but Exchange's Get-Contact returns CountryOrRegion as the full + country name (e.g. 'United States'). Comparing the two directly always reports a + difference, so callers normalise both sides through this helper before comparing. + + Resolution is done against Config\CountryList.json - the same list the frontend picker is + built from (frontend\src\data\countryList.json) - so the code<->name mapping matches what + produced the stored value. Matching is case-insensitive and accepts either a code or a + name. Anything that cannot be resolved (unknown value, unreadable list) is returned + trimmed and upper-cased so a raw comparison still works and no country is silently + dropped; null/empty input returns $null. + + Keep Config\CountryList.json in sync with frontend\src\data\countryList.json. + + .PARAMETER Country + A country code ('US') or name ('United States'). Accepts pipeline input. + + .EXAMPLE + ConvertTo-CIPPCountryCode 'US' # US + + .EXAMPLE + ConvertTo-CIPPCountryCode 'United States' # US + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Position = 0, ValueFromPipeline = $true)] + [AllowNull()] + [AllowEmptyString()] + $Country + ) + + process { + $Value = "$Country".Trim() + if ([string]::IsNullOrWhiteSpace($Value)) { return $null } + + if (-not $script:CIPPCountryList) { + try { + $ListPath = Join-Path $env:CIPPRootPath 'Config\CountryList.json' + $script:CIPPCountryList = [System.IO.File]::ReadAllText($ListPath) | ConvertFrom-Json + } catch { + Write-Warning "[CountryCode] Could not load CountryList.json: $($_.Exception.Message)" + return $Value.ToUpperInvariant() + } + } + + # -eq on strings is case-insensitive, so 'us'/'US' and 'united states'/'United States' both hit. + $Match = $script:CIPPCountryList | Where-Object { + $_.Code -eq $Value -or $_.Name -eq $Value + } | Select-Object -First 1 + + if ($Match) { return [string]$Match.Code } + return $Value.ToUpperInvariant() + } +} diff --git a/Modules/CIPPCore/Public/ConvertTo-CIPPIntunePolicyListItem.ps1 b/Modules/CIPPCore/Public/ConvertTo-CIPPIntunePolicyListItem.ps1 index b11330739f093..f20b43481a7c0 100644 --- a/Modules/CIPPCore/Public/ConvertTo-CIPPIntunePolicyListItem.ps1 +++ b/Modules/CIPPCore/Public/ConvertTo-CIPPIntunePolicyListItem.ps1 @@ -42,6 +42,8 @@ function ConvertTo-CIPPIntunePolicyListItem { '*windowsUpdateForBusinessConfiguration*' { 'Update Configuration' } '*windowsHealthMonitoringConfiguration*' { 'Health Monitoring' } '*microsoft.graph.macOSGeneralDeviceConfiguration*' { 'MacOS Configuration' } + '*microsoft.graph.macOSSoftwareUpdateConfiguration*' { 'macOS Update Configuration' } + '*microsoft.graph.windows10GeneralConfiguration*' { 'Windows Configuration' } '*microsoft.graph.macOSEndpointProtectionConfiguration*' { 'MacOS Endpoint Protection' } '*microsoft.graph.androidWorkProfileGeneralDeviceConfiguration*' { 'Android Configuration' } '*windowsFeatureUpdateProfiles*' { 'Feature Update' } @@ -67,7 +69,14 @@ function ConvertTo-CIPPIntunePolicyListItem { } elseif (-not [string]::IsNullOrWhiteSpace($DefaultPolicyTypeName)) { $PolicyTypeName = $DefaultPolicyTypeName } else { - $PolicyTypeName = $AssignmentContext + # Unmapped family: name it from the @odata type in the assignment context so the + # column never surfaces a raw Graph URL. + $OdataType = [regex]::Match([string]$AssignmentContext, 'microsoft\.graph\.([A-Za-z0-9]+)').Groups[1].Value + if ($OdataType) { + $PolicyTypeName = (($OdataType -creplace '([a-z\d])([A-Z])', '$1 $2') -creplace '([A-Z]+)([A-Z][a-z])', '$1 $2') + } else { + $PolicyTypeName = $AssignmentContext + } } } diff --git a/Modules/CIPPCore/Public/ConvertTo-CIPPSharePointSiteUsagePayload.ps1 b/Modules/CIPPCore/Public/ConvertTo-CIPPSharePointSiteUsagePayload.ps1 new file mode 100644 index 0000000000000..e4dd75e2d73fc --- /dev/null +++ b/Modules/CIPPCore/Public/ConvertTo-CIPPSharePointSiteUsagePayload.ps1 @@ -0,0 +1,61 @@ +function ConvertTo-CIPPSharePointSiteUsagePayload { + <# + .SYNOPSIS + Maps a SharePoint site listing row and usage row to the Invoke-ListSites payload shape. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + $Site, + + $SiteUsage, + + [string]$Tenant, + + $CacheTimestamp + ) + + $StorageUsedInGigabytes = if ($SiteUsage -and $null -ne $SiteUsage.storageUsedInBytes) { + [math]::Round([double]$SiteUsage.storageUsedInBytes / 1GB, 2) + } else { $null } + + $StorageAllocatedInGigabytes = if ($SiteUsage -and $null -ne $SiteUsage.storageAllocatedInBytes) { + [math]::Round([double]$SiteUsage.storageAllocatedInBytes / 1GB, 2) + } else { $null } + + $ArchiveGb = if ($null -ne $Site.archivedFileDiskUsedBytes) { + [math]::Round([double]$Site.archivedFileDiskUsedBytes / 1GB, 2) + } else { $null } + + $ReportItem = [PSCustomObject]@{ + siteId = $Site.sharepointIds.siteId + webId = $Site.sharepointIds.webId + createdDateTime = $Site.createdDateTime + displayName = $Site.displayName + webUrl = $Site.webUrl + ownerDisplayName = if ($SiteUsage) { $SiteUsage.ownerDisplayName } else { $null } + ownerPrincipalName = if ($SiteUsage) { $SiteUsage.ownerPrincipalName } else { $null } + lastActivityDate = if ($SiteUsage) { $SiteUsage.lastActivityDate } else { $null } + fileCount = if ($SiteUsage) { $SiteUsage.fileCount } else { $null } + storageUsedInGigabytes = $StorageUsedInGigabytes + storageAllocatedInGigabytes = $StorageAllocatedInGigabytes + storageUsedInBytes = if ($SiteUsage) { $SiteUsage.storageUsedInBytes } else { $null } + storageAllocatedInBytes = if ($SiteUsage) { $SiteUsage.storageAllocatedInBytes } else { $null } + rootWebTemplate = if ($SiteUsage) { $SiteUsage.rootWebTemplate } else { $null } + reportRefreshDate = if ($SiteUsage) { $SiteUsage.reportRefreshDate } else { $null } + archivedFileDiskUsedBytes = $Site.archivedFileDiskUsedBytes + archivedFileDiskUsedGigabytes = $ArchiveGb + allowFileArchive = $Site.allowFileArchive + AutoMapUrl = $Site.AutoMapUrl + } + + if ($Tenant) { + $ReportItem | Add-Member -NotePropertyName 'Tenant' -NotePropertyValue $Tenant -Force + } + + if ($CacheTimestamp) { + $ReportItem | Add-Member -NotePropertyName 'CacheTimestamp' -NotePropertyValue $CacheTimestamp -Force + } + + return $ReportItem +} diff --git a/Modules/CIPPCore/Public/ConvertTo-SPOAdminListInt64.ps1 b/Modules/CIPPCore/Public/ConvertTo-SPOAdminListInt64.ps1 new file mode 100644 index 0000000000000..831f2cba81d49 --- /dev/null +++ b/Modules/CIPPCore/Public/ConvertTo-SPOAdminListInt64.ps1 @@ -0,0 +1,31 @@ +function ConvertTo-SPOAdminListInt64 { + <# + .SYNOPSIS + Parse RenderAdminListData numeric fields (comma-separated strings) to int64. + + .DESCRIPTION + SPO.Tenant/RenderAdminListData returns counts and byte sizes as strings like + "1,073,741,824". Returns $null for empty or unparseable values. + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [AllowNull()] + $Raw + ) + + if ($null -eq $Raw -or $Raw -eq '') { return $null } + if ($Raw -is [int64]) { return $Raw } + if ($Raw -is [int] -or $Raw -is [long]) { return [int64]$Raw } + + $Clean = ([string]$Raw).Replace(',', '').Trim() + if ($Clean -eq '') { return $null } + + $Parsed = [int64]0 + if ([int64]::TryParse($Clean, [ref]$Parsed)) { + return $Parsed + } + return $null +} diff --git a/Modules/CIPPCore/Public/ConvertTo-SPOUsageRootWebTemplate.ps1 b/Modules/CIPPCore/Public/ConvertTo-SPOUsageRootWebTemplate.ps1 new file mode 100644 index 0000000000000..1cf81c1a9e0da --- /dev/null +++ b/Modules/CIPPCore/Public/ConvertTo-SPOUsageRootWebTemplate.ps1 @@ -0,0 +1,41 @@ +function ConvertTo-SPOUsageRootWebTemplate { + <# + .SYNOPSIS + Map SPO admin TemplateName to Graph getSharePointSiteUsageDetail rootWebTemplate values. + + .DESCRIPTION + RenderAdminListData returns template codes like GROUP#0 and STS#3. Cached SharePoint + usage consumers (sharing report, SharePoint Sites actions) expect the friendly values + from the Graph usage report, e.g. Group and Team Channel. + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [AllowNull()] + [string]$TemplateName + ) + + if ([string]::IsNullOrWhiteSpace($TemplateName)) { return $null } + + $Trimmed = $TemplateName.Trim() + if ($Trimmed -notmatch '#') { return $Trimmed } + + $Base = ($Trimmed -split '#', 2)[0].Trim() + + switch -Regex ($Base) { + '^(?i)GROUP$' { return 'Group' } + '^(?i)TEAMCHANNEL$' { return 'Team Channel' } + '^(?i)STS$' { return 'STS' } + '^(?i)SITEPAGEPUBLISHING$' { return 'Site Page Publishing' } + '^(?i)APPCATALOG$' { return 'App Catalog Site' } + '^(?i)REDIRECTSITE$' { return 'Redirect Site' } + '^(?i)TENANTADMIN$' { return 'Tenant Admin Site' } + '^(?i)SPSMSITEHOST$' { return 'My Site Host' } + '^(?i)SRCHCEN$' { return 'Basic Search Center' } + '^(?i)EDISC$' { return 'Compliance Policy Center' } + '^(?i)POINTPUBLISHINGTOPIC$' { return 'SharePoint Online Tenant Fundamental Site' } + default { return $Base } + } +} diff --git a/Modules/CIPPCore/Public/DeltaQueries/Get-DeltaQueryUrl.ps1 b/Modules/CIPPCore/Public/DeltaQueries/Get-DeltaQueryUrl.ps1 index c2ad699215d15..87af12a39482b 100644 --- a/Modules/CIPPCore/Public/DeltaQueries/Get-DeltaQueryUrl.ps1 +++ b/Modules/CIPPCore/Public/DeltaQueries/Get-DeltaQueryUrl.ps1 @@ -22,21 +22,26 @@ function Get-DeltaQueryUrl { $Table = Get-CIPPTable -TableName 'DeltaQueries' $DeltaQueryEntity = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq '$PartitionKey' and RowKey eq '$TenantFilter'" - if ($DeltaQueryEntity) { + # A row whose DeltaUrl is blank is as unusable as a missing row: an earlier delta query that + # ended without a deltaLink persisted an empty value, and handing that to New-GraphDeltaQuery + # fails parameter binding ("Cannot bind argument to parameter 'DeltaUrl' because it is an + # empty string") on every scheduled evaluation until the row is rebuilt. + if ($DeltaQueryEntity -and -not [string]::IsNullOrWhiteSpace($DeltaQueryEntity.DeltaUrl)) { return $DeltaQueryEntity.DeltaUrl } + $MissingReason = if ($DeltaQueryEntity) { 'has no delta link' } else { 'is missing' } $TaskTable = Get-CIPPTable -TableName 'ScheduledTasks' $Task = Get-CIPPAzDataTableEntity @TaskTable -Filter "PartitionKey eq 'ScheduledTask' and RowKey eq '$PartitionKey'" if (!$Task.Trigger) { - throw "Delta Query not found for Tenant '$TenantFilter' and PartitionKey '$PartitionKey', and no scheduled task with a trigger exists to rebuild it from." + throw "Delta Query for Tenant '$TenantFilter' and PartitionKey '$PartitionKey' $MissingReason, and no scheduled task with a trigger exists to rebuild it from." } - Write-Warning "Delta Query missing for Tenant '$TenantFilter' and PartitionKey '$PartitionKey'. Rebuilding it from task '$($Task.Name)'." + Write-Warning "Delta Query for Tenant '$TenantFilter' and PartitionKey '$PartitionKey' $MissingReason. Rebuilding it from task '$($Task.Name)'." $Rebuilt = New-CIPPTaskDeltaQuery -Trigger $Task.Trigger -TenantFilter $TenantFilter -PartitionKey $PartitionKey $DeltaUrl = $Rebuilt.'@odata.deltaLink' if (!$DeltaUrl) { - throw "Delta Query not found for Tenant '$TenantFilter' and PartitionKey '$PartitionKey' and could not be rebuilt." + throw "Delta Query for Tenant '$TenantFilter' and PartitionKey '$PartitionKey' $MissingReason and could not be rebuilt." } Write-LogMessage -API 'Scheduler_UserTasks' -tenant $TenantFilter -message "Rebuilt the missing delta query for task '$($Task.Name)'. Changes from before the rebuild were not captured and will not trigger this task." -sev Warning diff --git a/Modules/CIPPCore/Public/DeltaQueries/New-GraphDeltaQuery.ps1 b/Modules/CIPPCore/Public/DeltaQueries/New-GraphDeltaQuery.ps1 index 22891bf8d6a75..586612d2985d1 100644 --- a/Modules/CIPPCore/Public/DeltaQueries/New-GraphDeltaQuery.ps1 +++ b/Modules/CIPPCore/Public/DeltaQueries/New-GraphDeltaQuery.ps1 @@ -160,6 +160,12 @@ function New-GraphDeltaQuery { if ($DeltaError) { throw "Delta Query failed for tenant '$TenantFilter'." } + # Never persist a row without a delta link. A blank DeltaUrl is not "start over", it is a + # row that every later evaluation reads and then fails to bind, so the trigger silently + # stops working until someone rebuilds it. + if ([string]::IsNullOrWhiteSpace($deltaLink)) { + throw "Graph returned no deltaLink for the '$($DeltaQuery.Resource ?? $Resource)' delta query on tenant '$TenantFilter'. The delta query row was not updated." + } $DeltaQuery.RowKey = $TenantFilter $DeltaQuery.DeltaUrl = $deltaLink diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UpdatePermissionsOrchestrator.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UpdatePermissionsOrchestrator.ps1 index ea206ff721cf5..ead5e05b3da2a 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UpdatePermissionsOrchestrator.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UpdatePermissionsOrchestrator.ps1 @@ -63,9 +63,16 @@ function Start-UpdatePermissionsOrchestrator { OrchestratorName = 'UpdatePermissionsOrchestrator' Batch = @($TenantBatch) } - Start-CIPPOrchestrator -InputObject $InputObject + $InstanceId = Start-CIPPOrchestrator -InputObject $InputObject + Write-LogMessage -API 'CPVRefresh' -tenant 'Global' -message "Started CPV permissions refresh for $TenantCount tenant(s). QueueId=$($Queue.RowKey)" -Sev 'Info' + return $InstanceId } else { Write-Information 'No tenants require permissions update' + Write-LogMessage -API 'CPVRefresh' -tenant 'Global' -message 'CPV permissions refresh triggered; no tenants required an update' -Sev 'Info' + return $null } - } catch {} + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CPVRefresh' -tenant 'Global' -message "Failed to start CPV permissions refresh: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + } } diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 index 131cf714d7cbb..069473961ed66 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 @@ -106,7 +106,11 @@ function Start-UserTasksOrchestrator { throw "Command '$($task.Command)' not found and no module could be resolved from the command name for scheduled task '$($task.Name)'." } } - $HasTenantFilter = $CommandInfo.Parameters.ContainsKey('TenantFilter') + # The task's authorized tenant is injected into the most specific tenant-identifying + # parameter the command declares - stored parameter values must never select the tenant. + $TenantParamNames = [array](@('TenantFilter', 'Tenant', 'TenantId') | Where-Object { $CommandInfo.Parameters.ContainsKey($_) }) + $HasTenantFilter = $TenantParamNames.Count -gt 0 + $PrimaryTenantParam = $TenantParamNames.Count -gt 0 ? $TenantParamNames[0] : $null $ScheduledCommand = [pscustomobject]@{ Command = $task.Command @@ -115,20 +119,79 @@ function Start-UserTasksOrchestrator { FunctionName = 'ExecScheduledCommand' } - if ($task.Tenant -eq 'AllTenants') { - $ExcludedTenants = @($task.excludedTenants -split ',' | Where-Object { $_ }) - if ($task.excludedTenantGroups) { - # Expand excluded tenant groups at runtime so membership changes are honored - $ExcludedGroups = $task.excludedTenantGroups | ConvertFrom-Json -ErrorAction SilentlyContinue - if ($ExcludedGroups) { - $ExcludedTenants = @($ExcludedTenants + (Expand-CIPPTenantGroups -TenantFilter $ExcludedGroups).value | Where-Object { $_ }) + # Scope is resolved on every run so group membership stays current, as + # Test-CIPPAuditLogRules does for audit alerts. The stored selection is only trusted + # on a row the execution gates also read as multi-tenant, otherwise the fan-out here + # and Push-ExecScheduledCommand would disagree about the task's shape. + $UsesStoredSelection = $task.Tenants -and $task.Tenant -eq 'AllTenants' + if ($task.Tenants -and -not $UsesStoredSelection) { + Write-Information "Task $($task.Name): ignoring the stored selection, Tenant is '$($task.Tenant)' rather than AllTenants" + } + $Selection = if ($UsesStoredSelection) { + @($task.Tenants | ConvertFrom-Json -ErrorAction SilentlyContinue) + } elseif ($task.TenantGroup) { + @($task.TenantGroup | ConvertFrom-Json -ErrorAction SilentlyContinue) + } + + $TargetTenants = $null + $ResolvedScope = $false + if ($Selection) { + try { + $Expanded = Expand-CIPPTenantGroups -TenantFilter $Selection + } catch { + # Must not fall through to the single-tenant path below: Tenant is the + # AllTenants sentinel for a multi-entry selection. Fail the task instead. + throw "Failed to expand tenant selection for task $($task.Name): $($_.Exception.Message)" + } + # Non-group entries pass through unexpanded, so the sentinel survives. + $TargetTenants = if ($Expanded.value -contains 'AllTenants') { + $TenantList + } else { + @($TenantList | Where-Object { $_.defaultDomainName -in $Expanded.value }) + } + $ResolvedScope = $true + } elseif ($task.Tenant -eq 'AllTenants') { + # An explicit *All Tenants pick, with no selection stored alongside it + $TargetTenants = $TenantList + $ResolvedScope = $true + } + + # Rows predating runtime expansion merged a snapshot of every unselected tenant into + # excludedTenants, indistinguishable from the operator's own picks, so it is ignored + # for those. A selection carrying the AllTenants sentinel never had a snapshot + # written, so its exclusions are the operator's and are kept. excludedTenantGroups + # was never part of the snapshot either and always applies. + $IsLegacySnapshot = $UsesStoredSelection -and -not $task.TenantSelectionVersion -and ($Selection.value -notcontains 'AllTenants') + $ExcludedTenants = [System.Collections.Generic.List[string]]::new() + if ($task.excludedTenants) { + $StoredExclusions = @($task.excludedTenants -split ',' | Where-Object { $_ }) + if ($IsLegacySnapshot) { + # Only report a snapshot that would actually have dropped a tenant in scope + # now, or every run of every legacy row logs the same no-op indefinitely. + $Reinstated = @($StoredExclusions | Where-Object { $_ -in $TargetTenants.defaultDomainName }) + if ($Reinstated.Count -gt 0) { + Write-LogMessage -API 'Scheduler_UserTasks' -tenant $tenant -message "Task $($task.Name): ignored $($Reinstated.Count) stale snapshot exclusions, tenant group membership is now resolved at runtime" -Sev 'Info' } + } else { + $ExcludedTenants.AddRange([string[]]$StoredExclusions) + } + } + if ($task.excludedTenantGroups) { + $ExcludedGroups = $task.excludedTenantGroups | ConvertFrom-Json -ErrorAction SilentlyContinue + if ($ExcludedGroups) { + $ExcludedTenants.AddRange([string[]]@((Expand-CIPPTenantGroups -TenantFilter $ExcludedGroups).value | Where-Object { $_ })) } - Write-Host "Excluded Tenants from this task: $ExcludedTenants" - $AllTenantCommands = foreach ($Tenant in $TenantList | Where-Object { $_.defaultDomainName -notin $ExcludedTenants }) { + } + + if ($ResolvedScope) { + Write-Information "Task $($task.Name): $(@($TargetTenants).Count) tenants in scope, $($ExcludedTenants.Count) excluded" + $FanOutCommands = foreach ($Tenant in $TargetTenants | Where-Object { $_.defaultDomainName -notin $ExcludedTenants }) { $NewParams = $task.Parameters.Clone() if ($HasTenantFilter) { + # TenantFilter always carries the execution tenant context; it is stripped + # before splatting if the command does not declare it $NewParams.TenantFilter = $Tenant.defaultDomainName + $NewParams.$PrimaryTenantParam = $Tenant.defaultDomainName } # Clone TaskInfo to prevent shared object references $TaskInfoClone = $task.PSObject.Copy() @@ -139,75 +202,49 @@ function Start-UserTasksOrchestrator { FunctionName = 'ExecScheduledCommand' } } - $Batch.AddRange(@($AllTenantCommands)) - } elseif ($task.TenantGroup) { - # Handle tenant groups - expand group to individual tenants - try { - $TenantGroupObject = $task.TenantGroup | ConvertFrom-Json - Write-Host "Expanding tenant group: $($TenantGroupObject.label) with ID: $($TenantGroupObject.value)" - - # Create a tenant filter object for expansion - $TenantFilterForExpansion = @([PSCustomObject]@{ - type = 'Group' - value = $TenantGroupObject.value - label = $TenantGroupObject.label - }) - - # Expand the tenant group to individual tenants - $ExpandedTenants = Expand-CIPPTenantGroups -TenantFilter $TenantFilterForExpansion - - $ExcludedTenants = @($task.excludedTenants -split ',' | Where-Object { $_ }) - if ($task.excludedTenantGroups) { - # Expand excluded tenant groups at runtime so membership changes are honored - $ExcludedGroups = $task.excludedTenantGroups | ConvertFrom-Json -ErrorAction SilentlyContinue - if ($ExcludedGroups) { - $ExcludedTenants = @($ExcludedTenants + (Expand-CIPPTenantGroups -TenantFilter $ExcludedGroups).value | Where-Object { $_ }) - } - } - Write-Host "Excluded Tenants from this task: $ExcludedTenants" - - $GroupTenantCommands = foreach ($ExpandedTenant in $ExpandedTenants | Where-Object { $_.value -notin $ExcludedTenants }) { - $NewParams = $task.Parameters.Clone() - if ($HasTenantFilter) { - $NewParams.TenantFilter = $ExpandedTenant.value - } - # Clone TaskInfo to prevent shared object references - $TaskInfoClone = $task.PSObject.Copy() - [pscustomobject]@{ - Command = $task.Command - Parameters = $NewParams - TaskInfo = $TaskInfoClone - FunctionName = 'ExecScheduledCommand' - } - } - $Batch.AddRange(@($GroupTenantCommands)) - } catch { - Write-Host "Error expanding tenant group: $($_.Exception.Message)" - Write-LogMessage -API 'Scheduler_UserTasks' -tenant $tenant -message "Failed to expand tenant group for task $($task.Name): $($_.Exception.Message)" -sev Error - - # Fall back to treating as single tenant - if ($HasTenantFilter) { - $ScheduledCommand.Parameters['TenantFilter'] = $task.Tenant + if (@($FanOutCommands).Count -gt 0) { + $Batch.AddRange(@($FanOutCommands)) + } else { + # Every selected group resolved empty, or was deleted. Close the run out here: + # the row is already Pending, and with no batch item no orchestrator or post + # execution runs, so it would be reclaimed as stale every hour and a recurring + # task would never advance its schedule. + $NextRun = Get-CIPPScheduledTaskNextRun -Recurrence $task.Recurrence -ScheduledTime $task.ScheduledTime + $EmptyScopeEntity = @{ + PartitionKey = $task.PartitionKey + RowKey = $task.RowKey + Results = 'No tenants in scope for this task.' + ExecutedTime = "$currentUnixTime" + TaskState = $NextRun -gt 0 ? 'Planned' : 'Completed' } - $Batch.Add($ScheduledCommand) + if ($NextRun -gt 0) { $EmptyScopeEntity.ScheduledTime = "$NextRun" } + $null = Update-AzDataTableEntity -Force @Table -Entity $EmptyScopeEntity + Write-LogMessage -API 'Scheduler_UserTasks' -tenant $tenant -message "Task $($task.Name): no tenants in scope, nothing to run" -Sev 'Info' } } else { - # Handle single tenant + # Single tenant if ($HasTenantFilter) { $ScheduledCommand.Parameters['TenantFilter'] = $task.Tenant + $ScheduledCommand.Parameters[$PrimaryTenantParam] = $task.Tenant } $Batch.Add($ScheduledCommand) } } catch { $errorMessage = $_.Exception.Message - $null = Update-AzDataTableEntity -Force @Table -Entity @{ + # Failed is terminal - the pickup filter only reads Planned and Failed - Planned - so + # a recurring task parked there never runs again. A transient failure here (a tenant + # or group table read, say) must not permanently stop it. + $NextRun = Get-CIPPScheduledTaskNextRun -Recurrence $task.Recurrence -ScheduledTime $task.ScheduledTime + $FailureEntity = @{ PartitionKey = $task.PartitionKey RowKey = $task.RowKey Results = "$errorMessage" ExecutedTime = "$currentUnixTime" - TaskState = 'Failed' + TaskState = $NextRun -gt 0 ? 'Failed - Planned' : 'Failed' } + if ($NextRun -gt 0) { $FailureEntity.ScheduledTime = "$NextRun" } + $null = Update-AzDataTableEntity -Force @Table -Entity $FailureEntity Write-LogMessage -API 'Scheduler_UserTasks' -tenant $tenant -message "Failed to execute task $($task.Name): $errorMessage" -sev Error } } diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-TableCleanup.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-TableCleanup.ps1 index ef2c70496461b..a91a6dc0a1464 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-TableCleanup.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-TableCleanup.ps1 @@ -128,6 +128,18 @@ function Start-TableCleanup { Property = @('PartitionKey', 'RowKey', 'ETag') } } + @{ + # Live-progress rows of background jobs (SharePoint template deploys, user offboarding). + # They matter while the job runs; a month covers looking back at a task page afterwards. + FunctionName = 'TableCleanupTask' + Type = 'CleanupRule' + TableName = 'CacheAsyncDeployments' + DataTableProps = @{ + Filter = "Timestamp lt datetime'$((Get-Date).AddDays(-30).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ'))'" + First = 10000 + Property = @('PartitionKey', 'RowKey', 'ETag') + } + } @{ FunctionName = 'TableCleanupTask' Type = 'DeleteTable' diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 index 77a45fc69813e..4d0ddc5b22927 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 @@ -52,7 +52,12 @@ function Start-UpdateTokensTimer { Write-Information ($_.InvocationInfo.PositionMessage) } - if ($LastPasswordCredential.endDateTime -lt (Get-Date).AddDays(30).ToUniversalTime()) { + if ($env:CertificateAuthMode) { + # Certificate-exclusive mode: CIPP authenticates with the SAM certificate, so never + # generate a client secret. Doing so would fail on tenants blocking password addition, + # or silently re-create a secret on a secret-less install. The certificate is renewed below. + Write-Information "Certificate authentication is enabled for $AppId; skipping client secret generation." + } elseif ($LastPasswordCredential.endDateTime -lt (Get-Date).AddDays(30).ToUniversalTime()) { Write-Information "Application secret for $AppId is expiring soon. Generating a new application secret." $AppSecret = New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/applications/$($AppRegistration.id)/addPassword" -Body '{"passwordCredential":{"displayName":"UpdateTokens"}}' -NoAuthCheck $true -AsApp $true -ErrorAction Stop Write-Information "New application secret generated for $AppId. Expiration date: $($AppSecret.endDateTime)." diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UserSyncTimer.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UserSyncTimer.ps1 index 8060d563651be..dabf3a7ee0f1d 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UserSyncTimer.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UserSyncTimer.ps1 @@ -24,20 +24,46 @@ function Start-UserSyncTimer { $AccessGroupsTable = Get-CippTable -TableName AccessRoleGroups $AccessGroups = @(Get-CIPPAzDataTableEntity @AccessGroupsTable -Filter "PartitionKey eq 'AccessRoleGroups'") - # Get the group IDs we care about - $RoleGroupIds = @($AccessGroups | ForEach-Object { $_.GroupId } | Where-Object { $_ }) + # Load the roles that actually exist on this instance. A mapping whose role was never + # migrated across (or was later deleted) leaves an orphaned auto-role that the access + # check cannot resolve - which locks the user out of everything, base role included. + # Skipping those mappings here lets the sync self-heal: because each user's auto-roles + # are recomputed from scratch every run, the stale role is dropped from every affected + # user on the next pass instead of being re-stamped forever. This mirrors the same + # existence guard the live path already applies in Test-CIPPAccessUserRole. + # $CustomRoleNames stays $null when the lookup fails so a transient storage error + # degrades to "prune nothing" rather than stripping every custom role from every user. + $BaseRoles = @('superadmin', 'admin', 'editor', 'readonly') + $CustomRoleNames = $null + try { + $CustomRolesTable = Get-CippTable -TableName CustomRoles + $CustomRoleNames = @(Get-CIPPAzDataTableEntity @CustomRolesTable -Filter "PartitionKey eq 'CustomRoles'" | ForEach-Object { $_.RowKey }) + } catch { + Write-LogMessage -API $ApiName -tenant 'none' -message "User sync could not load custom roles; skipping stale-role pruning this run: $($_.Exception.Message)" -sev Warning + } - # Build a lookup: GroupId -> Role names (a group can map to multiple roles) + # Build a lookup: GroupId -> Role names (a group can map to multiple roles), keeping only + # roles that still exist. Orphaned role names are collected so the run that prunes them + # can say which ones, without re-logging on every steady-state pass afterwards. $GroupToRoles = @{} + $SkippedRoles = [System.Collections.Generic.List[string]]::new() foreach ($Mapping in $AccessGroups) { - if ($Mapping.GroupId) { - if (-not $GroupToRoles.ContainsKey($Mapping.GroupId)) { - $GroupToRoles[$Mapping.GroupId] = [System.Collections.Generic.List[string]]::new() - } - $GroupToRoles[$Mapping.GroupId].Add($Mapping.RowKey) + if (-not $Mapping.GroupId) { continue } + # $null CustomRoleNames means the lookup failed above - treat every role as valid. + $RoleExists = ($BaseRoles -contains $Mapping.RowKey) -or ($null -eq $CustomRoleNames) -or ($CustomRoleNames -contains $Mapping.RowKey) + if (-not $RoleExists) { + if ($SkippedRoles -notcontains $Mapping.RowKey) { $SkippedRoles.Add($Mapping.RowKey) } + continue } + if (-not $GroupToRoles.ContainsKey($Mapping.GroupId)) { + $GroupToRoles[$Mapping.GroupId] = [System.Collections.Generic.List[string]]::new() + } + $GroupToRoles[$Mapping.GroupId].Add($Mapping.RowKey) } + # Only fetch members of groups that still map to at least one real role + $RoleGroupIds = @($GroupToRoles.Keys) + # Fetch members of each role group from the partner tenant # Use transitiveMembers to catch nested group memberships $UserRoleMap = @{} # UPN -> HashSet of auto roles @@ -52,6 +78,19 @@ function Start-UserSyncTimer { foreach ($Member in $UserMembers) { $Upn = $Member.userPrincipalName if ([string]::IsNullOrWhiteSpace($Upn)) { continue } + + if ($Upn -match '#EXT#') { + if (-not [string]::IsNullOrWhiteSpace($Member.mail)) { + $Upn = $Member.mail + } else { + $Upn = ($Upn -replace '#EXT#@.+$', '') -replace '_([^_]+)$', '@$1' + } + } + + if ($Upn -match '[#/\\?]') { + Write-LogMessage -API $ApiName -tenant 'none' -message "User sync skipped '$($Member.userPrincipalName)': could not derive a Table Storage-safe key." -sev Warning + continue + } $Upn = $Upn.Trim().ToLower() if (-not $UserRoleMap.ContainsKey($Upn)) { @@ -223,7 +262,11 @@ function Start-UserSyncTimer { # Only log when something actually changed — no noise on steady-state runs. if ($ChangedCount -gt 0 -or $RemoveCount -gt 0) { - Write-LogMessage -API $ApiName -tenant 'none' -message "User sync completed: $ChangedCount users added/updated, $RemoveCount duplicate/stale rows removed." -sev Info + $Message = "User sync completed: $ChangedCount users added/updated, $RemoveCount duplicate/stale rows removed." + if ($SkippedRoles.Count -gt 0) { + $Message += " Pruned auto-role(s) with no matching definition on this instance: $($SkippedRoles -join ', ')." + } + Write-LogMessage -API $ApiName -tenant 'none' -message $Message -sev Info } } catch { diff --git a/Modules/CIPPCore/Public/Functions/Format-CIPPCAPolicy.ps1 b/Modules/CIPPCore/Public/Functions/Format-CIPPCAPolicy.ps1 index f97189819906f..236dde50cd5eb 100644 --- a/Modules/CIPPCore/Public/Functions/Format-CIPPCAPolicy.ps1 +++ b/Modules/CIPPCore/Public/Functions/Format-CIPPCAPolicy.ps1 @@ -24,6 +24,12 @@ function Format-CIPPCAPolicy { than being removed - null is accepted on a create and still clears on an update. Empty assignment arrays are deliberately KEPT: an explicit "includeGroups": [] is the only thing that strips a group off a policy that already has one. + + 3. sessionControls.signInFrequency - value is Int32 in Graph but has been saved as a + string by older editors, so a numeric string is cast. When frequencyInterval is + everyTime, value/type are forced explicitly null (added if absent) since Graph + requires both null there and a PATCH merge would otherwise let a stale value/type + survive from the tenant's existing policy. .PARAMETER Policy The parsed CA policy object. Mutated in place. .FUNCTIONALITY @@ -136,4 +142,19 @@ function Format-CIPPCAPolicy { $Policy.sessionControls = $null } } + + # signInFrequency.value is Int32 in Graph, but editors have saved it as a string - cast it. + # When frequencyInterval is everyTime, Graph requires value/type to be null rather than + # merely absent, and this is a PATCH merge, so a stale value/type from the tenant's existing + # policy would otherwise survive. + $SignInFrequency = $Policy.sessionControls.signInFrequency + if ($null -ne $SignInFrequency -and $SignInFrequency -is [PSCustomObject]) { + if ($SignInFrequency.PSObject.Properties.Name -contains 'value' -and $SignInFrequency.value -is [string] -and $SignInFrequency.value -match '^\d+$') { + $SignInFrequency.value = [int]$SignInFrequency.value + } + if ($SignInFrequency.frequencyInterval -eq 'everyTime') { + $SignInFrequency | Add-Member -NotePropertyName 'value' -NotePropertyValue $null -Force + $SignInFrequency | Add-Member -NotePropertyName 'type' -NotePropertyValue $null -Force + } + } } diff --git a/Modules/CIPPCore/Public/Functions/Get-CIPPAppServiceSite.ps1 b/Modules/CIPPCore/Public/Functions/Get-CIPPAppServiceSite.ps1 new file mode 100644 index 0000000000000..b729fc979f265 --- /dev/null +++ b/Modules/CIPPCore/Public/Functions/Get-CIPPAppServiceSite.ps1 @@ -0,0 +1,52 @@ +function Get-CIPPAppServiceSite { + <# + .SYNOPSIS + Resolves the App Service hosting this CIPP instance and the certificates its plan can bind. + .DESCRIPTION + One ARM read of the site (Microsoft.Web/sites/$env:WEBSITE_SITE_NAME, via the managed + identity) plus one of the certificates in the plan's resource group. A certificate binds + from the App Service PLAN's webspace, not the site's, so when the plan lives in another + resource group (a shared plan) certificates are created and looked up there. On a dedicated + plan both resource groups are the same. + + The certificate list is best effort: an identity without rights on the plan's resource + group gets an empty list, and the caller's create/bind then fails with the real 403. + .FUNCTIONALITY + Internal + .EXAMPLE + $AppService = Get-CIPPAppServiceSite + $AppService.Site.properties.hostNames + #> + [CmdletBinding()] + param( + [string]$ApiVersion = '2024-11-01' + ) + + $SiteName = $env:WEBSITE_SITE_NAME + $ResourceGroup = Get-CIPPFunctionAppResourceGroup -SiteName $SiteName + $SubscriptionId = Get-CIPPAzFunctionAppSubId + $ArmBase = "https://management.azure.com/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup/providers/Microsoft.Web/sites/$SiteName" + $Site = New-CIPPAzRestRequest -Uri "$ArmBase`?api-version=$ApiVersion" -Method GET -ErrorAction Stop + + $PlanId = [string]$Site.properties.serverFarmId + $CertResourceGroup = if ($PlanId -match '(?i)/resourceGroups/([^/]+)/') { $Matches[1] } else { $ResourceGroup } + $CertBase = "https://management.azure.com/subscriptions/$SubscriptionId/resourceGroups/$CertResourceGroup/providers/Microsoft.Web/certificates" + $Certificates = try { + @((New-CIPPAzRestRequest -Uri "$CertBase`?api-version=$ApiVersion" -Method GET -ErrorAction Stop).value) + } catch { + Write-Information "Could not list certificates in resource group '$CertResourceGroup': $($_.Exception.Message)" + @() + } + + [pscustomobject]@{ + SubscriptionId = $SubscriptionId + SiteName = $SiteName + ResourceGroup = $ResourceGroup + CertResourceGroup = $CertResourceGroup + ArmBase = $ArmBase + CertBase = $CertBase + ApiVersion = $ApiVersion + Site = $Site + Certificates = $Certificates + } +} diff --git a/Modules/CIPPCore/Public/Functions/Get-CIPPHostname.ps1 b/Modules/CIPPCore/Public/Functions/Get-CIPPHostname.ps1 index 02abf305acb1b..8309d2236028d 100644 --- a/Modules/CIPPCore/Public/Functions/Get-CIPPHostname.ps1 +++ b/Modules/CIPPCore/Public/Functions/Get-CIPPHostname.ps1 @@ -15,7 +15,10 @@ function Get-CIPPHostname { Resolve from the custom domain bound to the App Service ahead of the inbound request. Use it for anything that outlives the request - webhook registrations, stored URLs - so an admin who happens to browse in on the *.azurewebsites.net hostname does not pin - background work to it. + background work to it. Only a custom domain takes precedence: when none is bound to the + App Service the inbound request still decides, so a Static Web App deployment (custom + domain on the SWA, API as a linked backend) keeps generating links on the domain the + user is actually on rather than the platform hostname. .FUNCTIONALITY Internal .EXAMPLE @@ -30,17 +33,21 @@ function Get-CIPPHostname { $Hostname = $null - # Only an authoritative ARM answer wins here. When the lookup fails we fall through to the - # request host rather than guessing: demoting a working custom domain to the platform hostname - # on a transient 403 would silently rewrite every link CIPP sends out. + # Only an authoritative ARM answer that found a custom domain wins here. When the lookup fails + # we fall through to the request host rather than guessing: demoting a working custom domain to + # the platform hostname on a transient 403 would silently rewrite every link CIPP sends out. + # The same applies when ARM answers but no custom domain is bound: behind a Static Web App the + # custom domain lives on the SWA, so the function app's own hostname is never user-facing. if ($PreferCustomDomain.IsPresent) { try { $SiteState = Get-CIPPSiteHostname -IncludeStatus -NoFallback - if ($SiteState.Discovered -and ![string]::IsNullOrWhiteSpace($SiteState.PreferredHostname)) { + if ($SiteState.Discovered -and $SiteState.CustomHostnames.Count -gt 0 -and ![string]::IsNullOrWhiteSpace($SiteState.PreferredHostname)) { $Hostname = $SiteState.PreferredHostname if ($SiteState.CustomHostnames.Count -gt 1) { Write-Information "Get-CIPPHostname: $($SiteState.CustomHostnames.Count) custom domains bound ($($SiteState.CustomHostnames -join ', ')) - using the first, '$Hostname'" } + } elseif ($SiteState.Discovered) { + Write-Information 'Get-CIPPHostname: no custom domain is bound to this App Service - falling back to the request host' } else { Write-Information "Get-CIPPHostname: custom domain lookup was not authoritative, falling back to the request host: $($SiteState.Error)" } diff --git a/Modules/CIPPCore/Public/Functions/Get-CIPPTenantAlignment.ps1 b/Modules/CIPPCore/Public/Functions/Get-CIPPTenantAlignment.ps1 index 240e77e0aec6d..49ce40b910c84 100644 --- a/Modules/CIPPCore/Public/Functions/Get-CIPPTenantAlignment.ps1 +++ b/Modules/CIPPCore/Public/Functions/Get-CIPPTenantAlignment.ps1 @@ -90,6 +90,33 @@ function Get-CIPPTenantAlignment { $CATemplatesByPackage[$t.Package].Add($t) } } + # Every id a standards template can legitimately reference for each template type: the + # RowKey, the GUID column (the picker surfaces that one) and the bare guid of a built-in + # '..json' row. A reference that matches none of these points at a template that + # was deleted, and its standard can never get a report row - it would sit at NOT FOUND + # forever with nothing naming the culprit. + function Get-TemplateIdSet { + param($Rows) + $Set = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Row in @($Rows)) { + if ($Row.RowKey) { + [void]$Set.Add([string]$Row.RowKey) + if ($Row.RowKey -match '^([0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12})\.') { [void]$Set.Add($Matches[1]) } + } + if ($Row.GUID) { [void]$Set.Add([string]$Row.GUID) } + } + return , $Set + } + $KnownIntuneTemplateIds = Get-TemplateIdSet -Rows $TagTemplates + $KnownCATemplateIds = Get-TemplateIdSet -Rows $CATagTemplates + $KnownReusableTemplateIds = Get-TemplateIdSet -Rows (Get-CIPPAzDataTableEntity @TemplateTable -Filter "PartitionKey eq 'IntuneReusableSettingTemplate'") + function Get-MissingTemplateMessage { + param([string]$Kind, $Reference, [System.Collections.Generic.HashSet[string]]$KnownIds) + $Id = [string]$Reference.value + if ([string]::IsNullOrWhiteSpace($Id) -or $KnownIds.Contains($Id)) { return $null } + $Name = if ($Reference.label) { "'$($Reference.label)' " } else { '' } + return "$Kind template $Name($Id) no longer exists in the template library. Remove it from this standards template or select the template again." + } # Build tenant standards data structure $tenantData = @{} foreach ($Standard in $Standards) { @@ -218,6 +245,7 @@ function Get-CIPPTenantAlignment { [PSCustomObject]@{ StandardId = $IntuneStandardId ReportingEnabled = $IntuneReportingEnabled + MissingTemplate = Get-MissingTemplateMessage -Kind 'Intune' -Reference $IntuneTemplate.TemplateList -KnownIds $KnownIntuneTemplateIds } } @@ -252,6 +280,7 @@ function Get-CIPPTenantAlignment { [PSCustomObject]@{ StandardId = $CAStandardId ReportingEnabled = $CAReportingEnabled + MissingTemplate = Get-MissingTemplateMessage -Kind 'Conditional Access' -Reference $CATemplate.TemplateList -KnownIds $KnownCATemplateIds } } @@ -280,11 +309,14 @@ function Get-CIPPTenantAlignment { foreach ($RSTemplate in @($StandardConfig)) { $RSActions = if ($RSTemplate.action) { $RSTemplate.action } else { @() } $RSReportingEnabled = ($RSActions | Where-Object { $_.value -and ($_.value.ToLower() -eq 'report' -or $_.value.ToLower() -eq 'remediate') }).Count -gt 0 - foreach ($RSTemplateId in @($RSTemplate.TemplateList.value)) { + foreach ($RSReference in @($RSTemplate.TemplateList)) { + $RSTemplateId = if ($RSReference.value) { [string]$RSReference.value } else { [string]$RSReference } if ($RSTemplateId) { + $RSLookup = if ($RSReference.value) { $RSReference } else { [PSCustomObject]@{ value = $RSTemplateId; label = $null } } [PSCustomObject]@{ StandardId = "standards.ReusableSettingsTemplate.$RSTemplateId" ReportingEnabled = $RSReportingEnabled + MissingTemplate = Get-MissingTemplateMessage -Kind 'Reusable settings' -Reference $RSLookup -KnownIds $KnownReusableTemplateIds } } } @@ -312,6 +344,12 @@ function Get-CIPPTenantAlignment { } if (-not $StandardsData) { continue } + $MissingTemplateMessages = @{} + foreach ($Entry in @($StandardsData)) { + if ($Entry.PSObject.Properties['MissingTemplate'] -and $Entry.MissingTemplate -and $Entry.StandardId) { + $MissingTemplateMessages[[string]$Entry.StandardId] = [string]$Entry.MissingTemplate + } + } $AllStandards = @($StandardsData.StandardId | Where-Object { $_ }) if ($AllStandards.Count -eq 0) { continue } $AllStandardsArray = $AllStandards @@ -374,6 +412,23 @@ function Get-CIPPTenantAlignment { $IsReportingDisabled = $ReportingDisabledSet.Contains($StandardKey) # Use cached tenant data + # A standard pointing at a deleted template never gets a report row. Say so, + # naming the template, instead of reporting NOT FOUND until the end of time. + if ($MissingTemplateMessages.ContainsKey($StandardKey)) { + $ComparisonResults.Add([PSCustomObject]@{ + StandardName = $StandardKey + Compliant = $false + StandardValue = $MissingTemplateMessages[$StandardKey] + ComplianceStatus = if ($IsReportingDisabled) { 'Reporting Disabled' } else { 'Non-Compliant' } + ReportingDisabled = $IsReportingDisabled + LicenseAvailable = $null + CurrentValue = $MissingTemplateMessages[$StandardKey] + ExpectedValue = $null + TemplateMissing = $true + }) + continue + } + $HasStandard = $StandardKey -and $CurrentTenantStandards.ContainsKey($StandardKey) if ($HasStandard) { diff --git a/Modules/CIPPCore/Public/Functions/Get-CIPPURLName.ps1 b/Modules/CIPPCore/Public/Functions/Get-CIPPURLName.ps1 index cefb64d2a6c18..b90f8aaaf4518 100644 --- a/Modules/CIPPCore/Public/Functions/Get-CIPPURLName.ps1 +++ b/Modules/CIPPCore/Public/Functions/Get-CIPPURLName.ps1 @@ -139,6 +139,9 @@ function Get-CIPPURLName { '*officeSuiteApp' { 'deviceAppManagement/mobileApps' } + '*microsoftEdgeApp' { + 'deviceAppManagement/mobileApps' + } # Named Locations '*namedLocation' { diff --git a/Modules/CIPPCore/Public/Functions/Invoke-CIPPCustomDomainCertificate.ps1 b/Modules/CIPPCore/Public/Functions/Invoke-CIPPCustomDomainCertificate.ps1 new file mode 100644 index 0000000000000..4beed42040499 --- /dev/null +++ b/Modules/CIPPCore/Public/Functions/Invoke-CIPPCustomDomainCertificate.ps1 @@ -0,0 +1,131 @@ +function Invoke-CIPPCustomDomainCertificate { + <# + .SYNOPSIS + Issues an App Service Managed Certificate for a bound custom domain and enables its SNI binding. + .DESCRIPTION + Managed certificate issuance is asynchronous and regularly outlives a single request, so this + runs once inline from the Custom Domains wizard and then, while the certificate is still + pending or the attempt failed, reschedules itself as a hidden one-off task 15 minutes out. + It stops on success, when the hostname is no longer bound (the domain was removed in the + meantime), and after MaxAttempts - it never reschedules past that. + + Every run is idempotent: an existing certificate for the hostname is reused rather than + re-created, and a certificate whose issuance is already in flight (ARM answers 409) is + polled for instead of failing. + .PARAMETER Hostname + The custom domain. Its hostname binding must already exist on the App Service. + .PARAMETER Attempt + Current attempt number. Managed by the reschedule - callers should leave it at the default. + .PARAMETER MaxAttempts + Total attempts before giving up. Defaults to 4: the inline run plus three retries. + .FUNCTIONALITY + Internal + .EXAMPLE + Invoke-CIPPCustomDomainCertificate -Hostname 'portal.contoso.com' + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [ValidatePattern('^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$')] + [string]$Hostname, + + [int]$Attempt = 1, + + [int]$MaxAttempts = 4 + ) + + $AppService = Get-CIPPAppServiceSite + $Api = $AppService.ApiVersion + + if ($AppService.Site.properties.hostNames -notcontains $Hostname) { + $Message = "No hostname binding exists for '$Hostname' - the domain was removed or never added. Nothing to do." + Write-LogMessage -API 'CustomDomains' -message $Message -sev Warn + return $Message + } + + $SslState = ($AppService.Site.properties.hostNameSslStates | Where-Object { $_.name -eq $Hostname } | Select-Object -First 1).sslState + if ($SslState -in @('SniEnabled', 'IpBasedEnabled')) { + $Message = "'$Hostname' is already secured ($SslState)." + Write-LogMessage -API 'CustomDomains' -message $Message -sev Info + return $Message + } + + $Outcome = $null + try { + # Reuse whatever certificate already covers this hostname, whatever it is named: Azure keys + # uniqueness on canonicalName per plan, so a second PUT for the same hostname is rejected. + $Cert = $AppService.Certificates | Where-Object { $_.properties.canonicalName -eq $Hostname } | Select-Object -First 1 + if (-not $Cert) { + # Same name the Azure portal uses, so a portal-created certificate is the same resource. + $CertUri = "$($AppService.CertBase)/$Hostname-$($AppService.SiteName)?api-version=$Api" + $CertBody = @{ + location = $AppService.Site.location + properties = @{ + canonicalName = $Hostname + serverFarmId = $AppService.Site.properties.serverFarmId + } + } + try { + $Cert = New-CIPPAzRestRequest -Uri $CertUri -Method PUT -Body $CertBody -ErrorAction Stop + } catch { + # An issuance already in flight holds the hostname's slot before the resource exists, + # so the list above finds nothing and the PUT answers 409. Poll for it instead. + if ($_.Exception.Message -notmatch 'Conflict|duplicate') { throw } + Write-Information "Certificate creation for $Hostname returned 409 - an issuance is already pending, polling for it instead" + } + } + + # Brief poll (6 x 10 s): issuance usually takes a minute or two; anything longer is what + # the retry is for. + $Thumbprint = $Cert.properties.thumbprint + for ($Poll = 0; -not $Thumbprint -and $Poll -lt 6; $Poll++) { + Start-Sleep -Seconds 10 + $Issued = try { + (New-CIPPAzRestRequest -Uri "$($AppService.CertBase)?api-version=$Api" -Method GET -ErrorAction Stop).value | + Where-Object { $_.properties.canonicalName -eq $Hostname } | Select-Object -First 1 + } catch { $null } + $Thumbprint = $Issued.properties.thumbprint + } + + if ($Thumbprint) { + $BindBody = @{ properties = @{ sslState = 'SniEnabled'; thumbprint = $Thumbprint; toUpdate = $true } } + $null = New-CIPPAzRestRequest -Uri "$($AppService.ArmBase)/hostNameBindings/$Hostname`?api-version=$Api" -Method PUT -Body $BindBody -ErrorAction Stop + $Message = "Managed certificate issued and SNI SSL enabled for '$Hostname'. The domain is now secured." + Write-LogMessage -API 'CustomDomains' -message $Message -sev Info + return $Message + } + $Outcome = "The managed certificate for '$Hostname' is still being issued" + } catch { + $Outcome = "Certificate provisioning for '$Hostname' failed: $((Get-CippException -Exception $_).NormalizedError)" + } + + if ($Attempt -ge $MaxAttempts) { + $Message = "$Outcome. Giving up after $MaxAttempts attempts - check that the domain's DNS record points directly at this App Service (not through a proxy or CDN), then run 'Provision certificate' on the domain again." + Write-LogMessage -API 'CustomDomains' -message $Message -sev Error + return $Message + } + + $ScheduleResult = Add-CIPPScheduledTask -Hidden $true -Task ([pscustomobject]@{ + TenantFilter = $env:TenantID + Name = "Custom domain certificate: $Hostname" + Command = @{ value = 'Invoke-CIPPCustomDomainCertificate' } + Parameters = [pscustomobject]@{ + Hostname = $Hostname + Attempt = $Attempt + 1 + MaxAttempts = $MaxAttempts + } + ScheduledTime = [int64](([datetime]::UtcNow.AddMinutes(15)) - (Get-Date '1/1/1970')).TotalSeconds + Recurrence = '0' + PostExecution = @{} + Reference = "CustomDomainCert-$Hostname" + }) + if ("$ScheduleResult" -match '^Error') { + $Message = "$Outcome, and the retry could not be scheduled: $ScheduleResult" + Write-LogMessage -API 'CustomDomains' -message $Message -sev Error + return $Message + } + + $Message = "$Outcome (attempt $Attempt of $MaxAttempts). CIPP will try again in 15 minutes." + Write-LogMessage -API 'CustomDomains' -message $Message -sev Info + return $Message +} diff --git a/Modules/CIPPCore/Public/Functions/Test-CIPPCacheCapabilityError.ps1 b/Modules/CIPPCore/Public/Functions/Test-CIPPCacheCapabilityError.ps1 new file mode 100644 index 0000000000000..df81af8a4e87c --- /dev/null +++ b/Modules/CIPPCore/Public/Functions/Test-CIPPCacheCapabilityError.ps1 @@ -0,0 +1,51 @@ +function Test-CIPPCacheCapabilityError { + <# + .SYNOPSIS + Returns $true when a cache-collection exception reflects a benign tenant condition + rather than a real fault. + + .DESCRIPTION + License gating (Push-CIPPDBCacheData) skips whole collection groups a tenant is not + licensed for, but it cannot detect a service that is licensed yet not provisioned - for + example a Business Premium tenant that holds a Defender for Business (MDE_SMB) plan but + has never onboarded a device to Defender for Endpoint. Those endpoints answer with + 'No active license found' and similar, which is an expected state rather than an error + worth surfacing to an MSP. + + Collectors pass their caught exception message here to decide log severity: a match is + logged at Debug and treated as a clean skip; anything else stays an Error. + + .PARAMETER Message + The exception message to classify. + + .FUNCTIONALITY + Internal + + .EXAMPLE + $Sev = if (Test-CIPPCacheCapabilityError -Message $_.Exception.Message) { 'Debug' } else { 'Error' } + #> + [OutputType([bool])] + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$Message + ) + + if ([string]::IsNullOrWhiteSpace($Message)) { return $false } + + # High-confidence 'tenant lacks or has not provisioned this capability' signals. Kept + # deliberately specific so a genuine failure is never silently downgraded to Debug. + $BenignPatterns = @( + 'No active license found' + 'not licensed' + 'does not have a valid' + '(is )?not onboarded' + 'license.{0,20}(is )?(required|not found|missing)' + ) + + foreach ($Pattern in $BenignPatterns) { + if ($Message -match $Pattern) { return $true } + } + return $false +} diff --git a/Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1 b/Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1 index 699531c9b0754..04c1ada114d5c 100644 --- a/Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1 @@ -108,6 +108,16 @@ function Get-CIPPAuthentication { Write-LogMessage -message 'Could not preload or provision the SAM certificate. It will be retried by the weekly token update.' -Sev 'Warning' -API 'CIPP Authentication' -LogData (Get-CippException -Exception $_) } + # Mirror the CertificateAuthentication flag to an env var so the hot token path (Get-GraphToken) + # reads it without a table hit. The flag is the single source of truth; set when enabled, + # cleared when not - consumers do a plain truthiness check (same pattern as SetFromProfile). + try { + $CertFlag = Get-CIPPFeatureFlag -Id 'CertificateAuthentication' + $env:CertificateAuthMode = if ($CertFlag.Enabled -eq $true) { $true } else { $null } + } catch { + Write-Information "Could not resolve certificate auth mode: $($_.Exception.Message)" + } + Write-LogMessage -message 'Reloaded authentication data from KeyVault' -Sev 'debug' -API 'CIPP Authentication' return $true diff --git a/Modules/CIPPCore/Public/Get-CIPPBackup.ps1 b/Modules/CIPPCore/Public/Get-CIPPBackup.ps1 index fbfe7dc676577..13a45d81c9db9 100644 --- a/Modules/CIPPCore/Public/Get-CIPPBackup.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPBackup.ps1 @@ -88,8 +88,10 @@ function Get-CIPPBackup { } } } - $item | Add-Member -NotePropertyName 'BackupIsBlobLink' -NotePropertyValue $isBlobLink -Force - $item | Add-Member -NotePropertyName 'BlobResourcePath' -NotePropertyValue $blobPath -Force + $item | Add-Member -NotePropertyMembers ([ordered]@{ + BackupIsBlobLink = $isBlobLink + BlobResourcePath = $blobPath + }) -Force } } return $Info diff --git a/Modules/CIPPCore/Public/Get-CIPPCVEReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPCVEReport.ps1 index f995a1f470134..dd77ec9051353 100644 --- a/Modules/CIPPCore/Public/Get-CIPPCVEReport.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPCVEReport.ps1 @@ -68,8 +68,6 @@ function Get-CIPPCVEReport { TotalDeviceCount = 0 AffectedTenantsList = [System.Collections.Generic.List[object]]::new() AffectedDevicesList = [System.Collections.Generic.List[object]]::new() - DiskPathList = [System.Collections.Generic.List[object]]::new() - RegistryPathList = [System.Collections.Generic.List[object]]::new() ExceptionMatchCount = 0 TotalTenantGroupCount = 0 ExceptionSources = [System.Collections.Generic.HashSet[string]]::new() @@ -81,22 +79,14 @@ function Get-CIPPCVEReport { [void]$CveGroup.AffectedTenantsList.Add(@{ customerId = $Item.customerId }) - # Unpack the device JSON details from the row + # Trust the unique-device count the collector wrote rather than recounting unpacked + # rows; for AllTenants this sums each tenant's contribution to the same CVE. + $CveGroup.TotalDeviceCount += [int]$Item.deviceCount + + # Unpack the minimal per-device detail ({deviceId, deviceName}) from the row. if ($Item.deviceDetailsJson) { - $Devices = ConvertFrom-Json $Item.deviceDetailsJson | Sort-Object -Property deviceName -Unique - foreach ($Dev in $Devices) { - [void]$CveGroup.AffectedDevicesList.Add(@{ deviceName = $Dev.deviceName }) - if ($Dev.registryPaths) { - [void]$CveGroup.RegistryPathList.Add(@{ deviceName = $Dev.deviceName - registryPaths = $Dev.registryPaths - }) - } - if ($Dev.diskPaths) { - [void]$CveGroup.DiskPathList.Add(@{ deviceName = $Dev.deviceName - diskPaths = $Dev.diskPaths - }) - } - $CveGroup.TotalDeviceCount ++ + foreach ($Dev in @(ConvertFrom-Json $Item.deviceDetailsJson)) { + [void]$CveGroup.AffectedDevicesList.Add(@{ deviceId = $Dev.deviceId; deviceName = $Dev.deviceName }) } } } @@ -172,8 +162,6 @@ function Get-CIPPCVEReport { softwareVersion = $Target.softwareVersion deviceCount = $Target.TotalDeviceCount tenantCount = $Target.TotalTenantGroupCount - registryPaths = $Target.RegistryPathList - diskPaths = $Target.DiskPathList exceptionStatus = $ExceptionStatus hasException = $HasException affectedTenants = $Target.AffectedTenantsList diff --git a/Modules/CIPPCore/Public/Get-CIPPDbItemPage.ps1 b/Modules/CIPPCore/Public/Get-CIPPDbItemPage.ps1 new file mode 100644 index 0000000000000..3bfedaaa1ae6e --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPDbItemPage.ps1 @@ -0,0 +1,61 @@ +function Get-CIPPDbItemPage { + <# + .FUNCTIONALITY + Internal + .SYNOPSIS + Reads one page of items of a type from the CIPP Reporting database. + .DESCRIPTION + Continuation-token pager over CippReportingDB (PartitionKey = tenant, RowKey = '-'). + AllTenants walks every managed tenant that has a '-Count' row; a single tenant walks + its own partition. Returns raw entities minus the count markers; callers parse Data and + read the tenant from PartitionKey. A null NextToken means the walk is complete. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [Parameter(Mandatory = $true)] + [string]$Type, + [ValidateRange(1, 10000)] + [int]$PageSize = 5000, + [string]$ContinuationToken + ) + + # Enforce tenant lock when running inside custom script execution (parity with Get-CIPPDbItem) + if ($script:CIPPLockedTenant) { + $TenantFilter = $script:CIPPLockedTenant + } + + $Table = Get-CippTable -tablename 'CippReportingDB' + + if ($TenantFilter -eq 'AllTenants') { + $CountRows = Get-CIPPDbItem -TenantFilter 'allTenants' -Type $Type -CountsOnly + $TenantList = Get-Tenants -IncludeErrors + $Known = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($Domain in $TenantList.defaultDomainName) { + if ($Domain) { $null = $Known.Add([string]$Domain) } + } + # Ordinal ascending, to match the walker's range-scan order. + $Unique = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + foreach ($Partition in $CountRows.PartitionKey) { + if ($Partition -and $Known.Contains([string]$Partition)) { $null = $Unique.Add([string]$Partition) } + } + $Partitions = [string[]]@($Unique) + [System.Array]::Sort($Partitions, [System.Collections.IComparer][StringComparer]::Ordinal) + } else { + $Tenant = Get-Tenants -TenantFilter $TenantFilter + if (-not $Tenant) { + throw "Tenant '$TenantFilter' not found" + } + $Partitions = @($Tenant.defaultDomainName) + } + + $Page = Get-CIPPPagedTableRows -Table $Table -PartitionKeys $Partitions -RowKeyGe "$Type-" -RowKeyLt "$Type." -PageSize $PageSize -ContinuationToken $ContinuationToken + # Drop the count marker, which sorts inside the range. + $Items = @($Page.Rows | Where-Object { $_.RowKey -ne "$Type-Count" }) + + return [PSCustomObject]@{ + Items = $Items + NextToken = $Page.NextToken + } +} diff --git a/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 b/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 index 88a23d6a5b914..0bf9ae6653b16 100644 --- a/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 @@ -29,6 +29,26 @@ function Get-CIPPDrift { [switch]$AllTenants ) + # The label a standards template stores for a template reference, for the template types whose + # standard key ends in the referenced template id. Used when the template row itself is gone. + function Get-DriftTemplateLabel { + param($StandardName, $StandardSettings) + if (-not $StandardSettings) { return $null } + $Match = [regex]::Match([string]$StandardName, '^standards\.(IntuneTemplate|ConditionalAccessTemplate|ReusableSettingsTemplate)\.(.+)$') + if (-not $Match.Success) { return $null } + $Kind = $Match.Groups[1].Value + $Id = $Match.Groups[2].Value + foreach ($Entry in @($StandardSettings.$Kind)) { + foreach ($Item in @($Entry.TemplateList)) { + $Value = if ($Item.value) { [string]$Item.value } else { [string]$Item } + if ($Value -and ($Value -eq $Id -or $Value -like "$Id*" -or $Id -like "$Value*")) { + if ($Item.label) { return [string]$Item.label } + } + } + } + return $null + } + $IntuneCapable = Test-CIPPStandardLicense -StandardName 'IntuneTemplate_general' -TenantFilter $TenantFilter -Preset Intune $ConditionalAccessCapable = Test-CIPPStandardLicense -StandardName 'ConditionalAccessTemplate_general' -TenantFilter $TenantFilter -Preset Entra $IntuneTable = Get-CippTable -tablename 'templates' @@ -51,10 +71,12 @@ function Get-CIPPDrift { try { $JSONData = $RawTemplate.JSON | ConvertFrom-Json -Depth 100 -ErrorAction SilentlyContinue $data = $JSONData.RAWJson | ConvertFrom-Json -Depth 100 -ErrorAction SilentlyContinue - $data | Add-Member -NotePropertyName 'displayName' -NotePropertyValue $JSONData.Displayname -Force - $data | Add-Member -NotePropertyName 'description' -NotePropertyValue $JSONData.Description -Force - $data | Add-Member -NotePropertyName 'Type' -NotePropertyValue $JSONData.Type -Force - $data | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $RawTemplate.RowKey -Force + $data | Add-Member -NotePropertyMembers ([ordered]@{ + displayName = $JSONData.Displayname + description = $JSONData.Description + Type = $JSONData.Type + GUID = $RawTemplate.RowKey + }) -Force $IntuneTemplatesByGuid[$RawTemplate.RowKey] = $data # Built-in templates are seeded with RowKey = '.IntuneTemplate.json'; also index # by the bare guid so display-name lookups that extract the guid from a standard key hit @@ -123,13 +145,27 @@ function Get-CIPPDrift { $DriftTable = Get-CippTable -tablename 'tenantDrift' $DriftFilter = "PartitionKey eq '$TenantFilter'" $ExistingDriftStates = @{} + # Set only once every decided status has been read. Without them every deviation looks + # New, and writing or pruning on that view would replace accepted / customer-specific + # decisions with New - so both steps below are skipped when the read did not complete. + $DriftStatesLoaded = $false + $DriftEntities = @() try { - $DriftEntities = Get-CIPPAzDataTableEntity @DriftTable -Filter $DriftFilter + $DriftEntities = @(Get-CIPPAzDataTableEntity @DriftTable -Filter $DriftFilter) foreach ($Entity in $DriftEntities) { - $ExistingDriftStates[$Entity.StandardName] = $Entity + $EntityKey = [string]$Entity.StandardName + if ([string]::IsNullOrWhiteSpace($EntityKey)) { + # A row without a name cannot be matched to a deviation. Skipping it is the only + # option that keeps the rest of the table usable - a null hashtable key throws + # and would abandon every row after it. + Write-Warning "Drift state row '$($Entity.RowKey)' for tenant '$TenantFilter' has no StandardName and was ignored." + continue + } + $ExistingDriftStates[$EntityKey] = $Entity } + $DriftStatesLoaded = $true } catch { - Write-Warning "Failed to get existing drift states: $($_.Exception.Message)" + Write-Warning "Failed to get existing drift states for '$TenantFilter': $($_.Exception.Message). Drift decisions will not be written or pruned this run." } $Results = [System.Collections.Generic.List[object]]::new() @@ -197,6 +233,17 @@ function Get-CIPPDrift { $displayName = "Quarantine Policy: $(-join $Chars)" } } + # When the template row is gone (or its GUID column drifted from the RowKey) the + # lookups above find nothing. Fall back to the label the standards template still + # carries, so the deviation names the template to fix instead of showing a bare id. + if (-not $displayName) { + $FallbackLabel = Get-DriftTemplateLabel -StandardName $ComparisonItem.StandardName -StandardSettings $Alignment.standardSettings + if ($FallbackLabel) { $displayName = $FallbackLabel } + } + if ($ComparisonItem.PSObject.Properties['TemplateMissing'] -and $ComparisonItem.TemplateMissing) { + $displayName = "Missing template - $($displayName ?? $ComparisonItem.StandardName)" + $standardDescription = [string]$ComparisonItem.StandardValue + } $reason = if ($ExistingDriftStates.ContainsKey($ComparisonItem.StandardName)) { $ExistingDriftStates[$ComparisonItem.StandardName].Reason } $User = if ($ExistingDriftStates.ContainsKey($ComparisonItem.StandardName)) { $ExistingDriftStates[$ComparisonItem.StandardName].User } $IsLicenseMissing = $ComparisonItem.ComplianceStatus -eq 'License Missing' @@ -292,8 +339,14 @@ function Get-CIPPDrift { # Graph $batch returns 200 even when individual sub-requests fail (e.g. 429 # throttling on one endpoint), silently dropping that policy type from the # collection - which must not count as evidence the policies are gone, or their - # drift rows get pruned and decided statuses reset to New. - $IntunePoliciesCollected = @($IntuneGraphRequest | Where-Object { $_.status -and [int]$_.status -ge 400 }).Count -eq 0 + # drift rows get pruned and decided statuses reset to New. The same applies when + # a collection paged and a later page failed: New-GraphBulkRequest flags that on + # the item as PagingIncomplete while the status stays 200. + $IncompleteIntune = @($IntuneGraphRequest | Where-Object { ($_.status -and [int]$_.status -ge 400) -or $_.PagingIncomplete }) + $IntunePoliciesCollected = $IncompleteIntune.Count -eq 0 + if (-not $IntunePoliciesCollected) { + Write-Warning "Intune policy inventory for '$TenantFilter' is incomplete this run ($(($IncompleteIntune | ForEach-Object { "$($_.id): $($_.PagingError ?? $_.status)" }) -join '; ')). Policy drift rows will not be pruned." + } } catch { Write-Warning "Failed to get Intune policies: $($_.Exception.Message)" } @@ -310,9 +363,13 @@ function Get-CIPPDrift { ) $CAGraphRequest = New-GraphBulkRequest -Requests $CARequests -tenantid $TenantFilter -asapp $true $TenantCAPolicies = ($CAGraphRequest | Where-Object { $_.id -eq 'policies' }).body.value - # Same per-item check as the Intune collection: a throttled $batch item returns - # inside a 200 response and must not arm the prune. - $CAPoliciesCollected = @($CAGraphRequest | Where-Object { $_.status -and [int]$_.status -ge 400 }).Count -eq 0 + # Same per-item check as the Intune collection: a throttled $batch item or a + # failed continuation page returns inside a 200 response and must not arm the prune. + $IncompleteCA = @($CAGraphRequest | Where-Object { ($_.status -and [int]$_.status -ge 400) -or $_.PagingIncomplete }) + $CAPoliciesCollected = $IncompleteCA.Count -eq 0 + if (-not $CAPoliciesCollected) { + Write-Warning "Conditional Access policy inventory for '$TenantFilter' is incomplete this run ($(($IncompleteCA | ForEach-Object { "$($_.id): $($_.PagingError ?? $_.status)" }) -join '; ')). Policy drift rows will not be pruned." + } } catch { Write-Warning "Failed to get Conditional Access policies: $($_.Exception.Message)" $TenantCAPolicies = @() @@ -517,12 +574,14 @@ function Get-CIPPDrift { }) } } - if ($NewDriftEntities.Count -gt 0) { + if ($NewDriftEntities.Count -gt 0 -and $DriftStatesLoaded) { try { Add-CIPPAzDataTableEntity @DriftTable -Entity $NewDriftEntities -Force } catch { Write-Warning "Failed to persist new drift deviations: $($_.Exception.Message)" } + } elseif ($NewDriftEntities.Count -gt 0) { + Write-Warning "Skipped writing $($NewDriftEntities.Count) drift deviation rows for '$TenantFilter' because the existing drift states could not be read." } # License-missing standards are excluded from the deviation buckets so the counts match @@ -572,8 +631,9 @@ function Get-CIPPDrift { # are invisible to the score once their key leaves ComparisonDetails, so only undecided rows # ('New' or missing Status) are pruned there: Accepted/Denied*/CustomerSpecific decisions must # survive transient key-enumeration drops (package/tag membership changes, template - # re-saves). A template-scoped run cannot see every valid key, so it never prunes. - if (-not $TemplateId) { + # re-saves). A template-scoped run cannot see every valid key, so it never prunes, and + # neither does a run whose read of the existing states did not complete. + if (-not $TemplateId -and $DriftStatesLoaded) { $StaleDriftEntities = foreach ($Entity in $DriftEntities) { $EntityName = [string]$Entity.StandardName if ([string]::IsNullOrWhiteSpace($EntityName) -or $ValidDriftKeys.Contains($EntityName)) { continue } diff --git a/Modules/CIPPCore/Public/Get-CIPPDriveItemCloudPathLength.ps1 b/Modules/CIPPCore/Public/Get-CIPPDriveItemCloudPathLength.ps1 new file mode 100644 index 0000000000000..583e9f5337321 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPDriveItemCloudPathLength.ps1 @@ -0,0 +1,62 @@ +function Get-CIPPDriveItemCloudPathLength { + <# + .SYNOPSIS + Returns the decoded library-relative path length for a Graph drive item. + + .DESCRIPTION + Builds path from parentReference.path + name. Strips the Graph "/.../root:" prefix, + URL-decodes, and returns character length only — never the path string to callers + that might persist it. Used for OneDrive long-path counting. + + .PARAMETER ParentPath + driveItem.parentReference.path (e.g. /drives/{id}/root:/Folder/Sub) + + .PARAMETER Name + driveItem.name + + .OUTPUTS + System.Int32 + #> + [CmdletBinding()] + [OutputType([int])] + param( + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [AllowNull()] + [string]$ParentPath, + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [AllowNull()] + [string]$Name + ) + + if ([string]::IsNullOrWhiteSpace($Name)) { + return 0 + } + + $Relative = '' + if (-not [string]::IsNullOrWhiteSpace($ParentPath)) { + $Marker = 'root:' + $Idx = $ParentPath.IndexOf($Marker, [System.StringComparison]::OrdinalIgnoreCase) + if ($Idx -ge 0) { + $Relative = $ParentPath.Substring($Idx + $Marker.Length) + } + } + + if ([string]::IsNullOrWhiteSpace($Relative) -or $Relative -eq '/') { + $Combined = $Name + } else { + $Combined = $Relative.TrimEnd('/') + '/' + $Name + } + + $Combined = $Combined.TrimStart('/') + + try { + $Decoded = [uri]::UnescapeDataString($Combined) + } catch { + $Decoded = $Combined + } + + return $Decoded.Length +} diff --git a/Modules/CIPPCore/Public/Get-CIPPEdgeAppBody.ps1 b/Modules/CIPPCore/Public/Get-CIPPEdgeAppBody.ps1 new file mode 100644 index 0000000000000..8c2c8d6bd8d5d --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPEdgeAppBody.ps1 @@ -0,0 +1,52 @@ +function Get-CIPPEdgeAppBody { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + $Config + ) + + if ($Config.IntuneBody) { + $IntuneBody = $Config.IntuneBody + if ($IntuneBody -is [string]) { + $IntuneBody = $IntuneBody | ConvertFrom-Json -Depth 100 + } else { + $IntuneBody = $IntuneBody | ConvertTo-Json -Depth 100 | ConvertFrom-Json -Depth 100 + } + + $ReadOnlyProps = @( + 'id', 'createdDateTime', 'lastModifiedDateTime', 'uploadState', 'publishingState', + 'isAssigned', 'roleScopeTagIds', 'dependentAppCount', 'supersedingAppCount', + 'supersededAppCount', 'committedContentVersion', 'fileName', 'size', + 'assignments@odata.context', 'assignments', 'AppAssignment', 'AppExclude' + ) + foreach ($Prop in $ReadOnlyProps) { + if ($IntuneBody.PSObject.Properties[$Prop]) { + $IntuneBody.PSObject.Properties.Remove($Prop) + } + } + return $IntuneBody + } + + $Channel = if ($Config.edgeChannel.value) { $Config.edgeChannel.value } else { $Config.edgeChannel } + if (-not $Channel) { $Channel = 'stable' } + + $Body = [PSCustomObject]@{ + '@odata.type' = '#microsoft.graph.windowsMicrosoftEdgeApp' + 'displayName' = 'Microsoft Edge for Windows 10 and later' + 'description' = 'Microsoft Edge for Windows 10 and later' + 'publisher' = 'Microsoft' + 'isFeatured' = $false + 'informationUrl' = 'https://www.microsoft.com/edge' + 'privacyInformationUrl' = 'https://privacy.microsoft.com/en-us/privacystatement' + 'owner' = 'Microsoft' + 'notes' = '' + 'channel' = $Channel + } + + $Locale = if ($Config.displayLanguageLocale.value) { $Config.displayLanguageLocale.value } else { $Config.displayLanguageLocale } + if ($Locale) { + $Body | Add-Member -NotePropertyName 'displayLanguageLocale' -NotePropertyValue $Locale + } + + return $Body +} diff --git a/Modules/CIPPCore/Public/Get-CIPPGroupUsageReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPGroupUsageReport.ps1 new file mode 100644 index 0000000000000..f948ac7b8f5d9 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPGroupUsageReport.ps1 @@ -0,0 +1,234 @@ +function Get-CIPPGroupUsageReport { + <# + .SYNOPSIS + Compiles where each Entra group is used across the tenant from the CIPP Reporting database + + .DESCRIPTION + Reads the cached Groups, Conditional Access, Intune, role, application, license and + Exchange datasets from CippReportingDB and builds one row per group listing every + location that references it. No live Graph calls are made. + + .PARAMETER TenantFilter + The tenant to generate the report for, or 'AllTenants' for all tenants + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter + ) + + if ($TenantFilter -eq 'AllTenants') { + $AnyItems = Get-CIPPDbItem -TenantFilter 'allTenants' -Type 'Groups' + $Tenants = @($AnyItems | Where-Object { $_.RowKey -notlike '*-Count' } | Select-Object -ExpandProperty PartitionKey -Unique) + $TenantList = Get-Tenants -IncludeErrors + $Tenants = $Tenants | Where-Object { $TenantList.defaultDomainName -contains $_ } + + $AllResults = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($Tenant in $Tenants) { + try { + $TenantResults = Get-CIPPGroupUsageReport -TenantFilter $Tenant + foreach ($Result in $TenantResults) { + $Result | Add-Member -NotePropertyName 'Tenant' -NotePropertyValue $Tenant -Force + $AllResults.Add($Result) + } + } catch { + Write-LogMessage -API 'GroupUsageReport' -tenant $Tenant -message "Failed to get group usage report: $($_.Exception.Message)" -sev Warning + } + } + return $AllResults + } + + $GroupItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'Groups' | Where-Object { $_.RowKey -notlike '*-Count' } + if (-not $GroupItems) { + throw "No groups data found in reporting database for $TenantFilter. Sync the report data first." + } + $CacheTimestamp = ($GroupItems | Where-Object { $_.Timestamp } | Sort-Object Timestamp -Descending | Select-Object -First 1).Timestamp + + $Groups = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($Item in $GroupItems) { + try { + $Groups.Add(($Item.Data | ConvertFrom-Json -Depth 20 -ErrorAction Stop)) + } catch { + Write-LogMessage -API 'GroupUsageReport' -tenant $TenantFilter -message "Failed to parse group item: $($_.Exception.Message)" -sev Warning + } + } + + # Index groups by id and by mail (transport rules reference groups by SMTP address) + $GroupIndex = @{} + $MailIndex = @{} + foreach ($Group in $Groups) { + if (-not $Group.id) { continue } + $GroupIndex[[string]$Group.id] = $Group + if (-not [string]::IsNullOrWhiteSpace($Group.mail)) { + $MailIndex[([string]$Group.mail).ToLowerInvariant()] = [string]$Group.id + } + } + + $UsageByGroup = @{} + $SeenUsageKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $AddUsage = { + param($GroupId, $Category, $Location, $Name, $Id) + $GroupKey = [string]$GroupId + if ([string]::IsNullOrWhiteSpace($GroupKey) -or -not $GroupIndex.ContainsKey($GroupKey)) { return } + $DedupeKey = '{0}|{1}|{2}' -f $GroupKey, $Location, [string]$Id + if (-not $SeenUsageKeys.Add($DedupeKey)) { return } + if (-not $UsageByGroup.ContainsKey($GroupKey)) { + $UsageByGroup[$GroupKey] = [System.Collections.Generic.List[PSCustomObject]]::new() + } + $UsageByGroup[$GroupKey].Add([PSCustomObject]@{ + Category = $Category + Location = $Location + Name = [string]$Name + Id = [string]$Id + }) + } + + $ReadCache = { + param($Type) + try { @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type $Type -ErrorAction Stop) } catch { @() } + } + + # Conditional Access — raw policies carry group GUIDs in the user conditions + foreach ($Policy in (& $ReadCache 'ConditionalAccessPolicies')) { + foreach ($GroupId in @($Policy.conditions.users.includeGroups)) { + & $AddUsage $GroupId 'Conditional Access' 'Conditional Access' $Policy.displayName $Policy.id + } + foreach ($GroupId in @($Policy.conditions.users.excludeGroups)) { + & $AddUsage $GroupId 'Conditional Access' 'Conditional Access (Excluded)' $Policy.displayName $Policy.id + } + } + + # Intune — every cached family stores its Graph assignments verbatim + $IntuneCacheTypes = [ordered]@{ + IntuneDeviceConfigurations = 'Intune Configuration Profile' + IntuneConfigurationPolicies = 'Intune Settings Catalog Policy' + IntuneDeviceCompliancePolicies = 'Intune Compliance Policy' + IntuneGroupPolicyConfigurations = 'Intune Administrative Template' + IntuneMobileAppConfigurations = 'Intune App Configuration Policy' + IntuneWindowsDriverUpdateProfiles = 'Intune Driver Update Profile' + IntuneWindowsFeatureUpdateProfiles = 'Intune Feature Update Profile' + IntuneWindowsQualityUpdatePolicies = 'Intune Quality Update Policy' + IntuneWindowsQualityUpdateProfiles = 'Intune Quality Update Profile' + IntuneHardwareConfigurations = 'Intune Hardware Configuration' + IntuneIntents = 'Intune Endpoint Security Policy' + IntuneAppProtectionPolicies = 'Intune App Protection Policy' + IntuneAppProtectionManagedAppPolicies = 'Intune App Protection Policy' + IntuneApplications = 'Intune Application' + IntuneWindowsScripts = 'Intune Platform Script' + IntuneMacOSScripts = 'Intune Platform Script' + IntuneLinuxScripts = 'Intune Platform Script' + IntuneRemediationScripts = 'Intune Remediation Script' + IntuneWindowsAutopilotDeploymentProfiles = 'Autopilot Deployment Profile' + IntuneDeviceEnrollmentConfigurations = 'Intune Enrollment Configuration' + } + foreach ($CacheType in $IntuneCacheTypes.Keys) { + foreach ($Policy in (& $ReadCache $CacheType)) { + $PolicyName = $Policy.displayName ?? $Policy.name + foreach ($Assignment in @($Policy.assignments)) { + $GroupId = [string]$Assignment.target.groupId + if ([string]::IsNullOrWhiteSpace($GroupId)) { continue } + $Label = $IntuneCacheTypes[$CacheType] + if ($Assignment.target.'@odata.type' -eq '#microsoft.graph.exclusionGroupAssignmentTarget') { + $Label = "$Label (Excluded)" + } + & $AddUsage $GroupId 'Intune' $Label $PolicyName $Policy.id + } + } + } + + # Group-based licensing, Teams, and nested group membership — all from the Groups cache itself + $SkuNames = @{} + foreach ($Sku in (& $ReadCache 'LicenseOverview')) { + if (-not [string]::IsNullOrWhiteSpace($Sku.skuId)) { + $SkuNames[([string]$Sku.skuId).ToLowerInvariant()] = $Sku.License + } + } + foreach ($Group in $Groups) { + foreach ($License in @($Group.assignedLicenses)) { + if ([string]::IsNullOrWhiteSpace($License.skuId)) { continue } + $LicenseName = $SkuNames[([string]$License.skuId).ToLowerInvariant()] ?? [string]$License.skuId + & $AddUsage $Group.id 'Licensing' 'Group-Based Licensing' $LicenseName $License.skuId + } + if ($Group.teamsEnabled -eq $true) { + & $AddUsage $Group.id 'Teams' 'Microsoft Teams' $Group.displayName $Group.id + } + foreach ($Member in @($Group.members)) { + if ($Member.'@odata.type' -eq '#microsoft.graph.group' -and $Member.id) { + & $AddUsage $Member.id 'Group Nesting' 'Member of Group' $Group.displayName $Group.id + } + } + } + + # Entra directory roles + PIM assignments/eligibilities + $RoleNamesByTemplate = @{} + foreach ($Role in (& $ReadCache 'Roles')) { + if ($Role.roleTemplateId) { $RoleNamesByTemplate[[string]$Role.roleTemplateId] = $Role.displayName } + foreach ($Member in @($Role.members)) { + if ($Member.id -and $GroupIndex.ContainsKey([string]$Member.id)) { + & $AddUsage $Member.id 'Entra Roles' 'Entra Role' $Role.displayName $Role.id + } + } + } + $PimSources = @( + [PSCustomObject]@{ Type = 'RoleAssignmentScheduleInstances'; Location = 'PIM Role Assignment' } + [PSCustomObject]@{ Type = 'RoleEligibilitySchedules'; Location = 'PIM Role Eligibility' } + ) + foreach ($Source in $PimSources) { + foreach ($Schedule in (& $ReadCache $Source.Type)) { + $PrincipalId = [string]$Schedule.principalId + if (-not $GroupIndex.ContainsKey($PrincipalId)) { continue } + $RoleName = $RoleNamesByTemplate[[string]$Schedule.roleDefinitionId] ?? [string]$Schedule.roleDefinitionId + & $AddUsage $PrincipalId 'Entra Roles' $Source.Location $RoleName $Schedule.roleDefinitionId + } + } + + # Enterprise application assignments granted to groups + foreach ($Assignment in (& $ReadCache 'AppRoleAssignments')) { + if ([string]$Assignment.principalType -ne 'Group') { continue } + $AppName = $Assignment.resourceDisplayName ?? $Assignment.servicePrincipalDisplayName + & $AddUsage $Assignment.principalId 'Enterprise Applications' 'Enterprise Application' $AppName $Assignment.resourceId + } + + # Exchange transport rules reference groups by SMTP address + foreach ($Rule in (& $ReadCache 'ExoTransportRules')) { + $RuleName = $Rule.Name ?? $Rule.Identity + $RuleId = $Rule.Guid ?? $Rule.Identity + foreach ($Property in @('SentToMemberOf', 'FromMemberOf', 'ExceptIfSentToMemberOf', 'ExceptIfFromMemberOf')) { + foreach ($Address in @($Rule.$Property)) { + if ([string]::IsNullOrWhiteSpace($Address)) { continue } + $GroupId = $MailIndex[([string]$Address).ToLowerInvariant()] + if ($GroupId) { + & $AddUsage $GroupId 'Exchange' 'Exchange Transport Rule' $RuleName $RuleId + } + } + } + } + + $Results = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($Group in $Groups) { + if (-not $Group.id) { continue } + $GroupKey = [string]$Group.id + $UsedIn = [System.Collections.Generic.List[string]]::new() + $Categories = [System.Collections.Generic.SortedSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + if ($UsageByGroup.ContainsKey($GroupKey)) { + foreach ($Usage in ($UsageByGroup[$GroupKey] | Sort-Object -Property Location, Name)) { + $UsedIn.Add(('{0} - {1} ({2})' -f $Usage.Location, $Usage.Name, $Usage.Id)) + [void]$Categories.Add($Usage.Category) + } + } + $Results.Add([PSCustomObject]@{ + id = $GroupKey + displayName = $Group.displayName + groupType = $Group.groupType + mail = $Group.mail + dynamicGroup = [bool]$Group.dynamicGroupBool + usedLocations = @($Categories) + usedIn = @($UsedIn) + usageCount = $UsedIn.Count + isUsed = ($UsedIn.Count -gt 0) + CacheTimestamp = $CacheTimestamp + }) + } + + return ($Results | Sort-Object displayName) +} diff --git a/Modules/CIPPCore/Public/Get-CIPPGroupsReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPGroupsReport.ps1 index d553268202555..5c6c046f39e2f 100644 --- a/Modules/CIPPCore/Public/Get-CIPPGroupsReport.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPGroupsReport.ps1 @@ -5,13 +5,93 @@ function Get-CIPPGroupsReport { .PARAMETER TenantFilter The tenant to generate the report for, or 'AllTenants' for all tenants + + .PARAMETER PageSize + When set, returns one page of at most this many rows as @{ Items; NextToken }, in table walk order. + + .PARAMETER ContinuationToken + NextToken from the previous page. Only meaningful together with PageSize. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [int]$PageSize, + [string]$ContinuationToken, + # Return one page as { CippPagedJson; NextToken }: the stored blobs stitched into a + # JSON array verbatim, with per-row CacheTimestamp/Tenant spliced in. No member array + # is ever deserialized on the read path. + [switch]$AsRawJson ) + if ($PageSize -gt 0) { + $Page = Get-CIPPDbItemPage -TenantFilter $TenantFilter -Type 'Groups' -PageSize $PageSize -ContinuationToken $ContinuationToken + if ($TenantFilter -ne 'AllTenants' -and -not $ContinuationToken -and @($Page.Items).Count -eq 0 -and -not $Page.NextToken) { + throw "No groups data found in reporting database for $TenantFilter. Sync the report data first." + } + + if ($AsRawJson) { + $IsAllTenants = $TenantFilter -eq 'AllTenants' + $Builder = [System.Text.StringBuilder]::new() + $null = $Builder.Append('[') + $First = $true + foreach ($Item in $Page.Items) { + $Blob = [string]$Item.Data + if ([string]::IsNullOrWhiteSpace($Blob)) { continue } + $Blob = $Blob.Trim() + if ($Blob[0] -ne '{') { continue } + if (-not $First) { $null = $Builder.Append(',') } + $First = $false + # Emit the blob verbatim, then splice the two row-level fields onto its closing + # brace. membersCsv/ownersCsv already live in the blob (written at cache time). + $null = $Builder.Append($Blob, 0, $Blob.Length - 1) + $null = $Builder.Append(',"CacheTimestamp":').Append((ConvertTo-Json -InputObject $Item.Timestamp -Compress)) + if ($IsAllTenants) { + $null = $Builder.Append(',"Tenant":').Append((ConvertTo-Json -InputObject ([string]$Item.PartitionKey) -Compress)) + } + $null = $Builder.Append('}') + } + $null = $Builder.Append(']') + return [PSCustomObject]@{ + CippPagedJson = $Builder.ToString() + NextToken = $Page.NextToken + } + } + + $Results = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($Item in $Page.Items) { + try { + $Group = $Item.Data | ConvertFrom-Json -Depth 10 -ErrorAction Stop + # Collect every note property once, then attach in a single Add-Member call. + $NewProps = [ordered]@{} + if ($Group.members -and -not $Group.membersCsv) { + $NewProps['membersCsv'] = $Group.members.userPrincipalName -join ',' + } + if ($Group.owners -and -not $Group.ownersCsv) { + $NewProps['ownersCsv'] = $Group.owners.userPrincipalName -join ',' + } + if ($null -eq $Group.hasOwner) { + # Use the freshly computed ownersCsv if we just built one, else the stored value. + $OwnersCsv = if ($NewProps.Contains('ownersCsv')) { $NewProps['ownersCsv'] } else { $Group.ownersCsv } + $NewProps['hasOwner'] = -not [string]::IsNullOrEmpty($OwnersCsv) + } + # Per-item timestamp: a page may span tenants. + $NewProps['CacheTimestamp'] = $Item.Timestamp + if ($TenantFilter -eq 'AllTenants') { + $NewProps['Tenant'] = $Item.PartitionKey + } + $Group | Add-Member -NotePropertyMembers $NewProps -Force + $Results.Add($Group) + } catch { + Write-LogMessage -API 'GroupsReport' -tenant $Item.PartitionKey -message "Failed to parse group item: $($_.Exception.Message)" -sev Warning + } + } + return [PSCustomObject]@{ + Items = $Results + NextToken = $Page.NextToken + } + } + if ($TenantFilter -eq 'AllTenants') { $AnyItems = Get-CIPPDbItem -TenantFilter 'allTenants' -Type 'Groups' $Tenants = @($AnyItems | Where-Object { $_.RowKey -notlike '*-Count' } | Select-Object -ExpandProperty PartitionKey -Unique) @@ -44,13 +124,19 @@ function Get-CIPPGroupsReport { foreach ($Item in $Items) { try { $Group = $Item.Data | ConvertFrom-Json -Depth 10 -ErrorAction Stop + # Collect every note property once, then attach in a single Add-Member call. + $NewProps = [ordered]@{} if ($Group.members -and -not $Group.membersCsv) { - $Group | Add-Member -NotePropertyName 'membersCsv' -NotePropertyValue ($Group.members.userPrincipalName -join ',') -Force + $NewProps['membersCsv'] = $Group.members.userPrincipalName -join ',' } if ($Group.owners -and -not $Group.ownersCsv) { - $Group | Add-Member -NotePropertyName 'ownersCsv' -NotePropertyValue ($Group.owners.userPrincipalName -join ',') -Force + $NewProps['ownersCsv'] = $Group.owners.userPrincipalName -join ',' } - $Group | Add-Member -NotePropertyName 'CacheTimestamp' -NotePropertyValue $CacheTimestamp -Force + # Use the freshly computed ownersCsv if we just built one, else the stored value. + $OwnersCsv = if ($NewProps.Contains('ownersCsv')) { $NewProps['ownersCsv'] } else { $Group.ownersCsv } + $NewProps['hasOwner'] = -not [string]::IsNullOrEmpty($OwnersCsv) + $NewProps['CacheTimestamp'] = $CacheTimestamp + $Group | Add-Member -NotePropertyMembers $NewProps -Force $Results.Add($Group) } catch { Write-LogMessage -API 'GroupsReport' -tenant $TenantFilter -message "Failed to parse group item: $($_.Exception.Message)" -sev Warning diff --git a/Modules/CIPPCore/Public/Get-CIPPGuestUsersReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPGuestUsersReport.ps1 index bab411a0b6a6b..b764bc7afb4a3 100644 --- a/Modules/CIPPCore/Public/Get-CIPPGuestUsersReport.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPGuestUsersReport.ps1 @@ -10,13 +10,45 @@ function Get-CIPPGuestUsersReport { .PARAMETER TenantFilter The tenant to read cached guest users for, or 'AllTenants' for all tenants + + .PARAMETER PageSize + When set, returns one page of at most this many rows as @{ Items; NextToken }, in table walk order. .PARAMETER ContinuationToken + NextToken from the previous page. Only meaningful together with PageSize. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [int]$PageSize, + [string]$ContinuationToken ) + if ($PageSize -gt 0) { + $Page = Get-CIPPDbItemPage -TenantFilter $TenantFilter -Type 'Guests' -PageSize $PageSize -ContinuationToken $ContinuationToken + if ($TenantFilter -ne 'AllTenants' -and -not $ContinuationToken -and @($Page.Items).Count -eq 0 -and -not $Page.NextToken) { + throw "No guest user data found in reporting database for $TenantFilter. Sync the report data first." + } + $Results = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($Item in $Page.Items) { + try { + $Guest = $Item.Data | ConvertFrom-Json -Depth 10 -ErrorAction Stop + # Per-item timestamp: a page may span tenants. + $GuestProps = [ordered]@{ CacheTimestamp = $Item.Timestamp } + if ($TenantFilter -eq 'AllTenants') { + $GuestProps['Tenant'] = $Item.PartitionKey + } + $Guest | Add-Member -NotePropertyMembers $GuestProps -Force + $Results.Add($Guest) + } catch { + Write-LogMessage -API 'GuestUsersReport' -tenant $Item.PartitionKey -message "Failed to parse guest user item: $($_.Exception.Message)" -sev Warning + } + } + return [PSCustomObject]@{ + Items = $Results + NextToken = $Page.NextToken + } + } + if ($TenantFilter -eq 'AllTenants') { $AnyItems = Get-CIPPDbItem -TenantFilter 'allTenants' -Type 'Guests' $Tenants = @($AnyItems | Where-Object { $_.RowKey -notlike '*-Count' } | Select-Object -ExpandProperty PartitionKey -Unique) diff --git a/Modules/CIPPCore/Public/Get-CIPPIntuneAppProtectionPolicyReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPIntuneAppProtectionPolicyReport.ps1 index 46483af66431f..7c6840a265b67 100644 --- a/Modules/CIPPCore/Public/Get-CIPPIntuneAppProtectionPolicyReport.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPIntuneAppProtectionPolicyReport.ps1 @@ -91,11 +91,13 @@ function Get-CIPPIntuneAppProtectionPolicyReport { } } - $Policy | Add-Member -NotePropertyName 'PolicyTypeName' -NotePropertyValue $policyType -Force - $Policy | Add-Member -NotePropertyName 'PolicySource' -NotePropertyValue 'AppProtection' -Force - $Policy | Add-Member -NotePropertyName 'PolicyAssignment' -NotePropertyValue ($PolicyAssignment -join ', ') -Force - $Policy | Add-Member -NotePropertyName 'PolicyExclude' -NotePropertyValue ($PolicyExclude -join ', ') -Force - $Policy | Add-Member -NotePropertyName 'CacheTimestamp' -NotePropertyValue $CacheTimestamp -Force + $Policy | Add-Member -NotePropertyMembers ([ordered]@{ + PolicyTypeName = $policyType + PolicySource = 'AppProtection' + PolicyAssignment = ($PolicyAssignment -join ', ') + PolicyExclude = ($PolicyExclude -join ', ') + CacheTimestamp = $CacheTimestamp + }) -Force $Results.Add($Policy) } @@ -130,15 +132,18 @@ function Get-CIPPIntuneAppProtectionPolicyReport { } } - $Config | Add-Member -NotePropertyName 'PolicyTypeName' -NotePropertyValue $policyType -Force - $Config | Add-Member -NotePropertyName 'URLName' -NotePropertyValue 'mobileAppConfigurations' -Force - $Config | Add-Member -NotePropertyName 'PolicySource' -NotePropertyValue 'AppConfiguration' -Force - $Config | Add-Member -NotePropertyName 'PolicyAssignment' -NotePropertyValue ($PolicyAssignment -join ', ') -Force - $Config | Add-Member -NotePropertyName 'PolicyExclude' -NotePropertyValue ($PolicyExclude -join ', ') -Force + $ConfigProps = [ordered]@{ + PolicyTypeName = $policyType + URLName = 'mobileAppConfigurations' + PolicySource = 'AppConfiguration' + PolicyAssignment = ($PolicyAssignment -join ', ') + PolicyExclude = ($PolicyExclude -join ', ') + } if (-not $Config.PSObject.Properties['isAssigned']) { - $Config | Add-Member -NotePropertyName 'isAssigned' -NotePropertyValue $false -Force + $ConfigProps['isAssigned'] = $false } - $Config | Add-Member -NotePropertyName 'CacheTimestamp' -NotePropertyValue $CacheTimestamp -Force + $ConfigProps['CacheTimestamp'] = $CacheTimestamp + $Config | Add-Member -NotePropertyMembers $ConfigProps -Force $Results.Add($Config) } diff --git a/Modules/CIPPCore/Public/Get-CIPPIntuneApplicationReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPIntuneApplicationReport.ps1 index d6f53a245e65e..84f281656b4eb 100644 --- a/Modules/CIPPCore/Public/Get-CIPPIntuneApplicationReport.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPIntuneApplicationReport.ps1 @@ -71,9 +71,11 @@ function Get-CIPPIntuneApplicationReport { } } - $App | Add-Member -NotePropertyName 'AppAssignment' -NotePropertyValue ($AppAssignment -join ', ') -Force - $App | Add-Member -NotePropertyName 'AppExclude' -NotePropertyValue ($AppExclude -join ', ') -Force - $App | Add-Member -NotePropertyName 'CacheTimestamp' -NotePropertyValue $CacheTimestamp -Force + $App | Add-Member -NotePropertyMembers ([ordered]@{ + AppAssignment = ($AppAssignment -join ', ') + AppExclude = ($AppExclude -join ', ') + CacheTimestamp = $CacheTimestamp + }) -Force $Results.Add($App) } diff --git a/Modules/CIPPCore/Public/Get-CIPPIntuneCompareExclusions.ps1 b/Modules/CIPPCore/Public/Get-CIPPIntuneCompareExclusions.ps1 index f1a30d479507f..81fa4355a4bd3 100644 --- a/Modules/CIPPCore/Public/Get-CIPPIntuneCompareExclusions.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPIntuneCompareExclusions.ps1 @@ -45,7 +45,11 @@ function Get-CIPPIntuneCompareExclusions { 'templateId', 'source', 'package', - 'assignments' + 'assignments', + # App configuration policies name their apps by mobileApp id, which differs per tenant by + # construction; the app identity captured next to it is deployment metadata, not policy. + 'targetedMobileApps', + 'targetedMobileAppsDetails' ) if ($AppProtection) { $Exclusions = $Exclusions + @('apps', 'deployedAppCount', 'isAssigned') diff --git a/Modules/CIPPCore/Public/Get-CIPPIntuneCompliancePolicyReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPIntuneCompliancePolicyReport.ps1 index 70c70cbd75bff..31d16fb27d413 100644 --- a/Modules/CIPPCore/Public/Get-CIPPIntuneCompliancePolicyReport.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPIntuneCompliancePolicyReport.ps1 @@ -81,10 +81,12 @@ function Get-CIPPIntuneCompliancePolicyReport { } } - $Policy | Add-Member -NotePropertyName 'PolicyTypeName' -NotePropertyValue $policyType -Force - $Policy | Add-Member -NotePropertyName 'PolicyAssignment' -NotePropertyValue ($PolicyAssignment -join ', ') -Force - $Policy | Add-Member -NotePropertyName 'PolicyExclude' -NotePropertyValue ($PolicyExclude -join ', ') -Force - $Policy | Add-Member -NotePropertyName 'CacheTimestamp' -NotePropertyValue $CacheTimestamp -Force + $Policy | Add-Member -NotePropertyMembers ([ordered]@{ + PolicyTypeName = $policyType + PolicyAssignment = ($PolicyAssignment -join ', ') + PolicyExclude = ($PolicyExclude -join ', ') + CacheTimestamp = $CacheTimestamp + }) -Force $Results.Add($Policy) } diff --git a/Modules/CIPPCore/Public/Get-CIPPIntunePolicy.ps1 b/Modules/CIPPCore/Public/Get-CIPPIntunePolicy.ps1 index 039ec90c3353e..565771296eb04 100644 --- a/Modules/CIPPCore/Public/Get-CIPPIntunePolicy.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPIntunePolicy.ps1 @@ -242,6 +242,43 @@ function Get-CIPPIntunePolicy { return $policies } } + 'AppConfiguration' { + # Managed-device app configuration policies. Without this case the IntuneTemplate + # standard could never find a deployed app configuration and reported it missing on + # every run while remediation kept patching the policy that was already there. + $PlatformType = 'deviceAppManagement' + $TemplateTypeURL = 'mobileAppConfigurations' + $ExcludedProperties = @('id', 'createdDateTime', 'lastModifiedDateTime', 'version', '@odata.context') + + if ($DisplayName) { + $policies = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $tenantFilter + $policy = $policies | Where-Object -Property displayName -EQ $DisplayName | Sort-Object -Property lastModifiedDateTime -Descending | Select-Object -First 1 + if ($policy) { + $policyDetails = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL('$($policy.id)')" -tenantid $tenantFilter + $policyDetails = $policyDetails | Select-Object * -ExcludeProperty $ExcludedProperties + $policyJson = ConvertTo-Json -InputObject $policyDetails -Depth 100 -Compress + $policy | Add-Member -MemberType NoteProperty -Name 'cippconfiguration' -Value $policyJson -Force + } + return $policy + } elseif ($PolicyId) { + $policy = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL('$PolicyId')" -tenantid $tenantFilter + if ($policy) { + $policyDetails = $policy | Select-Object * -ExcludeProperty $ExcludedProperties + $policyJson = ConvertTo-Json -InputObject $policyDetails -Depth 100 -Compress + $policy | Add-Member -MemberType NoteProperty -Name 'cippconfiguration' -Value $policyJson -Force + } + return $policy + } else { + $policies = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $tenantFilter + foreach ($policy in $policies) { + $policyDetails = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL('$($policy.id)')" -tenantid $tenantFilter + $policyDetails = $policyDetails | Select-Object * -ExcludeProperty $ExcludedProperties + $policyJson = ConvertTo-Json -InputObject $policyDetails -Depth 100 -Compress + $policy | Add-Member -MemberType NoteProperty -Name 'cippconfiguration' -Value $policyJson -Force + } + return $policies + } + } 'Device' { $PlatformType = 'deviceManagement' $TemplateTypeURL = 'deviceConfigurations' diff --git a/Modules/CIPPCore/Public/Get-CIPPIntuneReusableSettingsReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPIntuneReusableSettingsReport.ps1 index 61b64e3d257f4..6fa4bbaac5198 100644 --- a/Modules/CIPPCore/Public/Get-CIPPIntuneReusableSettingsReport.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPIntuneReusableSettingsReport.ps1 @@ -46,8 +46,10 @@ function Get-CIPPIntuneReusableSettingsReport { $rawJson = $null } - $Setting | Add-Member -NotePropertyName 'RawJSON' -NotePropertyValue $rawJson -Force - $Setting | Add-Member -NotePropertyName 'CacheTimestamp' -NotePropertyValue $CacheTimestamp -Force + $Setting | Add-Member -NotePropertyMembers ([ordered]@{ + RawJSON = $rawJson + CacheTimestamp = $CacheTimestamp + }) -Force $Results.Add($Setting) } diff --git a/Modules/CIPPCore/Public/Get-CIPPIntuneScriptReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPIntuneScriptReport.ps1 index 9a57ab372595c..dadd504daa89d 100644 --- a/Modules/CIPPCore/Public/Get-CIPPIntuneScriptReport.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPIntuneScriptReport.ps1 @@ -93,10 +93,12 @@ function Get-CIPPIntuneScriptReport { } } - $script | Add-Member -NotePropertyName 'ScriptAssignment' -NotePropertyValue ($ScriptAssignment -join ', ') -Force - $script | Add-Member -NotePropertyName 'ScriptExclude' -NotePropertyValue ($ScriptExclude -join ', ') -Force - $script | Add-Member -MemberType NoteProperty -Name scriptType -Value $scriptId -Force - $script | Add-Member -NotePropertyName 'CacheTimestamp' -NotePropertyValue $CacheTimestamp -Force + $script | Add-Member -NotePropertyMembers ([ordered]@{ + ScriptAssignment = ($ScriptAssignment -join ', ') + ScriptExclude = ($ScriptExclude -join ', ') + scriptType = $scriptId + CacheTimestamp = $CacheTimestamp + }) -Force $Results.Add($script) } } diff --git a/Modules/CIPPCore/Public/Get-CIPPJITAdminAllowedRoles.ps1 b/Modules/CIPPCore/Public/Get-CIPPJITAdminAllowedRoles.ps1 new file mode 100644 index 0000000000000..8f646b2b91ac7 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPJITAdminAllowedRoles.ps1 @@ -0,0 +1,148 @@ +function Get-CIPPJITAdminAllowedRoles { + <# + .SYNOPSIS + Resolve which directory roles the calling user is permitted to assign via JIT Admin. + + .DESCRIPTION + JIT Role Templates are named allow-lists of Entra directory roles that can be attached to a + CIPP custom role (via the AllowedRolesTemplate property on the CustomRoles row). This function + resolves the calling user's roles and returns the effective allow-list. + + Restrictive semantics, matching how CIPP combines multiple custom roles everywhere else + ("assigning multiple custom roles is restrictive and not additive"): + - Base roles (superadmin/admin/editor/readonly) do not carry templates. admin/superadmin are + unaffected by custom roles and are always unrestricted. + - A custom role with NO template contributes "all roles" (the universal set), so it never + loosens the result - but on its own it does not restrict. + - If the caller holds AT LEAST ONE templated custom role they are restricted, and the allow-list + is the INTERSECTION of the templated roles' sets. An untemplated custom role therefore cannot + be used to bypass a template held alongside it. + - If NO custom role carries a template, the caller is unrestricted, so deployments with no + templates assigned anywhere are undisturbed. + + Fails closed for restricted callers: a template (or role row) that cannot be read contributes an + empty set to the intersection rather than opening access, so a lookup failure cannot escalate. + + .PARAMETER Headers + The request headers (containing x-ms-client-principal) used to resolve the caller. + + .OUTPUTS + PSCustomObject with: + Restricted [bool] - $true when the allow-list should be enforced. + AllowedRoleIds [string[]] - directory role template IDs the caller may assign (only meaningful when Restricted). + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + $Headers + ) + + $Unrestricted = [PSCustomObject]@{ Restricted = $false; AllowedRoleIds = @() } + + # Resolve the calling user's roles, including Entra group-based roles (mirrors Invoke-ExecRestoreBackup) + try { + $CallingUser = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Headers.'x-ms-client-principal')) | ConvertFrom-Json + } catch { + # Without a resolvable principal we cannot determine a custom role, so nothing is restricted. + return $Unrestricted + } + + if (($CallingUser.userRoles | Measure-Object).Count -eq 2 -and $CallingUser.userRoles -contains 'authenticated' -and $CallingUser.userRoles -contains 'anonymous') { + $CallingUser = Test-CIPPAccessUserRole -User $CallingUser + } + + # admin/superadmin are unaffected by custom roles (CIPP convention) -> never restricted. + if ($CallingUser.userRoles -contains 'admin' -or $CallingUser.userRoles -contains 'superadmin') { + return $Unrestricted + } + + $DefaultRoles = @('superadmin', 'admin', 'editor', 'readonly', 'anonymous', 'authenticated') + $CustomRoleNames = @($CallingUser.userRoles | Where-Object { $DefaultRoles -notcontains $_ }) + + # No custom role -> unrestricted (base roles have no template concept). + if ($CustomRoleNames.Count -eq 0) { + return $Unrestricted + } + + $Table = Get-CIPPTable -tablename 'CustomRoles' + $TemplateTable = Get-CIPPTable -tablename 'templates' + + # Each templated custom role contributes one set of allowed role IDs. Untemplated custom roles + # contribute nothing (they represent the universal set and never tighten the intersection). + $TemplatedSets = [System.Collections.Generic.List[object]]::new() + + foreach ($RoleName in $CustomRoleNames) { + try { + $SafeRole = ConvertTo-CIPPODataFilterValue -Value ($RoleName.ToLower()) -Type String + $RoleRow = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'CustomRoles' and RowKey eq '$SafeRole'" + } catch { + Write-Warning "JIT allowed-roles: failed to read custom role '$RoleName': $($_.Exception.Message)" + # Cannot confirm whether this role is templated -> fail closed: contribute an empty set. + $TemplatedSets.Add([string[]]@()) + continue + } + + # A role with no template assigned represents the universal set - skip it (it never restricts). + if (-not $RoleRow -or [string]::IsNullOrWhiteSpace($RoleRow.AllowedRolesTemplate)) { + continue + } + + try { + $TemplateRef = $RoleRow.AllowedRolesTemplate | ConvertFrom-Json -ErrorAction Stop + } catch { + $TemplateRef = $RoleRow.AllowedRolesTemplate + } + $TemplateGuid = if ($TemplateRef -is [string]) { $TemplateRef } else { $TemplateRef.value ?? $TemplateRef.GUID } + + # A blank template reference is equivalent to no template -> universal set, skip it. + if ([string]::IsNullOrWhiteSpace($TemplateGuid)) { + continue + } + + try { + $SafeGuid = ConvertTo-CIPPODataFilterValue -Value $TemplateGuid -Type Guid + $TemplateRow = Get-CIPPAzDataTableEntity @TemplateTable -Filter "PartitionKey eq 'JITRoleTemplate' and RowKey eq '$SafeGuid'" + } catch { + Write-Warning "JIT allowed-roles: failed to read JIT Role Template '$TemplateGuid': $($_.Exception.Message)" + $TemplateRow = $null + } + + # A templated role whose template cannot be resolved contributes an empty set (fail closed). + if (-not $TemplateRow) { + $TemplatedSets.Add([string[]]@()) + continue + } + + try { + $TemplateData = $TemplateRow.JSON | ConvertFrom-Json -Depth 10 -ErrorAction Stop + } catch { + $TemplatedSets.Add([string[]]@()) + continue + } + $Ids = foreach ($Role in @($TemplateData.roles)) { + $Id = if ($Role -is [string]) { $Role } else { $Role.value ?? $Role.ObjectId } + if (-not [string]::IsNullOrWhiteSpace($Id)) { [string]$Id } + } + $TemplatedSets.Add([string[]]@($Ids)) + } + + # No templated custom role -> nothing restricts the caller. + if ($TemplatedSets.Count -eq 0) { + return $Unrestricted + } + + # Restricted: the allow-list is the intersection of every templated role's set (most restrictive wins). + $Intersection = $null + foreach ($Set in $TemplatedSets) { + if ($null -eq $Intersection) { + $Intersection = [System.Collections.Generic.HashSet[string]]::new([string[]]@($Set)) + } else { + $Intersection.IntersectWith([string[]]@($Set)) + } + } + + return [PSCustomObject]@{ + Restricted = $true + AllowedRoleIds = @($Intersection) + } +} diff --git a/Modules/CIPPCore/Public/Get-CIPPLAPSPassword.ps1 b/Modules/CIPPCore/Public/Get-CIPPLAPSPassword.ps1 index b0cd9c6534a06..f54212d7e6699 100644 --- a/Modules/CIPPCore/Public/Get-CIPPLAPSPassword.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPLAPSPassword.ps1 @@ -18,7 +18,13 @@ function Get-CIPPLapsPassword { state = 'success' } } - if ($GraphRequest) { return $GraphRequest } else { return "No LAPS password found for $device" } + if ($GraphRequest) { + Write-LogMessage -headers $Headers -API $APIName -message "Retrieved LAPS password for $device" -Sev 'Info' -tenant $TenantFilter + return $GraphRequest + } else { + Write-LogMessage -headers $Headers -API $APIName -message "No LAPS password found for $device" -Sev 'Info' -tenant $TenantFilter + return "No LAPS password found for $device" + } } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -headers $Headers -API $APIName -message "Could not retrieve LAPS password for $($device). Error: $($ErrorMessage.NormalizedError)" -Sev 'Error' -tenant $TenantFilter -LogData $ErrorMessage diff --git a/Modules/CIPPCore/Public/Get-CIPPLastSignInDateTime.ps1 b/Modules/CIPPCore/Public/Get-CIPPLastSignInDateTime.ps1 new file mode 100644 index 0000000000000..8518f3503f5ca --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPLastSignInDateTime.ps1 @@ -0,0 +1,31 @@ +function Get-CIPPLastSignInDateTime { + <# + .SYNOPSIS + Returns the most recent sign-in timestamp from a Graph signInActivity object, in UTC. + .DESCRIPTION + Takes the newest of lastSignInDateTime (interactive), lastNonInteractiveSignInDateTime and + lastSuccessfulSignInDateTime. The first two record the last sign-in attempt whether it + succeeded or not, which is the view the Entra portal and the inactive-user alerts give; + the third can run ahead of both, so leaving it out would report a recently active user + as stale. Returns $null when the user has no sign-in activity on record. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param($SignInActivity) + + if (-not $SignInActivity) { return $null } + + $Latest = $null + foreach ($Property in 'lastSignInDateTime', 'lastNonInteractiveSignInDateTime', 'lastSuccessfulSignInDateTime') { + $Value = $SignInActivity.$Property + if ([string]::IsNullOrWhiteSpace("$Value")) { continue } + try { + $Candidate = ([datetime]$Value).ToUniversalTime() + } catch { + continue + } + if ($null -eq $Latest -or $Candidate -gt $Latest) { $Latest = $Candidate } + } + return $Latest +} diff --git a/Modules/CIPPCore/Public/Get-CIPPLicenseOptimization.ps1 b/Modules/CIPPCore/Public/Get-CIPPLicenseOptimization.ps1 new file mode 100644 index 0000000000000..752cdb30cc384 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPLicenseOptimization.ps1 @@ -0,0 +1,297 @@ +function Get-CIPPLicenseOptimization { + <# + .SYNOPSIS + Compute license waste and reclaimable spend for a tenant. + + .DESCRIPTION + Joins the cached license overview, users, and active-user-detail datasets with the resolved + price map (Get-CIPPLicensePrice) to produce a per-tenant optimization report: a monetary + summary plus a list of reclaim opportunities across five tiers: + + 1. UnassignedSeats - owned seats no one holds (CountAvailable > 0) + 2. DisabledAccount - a license assigned to a disabled account + 3. Inactive - a license on an enabled account with no sign-in in -InactiveDays + 4. Downgrade - a mailbox-only user on a premium SKU (review candidate) + 5. Overlap - a SKU whose service plans are fully covered by another SKU the user holds + + All inputs default to the reporting-DB cache but can be injected for testing or a live run. + + .PARAMETER TenantFilter + The tenant (domain or GUID) to report on. + + .PARAMETER Licenses + Optional. LicenseOverview records. Defaults to the cached 'LicenseOverview' type. + + .PARAMETER Users + Optional. User records. Defaults to the cached 'Users' type. + + .PARAMETER ActivityDetail + Optional. getOffice365ActiveUserDetail rows. Defaults to the cached 'ActiveUserDetail' type. + + .PARAMETER InactiveDays + Sign-in age (days) past which an enabled licensed user counts as inactive. Default 90. + + .PARAMETER Currency + ISO currency code the money figures are resolved in (passed to Get-CIPPLicensePrice). + Default USD. + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + $Licenses, + $Users, + $ActivityDetail, + [int]$InactiveDays = 90, + [string]$Currency = 'USD' + ) + + if (-not $PSBoundParameters.ContainsKey('Licenses')) { $Licenses = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'LicenseOverview') } + if (-not $PSBoundParameters.ContainsKey('Users')) { $Users = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Users') } + if (-not $PSBoundParameters.ContainsKey('ActivityDetail')) { $ActivityDetail = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'ActiveUserDetail') } + + $Licenses = @($Licenses) + $Users = @($Users) + $ActivityDetail = @($ActivityDetail) + + # --- price map (lowercased skuId -> price object) --- + $PriceBySku = @{} + foreach ($Price in @(Get-CIPPLicensePrice -Currency $Currency)) { + if ($Price.skuId) { $PriceBySku[([string]$Price.skuId).ToLowerInvariant()] = $Price } + } + $PriceOf = { + param($Sku) + $Key = ([string]$Sku).ToLowerInvariant() + if ($PriceBySku.ContainsKey($Key) -and $null -ne $PriceBySku[$Key].MonthlyPrice) { return [double]$PriceBySku[$Key].MonthlyPrice } + return $null + } + + # --- SKU lookup from the license overview: pretty name, service-plan set, seat counts --- + $SkuInfo = @{} + foreach ($Lic in $Licenses) { + if (-not $Lic.skuId) { continue } + $Key = ([string]$Lic.skuId).ToLowerInvariant() + $PlanIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Plan in @($Lic.ServicePlans)) { + if ($Plan.servicePlanId) { $null = $PlanIds.Add([string]$Plan.servicePlanId) } + } + $Total = [int]($Lic.TotalLicenses -as [int]) + $Used = [int]($Lic.CountUsed -as [int]) + $SkuInfo[$Key] = [pscustomobject]@{ + skuId = $Key + License = if ($Lic.License) { [string]$Lic.License } else { $Key } + PlanIds = $PlanIds + Total = $Total + Used = $Used + Available = $Total - $Used + } + } + $NameOf = { + param($Sku) + $Key = ([string]$Sku).ToLowerInvariant() + if ($SkuInfo.ContainsKey($Key)) { return $SkuInfo[$Key].License } + if ($PriceBySku.ContainsKey($Key) -and $PriceBySku[$Key].Product_Display_Name) { return [string]$PriceBySku[$Key].Product_Display_Name } + return $Key + } + + # --- activity map (lowercased UPN -> row); detect anonymized reports --- + $ActivityByUpn = @{} + foreach ($Row in $ActivityDetail) { + if ($Row.userPrincipalName) { $ActivityByUpn[([string]$Row.userPrincipalName).ToLowerInvariant()] = $Row } + } + $Cutoff = (Get-Date).AddDays(-$InactiveDays) + $ActiveIn = { + param($Row, $DateProp) + $Value = $Row.$DateProp + if ([string]::IsNullOrWhiteSpace([string]$Value)) { return $false } + $Parsed = [datetime]::MinValue + if ([datetime]::TryParse([string]$Value, [ref]$Parsed)) { return $Parsed -ge $Cutoff } + return $false + } + + # Real (non-service, non-guest) users only for per-user tiers + $RealUsers = @($Users | Where-Object { + $_.assignedLicenses -and @($_.assignedLicenses).Count -gt 0 -and + $_.userType -ne 'Guest' -and $_.isResourceAccount -ne $true + }) + + # Anonymized when activity exists but almost none of its UPNs match real users + $AnonymizedReports = $false + if ($ActivityByUpn.Count -gt 0 -and $RealUsers.Count -gt 0) { + $MatchCount = @($RealUsers | Where-Object { $ActivityByUpn.ContainsKey(([string]$_.userPrincipalName).ToLowerInvariant()) }).Count + if (($MatchCount / [double]$RealUsers.Count) -lt 0.1) { $AnonymizedReports = $true } + } + + $Opportunities = [System.Collections.Generic.List[object]]::new() + $NewOpportunity = { + param($Tier, $Finding, $Sku, $Seats, $MonthlySaving, $Action, $Users, $PriceKnown) + $Monthly = [math]::Round(([double]$MonthlySaving), 2) + $Opportunities.Add([pscustomobject]@{ + Tier = $Tier + FindingLabel = $Finding + License = & $NameOf $Sku + skuId = ([string]$Sku).ToLowerInvariant() + Seats = [int]$Seats + UnitCost = & $PriceOf $Sku + MonthlySaving = $Monthly + SuggestedAction = $Action + Users = @($Users) + PriceKnown = [bool]$PriceKnown + }) + } + + # --- Tier 1: unassigned (empty) seats --- + foreach ($Sku in $SkuInfo.Values) { + if ($Sku.Available -le 0) { continue } + $UnitPrice = & $PriceOf $Sku.skuId + & $NewOpportunity 'UnassignedSeats' 'Unassigned' $Sku.skuId $Sku.Available (($UnitPrice ?? 0) * $Sku.Available) 'Reduce seat count' @() ($null -ne $UnitPrice) + } + + # --- Tiers 2 & 3: disabled / inactive assigned seats (grouped by SKU) --- + $DisabledBySku = @{} + $InactiveBySku = @{} + foreach ($User in $RealUsers) { + $Upn = [string]$User.userPrincipalName + $Disabled = $User.accountEnabled -eq $false + + # Most-recent sign-in (interactive or non-interactive) + $LastSignIn = $null + foreach ($Prop in @('lastSignInDateTime', 'lastNonInteractiveSignInDateTime')) { + $Value = $User.signInActivity.$Prop + if (-not [string]::IsNullOrWhiteSpace([string]$Value)) { + $Parsed = [datetime]::MinValue + if ([datetime]::TryParse([string]$Value, [ref]$Parsed)) { + if ($null -eq $LastSignIn -or $Parsed -gt $LastSignIn) { $LastSignIn = $Parsed } + } + } + } + # Enabled + has a sign-in on record + that sign-in is stale. Never-signed-in enabled + # accounts are skipped by default to avoid flagging provisioning/service identities. + $Inactive = (-not $Disabled) -and ($null -ne $LastSignIn) -and ($LastSignIn -lt $Cutoff) + + if (-not $Disabled -and -not $Inactive) { continue } + $Bucket = if ($Disabled) { $DisabledBySku } else { $InactiveBySku } + foreach ($Assigned in @($User.assignedLicenses)) { + if (-not $Assigned.skuId) { continue } + $Key = ([string]$Assigned.skuId).ToLowerInvariant() + if (-not $Bucket.ContainsKey($Key)) { $Bucket[$Key] = [System.Collections.Generic.List[string]]::new() } + $Bucket[$Key].Add($Upn) + } + } + foreach ($Key in $DisabledBySku.Keys) { + $Upns = $DisabledBySku[$Key] + $UnitPrice = & $PriceOf $Key + & $NewOpportunity 'DisabledAccount' 'Disabled user' $Key $Upns.Count (($UnitPrice ?? 0) * $Upns.Count) 'Remove license' $Upns ($null -ne $UnitPrice) + } + foreach ($Key in $InactiveBySku.Keys) { + $Upns = $InactiveBySku[$Key] + $UnitPrice = & $PriceOf $Key + & $NewOpportunity 'Inactive' ("Inactive {0}d+" -f $InactiveDays) $Key $Upns.Count (($UnitPrice ?? 0) * $Upns.Count) 'Review / remove' $Upns ($null -ne $UnitPrice) + } + + # --- Tier 4: mailbox-only users on a premium suite SKU (flag for REVIEW, not a downgrade) --- + # A user who only uses email yet holds a full suite may be over-licensed - but a licensed + # (shared) mailbox is sometimes deliberate (large archive, litigation/in-place hold, >50 GB, an + # auto-mapped resource). So we make no assumption about downgrading and claim no saving; we + # surface the seats for the MSP to review and decide. + $ReviewSuiteSkus = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Guid in @( + '6fd2c87f-b296-42f0-b197-1e91e994b900', # Office 365 E3 + 'c7df2760-2c81-4ef7-b578-5b5392b571df', # Office 365 E5 + '05e9a617-0261-4cee-bb44-138d3ef5d965', # Microsoft 365 E3 + '06ebc4ee-1bb5-47dd-8120-11324bc54e06', # Microsoft 365 E5 + 'f245ecc8-75af-4f8e-b61f-27d8114de5f3', # Microsoft 365 Business Standard + 'cbdc14ab-d96c-4c30-b9f4-6ada7cdc1d46' # Microsoft 365 Business Premium + )) { $null = $ReviewSuiteSkus.Add($Guid) } + + $ReviewBySku = @{} + foreach ($User in $RealUsers) { + if ($User.accountEnabled -eq $false) { continue } + $Activity = $ActivityByUpn[([string]$User.userPrincipalName).ToLowerInvariant()] + if (-not $Activity) { continue } + $UsedExchange = & $ActiveIn $Activity 'exchangeLastActivityDate' + $UsedCollab = (& $ActiveIn $Activity 'oneDriveLastActivityDate') -or + (& $ActiveIn $Activity 'sharePointLastActivityDate') -or + (& $ActiveIn $Activity 'teamsLastActivityDate') -or + (& $ActiveIn $Activity 'yammerLastActivityDate') + if (-not $UsedExchange -or $UsedCollab) { continue } + foreach ($Assigned in @($User.assignedLicenses)) { + $Key = ([string]$Assigned.skuId).ToLowerInvariant() + if (-not $ReviewSuiteSkus.Contains($Key)) { continue } + if (-not $ReviewBySku.ContainsKey($Key)) { $ReviewBySku[$Key] = [System.Collections.Generic.List[string]]::new() } + $ReviewBySku[$Key].Add([string]$User.userPrincipalName) + } + } + foreach ($Key in $ReviewBySku.Keys) { + $Upns = $ReviewBySku[$Key] + # Review only - no assumed downgrade target, so no monetary saving is claimed. PriceKnown + # still reflects whether the SKU itself is priced (so the UI's set-price action only targets + # genuinely unpriced SKUs, not these). + $UnitPrice = & $PriceOf $Key + & $NewOpportunity 'Downgrade' 'Mailbox-only' $Key $Upns.Count 0 'Review: only using email' $Upns ($null -ne $UnitPrice) + } + + # --- Tier 5: redundant SKU whose service plans are fully covered by another SKU the user holds --- + $OverlapBySku = @{} + foreach ($User in $RealUsers) { + $Held = @(@($User.assignedLicenses).skuId | Where-Object { $_ } | ForEach-Object { ([string]$_).ToLowerInvariant() } | Select-Object -Unique) + if ($Held.Count -lt 2) { continue } + foreach ($A in $Held) { + if (-not $SkuInfo.ContainsKey($A) -or $SkuInfo[$A].PlanIds.Count -eq 0) { continue } + foreach ($B in $Held) { + if ($A -eq $B -or -not $SkuInfo.ContainsKey($B)) { continue } + # A is redundant if every plan in A is also in B (A is a subset of B) and B is larger + if ($SkuInfo[$B].PlanIds.Count -gt $SkuInfo[$A].PlanIds.Count -and $SkuInfo[$B].PlanIds.IsSupersetOf($SkuInfo[$A].PlanIds)) { + if (-not $OverlapBySku.ContainsKey($A)) { $OverlapBySku[$A] = [System.Collections.Generic.List[string]]::new() } + $OverlapBySku[$A].Add([string]$User.userPrincipalName) + break + } + } + } + } + foreach ($Key in $OverlapBySku.Keys) { + $Upns = @($OverlapBySku[$Key] | Select-Object -Unique) + $UnitPrice = & $PriceOf $Key + & $NewOpportunity 'Overlap' 'Redundant' $Key $Upns.Count (($UnitPrice ?? 0) * $Upns.Count) 'Remove redundant license' $Upns ($null -ne $UnitPrice) + } + + # --- summary --- (money resolved in the requested $Currency) + $MonthlySpend = 0.0 + $PricedSeats = 0 + $TotalAssignedSeats = 0 + foreach ($Sku in $SkuInfo.Values) { + $TotalAssignedSeats += $Sku.Used + $UnitPrice = & $PriceOf $Sku.skuId + if ($null -ne $UnitPrice) { + $MonthlySpend += $UnitPrice * $Sku.Used + $PricedSeats += $Sku.Used + } + } + $ReclaimableMonthly = 0.0 + foreach ($Opp in $Opportunities) { $ReclaimableMonthly += $Opp.MonthlySaving } + $ReclaimableSeats = 0 + foreach ($Opp in $Opportunities) { + if ($Opp.Tier -in @('UnassignedSeats', 'DisabledAccount', 'Inactive')) { $ReclaimableSeats += $Opp.Seats } + } + + $Summary = [pscustomobject]@{ + Tenant = $TenantFilter + Currency = $Currency + MonthlySpend = [math]::Round($MonthlySpend, 2) + ReclaimableMonthly = [math]::Round($ReclaimableMonthly, 2) + ReclaimableSeats = $ReclaimableSeats + AssignedSeats = $TotalAssignedSeats + PriceCoverage = if ($TotalAssignedSeats -gt 0) { [math]::Round($PricedSeats / [double]$TotalAssignedSeats, 3) } else { 0 } + OpportunityCount = $Opportunities.Count + AnonymizedReports = $AnonymizedReports + DataAvailable = ($Licenses.Count -gt 0) + } + + return [pscustomobject]@{ + Summary = $Summary + Opportunities = @($Opportunities | Sort-Object -Property MonthlySaving -Descending) + } +} diff --git a/Modules/CIPPCore/Public/Get-CIPPLicenseOverview.ps1 b/Modules/CIPPCore/Public/Get-CIPPLicenseOverview.ps1 index 7e7c32e5285eb..f583824cd2f4d 100644 --- a/Modules/CIPPCore/Public/Get-CIPPLicenseOverview.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPLicenseOverview.ps1 @@ -73,7 +73,7 @@ function Get-CIPPLicenseOverview { $null -eq $_.ExcludedEverywhere -or $_.ExcludedEverywhere -eq $true } | ForEach-Object { $_.GUID }) } - $DropdownVisibleGuids = @($ExcludedSkuList | Where-Object { $_.ShowInLicenseDropdown -eq $true } | ForEach-Object { $_.GUID }) + $HiddenFromDropdownGuids = @($ExcludedSkuList | Where-Object { $_.ShowInLicenseDropdown -eq $false } | ForEach-Object { $_.GUID }) $AllLicensedUsers = @(($Results | Where-Object { $_.id -eq 'licensedUsers' }).body.value) | Sort-Object -Property displayName $UsersBySku = @{} @@ -123,7 +123,7 @@ function Get-CIPPLicenseOverview { $skuId = $singleReq.Licenses foreach ($sku in $skuId) { if ($sku.skuId -in $EffectiveExcludedGuids) { - if (!$IncludeExcluded -or $sku.skuId -notin $DropdownVisibleGuids) { continue } + if (!$IncludeExcluded -or $sku.skuId -in $HiddenFromDropdownGuids) { continue } } $PrettyNameAdmin = $AdminPortalLicenses | Where-Object { $_.aadSkuId -eq $sku.skuId } | Select-Object -ExpandProperty displayName -First 1 $PrettyNameCSV = ($ConvertTable | Where-Object { $_.guid -eq $sku.skuid }).'Product_Display_Name' | Select-Object -Last 1 diff --git a/Modules/CIPPCore/Public/Get-CIPPLicensePrice.ps1 b/Modules/CIPPCore/Public/Get-CIPPLicensePrice.ps1 new file mode 100644 index 0000000000000..8994e35166dac --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPLicensePrice.ps1 @@ -0,0 +1,131 @@ +function Get-CIPPLicensePrice { + <# + .SYNOPSIS + Resolve the monthly price for one or all license SKUs, in a given currency. + + .DESCRIPTION + Merges the shipped MSRP estimate list (Config\LicensePricingDefaults.csv) with the + MSP-maintained override table (LicensePricing). An override always wins over the estimate. + Both are multi-currency: each SKU can have a row per ISO currency. Prices are MSP-global + (not per-tenant). + + Returns one price object per SKU for the requested -Currency, with a Source of: + - 'Override' : an explicit price the MSP entered for this currency + - 'Estimate' : the shipped public MSRP fallback for this currency (subject to drift) + - 'Unknown' : the SKU has no price in the requested currency (MonthlyPrice is $null) + + There is no cross-currency conversion: asking for AUD returns only AUD prices. A single + -SkuId lookup with no price in the requested currency is reported 'Unknown' (null price); the + full list (the price matrix) omits such SKUs entirely rather than showing empty rows. + + .PARAMETER SkuId + Optional. Return the single resolved price object for this SKU GUID. Omit to return every + known SKU (overrides merged over estimates) for the requested currency. + + .PARAMETER Currency + ISO currency code to resolve prices in. Defaults to USD. + + .PARAMETER ListCurrencies + Return the sorted list of currency codes present in the estimates + overrides instead of + prices. Used to populate the currency selector. + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [string]$SkuId, + [string]$Currency = 'USD', + [switch]$ListCurrencies + ) + + # currency (lower) -> @{ skuId (lower) -> price object } + $Estimate = @{} + $Override = @{} + # skuId (lower) -> metadata shared across currencies (name / part number) + $SkuMeta = @{} + $CurrencySet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + + # Shipped MSRP estimates (public list prices, subject to drift - labelled Estimate) + try { + $CsvPath = Join-Path $env:CIPPRootPath 'Config\LicensePricingDefaults.csv' + if (Test-Path $CsvPath) { + foreach ($Row in (Import-Csv -Path $CsvPath)) { + $Key = ([string]$Row.skuId).ToLowerInvariant() + if ([string]::IsNullOrWhiteSpace($Key)) { continue } + $Cur = if ($Row.Currency) { [string]$Row.Currency } else { 'USD' } + $null = $CurrencySet.Add($Cur) + $CurKey = $Cur.ToLowerInvariant() + if (-not $Estimate.ContainsKey($CurKey)) { $Estimate[$CurKey] = @{} } + $Estimate[$CurKey][$Key] = [pscustomobject]@{ + skuId = $Key + skuPartNumber = [string]$Row.skuPartNumber + Product_Display_Name = [string]$Row.Product_Display_Name + MonthlyPrice = [double]$Row.MonthlyPrice + Currency = $Cur + Source = 'Estimate' + } + if (-not $SkuMeta.ContainsKey($Key)) { + $SkuMeta[$Key] = [pscustomobject]@{ skuPartNumber = [string]$Row.skuPartNumber; Product_Display_Name = [string]$Row.Product_Display_Name } + } + } + } + } catch { + Write-Information "Get-CIPPLicensePrice: failed to read defaults CSV: $($_.Exception.Message)" + } + + # MSP overrides (win over estimates, per currency) + try { + $Table = Get-CIPPTable -TableName 'LicensePricing' + foreach ($Row in (Get-CIPPAzDataTableEntity @Table)) { + $Key = if ($Row.skuId) { ([string]$Row.skuId).ToLowerInvariant() } else { (([string]$Row.RowKey) -split '-')[0].ToLowerInvariant() } + if ([string]::IsNullOrWhiteSpace($Key)) { continue } + $Cur = if ($Row.Currency) { [string]$Row.Currency } else { 'USD' } + $null = $CurrencySet.Add($Cur) + $CurKey = $Cur.ToLowerInvariant() + if (-not $Override.ContainsKey($CurKey)) { $Override[$CurKey] = @{} } + $Override[$CurKey][$Key] = [pscustomobject]@{ + skuId = $Key + skuPartNumber = [string]$Row.skuPartNumber + Product_Display_Name = [string]$Row.Product_Display_Name + MonthlyPrice = [double]$Row.MonthlyPrice + Currency = $Cur + Source = 'Override' + } + if (-not $SkuMeta.ContainsKey($Key)) { + $SkuMeta[$Key] = [pscustomobject]@{ skuPartNumber = [string]$Row.skuPartNumber; Product_Display_Name = [string]$Row.Product_Display_Name } + } + } + } catch { + Write-Information "Get-CIPPLicensePrice: failed to read override table: $($_.Exception.Message)" + } + + if ($ListCurrencies) { + return @($CurrencySet | Sort-Object) + } + + $WantCur = $Currency.ToLowerInvariant() + $ResolveOne = { + param($Sku) + if ($Override.ContainsKey($WantCur) -and $Override[$WantCur].ContainsKey($Sku)) { return $Override[$WantCur][$Sku] } + if ($Estimate.ContainsKey($WantCur) -and $Estimate[$WantCur].ContainsKey($Sku)) { return $Estimate[$WantCur][$Sku] } + $Meta = $SkuMeta[$Sku] + return [pscustomobject]@{ + skuId = $Sku + skuPartNumber = if ($Meta) { $Meta.skuPartNumber } else { $null } + Product_Display_Name = if ($Meta) { $Meta.Product_Display_Name } else { $null } + MonthlyPrice = $null + Currency = $Currency + Source = 'Unknown' + } + } + + if ($SkuId) { + return & $ResolveOne ([string]$SkuId).ToLowerInvariant() + } + + # The full list is the price matrix: only SKUs that actually carry a price in this currency + # (a SKU priced in USD but not the requested currency is omitted, not shown as 'Unknown'). + $Result = foreach ($Sku in $SkuMeta.Keys) { & $ResolveOne $Sku } + return @($Result | Where-Object { $null -ne $_.MonthlyPrice } | Sort-Object -Property Product_Display_Name) +} diff --git a/Modules/CIPPCore/Public/Get-CIPPMFAStateReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPMFAStateReport.ps1 index 41f2bc9c41e79..a34d6c2ef50e3 100644 --- a/Modules/CIPPCore/Public/Get-CIPPMFAStateReport.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPMFAStateReport.ps1 @@ -26,6 +26,10 @@ function Get-CIPPMFAStateReport { .PARAMETER TenantFilter The tenant to generate the report for + .PARAMETER PageSize + When set, returns one page of at most this many rows as @{ Items; NextToken }, in table walk order. .PARAMETER ContinuationToken + NextToken from the previous page. Only meaningful together with PageSize. + .EXAMPLE Get-CIPPMFAStateReport -TenantFilter 'contoso.onmicrosoft.com' Gets MFA state for all users in the tenant @@ -33,10 +37,41 @@ function Get-CIPPMFAStateReport { [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [int]$PageSize, + [string]$ContinuationToken ) try { + if ($PageSize -gt 0) { + $Page = Get-CIPPDbItemPage -TenantFilter $TenantFilter -Type 'MFAState' -PageSize $PageSize -ContinuationToken $ContinuationToken + if ($TenantFilter -ne 'AllTenants' -and -not $ContinuationToken -and @($Page.Items).Count -eq 0 -and -not $Page.NextToken) { + throw 'No MFA state data found in reporting database. Sync the report data first.' + } + # Same memory discipline as the unpaged path below: hashtables, no Add-Member. + $Results = [System.Collections.Generic.List[object]]::new() + foreach ($Item in $Page.Items) { + $MFAUser = $Item.Data | ConvertFrom-Json -AsHashtable + + # Legacy rows stored these as embedded JSON strings rather than as objects. + if ($MFAUser['CAPolicies'] -is [string]) { + $MFAUser['CAPolicies'] = try { $MFAUser['CAPolicies'] | ConvertFrom-Json -AsHashtable } catch { $MFAUser['CAPolicies'] } + } + if ($MFAUser['MFAMethods'] -is [string]) { + $MFAUser['MFAMethods'] = try { $MFAUser['MFAMethods'] | ConvertFrom-Json -AsHashtable } catch { $MFAUser['MFAMethods'] } + } + + $MFAUser['CacheTimestamp'] = $Item.Timestamp + # Tenant is already stored on every row by Get-CIPPMFAState; only fill it + # in for older rows that predate that. + if (-not $MFAUser['Tenant']) { $MFAUser['Tenant'] = $Item.PartitionKey } + $Results.Add($MFAUser) + } + return [PSCustomObject]@{ + Items = $Results + NextToken = $Page.NextToken + } + } # Handle AllTenants if ($TenantFilter -eq 'AllTenants') { diff --git a/Modules/CIPPCore/Public/Get-CIPPMailboxRulesReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPMailboxRulesReport.ps1 index 641e979654528..9d664f3c0c9e7 100644 --- a/Modules/CIPPCore/Public/Get-CIPPMailboxRulesReport.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPMailboxRulesReport.ps1 @@ -62,13 +62,12 @@ function Get-CIPPMailboxRulesReport { foreach ($Item in $RulesItems | Where-Object { $_.RowKey -ne 'MailboxRules-Count' }) { $Rule = $Item.Data | ConvertFrom-Json - # Add cache timestamp to the rule - $Rule | Add-Member -NotePropertyName 'CacheTimestamp' -NotePropertyValue $CacheTimestamp -Force -ErrorAction SilentlyContinue - - # Ensure Tenant property is set + # Add cache timestamp to the rule; ensure Tenant property is set + $RuleProps = [ordered]@{ CacheTimestamp = $CacheTimestamp } if (-not $Rule.Tenant) { - $Rule | Add-Member -NotePropertyName 'Tenant' -NotePropertyValue $TenantFilter -Force -ErrorAction SilentlyContinue + $RuleProps['Tenant'] = $TenantFilter } + $Rule | Add-Member -NotePropertyMembers $RuleProps -Force -ErrorAction SilentlyContinue $AllRules.Add($Rule) } diff --git a/Modules/CIPPCore/Public/Get-CIPPMailboxesReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPMailboxesReport.ps1 index 7e87d65243f99..a852987913ef8 100644 --- a/Modules/CIPPCore/Public/Get-CIPPMailboxesReport.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPMailboxesReport.ps1 @@ -9,6 +9,10 @@ function Get-CIPPMailboxesReport { .PARAMETER TenantFilter The tenant to generate the report for + .PARAMETER PageSize + When set, returns one page of at most this many rows as @{ Items; NextToken }, in table walk order. .PARAMETER ContinuationToken + NextToken from the previous page. Only meaningful together with PageSize. + .EXAMPLE Get-CIPPMailboxesReport -TenantFilter 'contoso.onmicrosoft.com' Gets all mailboxes for the tenant from the report database @@ -16,10 +20,34 @@ function Get-CIPPMailboxesReport { [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [int]$PageSize, + [string]$ContinuationToken ) try { + if ($PageSize -gt 0) { + $Page = Get-CIPPDbItemPage -TenantFilter $TenantFilter -Type 'Mailboxes' -PageSize $PageSize -ContinuationToken $ContinuationToken + if ($TenantFilter -ne 'AllTenants' -and -not $ContinuationToken -and @($Page.Items).Count -eq 0 -and -not $Page.NextToken) { + throw 'No mailbox data found in reporting database. Sync the report data first.' + } + $Results = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($Item in $Page.Items) { + $Mailbox = $Item.Data | ConvertFrom-Json + # Per-item timestamp: a page may span tenants. + $MailboxProps = [ordered]@{ CacheTimestamp = $Item.Timestamp } + if ($TenantFilter -eq 'AllTenants') { + $MailboxProps['Tenant'] = $Item.PartitionKey + } + $Mailbox | Add-Member -NotePropertyMembers $MailboxProps -Force + $Results.Add($Mailbox) + } + return [PSCustomObject]@{ + Items = $Results + NextToken = $Page.NextToken + } + } + # Handle AllTenants if ($TenantFilter -eq 'AllTenants') { # Get all tenants that have mailbox data diff --git a/Modules/CIPPCore/Public/Get-CIPPOutOfOffice.ps1 b/Modules/CIPPCore/Public/Get-CIPPOutOfOffice.ps1 index fd80a389be8f8..163a2577ee1cb 100644 --- a/Modules/CIPPCore/Public/Get-CIPPOutOfOffice.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPOutOfOffice.ps1 @@ -11,8 +11,11 @@ function Get-CIPPOutOfOffice { $OutOfOffice = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-MailboxAutoReplyConfiguration' -cmdParams @{Identity = $UserID } -Anchor $UserID $Results = @{ AutoReplyState = $OutOfOffice.AutoReplyState - StartTime = $OutOfOffice.StartTime ? $OutOfOffice.StartTime.ToString('yyyy-MM-dd HH:mm') : $null - EndTime = $OutOfOffice.EndTime ? $OutOfOffice.EndTime.ToString('yyyy-MM-dd HH:mm') : $null + # Emit UTC with an explicit 'Z' marker. Get-MailboxAutoReplyConfiguration returns these + # as server-local DateTimes; without the marker the browser reparses the wall-clock in its + # own timezone, shifting a reopened schedule by the UTC offset (and drifting on re-save). + StartTime = $OutOfOffice.StartTime ? $OutOfOffice.StartTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') : $null + EndTime = $OutOfOffice.EndTime ? $OutOfOffice.EndTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') : $null InternalMessage = $OutOfOffice.InternalMessage ExternalMessage = $OutOfOffice.ExternalMessage CreateOOFEvent = $OutOfOffice.CreateOOFEvent diff --git a/Modules/CIPPCore/Public/Get-CIPPPagedTableRows.ps1 b/Modules/CIPPCore/Public/Get-CIPPPagedTableRows.ps1 new file mode 100644 index 0000000000000..fba60f3e68a5d --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPPagedTableRows.ps1 @@ -0,0 +1,115 @@ +function Get-CIPPPagedTableRows { + <# + .FUNCTIONALITY + Internal + .SYNOPSIS + Reads one page of rows from an Azure Table by scanning an ordinal range of partitions. + .DESCRIPTION + Continuation-token pager over an ordinal (PartitionKey, RowKey) range: one range query + per chunk, so page cost tracks rows returned, not partition count. Rows from partitions + outside $PartitionKeys are dropped but still advance the cursor. A null NextToken means + the walk is complete. + + Invariants: $PartitionKeys must be ordinal ascending; resume uses RowKey gt '~' + ('~' sorts after every key character and the '-partN' rows, but an id that prefix-extends + another id could be skipped at a boundary - GUID ids cannot); -First counts physical + rows, so only an empty chunk proves the range drained, and the module completes any + split entity cut at the boundary (RecoverMissingPartRows) so pages hold whole entities. + .PARAMETER Table + Table splat from Get-CIPPTable. + .PARAMETER PartitionKeys + Partition keys to serve, ordinal ascending; the caller owns membership filtering. + .PARAMETER RowKeyGe + Optional inclusive RowKey lower bound (e.g. 'Guests-'). + .PARAMETER RowKeyLt + Optional exclusive RowKey upper bound (e.g. 'Guests.'). + .PARAMETER ExtraFilterClauses + Optional OData clauses ANDed onto every query; values must already be escaped. + .PARAMETER PageSize + Target kept rows per page (also the physical-row chunk size per query). + .PARAMETER MaxQueries + Safety bound on round trips per call; normally one or two are needed. + .PARAMETER ContinuationToken + NextToken from the previous call. Opaque to callers. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [hashtable]$Table, + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [string[]]$PartitionKeys, + [string]$RowKeyGe, + [string]$RowKeyLt, + [string[]]$ExtraFilterClauses = @(), + [ValidateRange(1, 10000)] + [int]$PageSize = 5000, + [ValidateRange(1, 100)] + [int]$MaxQueries = 10, + [string]$ContinuationToken + ) + + $Rows = [System.Collections.Generic.List[object]]::new() + if ($PartitionKeys.Count -eq 0) { + return [PSCustomObject]@{ Rows = $Rows; NextToken = $null } + } + + $Known = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + foreach ($Pk in $PartitionKeys) { $null = $Known.Add($Pk) } + + $CursorPk = $null + $CursorRk = $null + if ($ContinuationToken) { + $TokenParts = $ContinuationToken.Split('|', 2) + $CursorPk = [System.Uri]::UnescapeDataString($TokenParts[0]) + if ($TokenParts.Count -eq 2 -and $TokenParts[1]) { + $CursorRk = [System.Uri]::UnescapeDataString($TokenParts[1]) + } + } + + $Queries = 0 + $Exhausted = $false + while (-not $Exhausted -and $Queries -lt $MaxQueries -and $Rows.Count -lt $PageSize) { + $Clauses = [System.Collections.Generic.List[string]]::new() + $Clauses.Add("PartitionKey ge '{0}'" -f (ConvertTo-CIPPODataFilterValue -Value $PartitionKeys[0] -Type String)) + $Clauses.Add("PartitionKey le '{0}'" -f (ConvertTo-CIPPODataFilterValue -Value $PartitionKeys[-1] -Type String)) + if ($RowKeyGe) { + $Clauses.Add("RowKey ge '{0}'" -f (ConvertTo-CIPPODataFilterValue -Value $RowKeyGe -Type String)) + } + if ($RowKeyLt) { + $Clauses.Add("RowKey lt '{0}'" -f (ConvertTo-CIPPODataFilterValue -Value $RowKeyLt -Type String)) + } + if ($CursorPk) { + $SafePk = ConvertTo-CIPPODataFilterValue -Value $CursorPk -Type String + if ($CursorRk) { + $SafeRk = ConvertTo-CIPPODataFilterValue -Value $CursorRk -Type String + $Clauses.Add("((PartitionKey gt '$SafePk') or (PartitionKey eq '$SafePk' and RowKey gt '$SafeRk~'))") + } else { + # A token naming a partition but no row resumes from that partition's start. + $Clauses.Add("PartitionKey ge '$SafePk'") + } + } + foreach ($Clause in $ExtraFilterClauses) { $Clauses.Add($Clause) } + + $Chunk = @(Get-CIPPAzDataTableEntity @Table -Filter ($Clauses -join ' and ') -First $PageSize) + $Queries++ + if ($Chunk.Count -eq 0) { + $Exhausted = $true + break + } + foreach ($Row in $Chunk) { + if ($Known.Contains([string]$Row.PartitionKey)) { $Rows.Add($Row) } + } + $CursorPk = [string]$Chunk[-1].PartitionKey + $CursorRk = [string]$Chunk[-1].RowKey + } + + $NextToken = if (-not $Exhausted) { + '{0}|{1}' -f [System.Uri]::EscapeDataString($CursorPk), $(if ($CursorRk) { [System.Uri]::EscapeDataString($CursorRk) } else { '' }) + } else { $null } + + return [PSCustomObject]@{ + Rows = $Rows + NextToken = $NextToken + } +} diff --git a/Modules/CIPPCore/Public/Get-CIPPSPOAdminListData.ps1 b/Modules/CIPPCore/Public/Get-CIPPSPOAdminListData.ps1 index 97e35811bb89f..91f16d0da35b4 100644 --- a/Modules/CIPPCore/Public/Get-CIPPSPOAdminListData.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPSPOAdminListData.ps1 @@ -9,7 +9,8 @@ function Get-CIPPSPOAdminListData { Returns flat admin list Row objects (all pages). Does not join Graph or map browser DTOs. Dotted numeric props (e.g. StorageUsed.) are an RLD quirk; -NormalizeRows copies them to - undotted names when present. + undotted names when present, coerces StorageUsed / NumOfFiles / StorageQuota to int64, and + adds StorageQuotaBytes (admin quota is MB unless already byte-sized). .PARAMETER TenantFilter Tenant to query. @@ -35,7 +36,8 @@ function Get-CIPPSPOAdminListData { Abort if paging exceeds this many pages. .PARAMETER NormalizeRows - Copy StorageUsed. / NumOfFiles. / etc. onto undotted property names. + Copy StorageUsed. / NumOfFiles. / etc. onto undotted property names, parse numerics, and + derive StorageQuotaBytes. .FUNCTIONALITY Internal @@ -179,6 +181,23 @@ function Get-CIPPSPOAdminListData { } } } + foreach ($Field in @('StorageUsed', 'NumOfFiles', 'StorageQuota')) { + if ($Row.PSObject.Properties.Name -contains $Field) { + $Coerced = ConvertTo-SPOAdminListInt64 -Raw $Row.$Field + if ($null -ne $Coerced) { + $Row | Add-Member -NotePropertyName $Field -NotePropertyValue $Coerced -Force + } elseif ($Row.$Field -is [string] -and -not [string]::IsNullOrWhiteSpace($Row.$Field)) { + $Row | Add-Member -NotePropertyName $Field -NotePropertyValue $null -Force + } + } + } + if ($Row.PSObject.Properties.Name -contains 'StorageQuota') { + $QuotaMb = $Row.StorageQuota + if ($null -ne $QuotaMb -and $QuotaMb -gt 0) { + $QuotaBytes = if ($QuotaMb -lt 1TB) { [int64]($QuotaMb * 1MB) } else { $QuotaMb } + $Row | Add-Member -NotePropertyName 'StorageQuotaBytes' -NotePropertyValue $QuotaBytes -Force + } + } } [void]$AllRows.Add($Row) } diff --git a/Modules/CIPPCore/Public/Get-CIPPSPOSite.ps1 b/Modules/CIPPCore/Public/Get-CIPPSPOSite.ps1 index 8f35bca98ef3a..16057d7829f8b 100644 --- a/Modules/CIPPCore/Public/Get-CIPPSPOSite.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPSPOSite.ps1 @@ -28,19 +28,26 @@ function Get-CIPPSPOSite { param( [Parameter(Mandatory = $true)] [string]$TenantFilter, - [string]$SiteUrl + [string]$SiteUrl, + # SharePoint app-only auth requires a certificate (secret app-only is rejected by SPO). When + # set, authenticate app-only with the SAM certificate instead of the delegated context. + [switch]$UseCertificate ) $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter $AdminUrl = $SharePointInfo.AdminUrl + # Threaded onto every SPO admin call below; empty = unchanged delegated behaviour. + $AuthSplat = @{} + if ($UseCertificate) { $AuthSplat['AsApp'] = $true; $AuthSplat['UseCertificate'] = $true } + if ($SiteUrl) { # Single-site fast path: Tenant Constructor -> GetSitePropertiesByUrl -> Query all properties $XML = @" $([System.Security.SecurityElement]::Escape($SiteUrl))true "@ $AdditionalHeaders = @{ 'Accept' = 'application/json;odata=verbose' } - $Results = New-GraphPostRequest -scope "$AdminUrl/.default" -tenantid $TenantFilter -Uri "$AdminUrl/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AddedHeaders $AdditionalHeaders + $Results = New-GraphPostRequest -scope "$AdminUrl/.default" -tenantid $TenantFilter -Uri "$AdminUrl/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AddedHeaders $AdditionalHeaders @AuthSplat $Site = $Results | Where-Object { $_._ObjectType_ -match 'SiteProperties' } | Select-Object -First 1 if (-not $Site) { throw "Could not retrieve site properties for $SiteUrl" @@ -66,7 +73,7 @@ function Get-CIPPSPOSite { "@ } - $Results = New-GraphPostRequest -scope "$AdminUrl/.default" -tenantid $TenantFilter -Uri "$AdminUrl/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AddedHeaders $AdditionalHeaders + $Results = New-GraphPostRequest -scope "$AdminUrl/.default" -tenantid $TenantFilter -Uri "$AdminUrl/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AddedHeaders $AdditionalHeaders @AuthSplat # The response contains multiple objects; find the one with _Child_Items_ (site list) and NextStartIndexFromSharePoint $SiteCollection = $Results | Where-Object { $_._Child_Items_ } diff --git a/Modules/CIPPCore/Public/Get-CIPPSPOSiteBulk.ps1 b/Modules/CIPPCore/Public/Get-CIPPSPOSiteBulk.ps1 new file mode 100644 index 0000000000000..22bc5f518c6a5 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPSPOSiteBulk.ps1 @@ -0,0 +1,138 @@ +function Get-CIPPSPOSiteBulk { + <# + .SYNOPSIS + Read individual SharePoint site properties for many sites (authoritative), batched and concurrent + + .DESCRIPTION + Concurrent counterpart to Get-CIPPSPOSite -SiteUrl. Fires single-site GetSitePropertiesByUrl CSOM + reads through CIPP.CIPPRestClient.SendConcurrent. Unlike the tenant-wide enumeration + (GetSitePropertiesFromSharePoint), the single-site read is AUTHORITATIVE and immediate, and it is + the ONLY source for ~19 per-site properties the enumeration returns as defaults (site owner, + per-site sharing controls, ShowPeoplePickerSuggestionsForGuestUsers, ...). + + Reads are grouped: each request carries -BatchSize GetSitePropertiesByUrl reads in one ProcessQuery + (fewer round-trips), and up to -MaxConcurrency requests run at once. SharePoint SERIALIZES the reads + inside a request and rejects large ones ("The request uses too many resources"), so batches stay + small; concurrency - not batch size - is what parallelises the work. The SPO admin token is + acquired once and reused across every request. + + Returns one object per input URL: @{ SiteUrl; Site; Success; Error }, where Site is the parsed + SiteProperties object (or $null on failure). A batch that fails marks every URL in it failed, so the + caller can fall back per site. + + .PARAMETER TenantFilter + Tenant to read from + + .PARAMETER SiteUrls + Array of full site URLs to read. + + .PARAMETER MaxConcurrency + Upper bound on in-flight requests (default 4). The SPO connection pool caps it to 5 regardless. + + .PARAMETER BatchSize + Site reads packed into a single ProcessQuery (default 5). Kept small - SharePoint rejects large + batched CSOM requests ("too many resources"); ~8 is the practical ceiling with SelectAllProperties. + + .PARAMETER UseCertificate + Authenticate app-only with the SAM certificate (SharePoint app-only requires it). + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [Parameter(Mandatory = $true)] + [string[]]$SiteUrls, + [int]$MaxConcurrency = 4, + [int]$MaxRetries = 3, + [int]$BatchSize = 5, + [switch]$UseCertificate + ) + + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $AdminUrl = $SharePointInfo.AdminUrl + $RequestUri = "$AdminUrl/_vti_bin/client.svc/ProcessQuery" + + $TokenSplat = @{ tenantid = $TenantFilter; scope = "$AdminUrl/.default" } + if ($UseCertificate) { $TokenSplat['AsApp'] = $true; $TokenSplat['UseCertificate'] = $true } + $Authorization = (Get-GraphToken @TokenSplat).Authorization + + if ($BatchSize -lt 1) { $BatchSize = 1 } + $CleanUrls = @($SiteUrls | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + if ($CleanUrls.Count -eq 0) { return @() } + + $Requests = [System.Collections.Generic.List[CIPP.CIPPConcurrentRequest]]::new() + $BatchUrls = [System.Collections.Generic.List[object]]::new() + + for ($Start = 0; $Start -lt $CleanUrls.Count; $Start += $BatchSize) { + $End = [Math]::Min($Start + $BatchSize - 1, $CleanUrls.Count - 1) + $Chunk = @($CleanUrls[$Start..$End]) + + $Actions = [System.Text.StringBuilder]::new() + $Paths = [System.Text.StringBuilder]::new() + $Index = 0 + foreach ($Url in $Chunk) { + $MethodId = 1000 + $Index; $PathId = 4000 + $Index; $QueryId = 7000 + $Index + [void]$Actions.Append("") + [void]$Paths.Append("$([System.Security.SecurityElement]::Escape($Url))true") + $Index++ + } + $XML = "$($Actions.ToString())$($Paths.ToString())" + + $Request = [CIPP.CIPPConcurrentRequest]::new() + $Request.Uri = $RequestUri + $Request.Method = 'POST' + $Request.Body = $XML + $Request.ContentType = 'text/xml' + $Headers = [System.Collections.Generic.Dictionary[string, string]]::new() + $Headers['Authorization'] = $Authorization + $Headers['Accept'] = 'application/json;odata=verbose' + $Request.Headers = $Headers + + $Requests.Add($Request) + $BatchUrls.Add($Chunk) + } + + $Results = [CIPP.CIPPRestClient]::SendConcurrent($Requests, $MaxConcurrency, $MaxRetries) + + @(foreach ($Result in $Results) { + $Chunk = $BatchUrls[$Result.Index] + $BatchError = $null + $Parsed = $null + if ($Result.Error) { + $BatchError = $Result.Error + } elseif ($Result.StatusCode -ne 200) { + $BatchError = "HTTP $($Result.StatusCode)" + } else { + try { + $Parsed = $Result.Result.Content | ConvertFrom-Json + # One action's error aborts the whole ProcessQuery, so a batch-level ErrorInfo fails + # every URL in the chunk - the caller falls back per site. + $CsomError = ($Parsed | Where-Object { $_.ErrorInfo } | Select-Object -First 1).ErrorInfo.ErrorMessage + if ($CsomError) { $BatchError = $CsomError } + } catch { + $BatchError = "Could not parse CSOM response: $($_.Exception.Message)" + } + } + + if ($BatchError) { + foreach ($Url in $Chunk) { + [PSCustomObject]@{ SiteUrl = $Url; Site = $null; Success = $false; Error = $BatchError } + } + } else { + # Match each returned SiteProperties to its input URL (order is not guaranteed, and a + # deleted/erroring site in the middle would leave a gap) rather than trusting position. + $SitesInBatch = @($Parsed | Where-Object { $_._ObjectType_ -match 'SiteProperties' -and $_.Url }) + foreach ($Url in $Chunk) { + $Site = $SitesInBatch | Where-Object { "$($_.Url)".TrimEnd('/') -ieq "$Url".TrimEnd('/') } | Select-Object -First 1 + if ($Site) { + [PSCustomObject]@{ SiteUrl = $Url; Site = $Site; Success = $true; Error = $null } + } else { + [PSCustomObject]@{ SiteUrl = $Url; Site = $null; Success = $false; Error = 'No SiteProperties returned' } + } + } + } + }) +} diff --git a/Modules/CIPPCore/Public/Get-CIPPSPOTenant.ps1 b/Modules/CIPPCore/Public/Get-CIPPSPOTenant.ps1 index 60e31c9f0245a..cc245c53a2586 100644 --- a/Modules/CIPPCore/Public/Get-CIPPSPOTenant.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPSPOTenant.ps1 @@ -7,9 +7,17 @@ function Get-CIPPSPOTenant { # Only meaningful alongside SharepointPrefix. Sovereign clouds are not on sharepoint.com # (see Get-SharePointAdminLink), so a prefix on its own cannot build the admin URL. [string]$SharepointDomain = 'sharepoint.com', - [switch]$SkipCache + [switch]$SkipCache, + # SharePoint app-only auth requires a certificate (secret app-only is rejected by SPO with + # 'Unsupported app only token'). When set, authenticate app-only with the SAM certificate + # instead of the default delegated (refresh-token) context. + [switch]$UseCertificate ) + # Threaded onto every SPO admin call below; empty = unchanged delegated behaviour. + $AuthSplat = @{} + if ($UseCertificate) { $AuthSplat['AsApp'] = $true; $AuthSplat['UseCertificate'] = $true } + if (!$SharepointPrefix) { # get sharepoint admin site $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter @@ -47,7 +55,7 @@ function Get-CIPPSPOTenant { # $AdminUrl, not $SharePointInfo.AdminUrl - the latter is empty when a prefix was supplied. try { - $Results = New-GraphPostRequest -scope "$($AdminUrl)/.default" -tenantid $TenantFilter -Uri "$($AdminUrl)/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AddedHeaders $AdditionalHeaders + $Results = New-GraphPostRequest -scope "$($AdminUrl)/.default" -tenantid $TenantFilter -Uri "$($AdminUrl)/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AddedHeaders $AdditionalHeaders @AuthSplat } catch { # The admin endpoint answers a bare 401 when the CIPP service principal holds no SharePoint # app-only consent in the tenant - the token is issued fine, SharePoint just refuses it. That diff --git a/Modules/CIPPCore/Public/Get-CIPPScheduledTaskNextRun.ps1 b/Modules/CIPPCore/Public/Get-CIPPScheduledTaskNextRun.ps1 new file mode 100644 index 0000000000000..48fe2e0c48255 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPScheduledTaskNextRun.ps1 @@ -0,0 +1,32 @@ +function Get-CIPPScheduledTaskNextRun { + <# + .SYNOPSIS + Next run time for a scheduled task, in unix seconds, or 0 when it does not repeat. + .DESCRIPTION + Recurrence is stored as 30m, 1h, 1d and so on; a bare number is a day count, the shape older + tasks carry. A run further back than one interval is treated as starting now, so a task that + was disabled or stuck does not replay a backlog of missed runs. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][AllowNull()]$Recurrence, + [Parameter(Mandatory = $true)][AllowNull()]$ScheduledTime + ) + + $Value = [string]$Recurrence + if ($Value -match '^\d+$') { $Value = '{0}d' -f $Value } + + $SecondsToAdd = switch -Regex ($Value) { + '(\d+)m$' { [int64]$Matches[1] * 60 } + '(\d+)h$' { [int64]$Matches[1] * 3600 } + '(\d+)d$' { [int64]$Matches[1] * 86400 } + default { 0 } + } + if ($SecondsToAdd -le 0) { return 0 } + + $Now = [int64](([datetime]::UtcNow) - (Get-Date '1/1/1970')).TotalSeconds + $Last = [int64]($ScheduledTime ?? 0) + if ($Last -lt ($Now - $SecondsToAdd)) { $Last = $Now } + + return $Last + $SecondsToAdd +} diff --git a/Modules/CIPPCore/Public/Get-CIPPSharePointCopyJobProgress.ps1 b/Modules/CIPPCore/Public/Get-CIPPSharePointCopyJobProgress.ps1 new file mode 100644 index 0000000000000..57730d8a36283 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPSharePointCopyJobProgress.ps1 @@ -0,0 +1,256 @@ +function Get-CIPPSharePointCopyJobProgress { + <# + .SYNOPSIS + Polls GetCopyJobProgress for one handle and returns sanitized aggregate metrics. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $true)] + [string]$SourceSiteUrl, + + [Parameter(Mandatory = $true)] + [object]$CopyJobInfo + ) + + $SanitizeCopyLogMessage = { + param([object]$Entry, [ValidateSet('Error', 'Warning')][string]$Kind) + + $Message = [string]($Entry.Message ?? $Entry.message ?? $Entry.ErrorMessage ?? '') + $ErrorType = [string]($Entry.ErrorType ?? $Entry.errorType ?? '') + $ErrorCode = [string]($Entry.ErrorCode ?? $Entry.errorCode ?? '') + $ObjectType = [string]($Entry.ObjectType ?? $Entry.objectType ?? '') + + if ($ErrorType -match '\.') { $ErrorType = ($ErrorType -split '\.')[-1] } + if ([string]::IsNullOrWhiteSpace($Message) -and ($ErrorType -or $ErrorCode)) { + $Message = if ($ErrorCode) { "$ErrorType ($ErrorCode)".Trim(' ()') } else { $ErrorType } + } + if ($ObjectType -and $Message) { $Message = "${ObjectType}: $Message" } + + $Fallback = if ($Kind -eq 'Error') { 'SharePoint reported a copy error.' } else { 'SharePoint reported a copy warning.' } + if ([string]::IsNullOrWhiteSpace($Message)) { return $Fallback } + + $Sanitized = $Message + $Sanitized = [regex]::Replace($Sanitized, 'https?://[^\s''"]+', '[url redacted]', 'IgnoreCase') + $Sanitized = [regex]::Replace($Sanitized, '\\[^\s''"]+', '[path redacted]', 'IgnoreCase') + $Sanitized = [regex]::Replace($Sanitized, '/(?:sites|teams)/[^\s''"]+', '[path redacted]', 'IgnoreCase') + $Sanitized = [regex]::Replace( + $Sanitized, + '[^\s\\/''"]+\.(docx?|xlsx?|pptx?|pdf|txt|csv|png|jpe?g|gif|zip|msg|one|aspx|html?|xml|json|mp4|mov|avi|wmv|rtf|md|svg|webp|heic|tif|tiff|7z|rar|tar|gz|ppt|xls|doc)\b', + '[file]', + 'IgnoreCase' + ) + $Sanitized = ($Sanitized -replace '\s{2,}', ' ').Trim() + if ([string]::IsNullOrWhiteSpace($Sanitized)) { return $Fallback } + return $Sanitized + } + + $MeasureCopyJobLogs = { + param([array]$RawLogs = @()) + + $ObjectsProcessed = 0 + $TotalExpected = $null + $FilesCreated = 0 + $BytesProcessed = 0 + $TotalErrors = 0 + $TotalWarnings = 0 + $ErrorMessages = [System.Collections.Generic.List[string]]::new() + $WarningMessages = [System.Collections.Generic.List[string]]::new() + $SeenErrors = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + $SeenWarnings = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + + foreach ($LogLine in @($RawLogs)) { + $Entry = $LogLine + if ($LogLine -is [string]) { + try { $Entry = $LogLine | ConvertFrom-Json } catch { continue } + } + if (-not $Entry) { continue } + + $EventName = [string]($Entry.Event ?? $Entry.event ?? $Entry.EventType ?? '') + $HasErrorDetails = -not [string]::IsNullOrWhiteSpace([string]($Entry.ErrorCode ?? $Entry.errorCode ?? '')) ` + -or -not [string]::IsNullOrWhiteSpace([string]($Entry.ErrorType ?? $Entry.errorType ?? '')) + + switch -Regex ($EventName) { + 'JobError|Error' { + $TotalErrors++ + $SanitizedMessage = & $SanitizeCopyLogMessage -Entry $Entry -Kind Error + if ($SeenErrors.Add($SanitizedMessage) -and $ErrorMessages.Count -lt 25) { + [void]$ErrorMessages.Add($SanitizedMessage) + } + } + 'JobWarning|Warning' { + $TotalWarnings++ + $SanitizedMessage = & $SanitizeCopyLogMessage -Entry $Entry -Kind Warning + if ($SeenWarnings.Add($SanitizedMessage) -and $WarningMessages.Count -lt 25) { + [void]$WarningMessages.Add($SanitizedMessage) + } + } + 'JobProgress|JobEnd|JobStart|JobQueued|JobFinishedObjectInfo' { + if ($null -ne $Entry.ObjectsProcessed) { $ObjectsProcessed = [int64]$Entry.ObjectsProcessed } + if ($null -ne $Entry.TotalExpectedSPObjects) { $TotalExpected = [int64]$Entry.TotalExpectedSPObjects } + if ($null -ne $Entry.FilesCreated) { $FilesCreated = [int64]$Entry.FilesCreated } + if ($null -ne $Entry.BytesProcessed) { $BytesProcessed = [int64]$Entry.BytesProcessed } + if ($null -ne $Entry.TotalErrors) { $TotalErrors = [int64]$Entry.TotalErrors } + if ($null -ne $Entry.TotalWarnings) { $TotalWarnings = [int64]$Entry.TotalWarnings } + } + default { + if ($HasErrorDetails) { + $TotalErrors++ + $SanitizedMessage = & $SanitizeCopyLogMessage -Entry $Entry -Kind Error + if ($SeenErrors.Add($SanitizedMessage) -and $ErrorMessages.Count -lt 25) { + [void]$ErrorMessages.Add($SanitizedMessage) + } + } + } + } + } + + if ($TotalErrors -gt 0 -and $ErrorMessages.Count -eq 0) { + foreach ($LogLine in @($RawLogs)) { + $Entry = $LogLine + if ($LogLine -is [string]) { + try { $Entry = $LogLine | ConvertFrom-Json } catch { continue } + } + if (-not $Entry) { continue } + if ([string]::IsNullOrWhiteSpace([string]($Entry.Message ?? $Entry.message ?? ''))) { continue } + $SanitizedMessage = & $SanitizeCopyLogMessage -Entry $Entry -Kind Error + if ($SeenErrors.Add($SanitizedMessage) -and $ErrorMessages.Count -lt 25) { + [void]$ErrorMessages.Add($SanitizedMessage) + } + } + } + + [PSCustomObject]@{ + ObjectsProcessed = $ObjectsProcessed + TotalExpectedObjects = $TotalExpected + FilesCreated = $FilesCreated + BytesProcessed = $BytesProcessed + TotalErrors = $TotalErrors + TotalWarnings = $TotalWarnings + ErrorMessages = @($ErrorMessages) + WarningMessages = @($WarningMessages) + } + } + + # Accept normalized handles, or legacy OData collection wrappers still in the table. + $Handle = $CopyJobInfo + if (-not ($Handle.JobId ?? $Handle.jobId) -and $null -ne $Handle.results) { + $Handle = @($Handle.results) | Select-Object -First 1 + } + + $JobId = [string]($Handle.JobId ?? $Handle.jobId ?? $Handle.JobID ?? '') + $JobQueueUri = $Handle.JobQueueUri ?? $Handle.jobQueueUri + if ($JobQueueUri -is [PSCustomObject]) { + $JobQueueUri = [string]($JobQueueUri.Url ?? $JobQueueUri.AbsoluteUri ?? $JobQueueUri) + } + $JobQueueUri = [string]$JobQueueUri + $EncryptionKey = $Handle.EncryptionKey ?? $Handle.encryptionKey + if ($EncryptionKey -is [PSCustomObject]) { + $EncryptionKey = $EncryptionKey.'#text' ?? $EncryptionKey.Value ?? $EncryptionKey.bytes + } + + if ([string]::IsNullOrWhiteSpace($JobId) -or [string]::IsNullOrWhiteSpace($JobQueueUri)) { + throw 'Copy job handle is missing JobId or JobQueueUri.' + } + + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $Scope = "$($SharePointInfo.SharePointUrl)/.default" + $Uri = "$($SourceSiteUrl.TrimEnd('/'))/_api/site/GetCopyJobProgress" + $Body = ConvertTo-Json -InputObject @{ + copyJobInfo = @{ + __metadata = @{ type = 'SP.CopyMigrationInfo' } + JobQueueUri = $JobQueueUri + JobId = $JobId + EncryptionKey = $EncryptionKey + } + } -Depth 5 -Compress + + $RawLogs = @() + $JobState = $null + $RestError = $null + + try { + $Response = New-GraphPOSTRequest -uri $Uri -tenantid $TenantFilter -scope $Scope -type POST -body $Body ` + -AddedHeaders @{ Accept = 'application/json;odata=verbose' } ` + -contentType 'application/json;odata=verbose' -UseCertificate -AsApp $true + + if ($Response -is [string]) { + $Response = $Response | ConvertFrom-Json + } + + $Progress = if ($Response.d.GetCopyJobProgress) { + $P = $Response.d.GetCopyJobProgress + if ($P -is [string]) { $P | ConvertFrom-Json } else { $P } + } elseif ($Response.d) { + $Response.d + } else { + $Response + } + + $JobState = $Progress.JobState ?? $Progress.jobState + $LogsProperty = $Progress.Logs ?? $Progress.logs + if ($null -eq $LogsProperty) { + $RawLogs = @() + } elseif ($LogsProperty.PSObject.Properties['results']) { + $RawLogs = @($LogsProperty.results) + } elseif ($LogsProperty -is [System.Collections.IEnumerable] -and $LogsProperty -isnot [string]) { + $RawLogs = @($LogsProperty) + } else { + $RawLogs = @($LogsProperty) + } + } catch { + $RestError = $_.Exception.Message + Write-Information "GetCopyJobProgress REST failed: $RestError" + } + + $Metrics = & $MeasureCopyJobLogs -RawLogs $RawLogs + + if ($EncryptionKey -and ($RestError -or $Metrics.ErrorMessages.Count -eq 0 -or $RawLogs.Count -eq 0)) { + try { + $QueueLogs = Get-CIPPSharePointCopyJobQueueLogs -JobQueueUri $JobQueueUri -EncryptionKey $EncryptionKey + if ($QueueLogs.Count -gt 0) { + $Metrics = & $MeasureCopyJobLogs -RawLogs (@($RawLogs) + @($QueueLogs)) + } + } catch { + Write-Verbose "SharePoint copy queue log read failed: $($_.Exception.Message)" + } + } + + if ($RestError -and $RawLogs.Count -eq 0 -and $Metrics.TotalErrors -eq 0 -and $Metrics.ErrorMessages.Count -eq 0) { + $Detail = [regex]::Replace([string]$RestError, 'https?://[^\s''"]+', '[url redacted]', 'IgnoreCase') + $Detail = ($Detail -replace '\s{2,}', ' ').Trim() + if ($Detail.Length -gt 240) { $Detail = $Detail.Substring(0, 240).Trim() + '…' } + throw "Failed to retrieve copy job progress from SharePoint: $Detail" + } + + $StateInt = if ($null -ne $JobState) { [int]$JobState } else { -1 } + $IsComplete = $StateInt -eq 0 + if (-not $IsComplete -and $StateInt -lt 0 -and ( + $Metrics.TotalErrors -gt 0 -or $Metrics.TotalExpectedObjects -gt 0 -or $Metrics.ObjectsProcessed -gt 0 + )) { + $IsComplete = $true + } + + $Status = switch ($true) { + { $IsComplete -and $Metrics.TotalErrors -gt 0 } { 'CompletedWithErrors' } + { $IsComplete } { 'Complete' } + { $StateInt -eq 2 } { 'Queued' } + default { 'Processing' } + } + + [PSCustomObject]@{ + Status = $Status + JobState = $StateInt + IsComplete = $IsComplete + ObjectsProcessed = $Metrics.ObjectsProcessed + TotalExpectedObjects = $Metrics.TotalExpectedObjects + FilesCreated = $Metrics.FilesCreated + BytesProcessed = $Metrics.BytesProcessed + TotalErrors = $Metrics.TotalErrors + TotalWarnings = $Metrics.TotalWarnings + ErrorMessages = @($Metrics.ErrorMessages) + WarningMessages = @($Metrics.WarningMessages) + } +} diff --git a/Modules/CIPPCore/Public/Get-CIPPSharePointCopyJobQueueLogs.ps1 b/Modules/CIPPCore/Public/Get-CIPPSharePointCopyJobQueueLogs.ps1 new file mode 100644 index 0000000000000..1460e7c88995e --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPSharePointCopyJobQueueLogs.ps1 @@ -0,0 +1,88 @@ +function Get-CIPPSharePointCopyJobQueueLogs { + <# + .SYNOPSIS + Peeks encrypted SharePoint copy job log messages from the job Azure Storage queue. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$JobQueueUri, + + [Parameter(Mandatory = $true)] + $EncryptionKey, + + [int]$MaxMessages = 100 + ) + + if ([string]::IsNullOrWhiteSpace($JobQueueUri)) { + return @() + } + + $KeyBytes = if ($EncryptionKey -is [byte[]]) { + $EncryptionKey + } else { + $KeyText = [string]$EncryptionKey + if ([string]::IsNullOrWhiteSpace($KeyText)) { throw 'EncryptionKey is empty.' } + try { + [Convert]::FromBase64String($KeyText) + } catch { + [System.Text.Encoding]::UTF8.GetBytes($KeyText) + } + } + + $Logs = [System.Collections.Generic.List[object]]::new() + $Separator = if ($JobQueueUri -match '\?') { '&' } else { '?' } + $Remaining = $MaxMessages + $Page = 0 + + while ($Remaining -gt 0 -and $Page -lt 8) { + $BatchSize = [Math]::Min(32, $Remaining) + $Uri = "$JobQueueUri${Separator}peekonly=true&numofmessages=$BatchSize&format=json" + $Response = Invoke-RestMethod -Uri $Uri -Method Get -ErrorAction Stop + + $Messages = @() + if ($null -ne $Response.QueueMessages) { + $Messages = @($Response.QueueMessages) + } elseif ($null -ne $Response.QueueMessage) { + $Messages = @($Response.QueueMessage) + } + + if ($Messages.Count -eq 0) { break } + + foreach ($Message in $Messages) { + $BodyText = [string]($Message.MessageText ?? $Message.messageText ?? '') + if ([string]::IsNullOrWhiteSpace($BodyText)) { continue } + + try { + $EnvelopeJson = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($BodyText)) + $Envelope = $EnvelopeJson | ConvertFrom-Json + if (-not $Envelope.IV -or -not $Envelope.Content) { continue } + + $Aes = [System.Security.Cryptography.Aes]::Create() + try { + $Aes.Mode = [System.Security.Cryptography.CipherMode]::CBC + $Aes.Padding = [System.Security.Cryptography.PaddingMode]::PKCS7 + $Aes.Key = $KeyBytes + $Aes.IV = [Convert]::FromBase64String([string]$Envelope.IV) + $Decryptor = $Aes.CreateDecryptor() + $Cipher = [Convert]::FromBase64String([string]$Envelope.Content) + $PlainBytes = $Decryptor.TransformFinalBlock($Cipher, 0, $Cipher.Length) + $PlainJson = [System.Text.Encoding]::UTF8.GetString($PlainBytes) + } finally { + $Aes.Dispose() + } + + if ([string]::IsNullOrWhiteSpace($PlainJson)) { continue } + [void]$Logs.Add(($PlainJson | ConvertFrom-Json)) + } catch { + continue + } + } + + $Remaining -= $Messages.Count + $Page++ + if ($Messages.Count -lt $BatchSize) { break } + } + + return @($Logs) +} diff --git a/Modules/CIPPCore/Public/Get-CIPPSharePointLibraryCopyOperation.ps1 b/Modules/CIPPCore/Public/Get-CIPPSharePointLibraryCopyOperation.ps1 new file mode 100644 index 0000000000000..862453ff6ed70 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPSharePointLibraryCopyOperation.ps1 @@ -0,0 +1,60 @@ +function Get-CIPPSharePointLibraryCopyOperation { + <# + .SYNOPSIS + Loads a SharePointLibraryCopy operation row and reassembles chunked CopyJobInfo handles. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $true)] + [string]$OperationId + ) + + $Table = Get-CIPPTable -TableName 'SharePointLibraryCopy' + $SafeTenant = $TenantFilter -replace "'", "''" + $SafeOp = $OperationId -replace "'", "''" + $Primary = @(Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq '$SafeTenant' and RowKey eq '$SafeOp'") | Select-Object -First 1 + if (-not $Primary) { + return $null + } + + $ChunkRows = @(Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq '$SafeTenant' and startswith(RowKey, '$SafeOp`_')") | + Sort-Object RowKey + + $HandlesParts = [System.Collections.Generic.List[string]]::new() + if ($Primary.CopyJobInfos) { [void]$HandlesParts.Add([string]$Primary.CopyJobInfos) } + foreach ($Chunk in $ChunkRows) { + if ($Chunk.CopyJobInfos) { [void]$HandlesParts.Add([string]$Chunk.CopyJobInfos) } + } + + $HandlesJson = -join $HandlesParts + $CopyJobInfos = @() + if (-not [string]::IsNullOrWhiteSpace($HandlesJson)) { + $CopyJobInfos = @($HandlesJson | ConvertFrom-Json) + } + + $HandleStates = @() + if ($Primary.HandleStates) { + $HandleStates = @($Primary.HandleStates | ConvertFrom-Json) + } + + [PSCustomObject]@{ + PartitionKey = $Primary.PartitionKey + RowKey = $Primary.RowKey + OperationId = $OperationId + SourceSiteUrl = $Primary.SourceSiteUrl + SourceSiteName = $Primary.SourceSiteName + SourceLibraryName = $Primary.SourceLibraryName + DestSiteName = $Primary.DestSiteName + DestLibraryName = $Primary.DestLibraryName + StartedBy = $Primary.StartedBy + Status = $Primary.Status + JobHandleCount = [int]$Primary.JobHandleCount + Expiry = $Primary.Expiry + CopyJobInfos = @($CopyJobInfos) + HandleStates = @($HandleStates) + SanitizedSnapshot = if ($Primary.SanitizedSnapshot) { $Primary.SanitizedSnapshot | ConvertFrom-Json } else { $null } + } +} diff --git a/Modules/CIPPCore/Public/Get-CIPPSharePointLibraryRootChildUris.ps1 b/Modules/CIPPCore/Public/Get-CIPPSharePointLibraryRootChildUris.ps1 new file mode 100644 index 0000000000000..e337dcc2d3aa9 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPSharePointLibraryRootChildUris.ps1 @@ -0,0 +1,80 @@ +function Get-CIPPSharePointLibraryRootChildUris { + <# + .SYNOPSIS + Enumerates eligible immediate children of a document library root for CreateCopyJobs. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $true)] + [string]$ListId, + + [string]$SiteUrl, + [string]$SiteId + ) + + if ([string]::IsNullOrWhiteSpace($SiteId)) { + if ([string]::IsNullOrWhiteSpace($SiteUrl)) { + throw 'SiteUrl or SiteId is required.' + } + $ParsedUrl = [System.Uri]$SiteUrl + $SiteSegment = if ($ParsedUrl.AbsolutePath -in @('', '/')) { + $ParsedUrl.Host + } else { + "$($ParsedUrl.Host):$($ParsedUrl.AbsolutePath):" + } + $SiteMeta = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/sites/$SiteSegment`?`$select=id" -tenantid $TenantFilter -asapp $true + $SiteId = $SiteMeta.id + } + + $Uris = [System.Collections.Generic.List[string]]::new() + $GraphUri = "https://graph.microsoft.com/v1.0/sites/$SiteId/lists/$ListId/drive/root/children?`$select=name,webUrl,folder,file&`$top=999" + + try { + $Children = @(New-GraphGetRequest -uri $GraphUri -tenantid $TenantFilter -asapp $true) + foreach ($Child in $Children) { + $Name = [string]$Child.name + if ([string]::IsNullOrWhiteSpace($Name) -or $Name -eq 'Forms' -or $Name.StartsWith('_') -or $Name -match '\.(aspx|dotx)$') { + continue + } + if ([string]::IsNullOrWhiteSpace($Child.webUrl)) { continue } + if (-not ($Child.folder -or $Child.file)) { continue } + $Uris.Add([string]$Child.webUrl) + } + } catch { + $RootInfo = Resolve-CIPPSharePointLibraryRootUri -TenantFilter $TenantFilter -ListId $ListId -SiteUrl $SiteUrl -SiteId $SiteId + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $Scope = "$($SharePointInfo.SharePointUrl)/.default" + $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } + $BaseUri = "$($RootInfo.SiteUrl)/_api" + $SafeListId = $ListId -replace "'", "''" + $EscapedDir = $RootInfo.ServerRelativeUrl -replace "'", "''" + $NextLink = "$BaseUri/web/lists(guid'$SafeListId')/items?`$filter=FileDirRef eq '$EscapedDir'&`$select=FileRef,FileLeafRef,FSObjType&`$top=5000" + + do { + $Page = New-GraphGetRequest -uri $NextLink -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true -noPagination + $Items = @($Page.value) + if ($Items.Count -eq 0 -and $Page.FileRef) { $Items = @($Page) } + + foreach ($Item in $Items) { + $Leaf = [string]$Item.FileLeafRef + if ([string]::IsNullOrWhiteSpace($Leaf) -or $Leaf -eq 'Forms' -or $Leaf.StartsWith('_') -or $Leaf -match '\.(aspx|dotx)$') { + continue + } + $FileRef = $Item.FileRef + if ([string]::IsNullOrWhiteSpace($FileRef)) { continue } + $Origin = ([System.Uri]$RootInfo.SiteUrl).GetLeftPart([System.UriPartial]::Authority) + $Uris.Add("$Origin$FileRef") + } + + $NextLink = $Page.'@odata.nextLink' + } while (-not [string]::IsNullOrWhiteSpace($NextLink)) + } + + [PSCustomObject]@{ + ChildUris = @($Uris) + EligibleRootCount = $Uris.Count + } +} diff --git a/Modules/CIPPCore/Public/Get-CIPPSharePointSiteUsageReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPSharePointSiteUsageReport.ps1 index f5b2def74674a..6556c06095c27 100644 --- a/Modules/CIPPCore/Public/Get-CIPPSharePointSiteUsageReport.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPSharePointSiteUsageReport.ps1 @@ -1,155 +1,94 @@ -function Get-CIPPSharePointSiteUsageReport { - <# - .SYNOPSIS - Generates a SharePoint site usage report from the CIPP Reporting database - - .DESCRIPTION - Retrieves cached SharePoint site listing and usage data and combines them to match - the payload shape of Invoke-ListSites for Type=SharePointSiteUsage. - - .PARAMETER TenantFilter - The tenant to generate the report for - #> - [CmdletBinding()] - param( - [Parameter(Mandatory = $true)] - [string]$TenantFilter - ) - - try { - if ($TenantFilter -eq 'AllTenants') { - # Bulk-fetch all site listings and usage data in 2 queries instead of per-tenant - $AllSiteItems = @(Get-CIPPDbItem -TenantFilter 'allTenants' -Type 'SharePointSiteListing' | Where-Object { $_.RowKey -ne 'SharePointSiteListing-Count' }) - $AllUsageItems = @(Get-CIPPDbItem -TenantFilter 'allTenants' -Type 'SharePointSiteUsage' | Where-Object { $_.RowKey -ne 'SharePointSiteUsage-Count' }) - - $TenantList = Get-Tenants -IncludeErrors - $ValidTenants = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - foreach ($T in $TenantList) { [void]$ValidTenants.Add($T.defaultDomainName) } - - # Build usage lookup keyed by siteId across all tenants - $UsageBySiteId = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) - foreach ($UsageItem in $AllUsageItems) { - $UsageRow = $UsageItem.Data | ConvertFrom-Json -Depth 10 - if (-not [string]::IsNullOrWhiteSpace($UsageRow.siteId)) { - $UsageBySiteId[[string]$UsageRow.siteId] = $UsageRow - } - } - - $AllResults = [System.Collections.Generic.List[PSCustomObject]]::new() - foreach ($SiteItem in $AllSiteItems) { - $Tenant = $SiteItem.PartitionKey - if (-not $ValidTenants.Contains($Tenant)) { continue } - - $Site = $SiteItem.Data | ConvertFrom-Json -Depth 10 - if ($Site.isPersonalSite -eq $true) { continue } - - $SiteUsage = $null - [void]$UsageBySiteId.TryGetValue([string]$Site.sharepointIds.siteId, [ref]$SiteUsage) - - # A site with no usage row has UNKNOWN storage, not zero storage. Coercing the - # null to 0 made those sites render an authoritative-looking '0' that is - # indistinguishable from a genuinely empty site, so leave them null and let the - # table show them as having no data. - $StorageUsedInGigabytes = if ($null -ne $SiteUsage.storageUsedInBytes) { [math]::round([double]$SiteUsage.storageUsedInBytes / 1GB, 2) } else { $null } - $StorageAllocatedInGigabytes = if ($null -ne $SiteUsage.storageAllocatedInBytes) { [math]::round([double]$SiteUsage.storageAllocatedInBytes / 1GB, 2) } else { $null } - - $AllResults.Add([PSCustomObject]@{ - Tenant = $Tenant - siteId = $Site.sharepointIds.siteId - webId = $Site.sharepointIds.webId - createdDateTime = $Site.createdDateTime - displayName = $Site.displayName - webUrl = $Site.webUrl - ownerDisplayName = $SiteUsage.ownerDisplayName - ownerPrincipalName = $SiteUsage.ownerPrincipalName - lastActivityDate = $SiteUsage.lastActivityDate - fileCount = $SiteUsage.fileCount - storageUsedInGigabytes = $StorageUsedInGigabytes - storageAllocatedInGigabytes = $StorageAllocatedInGigabytes - storageUsedInBytes = $SiteUsage.storageUsedInBytes - storageAllocatedInBytes = $SiteUsage.storageAllocatedInBytes - rootWebTemplate = $SiteUsage.rootWebTemplate - reportRefreshDate = $SiteUsage.reportRefreshDate - AutoMapUrl = $Site.AutoMapUrl - }) - } - return $AllResults - } - - $SiteItems = @(Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'SharePointSiteListing' | Where-Object { $_.RowKey -ne 'SharePointSiteListing-Count' }) - if (-not $SiteItems) { - throw 'No SharePoint site listing data found in reporting database. Sync SharePointSiteUsage cache first.' - } - - # No usage rows is a valid cached result, not a missing cache: getSharePointSiteUsageDetail - # returns an empty set for tenants Microsoft has no usage report for yet. The site listing - # is the backbone of this payload and the usage merge below is a left join, so an empty - # usage set yields the same rows-with-null-usage the live path returns. Throwing here made - # the single-tenant cached view fail on tenants the live view and the AllTenants branch of - # this same function both render fine. - $UsageItems = @(Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'SharePointSiteUsage' | Where-Object { $_.RowKey -ne 'SharePointSiteUsage-Count' }) - - $LatestSiteTimestamp = ($SiteItems | Where-Object { $_.Timestamp } | Sort-Object Timestamp -Descending | Select-Object -First 1).Timestamp - $LatestUsageTimestamp = ($UsageItems | Where-Object { $_.Timestamp } | Sort-Object Timestamp -Descending | Select-Object -First 1).Timestamp - $CacheTimestamp = if ($LatestSiteTimestamp -and $LatestUsageTimestamp) { - if ($LatestSiteTimestamp -gt $LatestUsageTimestamp) { $LatestSiteTimestamp } else { $LatestUsageTimestamp } - } else { - $LatestSiteTimestamp ?? $LatestUsageTimestamp - } - - $UsageBySiteId = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) - foreach ($UsageItem in $UsageItems) { - $UsageRow = $UsageItem.Data | ConvertFrom-Json -Depth 10 - if (-not [string]::IsNullOrWhiteSpace($UsageRow.siteId)) { - $UsageBySiteId[[string]$UsageRow.siteId] = $UsageRow - } - } - - $Report = [System.Collections.Generic.List[PSCustomObject]]::new() - foreach ($SiteItem in $SiteItems) { - $Site = $SiteItem.Data | ConvertFrom-Json -Depth 10 - if ($Site.isPersonalSite -eq $true) { - continue - } - - $SiteUsage = $null - [void]$UsageBySiteId.TryGetValue([string]$Site.sharepointIds.siteId, [ref]$SiteUsage) - - # Unknown storage stays null rather than becoming a misleading 0 - see the - # AllTenants branch above. - $StorageUsedInGigabytes = if ($null -ne $SiteUsage.storageUsedInBytes) { [math]::round([double]$SiteUsage.storageUsedInBytes / 1GB, 2) } else { $null } - $StorageAllocatedInGigabytes = if ($null -ne $SiteUsage.storageAllocatedInBytes) { [math]::round([double]$SiteUsage.storageAllocatedInBytes / 1GB, 2) } else { $null } - - $ReportItem = [PSCustomObject]@{ - siteId = $Site.sharepointIds.siteId - webId = $Site.sharepointIds.webId - createdDateTime = $Site.createdDateTime - displayName = $Site.displayName - webUrl = $Site.webUrl - ownerDisplayName = $SiteUsage.ownerDisplayName - ownerPrincipalName = $SiteUsage.ownerPrincipalName - lastActivityDate = $SiteUsage.lastActivityDate - fileCount = $SiteUsage.fileCount - storageUsedInGigabytes = $StorageUsedInGigabytes - storageAllocatedInGigabytes = $StorageAllocatedInGigabytes - storageUsedInBytes = $SiteUsage.storageUsedInBytes - storageAllocatedInBytes = $SiteUsage.storageAllocatedInBytes - rootWebTemplate = $SiteUsage.rootWebTemplate - reportRefreshDate = $SiteUsage.reportRefreshDate - AutoMapUrl = $Site.AutoMapUrl - } - - if ($CacheTimestamp) { - $ReportItem | Add-Member -NotePropertyName 'CacheTimestamp' -NotePropertyValue $CacheTimestamp -Force - } - - $Report.Add($ReportItem) - } - - return $Report | Sort-Object -Property displayName - - } catch { - Write-LogMessage -API 'SharePointSiteUsageReport' -tenant $TenantFilter -message "Failed to generate SharePoint site usage report: $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) - throw - } -} \ No newline at end of file +function Get-CIPPSharePointSiteUsageReport { + <# + .SYNOPSIS + Generates a SharePoint site usage report from the CIPP Reporting database + + .DESCRIPTION + Retrieves cached SharePoint site listing and usage data and combines them to match + the payload shape of Invoke-ListSites for Type=SharePointSiteUsage. + + .PARAMETER TenantFilter + The tenant to generate the report for + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter + ) + + try { + if ($TenantFilter -eq 'AllTenants') { + $AllSiteItems = @(Get-CIPPDbItem -TenantFilter 'allTenants' -Type 'SharePointSiteListing' | Where-Object { $_.RowKey -ne 'SharePointSiteListing-Count' }) + $AllUsageItems = @(Get-CIPPDbItem -TenantFilter 'allTenants' -Type 'SharePointSiteUsage' | Where-Object { $_.RowKey -ne 'SharePointSiteUsage-Count' }) + + $TenantList = Get-Tenants -IncludeErrors + $ValidTenants = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($T in $TenantList) { [void]$ValidTenants.Add($T.defaultDomainName) } + + $UsageBySiteId = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($UsageItem in $AllUsageItems) { + $UsageRow = $UsageItem.Data | ConvertFrom-Json -Depth 10 + if (-not [string]::IsNullOrWhiteSpace($UsageRow.siteId)) { + $UsageBySiteId[[string]$UsageRow.siteId.Trim('{}')] = $UsageRow + } + } + + $AllResults = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($SiteItem in $AllSiteItems) { + $Tenant = $SiteItem.PartitionKey + if (-not $ValidTenants.Contains($Tenant)) { continue } + + $Site = $SiteItem.Data | ConvertFrom-Json -Depth 10 + if ($Site.isPersonalSite -eq $true) { continue } + + $SiteUsage = $null + [void]$UsageBySiteId.TryGetValue([string]$Site.sharepointIds.siteId.Trim('{}'), [ref]$SiteUsage) + + $AllResults.Add((ConvertTo-CIPPSharePointSiteUsagePayload -Site $Site -SiteUsage $SiteUsage -Tenant $Tenant)) + } + return $AllResults + } + + $SiteItems = @(Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'SharePointSiteListing' | Where-Object { $_.RowKey -ne 'SharePointSiteListing-Count' }) + if (-not $SiteItems) { + throw 'No SharePoint site listing data found in reporting database. Sync SharePointSiteUsage cache first.' + } + + $UsageItems = @(Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'SharePointSiteUsage' | Where-Object { $_.RowKey -ne 'SharePointSiteUsage-Count' }) + + $LatestSiteTimestamp = ($SiteItems | Where-Object { $_.Timestamp } | Sort-Object Timestamp -Descending | Select-Object -First 1).Timestamp + $LatestUsageTimestamp = ($UsageItems | Where-Object { $_.Timestamp } | Sort-Object Timestamp -Descending | Select-Object -First 1).Timestamp + $CacheTimestamp = if ($LatestSiteTimestamp -and $LatestUsageTimestamp) { + if ($LatestSiteTimestamp -gt $LatestUsageTimestamp) { $LatestSiteTimestamp } else { $LatestUsageTimestamp } + } else { + $LatestSiteTimestamp ?? $LatestUsageTimestamp + } + + $UsageBySiteId = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($UsageItem in $UsageItems) { + $UsageRow = $UsageItem.Data | ConvertFrom-Json -Depth 10 + if (-not [string]::IsNullOrWhiteSpace($UsageRow.siteId)) { + $UsageBySiteId[[string]$UsageRow.siteId.Trim('{}')] = $UsageRow + } + } + + $Report = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($SiteItem in $SiteItems) { + $Site = $SiteItem.Data | ConvertFrom-Json -Depth 10 + if ($Site.isPersonalSite -eq $true) { + continue + } + + $SiteUsage = $null + [void]$UsageBySiteId.TryGetValue([string]$Site.sharepointIds.siteId.Trim('{}'), [ref]$SiteUsage) + + $Report.Add((ConvertTo-CIPPSharePointSiteUsagePayload -Site $Site -SiteUsage $SiteUsage -CacheTimestamp $CacheTimestamp)) + } + + return $Report | Sort-Object -Property displayName + + } catch { + Write-LogMessage -API 'SharePointSiteUsageReport' -tenant $TenantFilter -message "Failed to generate SharePoint site usage report: $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) + throw + } +} diff --git a/Modules/CIPPCore/Public/Get-CIPPSharePointSiteUsageRows.ps1 b/Modules/CIPPCore/Public/Get-CIPPSharePointSiteUsageRows.ps1 new file mode 100644 index 0000000000000..7a8e7fcb6ed9f --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPSharePointSiteUsageRows.ps1 @@ -0,0 +1,189 @@ +function Get-CIPPSharePointSiteUsageRows { + <# + .SYNOPSIS + Builds SharePoint site listing and usage rows from SPO admin RLD plus Graph enrichment. + + .DESCRIPTION + Active sites and usage metrics come from SPO admin RenderAdminListData. Graph getAllSites + supplies webId and composite ids; an optional Get-CIPPSPOSite pass adds file-level archive + fields; a Graph lists bulk pass fills AutoMapUrl. Used by the site usage cache collector + and the live Invoke-ListSites SharePoint path. + + .PARAMETER TenantFilter + Tenant to query. + + .PARAMETER IncludeArchive + When set, merges ArchivedFileDiskUsed and AllowFileArchive from Get-CIPPSPOSite. + + .PARAMETER LogApi + API name passed to Write-LogMessage for warnings and errors. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [switch]$IncludeArchive, + + [string]$LogApi = 'SharePointSiteUsage' + ) + + $Tenant = Get-Tenants -TenantFilter $TenantFilter + $TenantId = $Tenant.customerId + $ReportRefreshDate = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $AdminRows = @(Get-CIPPSPOAdminListData -TenantFilter $TenantFilter -AdminUrl $SharePointInfo.AdminUrl -Type SharePoint) + + $GraphBulk = New-GraphBulkRequest -tenantid $TenantFilter -Requests @( + @{ + id = 'listAllSites' + method = 'GET' + url = "sites/getAllSites?`$filter=isPersonalSite eq false&`$select=id,createdDateTime,description,name,displayName,isPersonalSite,lastModifiedDateTime,webUrl,siteCollection,sharepointIds&`$top=999" + } + ) -asapp $true + $SitesResponse = @($GraphBulk | Where-Object { $_.id -eq 'listAllSites' }) | Select-Object -First 1 + if ($null -eq $SitesResponse) { + throw 'getAllSites response missing from Graph bulk batch' + } + if ($SitesResponse.status -and $SitesResponse.status -ne 200) { + throw ($SitesResponse.body.error.message ?? "getAllSites failed with status $($SitesResponse.status)") + } + + $GraphBySiteId = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) + $GraphByWebUrl = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($GraphSite in @($SitesResponse.body.value)) { + if ($null -eq $GraphSite) { continue } + $Guid = [string]$GraphSite.sharepointIds.siteId + if (-not [string]::IsNullOrWhiteSpace($Guid)) { + $GraphBySiteId[$Guid.Trim('{}').ToLowerInvariant()] = $GraphSite + } + if (-not [string]::IsNullOrWhiteSpace($GraphSite.webUrl)) { + $GraphByWebUrl[$GraphSite.webUrl.TrimEnd('/').ToLowerInvariant()] = $GraphSite + } + } + + $ArchiveByUrl = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) + if ($IncludeArchive) { + try { + foreach ($SpoSite in @(Get-CIPPSPOSite -TenantFilter $TenantFilter)) { + if ([string]::IsNullOrWhiteSpace($SpoSite.Url)) { continue } + $ArchiveByUrl[$SpoSite.Url.TrimEnd('/').ToLowerInvariant()] = @{ + archivedFileDiskUsedBytes = $SpoSite.ArchivedFileDiskUsed + allowFileArchive = $SpoSite.AllowFileArchive + } + } + } catch { + if ($_.Exception.Data['SPOAccessDenied']) { + Write-LogMessage -API $LogApi -tenant $TenantFilter -message $_.Exception.Message -sev Warning + } else { + Write-LogMessage -API $LogApi -tenant $TenantFilter -message "SharePoint file archive enrichment skipped: $($_.Exception.Message)" -sev Warning -LogData (Get-CippException -Exception $_) + } + } + } + + $SiteListing = [System.Collections.Generic.List[object]]::new() + $UsageRows = [System.Collections.Generic.List[object]]::new() + + foreach ($Row in $AdminRows) { + if ($null -eq $Row) { continue } + + $RowUrl = [string]$Row.SiteUrl + $RowTitle = [string]$Row.Title + $RowSiteId = ([string]$Row.SiteId).Trim('{}') + if ([string]::IsNullOrWhiteSpace($RowSiteId) -and [string]::IsNullOrWhiteSpace($RowUrl)) { continue } + + $GraphSite = $null + if (-not [string]::IsNullOrWhiteSpace($RowSiteId)) { + $GraphSite = $GraphBySiteId[$RowSiteId.ToLowerInvariant()] + } + if (-not $GraphSite -and $RowUrl) { + $GraphSite = $GraphByWebUrl[$RowUrl.TrimEnd('/').ToLowerInvariant()] + } + + $SiteGuid = if ($GraphSite -and $GraphSite.sharepointIds.siteId) { [string]$GraphSite.sharepointIds.siteId } else { $RowSiteId } + $SiteGuid = $SiteGuid.Trim('{}') + if ([string]::IsNullOrWhiteSpace($SiteGuid)) { continue } + + $OwnerEmail = [string]$Row.SiteOwnerEmail + $OwnerName = [string]$Row.SiteOwnerName + if ([string]::IsNullOrWhiteSpace($OwnerName)) { $OwnerName = $OwnerEmail } + + $ListingItem = [PSCustomObject]@{ + id = $(if ($GraphSite -and $GraphSite.id) { $GraphSite.id } else { $SiteGuid }) + sharepointIds = [PSCustomObject]@{ + siteId = $SiteGuid + webId = $(if ($GraphSite -and $GraphSite.sharepointIds.webId) { $GraphSite.sharepointIds.webId } else { $null }) + } + createdDateTime = $(if ($Row.TimeCreated) { $Row.TimeCreated } elseif ($GraphSite) { $GraphSite.createdDateTime } else { $null }) + displayName = $(if ($RowTitle) { $RowTitle } elseif ($GraphSite) { $GraphSite.displayName } else { $SiteGuid }) + webUrl = $(if ($RowUrl) { $RowUrl } elseif ($GraphSite) { $GraphSite.webUrl } else { $null }) + isPersonalSite = $false + AutoMapUrl = '' + } + + if ($IncludeArchive -and -not [string]::IsNullOrWhiteSpace($ListingItem.webUrl)) { + $ArchiveFields = $null + if ($ArchiveByUrl.TryGetValue($ListingItem.webUrl.TrimEnd('/').ToLowerInvariant(), [ref]$ArchiveFields)) { + $ListingItem | Add-Member -NotePropertyMembers ([ordered]@{ + archivedFileDiskUsedBytes = $ArchiveFields.archivedFileDiskUsedBytes + allowFileArchive = $ArchiveFields.allowFileArchive + }) -Force + } + } + + [void]$SiteListing.Add($ListingItem) + + $UsageRows.Add([PSCustomObject]@{ + id = $SiteGuid + siteId = $SiteGuid + ownerDisplayName = $OwnerName + ownerPrincipalName = $OwnerEmail + lastActivityDate = $Row.LastActivityOn + fileCount = $Row.NumOfFiles + storageUsedInBytes = $Row.StorageUsed + storageAllocatedInBytes = $Row.StorageQuotaBytes + rootWebTemplate = ConvertTo-SPOUsageRootWebTemplate -TemplateName ([string]$Row.TemplateName) + reportRefreshDate = $ReportRefreshDate + }) + } + + $RequestId = 0 + $ListRequests = foreach ($Site in $SiteListing) { + if (-not $Site.sharepointIds.siteId) { continue } + @{ + id = $RequestId++ + method = 'GET' + url = "sites/$($Site.sharepointIds.siteId)/lists?`$select=id,name,list,parentReference" + } + } + + $ListIdBySiteKey = @{} + if (@($ListRequests).Count -gt 0) { + try { + $LibraryLists = (New-GraphBulkRequest -tenantid $TenantFilter -scope 'https://graph.microsoft.com/.default' -Requests @($ListRequests) -asapp $true).body.value + foreach ($List in @($LibraryLists)) { + if ($List.list.template -ne 'DocumentLibrary') { continue } + $ParentSiteId = $List.parentReference.siteId + if (-not $ParentSiteId) { continue } + foreach ($Key in ([string]$ParentSiteId -split ',')) { + if ($Key -and -not $ListIdBySiteKey.ContainsKey($Key)) { $ListIdBySiteKey[$Key] = $List.id } + } + } + $LibraryLists = $null + } catch { + Write-LogMessage -Message "Error getting auto map urls for SharePoint site usage: $($_.Exception.Message)" -Sev 'Error' -tenant $TenantFilter -API $LogApi -LogData (Get-CippException -Exception $_) + } + } + + foreach ($Site in $SiteListing) { + $SiteKey = [string]$Site.sharepointIds.siteId + $ListId = if ($SiteKey) { $ListIdBySiteKey[$SiteKey] } else { $null } + $Site.AutoMapUrl = "tenantId=$($TenantId)&webId={$($Site.sharepointIds.webId)}&siteid={$($Site.sharepointIds.siteId)}&webUrl=$($Site.webUrl)&listId={$ListId}" + } + + return [PSCustomObject]@{ + SiteListing = $SiteListing + UsageRows = $UsageRows + } +} diff --git a/Modules/CIPPCore/Public/Get-CIPPTenantAllowBlockListReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPTenantAllowBlockListReport.ps1 index a7e0e98096f77..8f61799da526c 100644 --- a/Modules/CIPPCore/Public/Get-CIPPTenantAllowBlockListReport.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPTenantAllowBlockListReport.ps1 @@ -43,9 +43,11 @@ function Get-CIPPTenantAllowBlockListReport { $Entries = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($Row in $Rows) { $Entry = $Row.Data | ConvertFrom-Json - $Entry | Add-Member -NotePropertyName 'Tenant' -NotePropertyValue $Row.PartitionKey -Force - # Per row rather than per report: each tenant is cached on its own schedule. - $Entry | Add-Member -NotePropertyName 'CacheTimestamp' -NotePropertyValue $Row.Timestamp -Force + $Entry | Add-Member -NotePropertyMembers ([ordered]@{ + Tenant = $Row.PartitionKey + # Per row rather than per report: each tenant is cached on its own schedule. + CacheTimestamp = $Row.Timestamp + }) -Force $Entries.Add($Entry) } diff --git a/Modules/CIPPCore/Public/Get-CippDbRole.ps1 b/Modules/CIPPCore/Public/Get-CippDbRole.ps1 index 5c38f0eb63a43..a416f9cff937a 100644 --- a/Modules/CIPPCore/Public/Get-CippDbRole.ps1 +++ b/Modules/CIPPCore/Public/Get-CippDbRole.ps1 @@ -13,39 +13,15 @@ function Get-CippDbRole { $Roles = Get-CIPPTestData -TenantFilter $TenantFilter -Type 'Roles' + # The id lists live in Get-CIPPPrivilegedRoleTemplateIds so every "privileged roles" scope in + # CIPP (tests, PIM pages, standards, alerts) means the same roles. if ($IncludePrivilegedRoles) { - $PrivilegedRoleTemplateIds = @( - '62e90394-69f5-4237-9190-012177145e10', - '194ae4cb-b126-40b2-bd5b-6091b380977d', - '9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3', - 'e8611ab8-c189-46e8-94e1-60213ab1f814', - '29232cdf-9323-42fd-ade2-1d097af3e4de', - 'b1be1c3e-b65d-4f19-8427-f6fa0d97feb9', - 'f28a1f50-f6e7-4571-818b-6a12f2af6b6c', - 'fe930be7-5e62-47db-91af-98c3a49a38b1', - '729827e3-9c14-49f7-bb1b-9608f156bbb8', - '966707d0-3269-4727-9be2-8c3a10f19b9d', - 'b0f54661-2d74-4c50-afa3-1ec803f12efe', - '7be44c8a-adaf-4e2a-84d6-ab2649e08a13', - '158c047a-c907-4556-b7ef-446551a6b5f7', - 'c4e39bd9-1100-46d3-8c65-fb160da0071f', - '9f06204d-73c1-4d4c-880a-6edb90606fd8', - '17315797-102d-40b4-93e0-432062caca18', - '4a5d8f65-41da-4de4-8968-e035b65339cf', - '75941009-915a-4869-abe7-691bff18279e' - ) + $PrivilegedRoleTemplateIds = Get-CIPPPrivilegedRoleTemplateIds -Set Privileged $Roles = $Roles | Where-Object { $PrivilegedRoleTemplateIds -contains $_.RoletemplateId } } if ($CisaHighlyPrivilegedRoles) { - $CisaRoleTemplateIds = @( - '62e90394-69f5-4237-9190-012177145e10', - '9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3', - '29232cdf-9323-42fd-ade2-1d097af3e4de', - '729827e3-9c14-49f7-bb1b-9608f156bbb8', - '966707d0-3269-4727-9be2-8c3a10f19b9d', - 'b0f54661-2d74-4c50-afa3-1ec803f12efe' - ) + $CisaRoleTemplateIds = Get-CIPPPrivilegedRoleTemplateIds -Set CisaHighlyPrivileged $Roles = $Roles | Where-Object { $CisaRoleTemplateIds -contains $_.RoletemplateId } } diff --git a/Modules/CIPPCore/Public/Get-CippTestDataFieldManifest.ps1 b/Modules/CIPPCore/Public/Get-CippTestDataFieldManifest.ps1 index b0598ac101d4d..76b010f8bb364 100644 --- a/Modules/CIPPCore/Public/Get-CippTestDataFieldManifest.ps1 +++ b/Modules/CIPPCore/Public/Get-CippTestDataFieldManifest.ps1 @@ -129,7 +129,7 @@ function Get-CippTestDataFieldManifest { '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') + 'Mailboxes' = @('UPN', 'UserPrincipalName', 'displayName', 'recipientTypeDetails', 'ExternalDirectoryObjectId', 'AuditEnabled', 'AuditOwner', 'AuditDelegate', 'AuditAdmin', 'DefaultAuditSet', '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') @@ -144,8 +144,8 @@ function Get-CippTestDataFieldManifest { # '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') + 'RoleAssignmentScheduleInstances' = @('id', 'roleDefinitionId', 'assignmentType', 'memberType', 'startDateTime', 'endDateTime', 'principalId', 'principal', 'directoryScopeId', 'roleAssignmentOriginId', 'roleAssignmentScheduleId') + 'RoleEligibilitySchedules' = @('id', 'roleDefinitionId', 'principalId', 'principal', 'scheduleInfo', 'directoryScopeId', 'memberType', 'status') # 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') diff --git a/Modules/CIPPCore/Public/Get-DefenderCves.ps1 b/Modules/CIPPCore/Public/Get-DefenderCves.ps1 index 50b42f0e0960e..c97cad5bffea8 100644 --- a/Modules/CIPPCore/Public/Get-DefenderCves.ps1 +++ b/Modules/CIPPCore/Public/Get-DefenderCves.ps1 @@ -45,6 +45,9 @@ function get-DefenderCVEs { try { $CveId = $Vuln.cveId + # Skip TVM software-inventory rows with no CVE before the hashtable lookup; + # ContainsKey($null) throws and was logged per-record as an 'Allover Build' error. + if ([string]::IsNullOrWhiteSpace($CveId)) { $SkippedCount++; return } if (-not $CveAggregator.ContainsKey($CveId)) { # Establish global CVE & software properties for this specific tenant diff --git a/Modules/CIPPCore/Public/Get-DefenderTvmRaw.ps1 b/Modules/CIPPCore/Public/Get-DefenderTvmRaw.ps1 index 01ae9151fb0f6..c09e88666c22a 100644 --- a/Modules/CIPPCore/Public/Get-DefenderTvmRaw.ps1 +++ b/Modules/CIPPCore/Public/Get-DefenderTvmRaw.ps1 @@ -79,7 +79,8 @@ function Get-DefenderTvmRaw { return $all } catch { - Write-LogMessage -API 'DefenderTVM' -tenant $TenantId -message "Error on page $page`: $($_.Exception.Message)" -Sev 'Error' + $Sev = if (Test-CIPPCacheCapabilityError -Message $_.Exception.Message) { 'Debug' } else { 'Error' } + Write-LogMessage -API 'DefenderTVM' -tenant $TenantId -message "Error on page $page`: $($_.Exception.Message)" -Sev $Sev throw } } diff --git a/Modules/CIPPCore/Public/GraphHelper/Get-ClassicAPIToken.ps1 b/Modules/CIPPCore/Public/GraphHelper/Get-ClassicAPIToken.ps1 index 5c9954f8401c6..f3375364c5b46 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Get-ClassicAPIToken.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Get-ClassicAPIToken.ps1 @@ -12,11 +12,20 @@ function Get-ClassicAPIToken($tenantID, $Resource) { $uri = "https://login.microsoftonline.com/$($TenantID)/oauth2/token" $Body = @{ client_id = $env:ApplicationID - client_secret = $env:ApplicationSecret resource = $Resource refresh_token = $env:RefreshToken grant_type = 'refresh_token' } + # Certificate-exclusive auth: sign an assertion instead of sending the client secret. The + # audience must be the classic v1 token endpoint this request targets. + if ($env:CertificateAuthMode) { + $SAMCert = Get-CIPPSAMCertificate -ErrorAction Stop + if (-not $SAMCert) { throw 'Certificate authentication is enabled but no SAM certificate is available.' } + $Body.client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' + $Body.client_assertion = New-CIPPCertificateAssertion -TenantId $TenantID -AppId $env:ApplicationID -Certificate $SAMCert.Certificate -Audience $uri + } else { + $Body.client_secret = $env:ApplicationSecret + } try { if (!$script:classictoken) { $script:classictoken = [HashTable]::Synchronized(@{}) } $script:classictoken.$TokenKey = Invoke-CIPPRestMethod -Uri $uri -Body $body -ContentType 'application/x-www-form-urlencoded' -ErrorAction SilentlyContinue -Method post diff --git a/Modules/CIPPCore/Public/GraphHelper/Get-GraphToken.ps1 b/Modules/CIPPCore/Public/GraphHelper/Get-GraphToken.ps1 index fa9b948eebf34..3e549741a2ab5 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Get-GraphToken.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Get-GraphToken.ps1 @@ -18,6 +18,12 @@ function Get-GraphToken { if (!$scope) { $scope = 'https://graph.microsoft.com/.default' } if (!$tenantid) { $tenantid = $env:TenantID } + # Certificate-exclusive auth: force the SAM certificate for CIPP's own SAM app tokens (app-only and + # delegated). Scoped to the SAM app - explicit $AppID/$AppSecret callers use their own credentials. + if ($env:CertificateAuthMode -and -not $AppID -and -not $AppSecret) { + $UseCertificate = $true + } + $UseSharedTokenCache = ($SkipCache -ne $true) -and ($null -ne ('CIPP.CIPPTokenCache' -as [type])) # ── Fast path: check shared .NET token cache before any table lookups ── diff --git a/Modules/CIPPCore/Public/GraphHelper/Get-Tenants.ps1 b/Modules/CIPPCore/Public/GraphHelper/Get-Tenants.ps1 index 4c15c67838398..8c8680f7d43be 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Get-Tenants.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Get-Tenants.ps1 @@ -43,7 +43,8 @@ function Get-Tenants { $IncludedTenantFilter = [scriptblock]::Create("`$_.customerId -eq '$SafeTenantFilter'") $RelationshipFilter = " and customer/tenantId eq '$SafeTenantFilter'" } else { - $Filter = "{0} and defaultDomainName eq '{1}' or initialDomainName eq '{1}'" -f $Filter, $SafeTenantFilter + # parens: OData 'and' binds tighter than 'or', which would leave the initialDomainName clause unscoped + $Filter = "{0} and (defaultDomainName eq '{1}' or initialDomainName eq '{1}')" -f $Filter, $SafeTenantFilter $IncludedTenantFilter = [scriptblock]::Create("`$_.defaultDomainName -eq '$SafeTenantFilter' -or `$_.initialDomainName -eq '$SafeTenantFilter'") $RelationshipFilter = '' } @@ -94,6 +95,13 @@ function Get-Tenants { if (!$env:RefreshToken) { throw 'RefreshToken not set. Cannot get tenant list.' } + # GDAP relationship objects carry customerId, not domains, so a domain-scoped refresh must key + # on the customerId of the row already read above - otherwise it matches zero relationships. + $ResolvedCustomerId = ($IncludedTenantsCache | Select-Object -First 1).customerId + if ($TenantFilter -and -not $RelationshipFilter -and $ResolvedCustomerId) { + $RelationshipFilter = " and customer/tenantId eq '$ResolvedCustomerId'" + $IncludedTenantFilter = [scriptblock]::Create("`$_.customerId -eq '$ResolvedCustomerId'") + } #get the full list of tenants $GDAPRelationships = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/tenantRelationships/delegatedAdminRelationships?`$filter=status eq 'active'$RelationshipFilter&`$select=customer,autoExtendDuration,endDateTime" -NoAuthCheck:$true # Filter out MLT relationships locally @@ -118,6 +126,12 @@ function Get-Tenants { # Write-Host "Processing $($_.Name), $($_.displayName) to add to tenant list." $ExistingTenantInfo = Get-CIPPAzDataTableEntity @TenantsTable -Filter "PartitionKey eq 'Tenants' and RowKey eq '$($_.Name)'" + # Reset per tenant so a fallback on one tenant does not leak RequiresRefresh onto the next. + $RequiresRefresh = $false + + # Resolved before the cache-hit check below, which compares against its displayName. + $LatestRelationship = $_.Group | Sort-Object -Property relationshipEnd | Select-Object -Last 1 + $Alias = (Get-AzDataTableEntity @PropertiesTable -Filter "PartitionKey eq '$($_.Name)' and RowKey eq 'Alias'").Value if ($Alias) { @@ -131,7 +145,14 @@ function Get-Tenants { Add-CIPPAzDataTableEntity @TenantsTable -Entity $ExistingTenantInfo -Force | Out-Null } - if ($ExistingTenantInfo -and $ExistingTenantInfo.RequiresRefresh -eq $false -and ($ExistingTenantInfo.displayName -eq $LatestRelationship.displayName -or $ExistingTenantInfo.displayName -eq $Alias)) { + # Re-read domains for a row last derived over 7 days ago even when it looks healthy: a custom + # domain can be made default in M365 after onboarding with nothing here to signal it, and the + # cache-hit branch below never re-reads domains. LastRefresh is stamped only on a real fetch. + # (Table returns a DateTimeOffset; [datetime] cannot cast it. Null/garbage throws -> stale.) + try { $DomainsStale = ([DateTimeOffset]$ExistingTenantInfo.LastRefresh) -lt [DateTimeOffset]::UtcNow.AddDays(-7) } catch { $DomainsStale = $true } + + # A refresh scoped to one tenant is an explicit "re-read this one now" - never shortcut it. + if ($ExistingTenantInfo -and $ExistingTenantInfo.RequiresRefresh -eq $false -and -not $DomainsStale -and -not ($TriggerRefresh.IsPresent -and $TenantFilter) -and ($ExistingTenantInfo.displayName -eq $LatestRelationship.displayName -or $ExistingTenantInfo.displayName -eq $Alias)) { Write-Host 'Existing tenant found. We already have it cached, skipping.' $DisplayNameUpdated = $false @@ -157,7 +178,6 @@ function Get-Tenants { $ExistingTenantInfo return } - $LatestRelationship = $_.Group | Sort-Object -Property relationshipEnd | Select-Object -Last 1 $AutoExtend = ($_.Group | Where-Object { $_.autoExtend -eq $true } | Measure-Object).Count -gt 0 if (!$SkipDomains.IsPresent) { try { @@ -172,14 +192,17 @@ function Get-Tenants { Write-Host "Domain variable is $Domain" $Domain = (New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/tenantRelationships/findTenantInformationByTenantId(tenantId='$($LatestRelationship.customerId)')" -NoAuthCheck:$true ).defaultDomainName Write-Host "Alternative method worked, got domain $Domain." - $RequiresRefresh = $true } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API 'Get-Tenants' -message "Tried adding $($LatestRelationship.customerId) to tenant list but failed to get domains - $($_.Exception.Message)" -Sev 'Critical' -LogData $ErrorMessage $Domain = 'Invalid' } finally { - $defaultDomainName = $Domain - $initialDomainName = $Domain + # Main read failed, so this value is provisional - always flag for retry. The fallback returns + # the initial (.onmicrosoft.com) domain for many tenants: keep a good cached custom default over it. + $RequiresRefresh = $true + $KeepCached = $ExistingTenantInfo.defaultDomainName -and $ExistingTenantInfo.defaultDomainName -notlike '*.onmicrosoft.com' + $defaultDomainName = if ($KeepCached) { $ExistingTenantInfo.defaultDomainName } else { $Domain } + $initialDomainName = if ($KeepCached) { $ExistingTenantInfo.initialDomainName } else { $Domain } } } Write-Host 'finished getting domain' diff --git a/Modules/CIPPCore/Public/GraphHelper/New-CIPPCertificateAssertion.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-CIPPCertificateAssertion.ps1 index bda4ea3aa77e6..5769dc6627820 100644 --- a/Modules/CIPPCore/Public/GraphHelper/New-CIPPCertificateAssertion.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/New-CIPPCertificateAssertion.ps1 @@ -21,7 +21,11 @@ function New-CIPPCertificateAssertion { [string]$AppId, [Parameter(Mandatory = $true)] - [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate + [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate, + + # Token endpoint the assertion is presented to (the aud claim). Defaults to the v2 endpoint; + # the classic v1 endpoint (/oauth2/token) must be passed explicitly so the STS accepts it. + [string]$Audience = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" ) # get sha256 hash of certificate for the x5t#S256 header @@ -52,7 +56,7 @@ function New-CIPPCertificateAssertion { iss = $AppId # What endpoint is allowed to use this JWT - aud = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" + aud = $Audience # JWT ID: random guid jti = [guid]::NewGuid() diff --git a/Modules/CIPPCore/Public/GraphHelper/New-CIPPMFAConnectorToken.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-CIPPMFAConnectorToken.ps1 new file mode 100644 index 0000000000000..994d7acb1e45b --- /dev/null +++ b/Modules/CIPPCore/Public/GraphHelper/New-CIPPMFAConnectorToken.ps1 @@ -0,0 +1,132 @@ +function New-CIPPMFAConnectorToken { + <# + .SYNOPSIS + Returns an access token for the Azure MFA StrongAuthenticationService connector. + + .DESCRIPTION + The connector token is minted from a client secret on the tenant's "Azure Multi-Factor Auth Client" + service principal. Provisioning that secret is expensive (it may adjust the tenant's app management + policy and add a credential), so a long-lived secret is cached per tenant - in Key Vault in + production, in the DevSecrets table in local development - and reused. A cached secret is only + reprovisioned when it is missing or the token exchange fails (e.g. it has expired). The provisioned + secret is capped at 180 days; refresh happens automatically on the next call after it lapses. + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '', Justification = 'The connector secret must be written to Key Vault as a SecureString and is encrypted at rest.')] + param( + [Parameter(Mandatory = $true)] + $TenantFilter, + $Headers, + [switch]$ForceProvision + ) + + $MFAAppID = '981f26a1-7f43-403b-a875-f8b09b8cd720' + $ConnectorResource = 'https://adnotifications.windowsazure.com/StrongAuthenticationService.svc/Connector' + $TokenUri = "https://login.microsoftonline.com/$TenantFilter/oauth2/token" + + # Stable, Key-Vault-safe secret name keyed on the tenant GUID (domains contain dots, which KV rejects). + $GuidPattern = '^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$' + $TenantId = if ($TenantFilter -match $GuidPattern) { $TenantFilter } else { (Get-Tenants -TenantFilter $TenantFilter).customerId } + if (-not $TenantId) { $TenantId = $TenantFilter } + $SecretName = "NPS-$TenantId" + $IsDevMode = $env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true' + + # --- dev-aware cached-secret storage ----------------------------------------------------------- + function Get-StoredSecret { + if ($IsDevMode) { + $Table = Get-CIPPTable -tablename 'DevSecrets' + $Row = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'NPSSecret' and RowKey eq '$TenantId'" + return $Row.SecretValue + } + return Get-CippKeyVaultSecret -Name $SecretName -AsPlainText -ErrorAction SilentlyContinue + } + function Set-StoredSecret { + param($Value) + if ($IsDevMode) { + $Table = Get-CIPPTable -tablename 'DevSecrets' + $Entity = @{ PartitionKey = 'NPSSecret'; RowKey = [string]$TenantId; SecretValue = [string]$Value } + Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force + } else { + $null = Set-CippKeyVaultSecret -Name $SecretName -SecretValue (ConvertTo-SecureString -String $Value -AsPlainText -Force) + } + } + + # Keep retrying the token exchange while Microsoft finishes provisioning a freshly added secret. + function Get-ConnectorToken { + param($Secret, [int]$MaxAttempts = 1) + $ClientBody = @{ + resource = $ConnectorResource + client_id = $MFAAppID + client_secret = $Secret + grant_type = 'client_credentials' + scope = 'openid' + } + for ($Attempt = 1; $Attempt -le $MaxAttempts; $Attempt++) { + try { + return (Invoke-RestMethod -Method Post -Uri $TokenUri -Body $ClientBody -ErrorAction Stop).access_token + } catch { + if ($Attempt -ge $MaxAttempts) { throw } + Start-Sleep 1 + } + } + } + + # 1. Reuse the cached secret when present (single token attempt - it is already active). + if (-not $ForceProvision) { + $CachedSecret = Get-StoredSecret + if ($CachedSecret) { + try { + return [pscustomobject]@{ AccessToken = (Get-ConnectorToken -Secret $CachedSecret) } + } catch { + # Cached secret is expired or revoked - fall through and reprovision. + Write-Information "Cached MFA connector secret for $TenantId failed token exchange; reprovisioning." + } + } + } + + # 2. Provision a fresh long-lived secret on the MFA client service principal. + $SPResult = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/servicePrincipals?`$top=999&`$select=id,appId" -tenantid $TenantFilter -AsApp $true + $SPID = ($SPResult | Where-Object { $_.appId -eq $MFAAppID }).id + if (!$SPID) { + $SPBody = [pscustomobject]@{ appId = $MFAAppID } | ConvertTo-Json -Depth 5 + $SPID = (New-GraphPostRequest -uri 'https://graph.microsoft.com/v1.0/servicePrincipals' -tenantid $TenantFilter -type POST -body $SPBody -AsApp $true).id + } + + try { + $PolicyUpdate = Update-AppManagementPolicy -TenantFilter $TenantFilter -ApplicationId $MFAAppID -ServicePrincipal + Write-Information $PolicyUpdate.PolicyAction + } catch { + Write-Information "Failed to update app management policy: $($_.Exception.Message)" + } + + $PassReqBody = @{ + 'passwordCredential' = @{ + 'displayName' = 'CIPP MFA Connector' + 'endDateTime' = $((Get-Date).AddDays(180)) + 'startDateTime' = $((Get-Date).AddMinutes(-5)) + } + } | ConvertTo-Json -Depth 5 + + $NewSecret = $null + $AddSecretError = $null + for ($Attempt = 1; $Attempt -le 5; $Attempt++) { + try { + $NewSecret = (New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/servicePrincipals/$SPID/addPassword" -tenantid $TenantFilter -type POST -body $PassReqBody -AsApp $true).secretText + break + } catch { + $AddSecretError = $_.Exception.Message + if ($Attempt -lt 5) { Start-Sleep -Seconds 4 } + } + } + if (-not $NewSecret) { + throw "Failed to add a credential to the MFA service principal. The tenant's app management policy may be blocking credential creation for this app. Error: $AddSecretError" + } + + $AccessToken = Get-ConnectorToken -Secret $NewSecret -MaxAttempts 20 + Set-StoredSecret -Value $NewSecret + + return [pscustomobject]@{ AccessToken = $AccessToken } +} diff --git a/Modules/CIPPCore/Public/GraphHelper/New-GraphBulkRequest.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-GraphBulkRequest.ps1 index c191724346e55..d89b5e8734202 100644 --- a/Modules/CIPPCore/Public/GraphHelper/New-GraphBulkRequest.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/New-GraphBulkRequest.ps1 @@ -68,6 +68,7 @@ function New-GraphBulkRequest { id = $MoreData.id url = $InitialNextUrl }) + $RetriedPages = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) while ($NextLinkQueue.Count -gt 0) { # Drain up to 20 nextLinks into a batch @@ -88,7 +89,36 @@ function New-GraphBulkRequest { $NextReturn = Invoke-CIPPRestMethod -Uri $URL -Method POST -Headers $headers -ContentType 'application/json; charset=utf-8' -Body $NextReqBody } + # A continuation page that fails (throttled, timed out, or missing from the batch + # reply) used to be dropped silently: the parent item kept status 200 with only + # its first page, so callers took a partial list for the complete one. The drift + # engine then pruned the decisions for every policy that sat on a later page and + # re-created them as New on the next run. Retry the page once, then mark the + # parent so callers can tell the collection is incomplete. + $AnsweredIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($NextResponse in $NextReturn.responses) { + $null = $AnsweredIds.Add([string]$NextResponse.id) + $PageStatus = $NextResponse.status -as [int] + if ($PageStatus -ge 400) { + $PageRequest = $NextBatchRequests | Where-Object { $_.id -eq $NextResponse.id } | Select-Object -First 1 + $RetryKey = "$($NextResponse.id)|$($PageRequest.url)" + if ($PageRequest -and $RetriedPages.Add($RetryKey)) { + $RetryAfter = [Math]::Min([Math]::Max(($NextResponse.headers.'Retry-After' -as [int]), 0), 30) + if ($RetryAfter -gt 0) { Start-Sleep -Seconds $RetryAfter } + $NextLinkQueue.Enqueue([PSCustomObject]@{ + id = $PageRequest.id + url = $PageRequest.url + }) + continue + } + $PageError = "continuation page returned $PageStatus$(if ($NextResponse.body.error.message) { ": $($NextResponse.body.error.message)" })" + Write-Warning "Graph bulk request for '$($NextResponse.id)' ($tenantid): $PageError. The result is incomplete." + $MoreData | Add-Member -NotePropertyMembers ([ordered]@{ + PagingIncomplete = $true + PagingError = $PageError + }) -Force + continue + } if ($NextResponse.body.value) { $NewValues = [System.Collections.Generic.List[PSCustomObject]]$MoreData.body.value foreach ($val in $NextResponse.body.value) { $NewValues.Add($val) } @@ -102,23 +132,37 @@ function New-GraphBulkRequest { }) } } + foreach ($Unanswered in ($NextBatchRequests | Where-Object { -not $AnsweredIds.Contains([string]$_.id) })) { + Write-Warning "Graph bulk request for '$($Unanswered.id)' ($tenantid): no reply for continuation page '$($Unanswered.url)'. The result is incomplete." + $MoreData | Add-Member -NotePropertyMembers ([ordered]@{ + PagingIncomplete = $true + PagingError = 'continuation page missing from the batch reply' + }) -Force + } } } } catch { Write-Host 'updating graph table because something failed.' + $ErrorRecord = $_ # $_ is the parse error inside the nested catch # Try to parse ErrorDetails.Message as JSON - if ($_.ErrorDetails.Message) { + $ErrorBody = [string]$ErrorRecord.ErrorDetails.Message + if ($ErrorBody) { try { - $ErrorJson = $_.ErrorDetails.Message | ConvertFrom-Json -ErrorAction Stop + $ErrorJson = $ErrorBody | ConvertFrom-Json -ErrorAction Stop $Message = $ErrorJson.error.message } catch { - $Message = $_.ErrorDetails.Message + $Message = $ErrorBody } } if ([string]::IsNullOrEmpty($Message)) { - $Message = $_.Exception.Message + $Message = $ErrorRecord.Exception.Message + } + + # An IIS error page ('Request Too Long') is HTML, not a Graph error; keep its text only. + if ($Message -match '(?i)]+>', ' ' -replace '\s+', ' ').Trim() } if ($Message -ne 'Request not applicable to target tenant.') { diff --git a/Modules/CIPPCore/Public/GraphHelper/Update-AppManagementPolicy.ps1 b/Modules/CIPPCore/Public/GraphHelper/Update-AppManagementPolicy.ps1 index 566f250f36c9f..553f77e88ef63 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Update-AppManagementPolicy.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Update-AppManagementPolicy.ps1 @@ -16,9 +16,18 @@ function Update-AppManagementPolicy { param( $TenantFilter = $env:TenantID, $ApplicationId = $env:ApplicationID, - $headers + $headers, + # Skip the password-addition exemption (leave secrets blocked, exempt only the SAM certificate). + # Defaults on only for the SAM app in certificate mode; other apps still get the password exemption. + [bool]$CertificateOnly = ([bool]$env:CertificateAuthMode -and ($ApplicationId -eq $env:ApplicationID)), + # Target a service principal instead of an application registration. First-party apps (e.g. the + # Azure MFA client) exist only as a service principal in the tenant, so the exemption must be + # resolved and assigned via servicePrincipals rather than applications. + [switch]$ServicePrincipal ) + $TargetResource = if ($ServicePrincipal) { 'servicePrincipals' } else { 'applications' } + try { # Create bulk request to fetch both policies at once $Requests = @( @@ -35,7 +44,7 @@ function Update-AppManagementPolicy { @{ id = 'appRegistration' method = 'GET' - url = "applications(appId='$ApplicationId')?`$select=id,appId,displayName" + url = "$TargetResource(appId='$ApplicationId')?`$select=id,appId,displayName" } ) @@ -134,9 +143,11 @@ function Update-AppManagementPolicy { $DefaultPolicyBlocksKeyCredentials = $DefaultKeyRestrictions.Count -gt 0 } - # If default policy blocks credentials and CIPP app doesn't have an exemption, create/update policy + # Create/update an exemption when the default policy blocks credentials. In certificate mode a + # password block is left in force, so only a key-credential block requires an exemption. $PolicyAction = $null - if (($DefaultPolicyBlocksCredentials -or $DefaultPolicyBlocksKeyCredentials) -and $CIPPApp) { + $RequiresExemption = $DefaultPolicyBlocksKeyCredentials -or (-not $CertificateOnly -and $DefaultPolicyBlocksCredentials) + if ($RequiresExemption -and $CIPPApp) { # Check if a CIPP-SAM Exemption Policy already exists $ExistingExemptionPolicy = $AppPolicies | Where-Object { $_.displayName -eq 'CIPP Exemption Policy' } | Select-Object -First 1 @@ -144,8 +155,10 @@ function Update-AppManagementPolicy { $CIPPHasExemption = $false if ($CIPPAppPolicyId) { $CIPPPolicy = $AppPolicies | Where-Object { $_.id -eq $CIPPAppPolicyId } - # Check if the policy explicitly allows credentials (no enabled passwordAddition/symmetricKeyAddition restriction) - if ($CIPPPolicy.restrictions.passwordCredentials) { + # In certificate mode the password block is intentional, so only the key exemption matters. + if ($CertificateOnly) { + $CIPPHasExemption = $true + } elseif ($CIPPPolicy.restrictions.passwordCredentials) { $CIPPHasExemption = -not ($CIPPPolicy.restrictions.passwordCredentials | Where-Object { $_.restrictionType -in @('passwordAddition', 'symmetricKeyAddition') -and $_.state -eq 'enabled' }) } else { # No password restrictions means it allows credentials @@ -164,37 +177,54 @@ function Update-AppManagementPolicy { if (-not $CIPPHasExemption) { # Need to create or update a policy for CIPP try { - # Define policy structure with disabled restrictions + # Only exempt the restriction types the default policy actually enforces, so the + # exemption body never carries a restriction Graph would reject as unneeded or malformed. + $Restrictions = @{} + + # Password restrictions are disabled only for secret installs; certificate mode leaves + # them blocked so secrets stay disallowed. + if (-not $CertificateOnly -and $DefaultPolicyBlocksCredentials) { + $Restrictions.passwordCredentials = @( + @{ + restrictionType = 'passwordAddition' + state = 'disabled' + restrictForAppsCreatedAfterDateTime = '0001-01-01T00:00:00Z' + } + @{ + restrictionType = 'symmetricKeyAddition' + state = 'disabled' + restrictForAppsCreatedAfterDateTime = '0001-01-01T00:00:00Z' + } + ) + } + + # Key restrictions are disabled so the SAM certificate can register. asymmetricKeyLifetime + # is a lifetime-type restriction; Graph rejects the whole policy body unless it carries a + # valid maxLifetime duration, even when the restriction is disabled. Echo the tenant + # default's value when present, otherwise fall back to a conservative duration. + if ($DefaultPolicyBlocksKeyCredentials) { + $AsymmetricKeyMaxLifetime = ($DefaultKeyRestrictions | Where-Object { $_.restrictionType -eq 'asymmetricKeyLifetime' } | Select-Object -First 1).maxLifetime + if (-not $AsymmetricKeyMaxLifetime) { $AsymmetricKeyMaxLifetime = 'P730D' } + + $Restrictions.keyCredentials = @( + @{ + restrictionType = 'asymmetricKeyLifetime' + state = 'disabled' + restrictForAppsCreatedAfterDateTime = '0001-01-01T00:00:00Z' + maxLifetime = $AsymmetricKeyMaxLifetime + } + @{ + restrictionType = 'trustedCertificateAuthority' + state = 'disabled' + restrictForAppsCreatedAfterDateTime = '0001-01-01T00:00:00Z' + } + ) + } $PolicyBody = @{ displayName = 'CIPP Exemption Policy' - description = 'Allows CIPP app to manage credentials' + description = if ($CertificateOnly) { 'Allows CIPP app to register certificates (password addition intentionally left blocked)' } else { 'Allows CIPP app to manage credentials' } isEnabled = $true - restrictions = @{ - passwordCredentials = @( - @{ - restrictionType = 'passwordAddition' - state = 'disabled' - restrictForAppsCreatedAfterDateTime = '0001-01-01T00:00:00Z' - } - @{ - restrictionType = 'symmetricKeyAddition' - state = 'disabled' - restrictForAppsCreatedAfterDateTime = '0001-01-01T00:00:00Z' - } - ) - keyCredentials = @( - @{ - restrictionType = 'asymmetricKeyLifetime' - state = 'disabled' - restrictForAppsCreatedAfterDateTime = '0001-01-01T00:00:00Z' - } - @{ - restrictionType = 'trustedCertificateAuthority' - state = 'disabled' - restrictForAppsCreatedAfterDateTime = '0001-01-01T00:00:00Z' - } - ) - } + restrictions = $Restrictions } if ($CIPPAppPolicyId) { @@ -206,12 +236,21 @@ function Update-AppManagementPolicy { $null = New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/policies/appManagementPolicies/$($ExistingExemptionPolicy.id)" -type PATCH -body ($PolicyBody | ConvertTo-Json -Depth 10) -asapp $true -NoAuthCheck $true -tenantid $TenantFilter -headers $headers if ($CIPPApp.id) { - # Assign existing policy to CIPP-SAM application + # Assign existing policy to the target app registration or service principal $AssignBody = @{ '@odata.id' = "https://graph.microsoft.com/beta/policies/appManagementPolicies/$($ExistingExemptionPolicy.id)" } - $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/applications/$($CIPPApp.id)/appManagementPolicies/`$ref" -type POST -body ($AssignBody | ConvertTo-Json) -asapp $true -NoAuthCheck $true -tenantid $TenantFilter -headers $headers - $PolicyAction = "Updated and assigned existing policy $($ExistingExemptionPolicy.id) to CIPP-SAM" + try { + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/$TargetResource/$($CIPPApp.id)/appManagementPolicies/`$ref" -type POST -body ($AssignBody | ConvertTo-Json) -asapp $true -NoAuthCheck $true -tenantid $TenantFilter -headers $headers + $PolicyAction = "Updated and assigned existing policy $($ExistingExemptionPolicy.id) to CIPP-SAM" + } catch { + # A duplicate reference means the policy is already assigned - that is the desired end state, not a failure. + if ($_.Exception.Message -match 'already exist') { + $PolicyAction = "Existing policy $($ExistingExemptionPolicy.id) already assigned to CIPP-SAM" + } else { + throw + } + } $CIPPAppPolicyId = $ExistingExemptionPolicy.id $CIPPAppTargeted = $true } else { @@ -222,12 +261,21 @@ function Update-AppManagementPolicy { $CreatedPolicy = New-GraphPostRequest -uri 'https://graph.microsoft.com/v1.0/policies/appManagementPolicies' -type POST -body ($PolicyBody | ConvertTo-Json -Depth 10) -asapp $true -NoAuthCheck $true -tenantid $TenantFilter -headers $headers if ($CIPPApp.id) { - # Assign policy to CIPP-SAM application using beta endpoint + # Assign policy to the target app registration or service principal using beta endpoint $AssignBody = @{ '@odata.id' = "https://graph.microsoft.com/beta/policies/appManagementPolicies/$($CreatedPolicy.id)" } - $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/applications/$($CIPPApp.id)/appManagementPolicies/`$ref" -type POST -body ($AssignBody | ConvertTo-Json) -asapp $true -NoAuthCheck $true -tenantid $TenantFilter -headers $headers - $PolicyAction = "Created new policy $($CreatedPolicy.id) and assigned to CIPP-SAM" + try { + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/$TargetResource/$($CIPPApp.id)/appManagementPolicies/`$ref" -type POST -body ($AssignBody | ConvertTo-Json) -asapp $true -NoAuthCheck $true -tenantid $TenantFilter -headers $headers + $PolicyAction = "Created new policy $($CreatedPolicy.id) and assigned to CIPP-SAM" + } catch { + # A duplicate reference means the policy is already assigned - that is the desired end state, not a failure. + if ($_.Exception.Message -match 'already exist') { + $PolicyAction = "Created new policy $($CreatedPolicy.id); already assigned to CIPP-SAM" + } else { + throw + } + } $CIPPAppPolicyId = $CreatedPolicy.id $CIPPAppTargeted = $true } else { diff --git a/Modules/CIPPCore/Public/GraphHelper/Write-LogMessage.ps1 b/Modules/CIPPCore/Public/GraphHelper/Write-LogMessage.ps1 index 741dd44d48c7b..975238e16573c 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Write-LogMessage.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Write-LogMessage.ps1 @@ -47,6 +47,12 @@ function Write-LogMessage { $TzId = if ($env:CIPP_TIMEZONE) { $env:CIPP_TIMEZONE } else { 'UTC' } $LocalNow = [TimeZoneInfo]::ConvertTimeBySystemTimeZoneId([DateTime]::UtcNow, $TzId) $PartitionKey = $LocalNow.ToString('yyyyMMdd') + # Inverted-ticks RowKey: the table service returns rows in ascending RowKey order, so + # (MaxValue - now) makes a partition read newest-first without scanning it whole. The + # suffix keeps concurrent writers from colliding; Invoke-ListLogs derives the day + # partition back out of the tick prefix. Rows written before this scheme have GUID + # RowKeys and simply sort in arbitrary order within their (historical) partitions. + $RowKey = '{0:D19}-{1}' -f ([DateTime]::MaxValue.Ticks - [DateTime]::UtcNow.Ticks), [guid]::NewGuid().ToString('N').Substring(0, 12) $TableRow = @{ 'Tenant' = [string]$tenant 'API' = [string]$API @@ -55,7 +61,7 @@ function Write-LogMessage { 'Severity' = [string]$sev 'sentAsAlert' = $false 'PartitionKey' = [string]$PartitionKey - 'RowKey' = [string]([guid]::NewGuid()).ToString() + 'RowKey' = [string]$RowKey 'FunctionNode' = [string]$env:WEBSITE_SITE_NAME 'LogData' = [string]$LogData } diff --git a/Modules/CIPPCore/Public/GraphRequests/Get-GraphRequestList.ps1 b/Modules/CIPPCore/Public/GraphRequests/Get-GraphRequestList.ps1 index 1e27e7b39b312..22a56bae7464b 100644 --- a/Modules/CIPPCore/Public/GraphRequests/Get-GraphRequestList.ps1 +++ b/Modules/CIPPCore/Public/GraphRequests/Get-GraphRequestList.ps1 @@ -81,10 +81,12 @@ function Get-GraphRequestList { [boolean]$AsApp = $false, [string]$Caller = 'Get-GraphRequestList', [switch]$UseBatchExpand, - [switch]$RawJsonArray + [switch]$RawJsonArray, + [int]$MaxPageBytes ) $SingleTenantThreshold = 8000 + $PagedAllTenants = $false Write-Information "Tenant: $TenantFilter" $TableName = ('cache{0}' -f ($Endpoint -replace '[^A-Za-z0-9]'))[0..62] -join '' $Endpoint = $Endpoint -replace '^/', '' @@ -210,7 +212,28 @@ function Get-GraphRequestList { $Filter = "PartitionKey eq '{0}' and (RowKey eq '{1}' or OriginalEntityId eq '{1}') and Timestamp ge datetime'{2}'" -f $PartitionKey, $TenantFilter, $Timestamp } $Tenants = Get-Tenants -IncludeErrors - $Rows = Get-CIPPAzDataTableEntity @Table -Filter $Filter | Where-Object { $_.OriginalEntityId -in $Tenants.defaultDomainName -or $_.RowKey -in $Tenants.defaultDomainName } + # Paged AllTenants serve: key scan here, bounded blob fetches in the serve branch. + $PagedAllTenants = $TenantFilter -eq 'AllTenants' -and $ManualPagination.IsPresent -and $RawJsonArray.IsPresent + if ($PagedAllTenants) { + # Keys only, and none of the split-entity markers: projecting a subset of them + # (e.g. OriginalEntityId alone) makes reassembly fail and drops split tenants. + $KeyRows = Get-CIPPAzDataTableEntity @Table -Filter $Filter -Property PartitionKey, RowKey + # Physical rows per tenant ('-part' rows fold into their head); sizes the spans below. + $PagedTenantRowCounts = [System.Collections.Generic.Dictionary[string, int]]::new([StringComparer]::Ordinal) + foreach ($KeyRow in @($KeyRows)) { + $TenantKey = [string]$KeyRow.RowKey -replace '-part\d+$', '' + if ($TenantKey) { + $PagedTenantRowCounts[$TenantKey] = 1 + $(if ($PagedTenantRowCounts.ContainsKey($TenantKey)) { $PagedTenantRowCounts[$TenantKey] } else { 0 }) + } + } + # Ordinal, to match the resume comparison below (a culture sort re-served tenants). + $PagedTenantPlan = [string[]]@($PagedTenantRowCounts.Keys | Where-Object { $_ -in $Tenants.defaultDomainName }) + [System.Array]::Sort($PagedTenantPlan, [System.Collections.IComparer][StringComparer]::Ordinal) + # $Rows gates queue-vs-serve below; an empty plan queues like an empty fetch. + $Rows = $PagedTenantPlan + } else { + $Rows = Get-CIPPAzDataTableEntity @Table -Filter $Filter | Where-Object { $_.OriginalEntityId -in $Tenants.defaultDomainName -or $_.RowKey -in $Tenants.defaultDomainName } + } $Type = 'Cache' Write-Information "Table: $TableName | PK: $PartitionKey | Cached: $(($Rows | Measure-Object).Count) rows (Type: $($Type))" $QueueReference = '{0}-{1}' -f $TenantFilter, $PartitionKey @@ -436,6 +459,71 @@ function Get-GraphRequestList { } } else { if ($RawJsonArray.IsPresent) { + if ($PagedAllTenants) { + # One page of whole tenant blobs, ended by the byte budget alone (never a tenant + # count); tenants are fetched in RowKey-range spans so split blobs reassemble. + $MaxPageChars = if ($MaxPageBytes -gt 0) { [Math]::Min([Math]::Max($MaxPageBytes, 262144), 8388608) } else { 4000000 } + # Rows are <= ~1MB each, so a row cap bounds a span's worst-case fetch. + $SpanRowCap = 40 + $StartAfter = if ($nextLink) { $nextLink } else { $null } + + $Remaining = [System.Collections.Generic.List[string]]::new() + foreach ($TenantKey in $PagedTenantPlan) { + if (-not $StartAfter -or [string]::CompareOrdinal($TenantKey, $StartAfter) -gt 0) { $Remaining.Add($TenantKey) } + } + + $JsonParts = [System.Collections.Generic.List[string]]::new() + $Chars = 0 + $Queries = 0 + $LastEmitted = $null + $BudgetReached = $false + $Index = 0 + while ($Index -lt $Remaining.Count -and -not $BudgetReached) { + # Build the next span: consecutive plan tenants until the row cap fills. + $SpanStart = $Index + $SpanRows = 0 + $SpanSet = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + while ($Index -lt $Remaining.Count) { + $Candidate = $Remaining[$Index] + $CandidateRows = $PagedTenantRowCounts[$Candidate] + if ($SpanSet.Count -gt 0 -and ($SpanRows + $CandidateRows) -gt $SpanRowCap) { break } + $null = $SpanSet.Add($Candidate) + $SpanRows += $CandidateRows + $Index++ + } + $SpanFirst = ConvertTo-CIPPODataFilterValue -Value $Remaining[$SpanStart] -Type String + $SpanLast = ConvertTo-CIPPODataFilterValue -Value $Remaining[$Index - 1] -Type String + # le '~' keeps the last tenant's '-partN' rows in range; non-member rows + # the range also catches are dropped below. + $SpanFilter = "PartitionKey eq '{0}' and RowKey ge '{1}' and RowKey le '{2}~' and Timestamp ge datetime'{3}'" -f $PartitionKey, $SpanFirst, $SpanLast, $Timestamp + # Budget is enforced per whole tenant; the rest of a span past it is discarded + # and re-fetched by the next page. + foreach ($Row in @(Get-CIPPAzDataTableEntity @Table -Filter $SpanFilter)) { + if (-not $SpanSet.Contains([string]$Row.RowKey)) { continue } + if ($BudgetReached) { break } + $LastEmitted = [string]$Row.RowKey + if ($Row.Data) { + $d = $Row.Data.Trim() + if ($d.Length -gt 2 -and $d[0] -eq '[' -and $d[-1] -eq ']') { + $JsonParts.Add($d.Substring(1, $d.Length - 2)) + $Chars += $d.Length + } elseif ($d.Length -gt 0 -and $d -ne '[]') { + $JsonParts.Add($d) + $Chars += $d.Length + } + } + if ($Chars -ge $MaxPageChars) { $BudgetReached = $true } + } + $Queries++ + } + # A drained plan is complete even if the last tenant landed on the budget. + $MoreRemain = $BudgetReached -and $null -ne $LastEmitted -and [string]::CompareOrdinal($LastEmitted, $Remaining[$Remaining.Count - 1]) -lt 0 + Write-Information "Paged AllTenants cache serve: $Queries spans, $Chars chars, last: $LastEmitted, more: $MoreRemain" + return [PSCustomObject]@{ + CippPagedJson = '[' + ($JsonParts -join ',') + ']' + CippNextLink = if ($MoreRemain) { $LastEmitted } else { $null } + } + } # Fast path: concatenate raw JSON strings without deserialization. This is much faster and uses less memory when no post-processing is needed, especially for large datasets. $JsonParts = [System.Collections.Generic.List[string]]::new() foreach ($Row in $Rows) { diff --git a/Modules/CIPPCore/Public/Invoke-CIPPCATemplateBatch.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPCATemplateBatch.ps1 index b5f031869ddcb..43a144947b838 100644 --- a/Modules/CIPPCore/Public/Invoke-CIPPCATemplateBatch.ps1 +++ b/Modules/CIPPCore/Public/Invoke-CIPPCATemplateBatch.ps1 @@ -48,7 +48,9 @@ function Invoke-CIPPCATemplateBatch { $TestResult = Test-CIPPStandardLicense -StandardName 'ConditionalAccessTemplate_general' -TenantFilter $Tenant -Preset Entra if ($TestResult -eq $false) { foreach ($t in $Templates) { - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($t.Settings.TemplateList.value)" -FieldValue 'This tenant does not have the required license for this standard.' -Tenant $Tenant + # LicenseAvailable is what the standards page keys off to show the licensing message; + # without it a bare string value renders as "data has not yet been collected". + Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($t.Settings.TemplateList.value)" -FieldValue 'This tenant does not have the required license for this standard.' -LicenseAvailable $false -Tenant $Tenant } return } @@ -92,16 +94,23 @@ function Invoke-CIPPCATemplateBatch { # Load each template's JSON once $CATemplates = foreach ($t in $Templates) { $TemplateValue = $t.Settings.TemplateList.value - $Filter = "PartitionKey eq 'CATemplate' and RowKey eq '$TemplateValue'" - $JSON = (Get-CippAzDataTableEntity @Table -Filter $Filter).JSON + # The template picker surfaces the GUID column while the engine keys on RowKey. CIPP writes + # both to the same value, but templates re-synced by older releases can differ - accept + # either so a template that is sitting in the table is not reported as missing. + $SafeTemplateValue = ConvertTo-CIPPODataFilterValue -Value $TemplateValue -Type String + $Filter = "PartitionKey eq 'CATemplate' and (RowKey eq '$SafeTemplateValue' or GUID eq '$SafeTemplateValue')" + $JSON = (Get-CippAzDataTableEntity @Table -Filter $Filter | Select-Object -First 1).JSON # Resolve custom variables once at load: the compare helper, the dependency # reconciliation objects and the deploy RawJSON must all see the same resolved # values, or the DependencyMap ends up keyed by raw %tokens% that the (resolved) # policies can never look up. if ($JSON) { $JSON = Get-CIPPTextReplacement -TenantFilter $Tenant -Text $JSON -EscapeForJson } if (-not $JSON) { - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Conditional Access template '$($t.Settings.TemplateList.label)' ($TemplateValue) could not be loaded from the template store - skipping." -Sev 'Error' - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$TemplateValue" -FieldValue "Template '$($t.Settings.TemplateList.label)' could not be loaded from the template store." -Tenant $Tenant + $MissingText = "Template '$($t.Settings.TemplateList.label)' ($TemplateValue) no longer exists in the template library. Remove it from the standards template or select the template again." + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Conditional Access template '$($t.Settings.TemplateList.label)' ($TemplateValue) could not be loaded from the template store - skipping. $MissingText" -Sev 'Error' + # Carry the reason into the report so the standards page shows it instead of "data has + # not yet been collected". + Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$TemplateValue" -CurrentValue @{ Differences = $MissingText } -ExpectedValue @{ Differences = @() } -Tenant $Tenant continue } [pscustomobject]@{ diff --git a/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 index 3c761f84c784d..a81a95e7c0fb2 100644 --- a/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 +++ b/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 @@ -9,13 +9,14 @@ function Invoke-CIPPDBCacheCollection { compared to individual per-type activities, eliminating replay overhead. Collection types map to license categories: - - Graph: Core tenant data (no special license needed) - - ExchangeConfig: Exchange Online policy/config data - - ExchangeData: Mailboxes, CAS mailboxes, usage reports - - ConditionalAccess: CA policies and registration details - - IdentityProtection: Risky users/SPs, risk detections, PIM - - Intune: Managed devices, policies, app protection - - Defender: Defender Vulnerabilities + - Graph: Core tenant data (no special license needed) + - ExchangeConfig: Exchange Online policy/config data + - ExchangeData: Mailboxes, CAS mailboxes, usage reports + - ConditionalAccess: CA policies and registration details + - IdentityProtection: Risky users/SPs, risk detections, PIM + - Intune: Managed devices, policies, app protection + - DefenderForOffice365: Safe Links/Attachments, ATP, Teams protection (MDO P1/P2) + - Defender: Defender for Endpoint vulnerabilities (TVM/CVE) .PARAMETER CollectionType The group of cache functions to execute @@ -32,7 +33,7 @@ function Invoke-CIPPDBCacheCollection { [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [ValidateSet('Graph', 'ExchangeConfig', 'ExchangeData', 'ConditionalAccess', 'IdentityProtection', 'Intune', 'Compliance', 'CopilotUsage', 'SharePoint', 'Teams', 'Defender')] + [ValidateSet('Graph', 'ExchangeConfig', 'ExchangeData', 'ConditionalAccess', 'IdentityProtection', 'Intune', 'Compliance', 'CopilotUsage', 'SharePoint', 'Teams', 'DefenderForOffice365', 'Defender')] [string]$CollectionType, [Parameter(Mandatory = $true)] @@ -69,6 +70,7 @@ function Invoke-CIPPDBCacheCollection { 'OAuth2PermissionGrants' 'AppRoleAssignments' 'LicenseOverview' + 'ActiveUserDetail' 'BitlockerKeys' 'AdminReportSettings' 'PeopleInsights' @@ -88,15 +90,12 @@ function Invoke-CIPPDBCacheCollection { ExchangeConfig = @( 'ExoAntiPhishPolicies' 'ExoMalwareFilterPolicies' - 'ExoSafeLinksPolicies' - 'ExoSafeAttachmentPolicies' 'ExoTransportRules' 'ExoDkimSigningConfig' 'ExoOrganizationConfig' 'ExoAcceptedDomains' 'ExoHostedContentFilterPolicy' 'ExoHostedOutboundSpamFilterPolicy' - 'ExoAtpPolicyForO365' 'ExoQuarantinePolicy' 'ExoRemoteDomain' 'ExoSharingPolicy' @@ -111,7 +110,6 @@ function Invoke-CIPPDBCacheCollection { 'ExoTransportConfig' 'ExoHostedConnectionFilterPolicy' 'ExoExternalInOutlook' - 'ExoTeamsProtectionPolicy' 'ExoOutboundConnector' 'ExoRoleAssignmentPolicy' 'ExoHostedContentFilterRule' @@ -181,6 +179,7 @@ function Invoke-CIPPDBCacheCollection { ) SharePoint = @( 'SPOTenant' + 'SPOSites' 'SPOTenantSyncClientRestriction' 'SharePointAdminSettings' 'SharePointSiteUsage' @@ -203,6 +202,12 @@ function Invoke-CIPPDBCacheCollection { Defender = @( 'DefenderCVEs' ) + DefenderForOffice365 = @( + 'ExoSafeLinksPolicies' + 'ExoSafeAttachmentPolicies' + 'ExoAtpPolicyForO365' + 'ExoTeamsProtectionPolicy' + ) } $CacheTypes = $Collections[$CollectionType] diff --git a/Modules/CIPPCore/Public/Invoke-CIPPOffboardingJob.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPOffboardingJob.ps1 index 3174219365396..0c5025cd28673 100644 --- a/Modules/CIPPCore/Public/Invoke-CIPPOffboardingJob.ps1 +++ b/Modules/CIPPCore/Public/Invoke-CIPPOffboardingJob.ps1 @@ -7,7 +7,11 @@ function Invoke-CIPPOffboardingJob { $Options, $APIName = 'Offboard user', $Headers, - $TaskInfo + $TaskInfo, + # Live-progress job created by the caller; when set, the user's status row is kept up to date + [string]$DeploymentId, + # Zero-based indices of the steps to run again (a step re-run); empty runs every selected task + [int[]]$StepIndexes = @() ) try { @@ -31,9 +35,30 @@ function Invoke-CIPPOffboardingJob { # Build dynamic batch of offboarding tasks based on selected options $Batch = [System.Collections.Generic.List[object]]::new() - # Build list of tasks in execution order with their cmdlets + # When the user is being deleted, only user removal and OneDrive access grants remain valid; every other task is skipped regardless of its flag + $DeleteUserSelected = $Options.DeleteUser -eq $true + $AllowedCmdletsWhenDeletingUser = @('Remove-CIPPUser', 'Set-CIPPSharePointPerms') + $SkippedForDeleteUser = [System.Collections.Generic.List[string]]::new() + + # Build list of tasks in execution order with their cmdlets. + # The account-only wipe must run before session revocation, sign-in disable and device removal: + # the wipe is delivered on the device's next Exchange connection, so the account must still be + # able to authenticate and the ActiveSync partnership must still exist when it is issued. $TaskOrder = @( @{ + Title = 'Wipe mobile devices (account data only)' + Condition = { $Options.WipeMobile -eq $true } + Cmdlet = 'Clear-CIPPMobileDevice' + Parameters = @{ + userid = $UserID + username = $Username + tenantFilter = $TenantFilter + APIName = $APIName + Headers = $Headers + } + } + @{ + Title = 'Revoke all sessions' Condition = { $Options.RevokeSessions -eq $true } Cmdlet = 'Revoke-CIPPSessions' Parameters = @{ @@ -45,6 +70,7 @@ function Invoke-CIPPOffboardingJob { } } @{ + Title = 'Reset password' Condition = { $Options.ResetPass -eq $true } Cmdlet = 'Set-CIPPResetPassword' Parameters = @{ @@ -56,6 +82,7 @@ function Invoke-CIPPOffboardingJob { } } @{ + Title = 'Disable sign in' Condition = { $Options.DisableSignIn -eq $true } Cmdlet = 'Set-CIPPSignInState' Parameters = @{ @@ -67,6 +94,7 @@ function Invoke-CIPPOffboardingJob { } } @{ + Title = 'Hide from Global Address List' Condition = { $Options.HideFromGAL -eq $true } Cmdlet = 'Set-CIPPHideFromGAL' Parameters = @{ @@ -78,6 +106,7 @@ function Invoke-CIPPOffboardingJob { } } @{ + Title = 'Remove from all groups' Condition = { $Options.RemoveGroups -eq $true } Cmdlet = 'Remove-CIPPGroups' Parameters = @{ @@ -89,6 +118,7 @@ function Invoke-CIPPOffboardingJob { } } @{ + Title = 'Remove all rules' Condition = { $Options.RemoveRules -eq $true } Cmdlet = 'Remove-CIPPMailboxRule' Parameters = @{ @@ -101,6 +131,7 @@ function Invoke-CIPPOffboardingJob { } } @{ + Title = 'Remove all mobile devices' Condition = { $Options.RemoveMobile -eq $true } Cmdlet = 'Remove-CIPPMobileDevice' Parameters = @{ @@ -112,6 +143,7 @@ function Invoke-CIPPOffboardingJob { } } @{ + Title = 'Cancel all calendar invites' Condition = { $Options.removeCalendarInvites -eq $true } Cmdlet = 'Remove-CIPPCalendarInvites' Parameters = @{ @@ -123,6 +155,7 @@ function Invoke-CIPPOffboardingJob { } } @{ + Title = 'Set Out of Office message' Condition = { -not [string]::IsNullOrEmpty($OooMessage) } Cmdlet = 'Set-CIPPOutOfOffice' Parameters = @{ @@ -136,6 +169,7 @@ function Invoke-CIPPOffboardingJob { } } @{ + Title = 'Forward email' Condition = { ![string]::IsNullOrEmpty($Options.forward) } Cmdlet = 'Set-CIPPForwarding' Parameters = @{ @@ -149,6 +183,7 @@ function Invoke-CIPPOffboardingJob { } } @{ + Title = 'Disable email forwarding' Condition = { $Options.disableForwarding -eq $true } Cmdlet = 'Set-CIPPForwarding' Parameters = @{ @@ -161,6 +196,7 @@ function Invoke-CIPPOffboardingJob { } } @{ + Title = 'Grant OneDrive full access' Condition = { $Options.OnedriveAccess.Count -gt 0 } Cmdlet = 'Set-CIPPSharePointPerms' Parameters = @{ @@ -172,6 +208,7 @@ function Invoke-CIPPOffboardingJob { } } @{ + Title = 'Disable OneDrive sharing links' Condition = { $Options.DisableOneDriveSharing -eq $true } Cmdlet = 'Set-CIPPOneDriveSharing' Parameters = @{ @@ -183,6 +220,7 @@ function Invoke-CIPPOffboardingJob { } } @{ + Title = 'Grant full access (no automap)' Condition = { $Options.AccessNoAutomap.Count -gt 0 } Cmdlet = 'Set-CIPPMailboxAccess' Parameters = @{ @@ -196,6 +234,7 @@ function Invoke-CIPPOffboardingJob { } } @{ + Title = 'Grant full access (automap)' Condition = { $Options.AccessAutomap.Count -gt 0 } Cmdlet = 'Set-CIPPMailboxAccess' Parameters = @{ @@ -209,6 +248,7 @@ function Invoke-CIPPOffboardingJob { } } @{ + Title = 'Grant Send As access' Condition = { $Options.AccessSendAs.Count -gt 0 } Cmdlet = 'Set-CIPPMailboxAccess' Parameters = @{ @@ -221,6 +261,7 @@ function Invoke-CIPPOffboardingJob { } } @{ + Title = 'Grant Send on Behalf access' Condition = { $Options.AccessSendOnBehalf.Count -gt 0 } Cmdlet = 'Set-CIPPMailboxAccess' Parameters = @{ @@ -233,6 +274,7 @@ function Invoke-CIPPOffboardingJob { } } @{ + Title = 'Remove user''s mailbox permissions' Condition = { $Options.removePermissions -eq $true } Cmdlet = 'Remove-CIPPMailboxPermissions' Parameters = @{ @@ -244,6 +286,7 @@ function Invoke-CIPPOffboardingJob { } } @{ + Title = 'Remove user''s calendar permissions' Condition = { $Options.removeCalendarPermissions -eq $true } Cmdlet = 'Remove-CIPPCalendarPermissions' Parameters = @{ @@ -255,6 +298,7 @@ function Invoke-CIPPOffboardingJob { } } @{ + Title = 'Convert to shared mailbox' Condition = { $Options.ConvertToShared -eq $true } Cmdlet = 'Set-CIPPMailboxType' Parameters = @{ @@ -267,6 +311,7 @@ function Invoke-CIPPOffboardingJob { } } @{ + Title = 'Remove all MFA devices' Condition = { $Options.RemoveMFADevices -eq $true } Cmdlet = 'Remove-CIPPUserMFA' Parameters = @{ @@ -277,6 +322,7 @@ function Invoke-CIPPOffboardingJob { } } @{ + Title = 'Remove Teams Phone DID' Condition = { $Options.RemoveTeamsPhoneDID -eq $true } Cmdlet = 'Remove-CIPPUserTeamsPhoneDIDs' Parameters = @{ @@ -288,6 +334,7 @@ function Invoke-CIPPOffboardingJob { } } @{ + Title = 'Remove licenses' Condition = { $Options.RemoveLicenses -eq $true } Cmdlet = 'Remove-CIPPLicense' Parameters = @{ @@ -300,6 +347,7 @@ function Invoke-CIPPOffboardingJob { } } @{ + Title = 'Clear Immutable ID' Condition = { $Options.ClearImmutableId -eq $true } Cmdlet = 'Clear-CIPPImmutableID' Parameters = @{ @@ -312,6 +360,7 @@ function Invoke-CIPPOffboardingJob { } } @{ + Title = 'Delete user' Condition = { $Options.DeleteUser -eq $true } Cmdlet = 'Remove-CIPPUser' Parameters = @{ @@ -326,13 +375,27 @@ function Invoke-CIPPOffboardingJob { # Build batch from selected tasks foreach ($Task in $TaskOrder) { - if (& $Task.Condition) { - $Batch.Add(@{ - FunctionName = 'CIPPOffboardingTask' - Cmdlet = $Task.Cmdlet - Parameters = $Task.Parameters - }) + if (-not (& $Task.Condition)) { + continue } + + if ($DeleteUserSelected -and $Task.Cmdlet -notin $AllowedCmdletsWhenDeletingUser) { + $SkippedForDeleteUser.Add($Task.Cmdlet) + continue + } + + $Batch.Add(@{ + FunctionName = 'CIPPOffboardingTask' + Cmdlet = $Task.Cmdlet + Title = $Task.Title + Parameters = $Task.Parameters + }) + } + + if ($SkippedForDeleteUser.Count -gt 0) { + $SkippedMessage = "Delete user selected for $Username. Skipped tasks: $($SkippedForDeleteUser -join ', ')" + Write-Information $SkippedMessage + Write-LogMessage -API $APIName -tenant $TenantFilter -message $SkippedMessage -sev Info } if ($Batch.Count -eq 0) { @@ -343,6 +406,46 @@ function Invoke-CIPPOffboardingJob { Write-Information "Built batch of $($Batch.Count) offboarding tasks for $Username" + # Live progress: the wizard pre-created a queued row per user under this job id. Replace it with + # the real step list and stamp every task with its step, so the workers (which run in parallel) + # each report to their own step. + if ($DeploymentId) { + if ($StepIndexes.Count -gt 0) { + # Re-running selected steps: keep the row and reset only those steps. + foreach ($Index in $StepIndexes) { + Set-CIPPAsyncDeploymentStep -JobId $DeploymentId -Name $Username -StepIndex $Index -StepStatus 'pending' -Message 'Waiting to start' + } + } else { + # Notification channels configured on the task are steps from the start, so the row does + # not look finished while the deliveries are still being made. + $NotifySteps = @( + foreach ($Channel in @(([string]$TaskInfo.PostExecution -split ',') | ForEach-Object { $_.Trim() } | Where-Object { $_ })) { + @{ Title = "Notify via $Channel"; Kind = 'notify'; Message = 'Sent once every action has finished' } + } + ) + try { + $null = New-CIPPAsyncDeployment -JobId $DeploymentId -Names @($Username) -StepTitles (@($Batch | ForEach-Object { $_.Title }) + $NotifySteps) -Source 'Offboarding' -TaskId $TaskInfo.RowKey -TenantFilter $TenantFilter + } catch { + # Progress is a nice-to-have: a storage hiccup here must not fail the offboarding itself. + Write-LogMessage -API $APIName -tenant $TenantFilter -message "Could not write the progress row for $Username : $($_.Exception.Message)" -sev Warn + } + } + for ($i = 0; $i -lt $Batch.Count; $i++) { + $Batch[$i].DeploymentId = $DeploymentId + $Batch[$i].DeploymentName = $Username + $Batch[$i].StepIndex = $i + } + Set-CIPPAsyncDeploymentStatus -JobId $DeploymentId -Name $Username -Status 'running' + } + + if ($StepIndexes.Count -gt 0) { + # Step re-run: the full list above keeps the indices stable; only the requested steps run. + $Batch = [System.Collections.Generic.List[object]]@($StepIndexes | Where-Object { $_ -ge 0 -and $_ -lt $Batch.Count } | ForEach-Object { $Batch[$_] }) + if ($Batch.Count -eq 0) { + throw "None of the requested steps ($($StepIndexes -join ', ')) exist for $Username" + } + } + # Start orchestration $InputObject = [PSCustomObject]@{ OrchestratorName = "OffboardingUser_$($Username)_$TenantFilter" @@ -359,6 +462,7 @@ function Invoke-CIPPOffboardingJob { TenantFilter = $TenantFilter Username = $Username Headers = $Headers + DeploymentId = $DeploymentId } } @@ -371,6 +475,9 @@ function Invoke-CIPPOffboardingJob { } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API 'Offboarding' -tenant $TenantFilter -message "Failed to start offboarding job for $Username : $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + if ($DeploymentId) { + Set-CIPPAsyncDeploymentStatus -JobId $DeploymentId -Name $Username -Status 'failed' -Logs $ErrorMessage.NormalizedError + } throw $ErrorMessage } } diff --git a/Modules/CIPPCore/Public/Invoke-CIPPSharePointCreateCopyJobs.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPSharePointCreateCopyJobs.ps1 new file mode 100644 index 0000000000000..240aba1fa9cc4 --- /dev/null +++ b/Modules/CIPPCore/Public/Invoke-CIPPSharePointCreateCopyJobs.ps1 @@ -0,0 +1,100 @@ +function Invoke-CIPPSharePointCreateCopyJobs { + <# + .SYNOPSIS + Submits a CreateCopyJobs request on the source SharePoint site. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $true)] + [string]$SourceSiteUrl, + + [Parameter(Mandatory = $true)] + [string[]]$ExportObjectUris, + + [Parameter(Mandatory = $true)] + [string]$DestinationUri, + + [int]$NameConflictBehavior = 1, + [bool]$SameWebCopyMoveOptimization = $false + ) + + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $Scope = "$($SharePointInfo.SharePointUrl)/.default" + + $Body = ConvertTo-Json -InputObject @{ + exportObjectUris = @($ExportObjectUris) + destinationUri = $DestinationUri + options = @{ + IsMoveMode = $false + MoveButKeepSource = $true + IgnoreVersionHistory = $false + AllowSchemaMismatch = $true + AllowSmallerVersionLimitOnDestination = $true + NameConflictBehavior = $NameConflictBehavior + BypassSharedLock = $true + SameWebCopyMoveOptimization = $SameWebCopyMoveOptimization + ExcludeChildren = $false + } + } -Depth 6 -Compress + + $Uri = "$($SourceSiteUrl.TrimEnd('/'))/_api/site/CreateCopyJobs" + $Response = New-GraphPOSTRequest -uri $Uri -tenantid $TenantFilter -scope $Scope -type POST -body $Body ` + -AddedHeaders @{ Accept = 'application/json;odata=verbose' } ` + -contentType 'application/json;odata=verbose' -UseCertificate -AsApp $true + + if ($Response -is [string]) { + $Response = $Response | ConvertFrom-Json + } + + $Jobs = @() + if ($Response.d -and $Response.d.CreateCopyJobs) { + $CreateCopyJobs = $Response.d.CreateCopyJobs + $Jobs = if ($null -ne $CreateCopyJobs.results) { @($CreateCopyJobs.results) } else { @($CreateCopyJobs) } + } elseif ($Response.value) { + $Jobs = @($Response.value) + } elseif ($Response -is [System.Array]) { + $Jobs = @($Response) + } elseif ($Response.d -and ($Response.d.JobId -or $Response.d.jobId)) { + $Jobs = @($Response.d) + } elseif ($Response.JobId -or $Response.jobId) { + $Jobs = @($Response) + } + + if ($Jobs.Count -eq 0) { + throw 'SharePoint CreateCopyJobs returned no job handles.' + } + + # Normalize to the three fields GetCopyJobProgress needs (strip SourceListItemUniqueIds / OData wrappers). + return @($Jobs | ForEach-Object { + $Candidate = $_ + if ($null -ne $_.results) { + $Nested = @($_.results) + if ($Nested.Count -eq 1 -and ($Nested[0].JobId -or $Nested[0].jobId)) { + $Candidate = $Nested[0] + } + } + + $JobId = [string]($Candidate.JobId ?? $Candidate.jobId ?? $Candidate.JobID ?? '') + $JobQueueUri = $Candidate.JobQueueUri ?? $Candidate.jobQueueUri + if ($JobQueueUri -is [PSCustomObject]) { + $JobQueueUri = [string]($JobQueueUri.Url ?? $JobQueueUri.AbsoluteUri ?? $JobQueueUri) + } + $EncryptionKey = $Candidate.EncryptionKey ?? $Candidate.encryptionKey + if ($EncryptionKey -is [PSCustomObject]) { + $EncryptionKey = $EncryptionKey.'#text' ?? $EncryptionKey.Value ?? $EncryptionKey.bytes + } + + if ([string]::IsNullOrWhiteSpace($JobId) -or [string]::IsNullOrWhiteSpace([string]$JobQueueUri)) { + throw 'SharePoint CreateCopyJobs returned a handle without JobId or JobQueueUri.' + } + + [PSCustomObject]@{ + JobId = $JobId + JobQueueUri = [string]$JobQueueUri + EncryptionKey = $EncryptionKey + } + }) +} diff --git a/Modules/CIPPCore/Public/New-CIPPAlertTemplate.ps1 b/Modules/CIPPCore/Public/New-CIPPAlertTemplate.ps1 index 5e2dfee582c63..116d4764adf9e 100644 --- a/Modules/CIPPCore/Public/New-CIPPAlertTemplate.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPAlertTemplate.ps1 @@ -287,7 +287,7 @@ function New-CIPPAlertTemplate { $Table = ($data | ConvertTo-Html -Fragment -As List | Out-String).Replace('', '
') if ($Appname) { $AppName = $AppName.'Application Name' } else { $appName = $data.ApplicationId } $Title = "$($Tenant) - a user has logged on from a location you've set up to receive alerts for." - $IntroText = "$($data.UserId) ($($data.Userkey)) has logged on from IP $($data.ClientIP) to the application $($Appname). According to our database this is located in $($LocationInfo.Country) - $($LocationInfo.City).

You have set up alerts to be notified when this happens. See the table below for more info.$Table" + $IntroText = "$($data.UserId) ($($data.Userkey)) has logged on from IP $($data.ClientIP) to the application $($Appname). According to our database this is located in $($LocationInfo.CountryOrRegion) - $($LocationInfo.City).

You have set up alerts to be notified when this happens. See the table below for more info.$Table" if ($ActionResults) { $IntroText = $IntroText + "

Based on the rule, the following actions have been taken: $($ActionResults -join '
' )

" } if ($LocationInfo) { $LocationTable = ($LocationInfo | ConvertTo-Html -Fragment -As List | Out-String).Replace('
', '
') @@ -325,23 +325,23 @@ function New-CIPPAlertTemplate { # $Data is a single object for audit logs but an array of rows for logbook alerts, # so only resolve when every row agrees - a multi-user alert has no one username. $ResolvedSubject = [regex]::Replace($CustomSubject, '%(\w+)%', { - param($Match) - $PropertyName = switch ($Match.Groups[1].Value) { - 'username' { 'UserId' } - 'tenant' { return $Tenant } - default { $Match.Groups[1].Value } - } - $Values = foreach ($Row in @($Data)) { - if ($null -eq $Row) { continue } - if ($Row -is [System.Collections.IDictionary]) { - $Row[$PropertyName] - } else { - ($Row.PSObject.Properties | Where-Object { $_.Name -ieq $PropertyName } | Select-Object -First 1).Value + param($Match) + $PropertyName = switch ($Match.Groups[1].Value) { + 'username' { 'UserId' } + 'tenant' { return $Tenant } + default { $Match.Groups[1].Value } } - } - $Distinct = @($Values | Where-Object { ![string]::IsNullOrWhiteSpace("$_") } | ForEach-Object { "$_" } | Select-Object -Unique) - if ($Distinct.Count -eq 1) { $Distinct[0] } else { $Match.Value } - }) + $Values = foreach ($Row in @($Data)) { + if ($null -eq $Row) { continue } + if ($Row -is [System.Collections.IDictionary]) { + $Row[$PropertyName] + } else { + ($Row.PSObject.Properties | Where-Object { $_.Name -ieq $PropertyName } | Select-Object -First 1).Value + } + } + $Distinct = @($Values | Where-Object { ![string]::IsNullOrWhiteSpace("$_") } | ForEach-Object { "$_" } | Select-Object -Unique) + if ($Distinct.Count -eq 1) { $Distinct[0] } else { $Match.Value } + }) $Title = '{0} - {1}' -f $Tenant, $ResolvedSubject } diff --git a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 index 74e613aa5f633..5f757c129ec61 100644 --- a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 @@ -45,11 +45,19 @@ function New-CIPPCAPolicy { } } elseif ($CreateGroups) { Write-Warning "Creating group $_ as it does not exist in the tenant" - if ($GroupTemplates.displayName -eq $_) { + # A template store with duplicate display names returns every match here. Passing that + # array to New-CIPPGroup makes the Graph create body's displayName an array, so the + # create fails ("Unexpected token: StartArray. Path 'resourcePayload.displayName'") and + # leaves an empty group id, which Graph then rejects with the opaque + # "1054: Invalid group value: ." on the whole policy. Select a single template. + $MatchingTemplates = @($GroupTemplates | Where-Object -Property displayName -EQ $_) + if ($MatchingTemplates.Count -gt 0) { + if ($MatchingTemplates.Count -gt 1) { + Write-Warning "Multiple group templates found with display name '$_'. Using the first match." + $null = Write-LogMessage -Headers $Headers -API $APIName -message "Multiple group templates found with display name '$_'. Using the first match; remove the duplicate group template." -Sev 'Warning' + } Write-Information "Creating group from template for $_" - $GroupTemplate = $GroupTemplates | Where-Object -Property displayName -EQ $_ - $NewGroup = New-CIPPGroup -GroupObject $GroupTemplate -TenantFilter $TenantFilter -APIName $APIName - $GroupIds.Add($NewGroup.GroupId) + $NewGroup = New-CIPPGroup -GroupObject ($MatchingTemplates | Select-Object -First 1) -TenantFilter $TenantFilter -APIName $APIName } else { Write-Information "No template found, creating security group for $_" $username = $_ -replace '[^a-zA-Z0-9]', '' @@ -63,8 +71,14 @@ function New-CIPPCAPolicy { securityEnabled = $true } $NewGroup = New-CIPPGroup -GroupObject $GroupObject -TenantFilter $TenantFilter -APIName $APIName - $GroupIds.Add($NewGroup.GroupId) } + # Fail closed: never add an empty id. A dropped exclusion silently widens the policy + # (e.g. break-glass accounts no longer excluded), and Graph rejects the empty value + # anyway. Surface why the create failed instead of the opaque 1054 group error. + if (-not $NewGroup.Success -or [string]::IsNullOrWhiteSpace($NewGroup.GroupId)) { + throw "Failed to create group '$_' in tenant $TenantFilter$(if ($NewGroup.Error) { ": $($NewGroup.Error)" }). Resolve the group manually or remove the duplicate group template, then redeploy." + } + $GroupIds.Add($NewGroup.GroupId) } else { Write-Warning "Group $_ not found in the tenant and CreateGroups is disabled" throw "Group '$_' not found in tenant $TenantFilter. Enable 'Create groups if they do not exist' or create the group manually before deploying this policy." @@ -488,8 +502,14 @@ function New-CIPPCAPolicy { } } switch ($ReplacePattern) { - 'none' { + { $_ -in 'none', 'leave' } { + # The deploy drawer sends 'leave'; treat it like 'none'. Write-Information 'Replacement pattern for inclusions and exclusions is none' + # Graph wants ids; a name-based template fails with an opaque 1054, so say why here. + $NamedGroups = @(@($JSONobj.conditions.users.includeGroups) + @($JSONobj.conditions.users.excludeGroups) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) -and -not (Test-IsGuid -String $_) }) + if ($NamedGroups.Count -gt 0) { + throw "Policy '$($JSONobj.displayName)' references groups by name ($($NamedGroups -join ', ')). Deploy it with 'Replace by display name' so the names are resolved to group ids, or store object ids in the template." + } break } 'AllUsers' { diff --git a/Modules/CIPPCore/Public/New-CIPPGDAPRoleMapping.ps1 b/Modules/CIPPCore/Public/New-CIPPGDAPRoleMapping.ps1 new file mode 100644 index 0000000000000..d8548e830f7da --- /dev/null +++ b/Modules/CIPPCore/Public/New-CIPPGDAPRoleMapping.ps1 @@ -0,0 +1,110 @@ +function New-CIPPGDAPRoleMapping { + <# + .SYNOPSIS + Creates or reuses the partner tenant security groups backing a set of GDAP roles + + .DESCRIPTION + For each role a group named 'M365 GDAP ' (optionally suffixed) is reused when it + already exists in the partner tenant, otherwise created. The resulting mappings are upserted + into the GDAPRoles table and returned for template writes. + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Roles, + [string]$CustomSuffix + ) + + $Table = Get-CIPPTable -TableName 'GDAPRoles' + + $Results = [System.Collections.Generic.List[string]]::new() + $Requests = [System.Collections.Generic.List[object]]::new() + $ExistingGroups = New-GraphGetRequest -NoAuthCheck $True -uri 'https://graph.microsoft.com/beta/groups' -tenantid $env:TenantID -AsApp $true + + $ExistingRoleMappings = foreach ($Role in $Roles) { + $RoleName = $Role.label ?? $Role.Name + $Value = $Role.value ?? $Role.ObjectId + + if ($CustomSuffix) { + $GroupName = "M365 GDAP $($RoleName) - $CustomSuffix" + $MailNickname = "M365GDAP$(($RoleName).replace(' ',''))$($CustomSuffix.replace(' ',''))" + } else { + $GroupName = "M365 GDAP $($RoleName)" + $MailNickname = "M365GDAP$(($RoleName).replace(' ',''))" + } + + if ($GroupName -in $ExistingGroups.displayName) { + @{ + PartitionKey = 'Roles' + RowKey = ($ExistingGroups | Where-Object -Property displayName -EQ $GroupName | Select-Object -First 1).id + RoleName = $RoleName + GroupName = $GroupName + GroupId = ($ExistingGroups | Where-Object -Property displayName -EQ $GroupName | Select-Object -First 1).id + roleDefinitionId = $Value + } + $Results.Add("$GroupName already exists") + } else { + $Requests.Add(@{ + id = $Value + url = '/groups' + method = 'POST' + headers = @{ + 'Content-Type' = 'application/json' + } + body = @{ + displayName = $GroupName + description = "This group is used to manage M365 partner tenants at the $($RoleName) level." + securityEnabled = $true + mailEnabled = $false + mailNickname = $MailNickname + } + }) + } + } + + if ($ExistingRoleMappings) { + Add-CIPPAzDataTableEntity @Table -Entity $ExistingRoleMappings -Force + } + + if ($Requests) { + $ReturnedData = New-GraphBulkRequest -Requests $Requests -tenantid $env:TenantID -NoAuthCheck $True -asapp $true + $NewRoleMappings = foreach ($Return in $ReturnedData) { + if ($Return.body.error) { + $Results.Add("Could not create GDAP group: $($Return.body.error.message)") + } else { + $GroupName = $Return.body.displayName + @{ + PartitionKey = 'Roles' + RowKey = $Return.body.id + RoleName = $Return.body.displayName -replace '^M365 GDAP ', '' -replace " - $CustomSuffix$", '' + GroupName = $Return.body.displayName + GroupId = $Return.body.id + roleDefinitionId = $Return.id + } + $Results.Add("Created $($GroupName)") + } + } + Write-Information ($NewRoleMappings | ConvertTo-Json -Depth 10 -Compress) + if ($NewRoleMappings) { + Add-CIPPAzDataTableEntity @Table -Entity $NewRoleMappings -Force + } + } + + $RoleMappings = [System.Collections.Generic.List[object]]::new() + foreach ($Mapping in @($ExistingRoleMappings) + @($NewRoleMappings)) { + if (!$Mapping) { continue } + $RoleMappings.Add([PSCustomObject]@{ + RoleName = $Mapping.RoleName + GroupName = $Mapping.GroupName + GroupId = $Mapping.GroupId + roleDefinitionId = $Mapping.roleDefinitionId + }) + } + + return [PSCustomObject]@{ + RoleMappings = $RoleMappings + Results = $Results + } +} diff --git a/Modules/CIPPCore/Public/New-CIPPIntuneAppDeployment.ps1 b/Modules/CIPPCore/Public/New-CIPPIntuneAppDeployment.ps1 index b9accba7a50e3..0ce44860f248d 100644 --- a/Modules/CIPPCore/Public/New-CIPPIntuneAppDeployment.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPIntuneAppDeployment.ps1 @@ -25,6 +25,11 @@ function New-CIPPIntuneAppDeployment { $ExcludeGroup = $AppConfig.excludeGroup $AppType = if ($AppConfig.type) { $AppConfig.type } else { 'Choco' } + # Older templates may hold a Graph-read body (has an id); only Office/Edge can deploy from one. + if ($IntuneBody.id -and $AppType -notin @('OfficeApp', 'EdgeApp')) { + throw "'$($AppConfig.Applicationname)' was templated from an existing Intune application with uploaded installer content. CIPP cannot deploy uploaded installer content; only script or package based applications can be templated. Rebuild this template entry as a Store, Chocolatey, Office, Edge, MSP or Custom Application." + } + # Build IntuneBody from raw config if not pre-built (template/standard path) if (-not $IntuneBody -and $AppType -eq 'WinGet') { $PackageId = $AppConfig.packagename ?? $AppConfig.PackageName @@ -116,13 +121,12 @@ function New-CIPPIntuneAppDeployment { $BaseUri = 'https://graph.microsoft.com/beta/deviceAppManagement/mobileApps' - # Check if app already exists (any type with matching display name). Office is a singleton per - # tenant and Graph names it 'Microsoft 365 Apps for Windows 10 and later' regardless of what the - # template calls it, so match that one on type instead or it is redeployed on every run. - $ApplicationList = if ($AppType -eq 'OfficeApp') { - New-GraphGetRequest -Uri $BaseUri -tenantid $TenantFilter | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.officeSuiteApp' } - } else { - New-GraphGetRequest -Uri $BaseUri -tenantid $TenantFilter | Where-Object { $_.DisplayName -eq $AppConfig.Applicationname } + # Check if app already exists (any type with matching display name). Office and Edge are + # singletons per tenant whose Graph display name may differ from the template, so match on type. + $ApplicationList = switch ($AppType) { + 'OfficeApp' { New-GraphGetRequest -Uri $BaseUri -tenantid $TenantFilter | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.officeSuiteApp' } } + 'EdgeApp' { New-GraphGetRequest -Uri $BaseUri -tenantid $TenantFilter | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.windowsMicrosoftEdgeApp' } } + default { New-GraphGetRequest -Uri $BaseUri -tenantid $TenantFilter | Where-Object { $_.DisplayName -eq $AppConfig.Applicationname } } } if ($ApplicationList.displayname.count -ge 1) { Write-LogMessage -API $APIName -tenant $TenantFilter -message "$($AppConfig.Applicationname) exists. Skipping this application" -Sev 'Info' @@ -209,6 +213,13 @@ function New-CIPPIntuneAppDeployment { } $NewApp = New-GraphPostRequest -Uri $BaseUri -tenantid $TenantFilter -Body (ConvertTo-Json -InputObject $ObjBody -Depth 10) -Type POST } + 'EdgeApp' { + $ObjBody = Get-CIPPEdgeAppBody -Config $AppConfig + if (-not $ObjBody) { + throw "No Edge configuration could be built from the supplied settings for '$($AppConfig.Applicationname)'." + } + $NewApp = New-GraphPostRequest -Uri $BaseUri -tenantid $TenantFilter -Body (ConvertTo-Json -InputObject $ObjBody -Depth 10) -Type POST + } default { throw "Unsupported app type: $AppType" } @@ -224,6 +235,7 @@ function New-CIPPIntuneAppDeployment { 'WinGet' { 'WinGet' } 'WinGetNew' { 'WinGet' } 'OfficeApp' { $null } + 'EdgeApp' { $null } default { 'Win32Lob' } } Start-Sleep -Milliseconds 200 diff --git a/Modules/CIPPCore/Public/New-CIPPIntuneTemplate.ps1 b/Modules/CIPPCore/Public/New-CIPPIntuneTemplate.ps1 index db4dc19e6e7aa..c38d5d39f0540 100644 --- a/Modules/CIPPCore/Public/New-CIPPIntuneTemplate.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPIntuneTemplate.ps1 @@ -61,6 +61,29 @@ function New-CIPPIntuneTemplate { 'mobileAppConfigurations' { $Type = 'AppConfiguration' $Template = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceAppManagement/$($urlname)('$($ID)')" -tenantid $TenantFilter + # targetedMobileApps are mobileApp ids, which only mean something in the tenant the + # policy was captured from - deploying them elsewhere fails with an unknown app. Record + # each app's identity (bundle / package id, name, type) so deployment can find the same + # app in the target tenant. See Resolve-CIPPIntuneTargetedMobileApps. + $TargetedAppDetails = foreach ($AppId in @($Template.targetedMobileApps | Where-Object { $_ })) { + try { + $App = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId" -tenantid $TenantFilter + [PSCustomObject]@{ + id = $App.id + displayName = $App.displayName + '@odata.type' = $App.'@odata.type' + bundleId = $App.bundleId + packageId = $App.packageId + packageIdentifier = $App.packageIdentifier + appStoreUrl = $App.appStoreUrl + } + } catch { + Write-Warning "Could not read targeted app $AppId for app configuration '$($Template.displayName)': $($_.Exception.Message)" + } + } + if ($TargetedAppDetails) { + $Template | Add-Member -NotePropertyName 'targetedMobileAppsDetails' -NotePropertyValue @($TargetedAppDetails) -Force + } $DisplayName = $Template.displayName $TemplateJson = ConvertTo-Json -InputObject $Template -Depth 100 -Compress } diff --git a/Modules/CIPPCore/Public/New-CIPPSharePointLibrary.ps1 b/Modules/CIPPCore/Public/New-CIPPSharePointLibrary.ps1 index f58c9520102a6..98911e5e6d0b2 100644 --- a/Modules/CIPPCore/Public/New-CIPPSharePointLibrary.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPSharePointLibrary.ps1 @@ -37,10 +37,10 @@ function New-CIPPSharePointLibrary { $Headers ) - $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter - $Scope = "$($SharePointInfo.SharePointUrl)/.default" - $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } - $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + $RestContext = Resolve-CIPPSharePointRestContext -TenantFilter $TenantFilter -SiteUrl $SiteUrl + $Scope = $RestContext.Scope + $JsonAccept = $RestContext.Headers + $BaseUri = $RestContext.BaseUri # Idempotency: return the existing library when one with this title is already present. $EscapedTitle = $LibraryName -replace "'", "''" diff --git a/Modules/CIPPCore/Public/New-CIPPTemplateRun.ps1 b/Modules/CIPPCore/Public/New-CIPPTemplateRun.ps1 index dc7ab7c8899c1..08fcdec56df1e 100644 --- a/Modules/CIPPCore/Public/New-CIPPTemplateRun.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPTemplateRun.ps1 @@ -5,14 +5,28 @@ function New-CIPPTemplateRun { $TenantFilter ) $Table = Get-CippTable -tablename 'templates' + + # Templates created from a tenant are keyed by that tenant in their Source column and matched + # on it to update-in-place. The tenant can be addressed by any of its names (default domain, + # initial onmicrosoft domain, tenant id), and a second sync task created under a different name + # used to see none of the existing templates and create a full duplicate set on every run. + # Match on every name the tenant is known by and write the canonical one. + $TenantInfo = Get-Tenants -IncludeErrors -TenantFilter $TenantFilter | Select-Object -First 1 + $SourceAliases = @(@($TenantFilter, $TenantInfo.defaultDomainName, $TenantInfo.initialDomainName, $TenantInfo.customerId) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique) + $SourceName = if ($TenantInfo.defaultDomainName) { [string]$TenantInfo.defaultDomainName } else { [string]$TenantFilter } + $ExistingTemplates = (Get-CIPPAzDataTableEntity @Table) | ForEach-Object { try { $data = $_.JSON | ConvertFrom-Json -ErrorAction SilentlyContinue -Depth 100 - $data | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $_.RowKey -Force -ErrorAction Stop - $data | Add-Member -NotePropertyName 'PartitionKey' -NotePropertyValue $_.PartitionKey -Force -ErrorAction Stop - $data | Add-Member -NotePropertyName 'SHA' -NotePropertyValue $_.SHA -Force -ErrorAction SilentlyContinue - $data | Add-Member -NotePropertyName 'Package' -NotePropertyValue $_.Package -Force -ErrorAction SilentlyContinue - $data | Add-Member -NotePropertyName 'Source' -NotePropertyValue $_.Source -Force -ErrorAction SilentlyContinue + $data | Add-Member -NotePropertyMembers ([ordered]@{ + GUID = $_.RowKey + PartitionKey = $_.PartitionKey + }) -Force -ErrorAction Stop + $data | Add-Member -NotePropertyMembers ([ordered]@{ + SHA = $_.SHA + Package = $_.Package + Source = $_.Source + }) -Force -ErrorAction SilentlyContinue $data } catch { return @@ -117,7 +131,7 @@ function New-CIPPTemplateRun { foreach ($policy in $policies) { try { $Hash = Get-StringHash -String ($policy | ConvertTo-Json -Depth 100 -Compress) - $ExistingPolicy = $ExistingTemplates | Where-Object { $_.PartitionKey -eq 'CATemplate' -and $_.displayName -eq $policy.displayName -and $_.Source -eq $TenantFilter } | Select-Object -First 1 + $ExistingPolicy = $ExistingTemplates | Where-Object { $_.PartitionKey -eq 'CATemplate' -and $_.displayName -eq $policy.displayName -and $_.Source -in $SourceAliases } | Select-Object -First 1 if ($ExistingPolicy -and $ExistingPolicy.SHA -eq $Hash) { "CA Policy $($policy.displayName) found, SHA matches, skipping template creation" continue @@ -127,10 +141,12 @@ function New-CIPPTemplateRun { if ($ExistingPolicy -and $ExistingPolicy.PartitionKey -eq 'CATemplate') { "CA Policy $($policy.displayName) found, updating template" + # Full replace: carry Package across like the Intune branches do. Add-CIPPAzDataTableEntity @Table -Entity @{ JSON = "$Template" RowKey = $ExistingPolicy.GUID PartitionKey = 'CATemplate' + Package = $ExistingPolicy.Package GUID = $ExistingPolicy.GUID SHA = $Hash Source = $ExistingPolicy.Source @@ -144,7 +160,7 @@ function New-CIPPTemplateRun { PartitionKey = 'CATemplate' GUID = "$GUID" SHA = $Hash - Source = $TenantFilter + Source = $SourceName } } @@ -188,7 +204,7 @@ function New-CIPPTemplateRun { $Hash = Get-StringHash -String ($Policy | ConvertTo-Json -Depth 100 -Compress) $DisplayName = $Policy.displayName ?? $Policy.name - $ExistingPolicy = $ExistingTemplates | Where-Object { $_.PartitionKey -eq 'IntuneTemplate' -and $_.displayName -eq $DisplayName -and $_.Source -eq $TenantFilter } | Select-Object -First 1 + $ExistingPolicy = $ExistingTemplates | Where-Object { $_.PartitionKey -eq 'IntuneTemplate' -and $_.displayName -eq $DisplayName -and $_.Source -in $SourceAliases } | Select-Object -First 1 Write-Information "Processing Intune Configuration Policy $($DisplayName) - $($ExistingPolicy ? 'Existing template found' : 'No existing template found')" @@ -234,7 +250,7 @@ function New-CIPPTemplateRun { PartitionKey = 'IntuneTemplate' GUID = "$GUID" SHA = $Hash - Source = $TenantFilter + Source = $SourceName } -Force } } catch { @@ -253,7 +269,7 @@ function New-CIPPTemplateRun { foreach ($Policy in $Policies) { try { $Hash = Get-StringHash -String (ConvertTo-Json -Depth 100 -Compress -InputObject $Policy) - $ExistingPolicy = $ExistingTemplates | Where-Object { $_.PartitionKey -eq 'IntuneTemplate' -and $Policy.displayName -eq $_.DisplayName -and $_.Source -eq $TenantFilter } | Select-Object -First 1 + $ExistingPolicy = $ExistingTemplates | Where-Object { $_.PartitionKey -eq 'IntuneTemplate' -and $Policy.displayName -eq $_.DisplayName -and $_.Source -in $SourceAliases } | Select-Object -First 1 if ($ExistingPolicy -and $ExistingPolicy.SHA -eq $Hash) { "Intune Compliance Policy $($Policy.displayName) found, SHA matches, skipping template creation" continue @@ -296,7 +312,7 @@ function New-CIPPTemplateRun { PartitionKey = 'IntuneTemplate' SHA = $Hash GUID = "$GUID" - Source = $TenantFilter + Source = $SourceName } -Force } } catch { @@ -312,7 +328,7 @@ function New-CIPPTemplateRun { foreach ($Policy in $Policies) { try { $Hash = Get-StringHash -String (ConvertTo-Json -Depth 100 -Compress -InputObject $Policy) - $ExistingPolicy = $ExistingTemplates | Where-Object { $_.PartitionKey -eq 'IntuneTemplate' -and $Policy.displayName -eq $_.DisplayName -and $_.Source -eq $TenantFilter } | Select-Object -First 1 + $ExistingPolicy = $ExistingTemplates | Where-Object { $_.PartitionKey -eq 'IntuneTemplate' -and $Policy.displayName -eq $_.DisplayName -and $_.Source -in $SourceAliases } | Select-Object -First 1 if ($ExistingPolicy -and $ExistingPolicy.SHA -eq $Hash) { "Intune Protection Policy $($Policy.displayName) found, SHA matches, skipping template creation" continue @@ -355,7 +371,7 @@ function New-CIPPTemplateRun { PartitionKey = 'IntuneTemplate' SHA = $Hash GUID = "$GUID" - Source = $TenantFilter + Source = $SourceName } -Force } } catch { diff --git a/Modules/CIPPCore/Public/New-CIPPUserTask.ps1 b/Modules/CIPPCore/Public/New-CIPPUserTask.ps1 index a793bb717dde4..b47c28f8111b6 100644 --- a/Modules/CIPPCore/Public/New-CIPPUserTask.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPUserTask.ps1 @@ -164,13 +164,23 @@ function New-CIPPUserTask { # task per level. They are separate Exchange operations anyway. $MailboxPermissions = @(@($UserObj.sharedMailboxPermission) | ForEach-Object { if ($_.value) { $_.value } else { $_ } } | Where-Object { $_ }) if (-not $MailboxPermissions) { $MailboxPermissions = @('FullAccess') } + # FullAccessNoAutoMap is Full Access granted with automapping off. A grant carries one + # automapping flag, so when both variants are selected the explicit no-automapping wins. + if ($MailboxPermissions -contains 'FullAccessNoAutoMap') { + $MailboxPermissions = @($MailboxPermissions | Where-Object { $_ -ne 'FullAccess' }) + } foreach ($Mailbox in @($UserObj.sharedMailboxes)) { $MailboxId = if ($Mailbox.value) { $Mailbox.value } else { $Mailbox } $MailboxLabel = if ($Mailbox.label) { $Mailbox.label } else { $MailboxId } foreach ($MailboxPermission in $MailboxPermissions) { + $PermissionLevel = if ($MailboxPermission -eq 'FullAccessNoAutoMap') { 'FullAccess' } else { $MailboxPermission } + $AutoMap = $MailboxPermission -ne 'FullAccessNoAutoMap' # AutoMap only applies to FullAccess, and is what makes Outlook mount the mailbox # on its own, so no invitation is needed on this side of the feature. - $AutoMapNote = if ($MailboxPermission -eq 'FullAccess') { ' Outlook adds the mailbox automatically.' } else { '' } + $AutoMapNote = if ($PermissionLevel -ne 'FullAccess') { '' } + elseif ($AutoMap) { ' Outlook adds the mailbox automatically.' } + else { ' Automapping is off, so the user adds the mailbox to Outlook themselves.' } + $PermissionDisplay = if ($AutoMap) { $PermissionLevel } else { 'FullAccess (no automapping)' } $SharedAccessGrants.Add([PSCustomObject]@{ Identity = $MailboxId Kind = 'mailbox' @@ -181,12 +191,12 @@ function New-CIPPUserTask { TenantFilter = $UserObj.tenantFilter UserId = $MailboxId AccessUser = $CreationResults.Username - PermissionLevel = $MailboxPermission + PermissionLevel = $PermissionLevel Action = 'Add' - AutoMap = $true + AutoMap = $AutoMap APIName = 'Shared Mailbox Onboarding' } - Success = "Scheduled $MailboxPermission on the shared mailbox $MailboxLabel in 15 minutes.$AutoMapNote" + Success = "Scheduled $PermissionDisplay on the shared mailbox $MailboxLabel in 15 minutes.$AutoMapNote" }) } } @@ -230,6 +240,16 @@ function New-CIPPUserTask { $Results.Add($SponsorResults.Result) } + try { + if ($UserObj.perUserMfa -eq $true) { + $MfaResult = Set-CIPPPerUserMFA -TenantFilter $UserObj.tenantFilter -userId $CreationResults.Username -State 'enforced' -Headers $Headers -APIName $APIName + $Results.Add($MfaResult) + } + } catch { + Write-LogMessage -headers $Headers -API $APIName -tenant $($UserObj.tenantFilter) -message "Failed to set per-user MFA. Error:$($_.Exception.Message)" -Sev 'Error' + $Results.Add("Failed to set per-user MFA: $($_.Exception.Message)") + } + return @{ Results = $Results Username = $CreationResults.Username diff --git a/Modules/CIPPCore/Public/New-CippStandardsDriftClone.ps1 b/Modules/CIPPCore/Public/New-CippStandardsDriftClone.ps1 index f66d3af836e4a..9854c36ffb699 100644 --- a/Modules/CIPPCore/Public/New-CippStandardsDriftClone.ps1 +++ b/Modules/CIPPCore/Public/New-CippStandardsDriftClone.ps1 @@ -2,8 +2,7 @@ function New-CippStandardsDriftClone { [CmdletBinding()] param ( [Parameter(Mandatory)][string]$TemplateId, - [Parameter(Mandatory)][switch]$UpgradeToDrift, - $Headers + [Parameter(Mandatory)][switch]$UpgradeToDrift ) $Table = Get-CippTable -tablename 'templates' @@ -32,8 +31,8 @@ function New-CippStandardsDriftClone { $Entity.JSON = "$(ConvertTo-Json -InputObject $data -Compress -Depth 100)" $Entity.RowKey = "$($data.GUID)" $Entity.GUID = $data.GUID - $update = Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force - return 'Clone Completed successfully' + $null = Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force + return "Created drift template '$($data.templateName)' ($($data.GUID)) from $TemplateId" } catch { return "Failed to Clone template to Drift Template: $_" } diff --git a/Modules/CIPPCore/Public/PIM/Compare-CIPPPIMRoleSettings.ps1 b/Modules/CIPPCore/Public/PIM/Compare-CIPPPIMRoleSettings.ps1 new file mode 100644 index 0000000000000..2b062bca635e4 --- /dev/null +++ b/Modules/CIPPCore/Public/PIM/Compare-CIPPPIMRoleSettings.ps1 @@ -0,0 +1,152 @@ +function Compare-CIPPPIMRoleSettings { + <# + .SYNOPSIS + Lists the differences between desired PIM policy rules and a role's current rules. + + .DESCRIPTION + Property-level comparison per managed rule so the PIMRoleSettings standard can report + drift precisely and remediate only the rules that differ. Durations compare as timespans + (PT480M equals PT8H); enabled-rule and recipient lists compare as sets. + + .PARAMETER DesiredRules + Output of ConvertTo-CIPPPIMPolicyRules. + + .PARAMETER CurrentRules + The role's current rules. + + .PARAMETER RoleName + Display name used in the output rows. + + .OUTPUTS + PSCustomObject rows: Role, Rule, Property, Expected, Current. Empty when compliant. + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [array]$DesiredRules, + + [Parameter(Mandatory = $true)] + [AllowNull()] + [AllowEmptyCollection()] + $CurrentRules, + + [string]$RoleName = '' + ) + + $ById = @{} + foreach ($Rule in @($CurrentRules)) { + if ($Rule.id) { $ById[$Rule.id] = $Rule } + } + + $Differences = [System.Collections.Generic.List[object]]::new() + $RoleLabel = $RoleName + + function Add-Difference { + param([string]$Rule, [string]$Property, $Expected, $Current) + $Differences.Add([PSCustomObject]@{ + Role = $RoleLabel + Rule = $Rule + Property = $Property + Expected = $Expected + Current = $Current + }) + } + + function Test-SameDuration { + param([string]$Expected, [string]$Current) + if ([string]::IsNullOrWhiteSpace($Expected) -or [string]::IsNullOrWhiteSpace($Current)) { return ($Expected -eq $Current) } + try { + return ([System.Xml.XmlConvert]::ToTimeSpan($Expected) -eq [System.Xml.XmlConvert]::ToTimeSpan($Current)) + } catch { + return ($Expected -eq $Current) + } + } + + function Test-SameSet { + param($Expected, $Current) + $ExpectedSet = @($Expected | ForEach-Object { "$_".ToLowerInvariant() } | Where-Object { $_ } | Sort-Object -Unique) + $CurrentSet = @($Current | ForEach-Object { "$_".ToLowerInvariant() } | Where-Object { $_ } | Sort-Object -Unique) + if ($ExpectedSet.Count -ne $CurrentSet.Count) { return $false } + for ($i = 0; $i -lt $ExpectedSet.Count; $i++) { + if ($ExpectedSet[$i] -ne $CurrentSet[$i]) { return $false } + } + return $true + } + + function Get-ApproverKeys { + param($Setting) + $Keys = @() + if ($Setting -and $Setting.approvalStages) { + foreach ($Stage in @($Setting.approvalStages)) { + foreach ($Approver in @($Stage.primaryApprovers)) { + # Property access works for both the hashtables we build and Graph's objects. + $Keys += ($Approver.groupId ?? $Approver.userId) + } + } + } + return @($Keys | Where-Object { $_ }) + } + + foreach ($Desired in $DesiredRules) { + $Id = $Desired['id'] + $Current = $ById[$Id] + if (-not $Current) { + Add-Difference -Rule $Id -Property 'rule' -Expected 'present' -Current 'missing' + continue + } + + switch -Wildcard ($Desired['@odata.type']) { + '*ExpirationRule' { + if ([bool]$Current.isExpirationRequired -ne [bool]$Desired['isExpirationRequired']) { + Add-Difference -Rule $Id -Property 'isExpirationRequired' -Expected $Desired['isExpirationRequired'] -Current $Current.isExpirationRequired + } + if ($Desired['isExpirationRequired'] -and -not (Test-SameDuration -Expected $Desired['maximumDuration'] -Current $Current.maximumDuration)) { + Add-Difference -Rule $Id -Property 'maximumDuration' -Expected $Desired['maximumDuration'] -Current $Current.maximumDuration + } + } + '*EnablementRule' { + if (-not (Test-SameSet -Expected $Desired['enabledRules'] -Current $Current.enabledRules)) { + Add-Difference -Rule $Id -Property 'enabledRules' -Expected (@($Desired['enabledRules']) -join ', ') -Current (@($Current.enabledRules) -join ', ') + } + } + '*AuthenticationContextRule' { + if ([bool]$Current.isEnabled -ne [bool]$Desired['isEnabled']) { + Add-Difference -Rule $Id -Property 'isEnabled' -Expected $Desired['isEnabled'] -Current $Current.isEnabled + } + if ($Desired['isEnabled'] -and "$($Current.claimValue)" -ne "$($Desired['claimValue'])") { + Add-Difference -Rule $Id -Property 'claimValue' -Expected $Desired['claimValue'] -Current $Current.claimValue + } + } + '*ApprovalRule' { + $ExpectedRequired = [bool]$Desired['setting']['isApprovalRequired'] + $CurrentRequired = [bool]($Current.setting -and $Current.setting.isApprovalRequired -eq $true) + if ($ExpectedRequired -ne $CurrentRequired) { + Add-Difference -Rule $Id -Property 'setting.isApprovalRequired' -Expected $ExpectedRequired -Current $CurrentRequired + } elseif ($ExpectedRequired) { + $ExpectedApprovers = Get-ApproverKeys -Setting $Desired['setting'] + $CurrentApprovers = Get-ApproverKeys -Setting $Current.setting + if (-not (Test-SameSet -Expected $ExpectedApprovers -Current $CurrentApprovers)) { + Add-Difference -Rule $Id -Property 'setting.approvalStages.primaryApprovers' -Expected ($ExpectedApprovers -join ', ') -Current ($CurrentApprovers -join ', ') + } + } + } + '*NotificationRule' { + if ("$($Current.notificationLevel)" -ne "$($Desired['notificationLevel'])") { + Add-Difference -Rule $Id -Property 'notificationLevel' -Expected $Desired['notificationLevel'] -Current $Current.notificationLevel + } + if ([bool]$Current.isDefaultRecipientsEnabled -ne [bool]$Desired['isDefaultRecipientsEnabled']) { + Add-Difference -Rule $Id -Property 'isDefaultRecipientsEnabled' -Expected $Desired['isDefaultRecipientsEnabled'] -Current $Current.isDefaultRecipientsEnabled + } + if (-not (Test-SameSet -Expected $Desired['notificationRecipients'] -Current $Current.notificationRecipients)) { + Add-Difference -Rule $Id -Property 'notificationRecipients' -Expected (@($Desired['notificationRecipients']) -join ', ') -Current (@($Current.notificationRecipients) -join ', ') + } + } + } + } + + return @($Differences) +} diff --git a/Modules/CIPPCore/Public/PIM/ConvertFrom-CIPPPIMPolicyRules.ps1 b/Modules/CIPPCore/Public/PIM/ConvertFrom-CIPPPIMPolicyRules.ps1 new file mode 100644 index 0000000000000..054711b26e782 --- /dev/null +++ b/Modules/CIPPCore/Public/PIM/ConvertFrom-CIPPPIMPolicyRules.ps1 @@ -0,0 +1,87 @@ +function ConvertFrom-CIPPPIMPolicyRules { + <# + .SYNOPSIS + Reads a tenant's PIM role management policy rules into the canonical settings shape. + + .DESCRIPTION + Inverse of ConvertTo-CIPPPIMPolicyRules. Lets a live policy be graded against the secure + floor (Test-CIPPPIMRoleSettingsFloor) and summarised (Get-CIPPPIMPolicySummary) with the + same code that handles templates. + + A $null duration means the rule does not require expiration, i.e. permanent + assignments/eligibilities are allowed by that policy. + + .PARAMETER Rules + The policy's rules (unifiedRoleManagementPolicyRule collection), from + policies/roleManagementPolicies/{id}/rules or the RoleManagementPolicies cache. + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [AllowEmptyCollection()] + $Rules + ) + + $ById = @{} + foreach ($Rule in @($Rules)) { + if ($Rule.id) { $ById[$Rule.id] = $Rule } + } + + function Get-ExpirationDuration { + param([string]$RuleId) + $Rule = $ById[$RuleId] + if (-not $Rule) { return $null } + if ($Rule.isExpirationRequired -ne $true) { return $null } + return $Rule.maximumDuration + } + + function Get-EnabledRules { + param([string]$RuleId) + $Rule = $ById[$RuleId] + if (-not $Rule) { return @() } + return @($Rule.enabledRules) + } + + $ActivationEnablement = Get-EnabledRules 'Enablement_EndUser_Assignment' + $AuthContext = $ById['AuthenticationContext_EndUser_Assignment'] + $Approval = $ById['Approval_EndUser_Assignment'] + $AdminEnablement = Get-EnabledRules 'Enablement_Admin_Assignment' + $Notification = $ById['Notification_Admin_EndUser_Assignment'] + + $ActivationRequires = if ($AuthContext -and $AuthContext.isEnabled -eq $true) { + 'AuthenticationContext' + } elseif ($ActivationEnablement -contains 'MultiFactorAuthentication') { + 'MFA' + } else { + 'None' + } + + $Approvers = @() + if ($Approval -and $Approval.setting -and $Approval.setting.approvalStages) { + foreach ($Stage in @($Approval.setting.approvalStages)) { + foreach ($Approver in @($Stage.primaryApprovers)) { + $Approvers += ($Approver.description ?? $Approver.groupId ?? $Approver.userId) + } + } + } + + [PSCustomObject]@{ + activationMaxDuration = Get-ExpirationDuration 'Expiration_EndUser_Assignment' + activationRequires = $ActivationRequires + authenticationContextClaimValue = if ($AuthContext) { $AuthContext.claimValue } else { '' } + activationRequiresJustification = ($ActivationEnablement -contains 'Justification') + activationRequiresTicket = ($ActivationEnablement -contains 'Ticketing') + activationRequiresApproval = [bool]($Approval -and $Approval.setting -and $Approval.setting.isApprovalRequired -eq $true) + approvers = ($Approvers | Where-Object { $_ }) -join ', ' + eligibilityMaxDuration = Get-ExpirationDuration 'Expiration_Admin_Eligibility' + activeAssignmentMaxDuration = Get-ExpirationDuration 'Expiration_Admin_Assignment' + activeAssignmentRequiresMfa = ($AdminEnablement -contains 'MultiFactorAuthentication') + activeAssignmentRequiresJustification = ($AdminEnablement -contains 'Justification') + notificationRecipients = if ($Notification) { @($Notification.notificationRecipients) -join ', ' } else { '' } + notificationLevel = if ($Notification) { $Notification.notificationLevel } else { 'All' } + } +} diff --git a/Modules/CIPPCore/Public/PIM/ConvertTo-CIPPPIMPolicyRules.ps1 b/Modules/CIPPCore/Public/PIM/ConvertTo-CIPPPIMPolicyRules.ps1 new file mode 100644 index 0000000000000..ed1c5d827a025 --- /dev/null +++ b/Modules/CIPPCore/Public/PIM/ConvertTo-CIPPPIMPolicyRules.ps1 @@ -0,0 +1,160 @@ +function ConvertTo-CIPPPIMPolicyRules { + <# + .SYNOPSIS + Turns canonical PIM role settings into the policy rule objects Graph expects. + + .DESCRIPTION + Produces only the rules CIPP manages, each carrying '@odata.type', 'id' and the 'target' + copied from the tenant's current rule (Graph requires the target on PATCH). Rules CIPP does + not manage (requestor/approver notifications, ticketing for admin assignment, ...) are left + untouched in the tenant. + + Callers validate the settings against the floor BEFORE calling this; it does not re-check. + + .PARAMETER Settings + Canonical settings (ConvertTo-CIPPPIMRoleSettings). + + .PARAMETER CurrentRules + The role's current rules, used for the 'target' and to keep unmanaged approval-stage + details when approval is switched off. + + .PARAMETER ResolvedApprovers + Approver objects already resolved in the tenant: + @{ '@odata.type' = '#microsoft.graph.groupMembers'; groupId = '...'; description = 'Name' } or + @{ '@odata.type' = '#microsoft.graph.singleUser'; userId = '...'; description = 'upn' }. + Required when activationRequiresApproval is true. + + .OUTPUTS + Ordered hashtables, one per managed rule. + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + $Settings, + + [Parameter(Mandatory = $true)] + [AllowNull()] + [AllowEmptyCollection()] + $CurrentRules, + + [array]$ResolvedApprovers = @() + ) + + $ById = @{} + foreach ($Rule in @($CurrentRules)) { + if ($Rule.id) { $ById[$Rule.id] = $Rule } + } + + function Get-Target { + param([string]$RuleId) + $Rule = $ById[$RuleId] + if ($Rule -and $Rule.target) { return $Rule.target } + return $null + } + + function ConvertTo-Rule { + param([string]$Id, [string]$Type, [System.Collections.IDictionary]$Properties) + $Rule = [ordered]@{ + '@odata.type' = $Type + id = $Id + } + $Target = Get-Target $Id + if ($Target) { $Rule.target = $Target } + foreach ($Key in $Properties.Keys) { $Rule[$Key] = $Properties[$Key] } + return $Rule + } + + $ExpirationType = '#microsoft.graph.unifiedRoleManagementPolicyExpirationRule' + $EnablementType = '#microsoft.graph.unifiedRoleManagementPolicyEnablementRule' + $AuthContextType = '#microsoft.graph.unifiedRoleManagementPolicyAuthenticationContextRule' + $ApprovalType = '#microsoft.graph.unifiedRoleManagementPolicyApprovalRule' + $NotificationType = '#microsoft.graph.unifiedRoleManagementPolicyNotificationRule' + + $Desired = [System.Collections.Generic.List[object]]::new() + + # Activation (end user) + $Desired.Add((ConvertTo-Rule -Id 'Expiration_EndUser_Assignment' -Type $ExpirationType -Properties ([ordered]@{ + isExpirationRequired = $true + maximumDuration = $Settings.activationMaxDuration + }))) + + $ActivationEnabled = [System.Collections.Generic.List[string]]::new() + if ("$($Settings.activationRequires)" -eq 'MFA') { $ActivationEnabled.Add('MultiFactorAuthentication') } + if ($Settings.activationRequiresJustification) { $ActivationEnabled.Add('Justification') } + if ($Settings.activationRequiresTicket) { $ActivationEnabled.Add('Ticketing') } + $Desired.Add((ConvertTo-Rule -Id 'Enablement_EndUser_Assignment' -Type $EnablementType -Properties ([ordered]@{ + enabledRules = @($ActivationEnabled) + }))) + + $UseAuthContext = ("$($Settings.activationRequires)" -eq 'AuthenticationContext') + $AuthContextProps = [ordered]@{ isEnabled = $UseAuthContext } + if ($UseAuthContext) { $AuthContextProps.claimValue = $Settings.authenticationContextClaimValue } + $Desired.Add((ConvertTo-Rule -Id 'AuthenticationContext_EndUser_Assignment' -Type $AuthContextType -Properties $AuthContextProps)) + + $CurrentApproval = $ById['Approval_EndUser_Assignment'] + if ($Settings.activationRequiresApproval) { + $Desired.Add((ConvertTo-Rule -Id 'Approval_EndUser_Assignment' -Type $ApprovalType -Properties ([ordered]@{ + setting = [ordered]@{ + isApprovalRequired = $true + isApprovalRequiredForExtension = $false + isRequestorJustificationRequired = $true + approvalMode = 'SingleStage' + approvalStages = @( + [ordered]@{ + approvalStageTimeOutInDays = 1 + isApproverJustificationRequired = $true + escalationTimeInMinutes = 0 + primaryApprovers = @($ResolvedApprovers) + isEscalationEnabled = $false + escalationApprovers = @() + } + ) + } + }))) + } else { + # Keep the tenant's stage configuration; only flip the requirement off. + $Setting = [ordered]@{ isApprovalRequired = $false } + if ($CurrentApproval -and $CurrentApproval.setting) { + foreach ($Property in $CurrentApproval.setting.PSObject.Properties) { + if ($Property.Name -ne 'isApprovalRequired') { $Setting[$Property.Name] = $Property.Value } + } + } + $Desired.Add((ConvertTo-Rule -Id 'Approval_EndUser_Assignment' -Type $ApprovalType -Properties ([ordered]@{ setting = $Setting }))) + } + + # Admin eligibility / assignment + $Desired.Add((ConvertTo-Rule -Id 'Expiration_Admin_Eligibility' -Type $ExpirationType -Properties ([ordered]@{ + isExpirationRequired = $true + maximumDuration = $Settings.eligibilityMaxDuration + }))) + $Desired.Add((ConvertTo-Rule -Id 'Expiration_Admin_Assignment' -Type $ExpirationType -Properties ([ordered]@{ + isExpirationRequired = $true + maximumDuration = $Settings.activeAssignmentMaxDuration + }))) + + $AdminEnabled = [System.Collections.Generic.List[string]]::new() + if ($Settings.activeAssignmentRequiresMfa) { $AdminEnabled.Add('MultiFactorAuthentication') } + if ($Settings.activeAssignmentRequiresJustification) { $AdminEnabled.Add('Justification') } + $Desired.Add((ConvertTo-Rule -Id 'Enablement_Admin_Assignment' -Type $EnablementType -Properties ([ordered]@{ + enabledRules = @($AdminEnabled) + }))) + + # Notifications to additional admins - only managed when the template names recipients. + $Recipients = @("$($Settings.notificationRecipients)" -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + if ($Recipients.Count -gt 0) { + foreach ($NotificationId in @('Notification_Admin_Admin_Eligibility', 'Notification_Admin_Admin_Assignment', 'Notification_Admin_EndUser_Assignment')) { + $Desired.Add((ConvertTo-Rule -Id $NotificationId -Type $NotificationType -Properties ([ordered]@{ + notificationType = 'Email' + recipientType = 'Admin' + notificationLevel = $Settings.notificationLevel + isDefaultRecipientsEnabled = $true + notificationRecipients = @($Recipients) + }))) + } + } + + return @($Desired) +} diff --git a/Modules/CIPPCore/Public/PIM/ConvertTo-CIPPPIMRoleSettings.ps1 b/Modules/CIPPCore/Public/PIM/ConvertTo-CIPPPIMRoleSettings.ps1 new file mode 100644 index 0000000000000..951b7909d77de --- /dev/null +++ b/Modules/CIPPCore/Public/PIM/ConvertTo-CIPPPIMRoleSettings.ps1 @@ -0,0 +1,92 @@ +function ConvertTo-CIPPPIMRoleSettings { + <# + .SYNOPSIS + Normalises PIM role settings from a request body or stored template into the canonical shape. + + .DESCRIPTION + The template editor posts autoComplete fields as { label, value } objects and switches as + booleans (or 'true'/'false' strings after a JSON round trip). Everything that consumes a + template - the floor check, the rule converter, the standard - works on this one flat + shape with ISO 8601 durations and real booleans, so the unwrapping lives here once. + + Unknown properties are dropped; missing ones take the secure defaults so an older + template keeps validating after new settings are introduced. + + .PARAMETER InputObject + A hashtable or PSCustomObject with any subset of the settings properties. + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + $InputObject + ) + + function Get-Scalar { + param($Value) + if ($null -eq $Value) { return $null } + if ($Value -is [string] -or $Value -is [bool] -or $Value -is [System.ValueType]) { return $Value } + if ($Value -is [System.Collections.IDictionary]) { + if ($Value.Contains('value')) { return $Value['value'] } + return $null + } + if ($Value.PSObject.Properties['value']) { return $Value.value } + return "$Value" + } + + function Get-Bool { + param($Value, [bool]$Default) + $Scalar = Get-Scalar $Value + if ($null -eq $Scalar -or "$Scalar" -eq '') { return $Default } + if ($Scalar -is [bool]) { return $Scalar } + return ("$Scalar" -match '^(true|1|yes)$') + } + + function Get-Text { + param($Value, [string]$Default = '') + $Scalar = Get-Scalar $Value + if ($null -eq $Scalar) { return $Default } + $Text = "$Scalar".Trim() + if ($Text -eq '') { return $Default } + return $Text + } + + function Get-Prop { + param($Object, [string]$Name) + if ($null -eq $Object) { return $null } + if ($Object -is [System.Collections.IDictionary]) { return $Object[$Name] } + return $Object.$Name + } + + # A multi-select of recipients/approvers may arrive as an array of strings or label/value objects. + function Get-List { + param($Value) + if ($null -eq $Value) { return '' } + if ($Value -is [string]) { return $Value.Trim() } + if ($Value -is [System.Collections.IEnumerable]) { + return (@($Value | ForEach-Object { Get-Text $_ } | Where-Object { $_ }) -join ', ') + } + return (Get-Text $Value) + } + + $Source = $InputObject + + [PSCustomObject]@{ + activationMaxDuration = Get-Text (Get-Prop $Source 'activationMaxDuration') 'PT8H' + activationRequires = Get-Text (Get-Prop $Source 'activationRequires') 'MFA' + authenticationContextClaimValue = Get-Text (Get-Prop $Source 'authenticationContextClaimValue') + activationRequiresJustification = Get-Bool (Get-Prop $Source 'activationRequiresJustification') $true + activationRequiresTicket = Get-Bool (Get-Prop $Source 'activationRequiresTicket') $false + activationRequiresApproval = Get-Bool (Get-Prop $Source 'activationRequiresApproval') $false + approvers = Get-List (Get-Prop $Source 'approvers') + eligibilityMaxDuration = Get-Text (Get-Prop $Source 'eligibilityMaxDuration') 'P365D' + activeAssignmentMaxDuration = Get-Text (Get-Prop $Source 'activeAssignmentMaxDuration') 'P180D' + activeAssignmentRequiresMfa = Get-Bool (Get-Prop $Source 'activeAssignmentRequiresMfa') $true + activeAssignmentRequiresJustification = Get-Bool (Get-Prop $Source 'activeAssignmentRequiresJustification') $true + notificationRecipients = Get-List (Get-Prop $Source 'notificationRecipients') + notificationLevel = Get-Text (Get-Prop $Source 'notificationLevel') 'All' + } +} diff --git a/Modules/CIPPCore/Public/PIM/Get-CIPPPIMPolicySummary.ps1 b/Modules/CIPPCore/Public/PIM/Get-CIPPPIMPolicySummary.ps1 new file mode 100644 index 0000000000000..266bb48b974ff --- /dev/null +++ b/Modules/CIPPCore/Public/PIM/Get-CIPPPIMPolicySummary.ps1 @@ -0,0 +1,81 @@ +function Get-CIPPPIMPolicySummary { + <# + .SYNOPSIS + Summarises PIM role settings for display and grades them against the secure floor. + + .PARAMETER Settings + Canonical settings (ConvertTo-CIPPPIMRoleSettings or ConvertFrom-CIPPPIMPolicyRules output). + + .OUTPUTS + PSCustomObject with SummaryText (e.g. "Activation <= 8h | MFA | Justification | Eligibility <= 1y + | Active <= 6mo"), the individual flags, BelowFloor and FloorIssues. + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + $Settings + ) + + function ConvertTo-FriendlyDuration { + param([string]$Iso) + if ([string]::IsNullOrWhiteSpace($Iso)) { return $null } + try { $Span = [System.Xml.XmlConvert]::ToTimeSpan($Iso) } catch { return $Iso } + if ($Span.TotalDays -ge 365 -and ($Span.TotalDays % 365) -eq 0) { return "$([int]($Span.TotalDays / 365))y" } + if ($Span.TotalDays -ge 30 -and ($Span.TotalDays % 30) -eq 0) { return "$([int]($Span.TotalDays / 30))mo" } + if ($Span.TotalDays -ge 1 -and $Span.TotalDays -eq [math]::Floor($Span.TotalDays)) { return "$([int]$Span.TotalDays)d" } + if ($Span.TotalHours -ge 1 -and $Span.TotalHours -eq [math]::Floor($Span.TotalHours)) { return "$([int]$Span.TotalHours)h" } + return "$([int]$Span.TotalMinutes)m" + } + + if ($null -eq $Settings) { + return [PSCustomObject]@{ + SummaryText = 'No PIM policy' + MaxActivation = $null + RequiresMfa = $false + RequiresAuthenticationContext = $false + RequiresJustification = $false + RequiresTicket = $false + RequiresApproval = $false + EligibilityExpirationRequired = $false + ActiveAssignmentExpirationRequired = $false + BelowFloor = $true + FloorIssues = @('No PIM policy found for this role.') + } + } + + $Parts = [System.Collections.Generic.List[string]]::new() + $Activation = ConvertTo-FriendlyDuration $Settings.activationMaxDuration + $Parts.Add($(if ($Activation) { "Activation <= $Activation" } else { 'Activation unlimited' })) + switch ("$($Settings.activationRequires)") { + 'MFA' { $Parts.Add('MFA') } + 'AuthenticationContext' { $Parts.Add("Auth context $($Settings.authenticationContextClaimValue)") } + default { $Parts.Add('No MFA') } + } + $Parts.Add($(if ($Settings.activationRequiresJustification) { 'Justification' } else { 'No justification' })) + if ($Settings.activationRequiresTicket) { $Parts.Add('Ticket') } + if ($Settings.activationRequiresApproval) { $Parts.Add('Approval') } + $Eligibility = ConvertTo-FriendlyDuration $Settings.eligibilityMaxDuration + $Parts.Add($(if ($Eligibility) { "Eligibility <= $Eligibility" } else { 'Permanent eligibility allowed' })) + $Active = ConvertTo-FriendlyDuration $Settings.activeAssignmentMaxDuration + $Parts.Add($(if ($Active) { "Active <= $Active" } else { 'Permanent active allowed' })) + + $Floor = Test-CIPPPIMRoleSettingsFloor -Settings $Settings + + [PSCustomObject]@{ + SummaryText = ($Parts -join ' | ') + MaxActivation = $Settings.activationMaxDuration + RequiresMfa = ("$($Settings.activationRequires)" -eq 'MFA') + RequiresAuthenticationContext = ("$($Settings.activationRequires)" -eq 'AuthenticationContext') + RequiresJustification = [bool]$Settings.activationRequiresJustification + RequiresTicket = [bool]$Settings.activationRequiresTicket + RequiresApproval = [bool]$Settings.activationRequiresApproval + EligibilityExpirationRequired = -not [string]::IsNullOrWhiteSpace($Settings.eligibilityMaxDuration) + ActiveAssignmentExpirationRequired = -not [string]::IsNullOrWhiteSpace($Settings.activeAssignmentMaxDuration) + BelowFloor = -not $Floor.Valid + FloorIssues = @($Floor.Errors) + } +} diff --git a/Modules/CIPPCore/Public/PIM/Get-CIPPPIMRoleAssignments.ps1 b/Modules/CIPPCore/Public/PIM/Get-CIPPPIMRoleAssignments.ps1 new file mode 100644 index 0000000000000..aa2c4de378ff1 --- /dev/null +++ b/Modules/CIPPCore/Public/PIM/Get-CIPPPIMRoleAssignments.ps1 @@ -0,0 +1,301 @@ +function Get-CIPPPIMRoleAssignments { + <# + .SYNOPSIS + Lists a tenant's directory role assignments with their PIM assignment type. + + .DESCRIPTION + One row per principal x role x scope x assignment kind, merged from: + - roleAssignmentScheduleInstances (v1.0, $expand=principal): every ACTIVE assignment, + including ones made outside PIM (those show as assignmentType 'Assigned', memberType + 'Direct', endDateTime null). 'Activated' = activated from an eligibility. + - roleEligibilitySchedules (v1.0, $expand=principal): ELIGIBLE assignments. + Both need -AsApp: RoleManagement.*.Directory is an application permission. + + Tenants without Entra ID P2 have no PIM API; there the unified roleManagement/directory/ + roleAssignments list is used and every row is Permanent/Direct (PIMCapable = $false). + + AssignmentType values: + Permanent active, no end date (what the PermanentActiveAdminAssigned alert watches) + Active active, time-bound (endDateTime set) + ActivatedFromEligible active because the principal activated an eligibility + Eligible eligible; EligibilityPermanent tells whether the eligibility itself expires + + MemberType 'Group' rows are inherited through a role-assignable group: the principal shown + is the member, and the assignment to act on belongs to the group. + + .PARAMETER PrincipalId + Restrict to one principal (pushed into the Graph filter - cheap for the user page). + + .PARAMETER RoleDefinitionId + Restrict to one role template id. + + .PARAMETER FromCache + Read the CIPPDB cache (RoleAssignmentScheduleInstances / RoleEligibilitySchedules, falling + back to the directoryRoles 'Roles' cache for tenants without PIM rows). Used for AllTenants. + + .PARAMETER IncludePolicy + Attach the role's PIM policy summary (PolicySummary, PolicyBelowFloor) to every row. + + .PARAMETER IncludeUnassignedRoles + Live reads only: also return one row per role definition that has no assignment at all + (AssignmentType 'Unassigned', no principal), so the result doubles as the role catalogue. + Ignored when -PrincipalId is given. + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [string]$PrincipalId, + + [string]$RoleDefinitionId, + + [switch]$FromCache, + + [switch]$IncludePolicy, + + [switch]$IncludeUnassignedRoles + ) + + $PrivilegedCatalog = Get-CIPPPrivilegedRoleTemplateIds -WithNames + $PrivilegedIds = [System.Collections.Generic.HashSet[string]]::new([string[]]@($PrivilegedCatalog.Id), [System.StringComparer]::OrdinalIgnoreCase) + $RoleNames = @{} + # Description / built-in flag per role id, filled from the definitions (live) or the Roles cache. + $RoleInfo = @{} + foreach ($Entry in $PrivilegedCatalog) { $RoleNames[$Entry.Id] = $Entry.DisplayName } + + function ConvertTo-PrincipalType { + param([string]$ODataType) + switch -Wildcard ($ODataType) { + '*user' { 'User' } + '*group' { 'Group' } + '*servicePrincipal' { 'ServicePrincipal' } + default { 'Unknown' } + } + } + + function ConvertTo-DateTime { + param($Value) + if ($null -eq $Value -or "$Value" -eq '') { return $null } + if ($Value -is [datetime]) { return $Value.ToUniversalTime() } + try { return ([datetime]$Value).ToUniversalTime() } catch { return $null } + } + + $Rows = [System.Collections.Generic.List[object]]::new() + $ScopeIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + + function ConvertTo-Row { + param( + $Principal, [string]$PrincipalObjectId, [string]$RoleId, [string]$AssignmentType, [string]$MemberType, + [string]$ScopeId, $Start, $End, [string]$Source, [string]$ScheduleId, [string]$RoleAssignmentId, + [bool]$PIMCapable, [bool]$EligibilityPermanent = $false + ) + if ([string]::IsNullOrWhiteSpace($ScopeId)) { $ScopeId = '/' } + $null = $ScopeIds.Add($ScopeId) + $Type = ConvertTo-PrincipalType ($Principal.'@odata.type') + [PSCustomObject]@{ + Tenant = $TenantFilter + Id = "$PrincipalObjectId|$RoleId|$ScopeId|$AssignmentType" + PrincipalId = $PrincipalObjectId + PrincipalDisplayName = $Principal.displayName + PrincipalUserPrincipalName = $Principal.userPrincipalName + PrincipalType = $Type + PrincipalAppId = $Principal.appId + RoleDefinitionId = $RoleId + RoleDisplayName = $RoleNames[$RoleId] ?? $RoleId + RoleDescription = $null + RoleIsBuiltIn = $null + IsPrivilegedRole = $PrivilegedIds.Contains($RoleId) + AssignmentType = $AssignmentType + IsAssigned = ($AssignmentType -ne 'Unassigned') + IsPermanent = ($AssignmentType -eq 'Permanent') + EligibilityPermanent = $EligibilityPermanent + MemberType = $MemberType + DirectoryScopeId = $ScopeId + Scope = if ($ScopeId -eq '/') { 'Directory' } else { $ScopeId } + StartDateTime = ConvertTo-DateTime $Start + EndDateTime = ConvertTo-DateTime $End + Source = $Source + ScheduleId = $ScheduleId + RoleAssignmentId = $RoleAssignmentId + PIMCapable = $PIMCapable + PolicySummary = $null + PolicyBelowFloor = $null + } + } + + function Add-InstanceRows { + param($Instances, [bool]$PIMCapable = $true) + foreach ($Instance in @($Instances)) { + if (-not $Instance.principalId -or -not $Instance.roleDefinitionId) { continue } + $End = ConvertTo-DateTime $Instance.endDateTime + $Type = if ($Instance.assignmentType -eq 'Activated') { + 'ActivatedFromEligible' + } elseif ($null -eq $End) { + 'Permanent' + } else { + 'Active' + } + $Source = if ($Type -eq 'Permanent' -and $Instance.memberType -ne 'Group') { 'Direct' } else { 'PIM' } + $Principal = $Instance.principal ?? [PSCustomObject]@{ displayName = $null; userPrincipalName = $null; '@odata.type' = $null; appId = $null } + $Rows.Add((ConvertTo-Row -Principal $Principal -PrincipalObjectId $Instance.principalId -RoleId $Instance.roleDefinitionId -AssignmentType $Type -MemberType ($Instance.memberType ?? 'Direct') -ScopeId $Instance.directoryScopeId -Start $Instance.startDateTime -End $Instance.endDateTime -Source $Source -ScheduleId $Instance.roleAssignmentScheduleId -RoleAssignmentId $Instance.roleAssignmentOriginId -PIMCapable $PIMCapable)) + } + } + + function Add-EligibilityRows { + param($Schedules) + foreach ($Schedule in @($Schedules)) { + if (-not $Schedule.principalId -or -not $Schedule.roleDefinitionId) { continue } + if ($Schedule.status -and $Schedule.status -notin @('Provisioned', 'PendingProvisioning', 'ScheduleCreated')) { continue } + $Principal = $Schedule.principal ?? [PSCustomObject]@{ displayName = $null; userPrincipalName = $null; '@odata.type' = $null; appId = $null } + $Expiration = $Schedule.scheduleInfo.expiration + $EligibilityPermanent = ($null -eq $Expiration -or $Expiration.type -eq 'noExpiration') + $Rows.Add((ConvertTo-Row -Principal $Principal -PrincipalObjectId $Schedule.principalId -RoleId $Schedule.roleDefinitionId -AssignmentType 'Eligible' -MemberType ($Schedule.memberType ?? 'Direct') -ScopeId $Schedule.directoryScopeId -Start $Schedule.scheduleInfo.startDateTime -End $Expiration.endDateTime -Source 'PIM' -ScheduleId $Schedule.id -RoleAssignmentId $null -PIMCapable $true -EligibilityPermanent $EligibilityPermanent)) + } + } + + if ($FromCache.IsPresent) { + $Instances = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'RoleAssignmentScheduleInstances') + $Eligibilities = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'RoleEligibilitySchedules') + $CachedRoles = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Roles') + foreach ($Role in $CachedRoles) { + if ($Role.roleTemplateId -and $Role.displayName) { $RoleNames[$Role.roleTemplateId] = $Role.displayName } + if ($Role.roleTemplateId) { $RoleInfo[$Role.roleTemplateId] = @{ Description = $Role.description; IsBuiltIn = $null } } + } + + if ($Instances.Count -gt 0 -or $Eligibilities.Count -gt 0) { + Add-InstanceRows -Instances $Instances -PIMCapable $true + Add-EligibilityRows -Schedules $Eligibilities + } else { + # No PIM data cached (non-P2 tenant or PIM never onboarded): directoryRoles members are + # all permanent, direct assignments. + foreach ($Role in $CachedRoles) { + foreach ($Member in @($Role.members)) { + if (-not $Member.id) { continue } + $Rows.Add((ConvertTo-Row -Principal $Member -PrincipalObjectId $Member.id -RoleId ($Role.roleTemplateId ?? $Role.id) -AssignmentType 'Permanent' -MemberType 'Direct' -ScopeId '/' -Start $null -End $null -Source 'Direct' -ScheduleId $null -RoleAssignmentId $null -PIMCapable $false)) + } + } + } + } else { + $PIMCapable = [bool](Test-CIPPStandardLicense -StandardName 'PIMRoleAssignments' -TenantFilter $TenantFilter -Preset EntraP2 -SkipLog) + + # Role names come from the tenant's definitions (custom roles included); PIM's + # roleDefinitionId equals the template id for built-in roles and the definition id otherwise. + # beta: isPrivileged is not on the v1.0 unifiedRoleDefinition and a $select of it fails the + # whole request, which left every row showing the template GUID. + $Definitions = @() + try { + $Definitions = @(New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/roleManagement/directory/roleDefinitions?$select=id,templateId,displayName,description,isBuiltIn,isPrivileged' -tenantid $TenantFilter) + foreach ($Definition in @($Definitions)) { + $Info = @{ Description = $Definition.description; IsBuiltIn = [bool]$Definition.isBuiltIn } + if ($Definition.id) { $RoleNames[$Definition.id] = $Definition.displayName; $RoleInfo[$Definition.id] = $Info } + if ($Definition.templateId) { $RoleNames[$Definition.templateId] = $Definition.displayName; $RoleInfo[$Definition.templateId] = $Info } + if ($Definition.isPrivileged -eq $true) { + $null = $PrivilegedIds.Add($Definition.id) + if ($Definition.templateId) { $null = $PrivilegedIds.Add($Definition.templateId) } + } + } + } catch { + Write-Information "Could not list role definitions for $TenantFilter`: $($_.Exception.Message)" + } + + $Filters = [System.Collections.Generic.List[string]]::new() + if ($PrincipalId) { $Filters.Add("principalId eq '$PrincipalId'") } + if ($RoleDefinitionId) { $Filters.Add("roleDefinitionId eq '$RoleDefinitionId'") } + $FilterClause = if ($Filters.Count -gt 0) { "&`$filter=$($Filters -join ' and ')" } else { '' } + + if ($PIMCapable) { + $Instances = @(New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignmentScheduleInstances?`$expand=principal$FilterClause" -tenantid $TenantFilter -AsApp $true) + Add-InstanceRows -Instances $Instances -PIMCapable $true + $Eligibilities = @(New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/roleManagement/directory/roleEligibilitySchedules?`$expand=principal$FilterClause" -tenantid $TenantFilter -AsApp $true) + Add-EligibilityRows -Schedules $Eligibilities + } else { + $Assignments = @(New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments?`$select=id,principalId,roleDefinitionId,directoryScopeId&`$top=999$FilterClause" -tenantid $TenantFilter) + # Resolve principals in bulk; getByIds returns in milliseconds where $expand costs seconds. + $Principals = @{} + $PrincipalIds = @($Assignments.principalId | Where-Object { $_ } | Sort-Object -Unique) + for ($i = 0; $i -lt $PrincipalIds.Count; $i += 1000) { + $Body = ConvertTo-Json -InputObject @{ ids = @($PrincipalIds[$i..([Math]::Min($i + 999, $PrincipalIds.Count - 1))]) } -Compress + $Resolved = New-GraphPOSTRequest -tenantid $TenantFilter -uri 'https://graph.microsoft.com/v1.0/directoryObjects/getByIds?$select=id,displayName,userPrincipalName,appId' -body $Body + foreach ($Principal in @($Resolved.value)) { $Principals[$Principal.id] = $Principal } + } + foreach ($Assignment in $Assignments) { + if (-not $Assignment.principalId) { continue } + $Principal = $Principals[$Assignment.principalId] ?? [PSCustomObject]@{ displayName = $null; userPrincipalName = $null; '@odata.type' = $null; appId = $null } + $Rows.Add((ConvertTo-Row -Principal $Principal -PrincipalObjectId $Assignment.principalId -RoleId $Assignment.roleDefinitionId -AssignmentType 'Permanent' -MemberType 'Direct' -ScopeId $Assignment.directoryScopeId -Start $null -End $null -Source 'Direct' -ScheduleId $null -RoleAssignmentId $Assignment.id -PIMCapable $false)) + } + } + + # The role catalogue: one row per definition nobody holds, so the PIM page can list every + # role the way the old Roles page did. Skipped for a single-principal read. + if ($IncludeUnassignedRoles.IsPresent -and -not $PrincipalId) { + $AssignedRoleIds = [System.Collections.Generic.HashSet[string]]::new([string[]]@($Rows.RoleDefinitionId | Where-Object { $_ }), [System.StringComparer]::OrdinalIgnoreCase) + foreach ($Definition in $Definitions) { + $RoleId = $Definition.templateId ?? $Definition.id + if (-not $RoleId) { continue } + if ($RoleDefinitionId -and $RoleId -ne $RoleDefinitionId -and $Definition.id -ne $RoleDefinitionId) { continue } + if ($AssignedRoleIds.Contains($RoleId) -or ($Definition.id -and $AssignedRoleIds.Contains($Definition.id))) { continue } + $NoPrincipal = [PSCustomObject]@{ displayName = $null; userPrincipalName = $null; '@odata.type' = $null; appId = $null } + $Row = ConvertTo-Row -Principal $NoPrincipal -PrincipalObjectId '' -RoleId $RoleId -AssignmentType 'Unassigned' -MemberType '' -ScopeId '/' -Start $null -End $null -Source '' -ScheduleId $null -RoleAssignmentId $null -PIMCapable $PIMCapable + $Row.PrincipalId = $null + $Row.PrincipalType = 'None' + $Rows.Add($Row) + } + } + + # Administrative-unit scopes: show the unit's name instead of its id. + $UnitIds = @($ScopeIds | Where-Object { $_ -match '^/administrativeUnits/' } | ForEach-Object { $_ -replace '^/administrativeUnits/', '' }) + if ($UnitIds.Count -gt 0) { + try { + $UnitRequests = @(foreach ($UnitId in $UnitIds) { + @{ id = $UnitId; method = 'GET'; url = "/directory/administrativeUnits/$UnitId`?`$select=id,displayName" } + }) + $UnitResults = New-GraphBulkRequest -tenantid $TenantFilter -Requests $UnitRequests -Version 'v1.0' + $UnitNames = @{} + foreach ($Result in @($UnitResults)) { + if ($Result.body.displayName) { $UnitNames["/administrativeUnits/$($Result.id)"] = "AU: $($Result.body.displayName)" } + } + foreach ($Row in $Rows) { + if ($UnitNames.ContainsKey($Row.DirectoryScopeId)) { $Row.Scope = $UnitNames[$Row.DirectoryScopeId] } + } + } catch { + Write-Information "Could not resolve administrative unit names for $TenantFilter`: $($_.Exception.Message)" + } + } + } + + # Names for rows whose role was not resolvable earlier (cache path, roles never activated). + foreach ($Row in $Rows) { + if ($Row.RoleDisplayName -eq $Row.RoleDefinitionId -and $RoleNames.ContainsKey($Row.RoleDefinitionId)) { + $Row.RoleDisplayName = $RoleNames[$Row.RoleDefinitionId] + } + if (-not $Row.IsPrivilegedRole -and $PrivilegedIds.Contains($Row.RoleDefinitionId)) { $Row.IsPrivilegedRole = $true } + $Info = $RoleInfo[$Row.RoleDefinitionId] + if ($Info) { + $Row.RoleDescription = $Info.Description + $Row.RoleIsBuiltIn = $Info.IsBuiltIn + } + } + + if ($IncludePolicy.IsPresent -and $Rows.Count -gt 0) { + try { + $Policies = @(Get-CIPPPIMRolePolicies -TenantFilter $TenantFilter -FromCache:$FromCache) + $PolicyByRole = @{} + foreach ($Policy in $Policies) { $PolicyByRole[$Policy.RoleDefinitionId] = $Policy } + foreach ($Row in $Rows) { + $Policy = $PolicyByRole[$Row.RoleDefinitionId] + if ($Policy) { + $Row.PolicySummary = $Policy.Summary.SummaryText + $Row.PolicyBelowFloor = $Policy.Summary.BelowFloor + } + } + } catch { + Write-Information "Could not load PIM policies for $TenantFilter`: $($_.Exception.Message)" + } + } + + return @($Rows) +} diff --git a/Modules/CIPPCore/Public/PIM/Get-CIPPPIMRolePolicies.ps1 b/Modules/CIPPCore/Public/PIM/Get-CIPPPIMRolePolicies.ps1 new file mode 100644 index 0000000000000..59377c7f476c1 --- /dev/null +++ b/Modules/CIPPCore/Public/PIM/Get-CIPPPIMRolePolicies.ps1 @@ -0,0 +1,82 @@ +function Get-CIPPPIMRolePolicies { + <# + .SYNOPSIS + Returns the PIM role management policy for each directory role, with canonical settings + and a display summary. + + .DESCRIPTION + Reads policies/roleManagementPolicyAssignments (the assignment is the only record that + carries roleDefinitionId - see Set-CIPPDBCacheRoleManagementPolicies) with the policy and + its rules expanded. -AsApp is required: RoleManagement.*.Directory is an application + permission. A tenant that has never onboarded PIM answers "MissingProvider"; that is tenant + state and yields an empty result rather than an error. + + .PARAMETER RoleDefinitionId + Optional role template ids to restrict the query to (pushed into the Graph filter). + + .PARAMETER FromCache + Read the RoleManagementPolicies cache instead of Graph. + + .OUTPUTS + PSCustomObject per role: RoleDefinitionId, PolicyId, Rules, Settings, Summary. + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [string[]]$RoleDefinitionId, + + [switch]$FromCache + ) + + $Records = @() + if ($FromCache.IsPresent) { + $Records = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'RoleManagementPolicies' | ForEach-Object { + [PSCustomObject]@{ + roleDefinitionId = $_.roleDefinitionId + policyId = $_.policyId + rules = @($_.rules) + } + }) + } else { + $Filter = "scopeId eq '/' and scopeType eq 'DirectoryRole'" + if ($RoleDefinitionId -and $RoleDefinitionId.Count -eq 1) { + $Filter = "$Filter and roleDefinitionId eq '$($RoleDefinitionId[0])'" + } + $Uri = "https://graph.microsoft.com/beta/policies/roleManagementPolicyAssignments?`$filter=$Filter&`$expand=policy(`$expand=rules)" + try { + $Records = @(New-GraphGetRequest -uri $Uri -tenantid $TenantFilter -AsApp $true | ForEach-Object { + [PSCustomObject]@{ + roleDefinitionId = $_.roleDefinitionId + policyId = $_.policyId + rules = @($_.policy.rules) + } + }) + } catch { + if ($_.Exception.Message -match 'MissingProvider|provider is missing') { + Write-Information "PIM is not onboarded in $TenantFilter (MissingProvider); no role management policies." + return @() + } + throw + } + } + + if ($RoleDefinitionId) { + $Records = @($Records | Where-Object { $RoleDefinitionId -contains $_.roleDefinitionId }) + } + + foreach ($Record in $Records) { + $Settings = ConvertFrom-CIPPPIMPolicyRules -Rules $Record.rules + [PSCustomObject]@{ + RoleDefinitionId = $Record.roleDefinitionId + PolicyId = $Record.policyId + Rules = @($Record.rules) + Settings = $Settings + Summary = Get-CIPPPIMPolicySummary -Settings $Settings + } + } +} diff --git a/Modules/CIPPCore/Public/PIM/Get-CIPPPrivilegedRoleTemplateIds.ps1 b/Modules/CIPPCore/Public/PIM/Get-CIPPPrivilegedRoleTemplateIds.ps1 new file mode 100644 index 0000000000000..cd745f8540df5 --- /dev/null +++ b/Modules/CIPPCore/Public/PIM/Get-CIPPPrivilegedRoleTemplateIds.ps1 @@ -0,0 +1,77 @@ +function Get-CIPPPrivilegedRoleTemplateIds { + <# + .SYNOPSIS + Returns the Entra role TEMPLATE ids that CIPP treats as privileged. + + .DESCRIPTION + Single source for every "privileged roles" scope in CIPP: Get-CippDbRole, the PIM role + assignment surfaces, the PIM role settings templates and the PermanentActiveAdminAssigned + alert all use this list so that the phrase means the same roles everywhere. + + Template ids are what PIM's roleDefinitionId carries for built-in roles, so these compare + directly against roleAssignmentScheduleInstances / roleEligibilitySchedules records. + + .PARAMETER Set + Privileged - CIPP's privileged set (18 roles). Default. + CisaHighlyPrivileged - the six roles CISA SCuBA calls highly privileged. + GlobalAdministrator - only Global Administrator. + + .PARAMETER WithNames + Return objects with Id and DisplayName instead of bare ids, for callers that need a + name fallback when the tenant's role definitions are not at hand (cached AllTenants views). + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [ValidateSet('Privileged', 'CisaHighlyPrivileged', 'GlobalAdministrator')] + [string]$Set = 'Privileged', + + [switch]$WithNames + ) + + $Catalog = [ordered]@{ + '62e90394-69f5-4237-9190-012177145e10' = 'Global Administrator' + '194ae4cb-b126-40b2-bd5b-6091b380977d' = 'Security Administrator' + '9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3' = 'Application Administrator' + 'e8611ab8-c189-46e8-94e1-60213ab1f814' = 'Privileged Role Administrator' + '29232cdf-9323-42fd-ade2-1d097af3e4de' = 'Exchange Administrator' + 'b1be1c3e-b65d-4f19-8427-f6fa0d97feb9' = 'Conditional Access Administrator' + 'f28a1f50-f6e7-4571-818b-6a12f2af6b6c' = 'SharePoint Administrator' + 'fe930be7-5e62-47db-91af-98c3a49a38b1' = 'User Administrator' + '729827e3-9c14-49f7-bb1b-9608f156bbb8' = 'Helpdesk Administrator' + '966707d0-3269-4727-9be2-8c3a10f19b9d' = 'Password Administrator' + 'b0f54661-2d74-4c50-afa3-1ec803f12efe' = 'Billing Administrator' + '7be44c8a-adaf-4e2a-84d6-ab2649e08a13' = 'Privileged Authentication Administrator' + '158c047a-c907-4556-b7ef-446551a6b5f7' = 'Cloud Application Administrator' + 'c4e39bd9-1100-46d3-8c65-fb160da0071f' = 'Authentication Administrator' + '9f06204d-73c1-4d4c-880a-6edb90606fd8' = 'Azure AD Joined Device Local Administrator' + '17315797-102d-40b4-93e0-432062caca18' = 'Compliance Administrator' + '4a5d8f65-41da-4de4-8968-e035b65339cf' = 'Reports Reader' + '75941009-915a-4869-abe7-691bff18279e' = 'Skype for Business Administrator' + } + + $Ids = switch ($Set) { + 'GlobalAdministrator' { @('62e90394-69f5-4237-9190-012177145e10') } + 'CisaHighlyPrivileged' { + @( + '62e90394-69f5-4237-9190-012177145e10', + '9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3', + '29232cdf-9323-42fd-ade2-1d097af3e4de', + '729827e3-9c14-49f7-bb1b-9608f156bbb8', + '966707d0-3269-4727-9be2-8c3a10f19b9d', + 'b0f54661-2d74-4c50-afa3-1ec803f12efe' + ) + } + default { @($Catalog.Keys) } + } + + if ($WithNames.IsPresent) { + return @(foreach ($Id in $Ids) { + [PSCustomObject]@{ Id = $Id; DisplayName = $Catalog[$Id] } + }) + } + + return @($Ids) +} diff --git a/Modules/CIPPCore/Public/PIM/Invoke-CIPPPIMAssignmentAction.ps1 b/Modules/CIPPCore/Public/PIM/Invoke-CIPPPIMAssignmentAction.ps1 new file mode 100644 index 0000000000000..5d2e66cdc4c91 --- /dev/null +++ b/Modules/CIPPCore/Public/PIM/Invoke-CIPPPIMAssignmentAction.ps1 @@ -0,0 +1,306 @@ +function Invoke-CIPPPIMAssignmentAction { + <# + .SYNOPSIS + Performs a secure-direction change to a directory role assignment through PIM. + + .DESCRIPTION + The single write path for role assignments in CIPP's PIM surfaces, used by the + ExecPIMRoleAssignment endpoint (and any future standard or automation that changes assignments). + + Actions: + ConvertToEligible permanent/time-bound active -> eligible. Creates the eligibility, READS + IT BACK to confirm it exists, and only then removes the active + assignment. A principal never loses access without gaining eligibility. + GrantActive eligible (or nothing) -> time-bound active assignment (the JIT equivalent; + Entra removes it at the end date). + Extend / Renew push out the end of a time-bound active assignment or an eligibility. + Remove remove an eligibility or an active assignment. + + What it refuses, regardless of caller: + - anything without an expiration (New-CIPPPIMScheduleRequest throws); + - a lifetime above the tightest cap: the role's PIM policy maximum, the JIT admin + MaxDuration setting (GrantActive/Extend/Renew of actives) and CIPP's P365D ceiling; + - rows inherited through a group (MemberType 'Group') - the group's assignment is the + real one; + - the CIPP-SAM service principal's own assignments; + - removing or converting the LAST active Global Administrator; + - converting a service principal (PIM eligibility is users and groups only). + + Every change is logged with the assignment type before and after. + + .PARAMETER AssignmentType + The row's current type (Permanent | Active | ActivatedFromEligible | Eligible). Decides + whether Extend/Renew/Remove target the eligibility or the assignment schedule. + + .PARAMETER Duration + ISO 8601 lifetime for the new/extended schedule. Mutually exclusive with -EndDateTime. + + .OUTPUTS + PSCustomObject: resultText, state, Before, After, EndDateTime. + + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $true)] + [ValidateSet('ConvertToEligible', 'GrantActive', 'Extend', 'Renew', 'Remove')] + [string]$Action, + + [Parameter(Mandatory = $true)] + [string]$PrincipalId, + + [Parameter(Mandatory = $true)] + [string]$RoleDefinitionId, + + [string]$DirectoryScopeId = '/', + + [ValidateSet('', 'Permanent', 'Active', 'ActivatedFromEligible', 'Eligible')] + [string]$AssignmentType = '', + + [string]$Duration, + + [datetime]$EndDateTime, + + [string]$Justification, + + # IANA time zone of the caller (the browser sends it) used only to word end times in the + # result text; UTC when absent or unknown. Never changes what is written to Graph. + [string]$TimeZone, + + $Headers, + + [string]$APIName = 'PIMRoleAssignment' + ) + + $GlobalAdminTemplateId = '62e90394-69f5-4237-9190-012177145e10' + # End times are stored in UTC; word them in the caller's zone so "until 08:06" reads as the + # time the operator will see on their clock. Falls back to UTC (labelled) for an unknown zone. + $Zone = $null + if (-not [string]::IsNullOrWhiteSpace($TimeZone)) { + try { $Zone = [System.TimeZoneInfo]::FindSystemTimeZoneById($TimeZone) } catch { $Zone = $null } + } + $FormatEnd = { + param($Value) + if ($null -eq $Value -or "$Value" -eq '') { return '' } + $Utc = if ($Value -is [datetime]) { + if ($Value.Kind -eq 'Local') { $Value.ToUniversalTime() } else { [datetime]::SpecifyKind($Value, 'Utc') } + } else { ([datetime]$Value).ToUniversalTime() } + if ($Zone) { "$([System.TimeZoneInfo]::ConvertTimeFromUtc($Utc, $Zone).ToString('yyyy-MM-dd HH:mm')) ($TimeZone)" } else { "$($Utc.ToString('yyyy-MM-dd HH:mm')) UTC" } + } + if ([string]::IsNullOrWhiteSpace($DirectoryScopeId)) { $DirectoryScopeId = '/' } + if ([string]::IsNullOrWhiteSpace($Justification)) { $Justification = 'Changed via CIPP' } + + $PIMCapable = [bool](Test-CIPPStandardLicense -StandardName 'PIMRoleAssignment' -TenantFilter $TenantFilter -Preset EntraP2 -SkipLog) + if (-not $PIMCapable) { + throw "Tenant $TenantFilter is not licensed for Entra ID P2 / Privileged Identity Management, so PIM assignment changes are not available. Assignments can still be removed from the PIM page." + } + + # Current state for this principal (cheap: filtered at Graph) and the role's policy caps. + $PrincipalRows = @(Get-CIPPPIMRoleAssignments -TenantFilter $TenantFilter -PrincipalId $PrincipalId) + $RoleRows = @($PrincipalRows | Where-Object { $_.RoleDefinitionId -eq $RoleDefinitionId -and $_.DirectoryScopeId -eq $DirectoryScopeId }) + $RoleName = ($RoleRows | Select-Object -First 1).RoleDisplayName ?? $RoleDefinitionId + $PrincipalName = ($PrincipalRows | Where-Object { $_.PrincipalUserPrincipalName -or $_.PrincipalDisplayName } | Select-Object -First 1) + # Groups and service principals have no UPN (and the value may be '' rather than $null). + $PrincipalLabel = @($PrincipalName.PrincipalUserPrincipalName, $PrincipalName.PrincipalDisplayName, $PrincipalId) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -First 1 + $PrincipalType = ($PrincipalRows | Select-Object -First 1).PrincipalType + $PrincipalAppId = ($PrincipalRows | Select-Object -First 1).PrincipalAppId + + $ActiveRow = $RoleRows | Where-Object { $_.AssignmentType -in @('Permanent', 'Active', 'ActivatedFromEligible') } | Select-Object -First 1 + $EligibleRow = $RoleRows | Where-Object { $_.AssignmentType -eq 'Eligible' } | Select-Object -First 1 + $TargetRow = switch ($AssignmentType) { + 'Eligible' { $EligibleRow } + '' { $ActiveRow ?? $EligibleRow } + default { $ActiveRow } + } + + if ($TargetRow -and $TargetRow.MemberType -eq 'Group') { + throw "$PrincipalLabel holds $RoleName through a role-assignable group. Change the group's assignment instead of the member's." + } + if ($PrincipalAppId -and $env:ApplicationID -and $PrincipalAppId -eq $env:ApplicationID) { + throw "Refusing to change the CIPP-SAM application's own role assignment for $RoleName." + } + + $Before = if ($TargetRow) { + "$($TargetRow.AssignmentType)$(if ($TargetRow.EndDateTime) { " until $(& $FormatEnd $TargetRow.EndDateTime)" })" + } else { + 'None' + } + + function Get-MinimumDuration { + param([string[]]$Candidates) + $Best = $null + $BestSpan = $null + foreach ($Candidate in @($Candidates | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })) { + try { $Span = [System.Xml.XmlConvert]::ToTimeSpan($Candidate) } catch { continue } + if ($null -eq $BestSpan -or $Span -lt $BestSpan) { $Best = $Candidate; $BestSpan = $Span } + } + return $Best + } + + $Policy = $null + try { + $Policy = Get-CIPPPIMRolePolicies -TenantFilter $TenantFilter -RoleDefinitionId $RoleDefinitionId | Select-Object -First 1 + } catch { + Write-Information "Could not read the PIM policy for $RoleName in $TenantFilter`: $($_.Exception.Message)" + } + + $JitMaxDuration = $null + try { + $ConfigTable = Get-CIPPTable -TableName Config + $JITAdminConfig = Get-CIPPAzDataTableEntity @ConfigTable -Filter "PartitionKey eq 'JITAdminSettings' and RowKey eq 'JITAdminSettings'" + if ($JITAdminConfig -and -not [string]::IsNullOrWhiteSpace($JITAdminConfig.MaxDuration)) { $JitMaxDuration = $JITAdminConfig.MaxDuration } + } catch { + Write-Information "Could not read the JIT admin maximum duration: $($_.Exception.Message)" + } + + $EligibilityCap = Get-MinimumDuration @('P365D', $Policy.Settings.eligibilityMaxDuration) + $AssignmentCap = Get-MinimumDuration @('P365D', $Policy.Settings.activeAssignmentMaxDuration, $JitMaxDuration) + + function Test-LastGlobalAdmin { + if ($RoleDefinitionId -ne $GlobalAdminTemplateId) { return } + $OtherActive = @(Get-CIPPPIMRoleAssignments -TenantFilter $TenantFilter -RoleDefinitionId $GlobalAdminTemplateId | Where-Object { + $_.AssignmentType -in @('Permanent', 'Active', 'ActivatedFromEligible') -and $_.PrincipalId -ne $PrincipalId + }) + if ($OtherActive.Count -eq 0) { + throw "Refusing: $PrincipalLabel is the last active Global Administrator in $TenantFilter. Assign another active Global Administrator first." + } + } + + function Send-ScheduleRequest { + param($Request) + $Json = ConvertTo-Json -InputObject $Request.Body -Depth 10 -Compress + return New-GraphPOSTRequest -uri $Request.Uri -body $Json -tenantid $TenantFilter -AsApp $true + } + + function Invoke-ActiveAssignmentRemoval { + # adminRemove covers PIM-created and legacy direct assignments alike; the unified RBAC + # delete is only a fallback for the rare record PIM does not recognise. + $Request = New-CIPPPIMScheduleRequest -Kind Assignment -Action adminRemove -PrincipalId $PrincipalId -RoleDefinitionId $RoleDefinitionId -DirectoryScopeId $DirectoryScopeId -Justification $Justification + try { + $null = Send-ScheduleRequest -Request $Request + } catch { + # Entra refuses to retire an active assignment younger than five minutes + # ("The Active duration is too short. Minimum Required is 5 minutes"). + if ($_.Exception.Message -match 'duration is too short') { + throw "Entra requires an active assignment to exist for at least 5 minutes before it can be removed; $PrincipalLabel's $RoleName assignment was created too recently. Try again in a few minutes." + } + if ($_.Exception.Message -notmatch 'RoleAssignmentDoesNotExist|does not exist|NotFound|not found') { throw } + $Existing = @(New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments?`$filter=principalId eq '$PrincipalId' and roleDefinitionId eq '$RoleDefinitionId'" -tenantid $TenantFilter | Where-Object { ($_.directoryScopeId ?? '/') -eq $DirectoryScopeId }) + if ($Existing.Count -eq 0) { throw } + foreach ($Assignment in $Existing) { + $null = New-GraphPOSTRequest -type DELETE -uri "https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments/$($Assignment.id)" -tenantid $TenantFilter + } + } + # Graph accepts the removal (status Revoked) and retires the instance a few seconds later; + # wait for that so the reported state - and the table refresh behind it - is the real one. + for ($Attempt = 0; $Attempt -lt 8; $Attempt++) { + $Remaining = @(New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignmentScheduleInstances?`$filter=principalId eq '$PrincipalId' and roleDefinitionId eq '$RoleDefinitionId'" -tenantid $TenantFilter -AsApp $true | Where-Object { ($_.directoryScopeId ?? '/') -eq $DirectoryScopeId }) + if ($Remaining.Count -eq 0) { return $true } + Start-Sleep -Seconds 3 + } + return $false + } + + $DurationParams = @{} + if (-not [string]::IsNullOrWhiteSpace($Duration)) { $DurationParams.Duration = $Duration } + if ($PSBoundParameters.ContainsKey('EndDateTime') -and $null -ne $EndDateTime) { $DurationParams.EndDateTime = $EndDateTime } + + if (-not $PSCmdlet.ShouldProcess("$PrincipalLabel / $RoleName in $TenantFilter", $Action)) { return } + + $After = $Before + $ResultEnd = $null + switch ($Action) { + 'ConvertToEligible' { + if (-not $ActiveRow) { throw "$PrincipalLabel has no active $RoleName assignment to convert." } + if ($PrincipalType -eq 'ServicePrincipal') { throw "$PrincipalLabel is a service principal; PIM eligibility is only supported for users and groups. Remove the assignment instead if it is not needed." } + Test-LastGlobalAdmin + + if ($DurationParams.Count -eq 0) { $DurationParams.Duration = $EligibilityCap } + if (-not $EligibleRow) { + $Request = New-CIPPPIMScheduleRequest -Kind Eligibility -Action adminAssign -PrincipalId $PrincipalId -RoleDefinitionId $RoleDefinitionId -DirectoryScopeId $DirectoryScopeId -Justification $Justification -MaxDuration $EligibilityCap @DurationParams + try { + $null = Send-ScheduleRequest -Request $Request + } catch { + if ($_.Exception.Message -notmatch 'RoleAssignmentExists|already exists') { throw } + } + $ResultEnd = $Request.EndDateTime + } else { + $ResultEnd = $EligibleRow.EndDateTime + } + + # Verify the eligibility is really there before taking the active assignment away. + $Confirmed = $null + for ($Attempt = 0; $Attempt -lt 6 -and -not $Confirmed; $Attempt++) { + if ($Attempt -gt 0) { Start-Sleep -Seconds 2 } + $Confirmed = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/roleManagement/directory/roleEligibilitySchedules?`$filter=principalId eq '$PrincipalId' and roleDefinitionId eq '$RoleDefinitionId'" -tenantid $TenantFilter -AsApp $true | Where-Object { ($_.directoryScopeId ?? '/') -eq $DirectoryScopeId } | Select-Object -First 1 + } + if (-not $Confirmed) { + throw "The eligibility for $PrincipalLabel on $RoleName could not be confirmed; the active assignment was left in place." + } + + $RemovalSeen = Invoke-ActiveAssignmentRemoval + $After = "Eligible$(if ($ResultEnd) { " until $(& $FormatEnd $ResultEnd)" })" + $ResultText = "Converted $PrincipalLabel on $RoleName from $Before to $After.$(if (-not $RemovalSeen) { ' The removal of the active assignment was accepted by Entra and is still propagating; refresh in a minute.' })" + } + 'GrantActive' { + if ($DurationParams.Count -eq 0) { throw 'GrantActive needs a Duration or EndDateTime.' } + if ($ActiveRow -and $ActiveRow.AssignmentType -eq 'Permanent') { throw "$PrincipalLabel already holds $RoleName permanently; convert it to eligible instead." } + $Request = New-CIPPPIMScheduleRequest -Kind Assignment -Action adminAssign -PrincipalId $PrincipalId -RoleDefinitionId $RoleDefinitionId -DirectoryScopeId $DirectoryScopeId -Justification $Justification -MaxDuration $AssignmentCap @DurationParams + $null = Send-ScheduleRequest -Request $Request + $ResultEnd = $Request.EndDateTime + $After = "Active until $(& $FormatEnd $ResultEnd)" + $ResultText = "Granted $PrincipalLabel a time-bound active $RoleName assignment until $(& $FormatEnd $ResultEnd)." + } + { $_ -in @('Extend', 'Renew') } { + if (-not $TargetRow) { throw "$PrincipalLabel has no $RoleName assignment to $($Action.ToLower())." } + if ($TargetRow.AssignmentType -eq 'Permanent') { throw "A permanent assignment cannot be extended; convert it to eligible instead." } + if ($DurationParams.Count -eq 0) { throw "$Action needs a Duration or EndDateTime." } + $Kind = if ($TargetRow.AssignmentType -eq 'Eligible') { 'Eligibility' } else { 'Assignment' } + $Cap = if ($Kind -eq 'Eligibility') { $EligibilityCap } else { $AssignmentCap } + $GraphAction = if ($Action -eq 'Extend') { 'adminExtend' } else { 'adminRenew' } + $Request = New-CIPPPIMScheduleRequest -Kind $Kind -Action $GraphAction -PrincipalId $PrincipalId -RoleDefinitionId $RoleDefinitionId -DirectoryScopeId $DirectoryScopeId -Justification $Justification -MaxDuration $Cap @DurationParams + $null = Send-ScheduleRequest -Request $Request + $ResultEnd = $Request.EndDateTime + $After = "$($TargetRow.AssignmentType) until $(& $FormatEnd $ResultEnd)" + $ResultText = "$($Action)ed $PrincipalLabel's $($TargetRow.AssignmentType.ToLower()) $RoleName assignment until $(& $FormatEnd $ResultEnd)." + } + 'Remove' { + if (-not $TargetRow) { throw "$PrincipalLabel has no $RoleName assignment to remove." } + $RemovalSeen = $true + if ($TargetRow.AssignmentType -eq 'Eligible') { + $Request = New-CIPPPIMScheduleRequest -Kind Eligibility -Action adminRemove -PrincipalId $PrincipalId -RoleDefinitionId $RoleDefinitionId -DirectoryScopeId $DirectoryScopeId -Justification $Justification + $null = Send-ScheduleRequest -Request $Request + } else { + Test-LastGlobalAdmin + $RemovalSeen = Invoke-ActiveAssignmentRemoval + } + $After = 'None' + $ResultText = "Removed $PrincipalLabel's $($TargetRow.AssignmentType.ToLower()) $RoleName assignment.$(if (-not $RemovalSeen) { ' Entra accepted the removal and is still propagating it; refresh in a minute.' })" + } + } + + $LogData = @{ + Action = $Action + PrincipalId = $PrincipalId + Principal = $PrincipalLabel + RoleDefinitionId = $RoleDefinitionId + Role = $RoleName + DirectoryScopeId = $DirectoryScopeId + Before = $Before + After = $After + Justification = $Justification + } + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "$ResultText (before: $Before, after: $After)" -Sev 'Info' -LogData $LogData + + return [PSCustomObject]@{ + resultText = $ResultText + state = 'success' + Before = $Before + After = $After + EndDateTime = $ResultEnd + } +} diff --git a/Modules/CIPPCore/Public/PIM/New-CIPPPIMScheduleRequest.ps1 b/Modules/CIPPCore/Public/PIM/New-CIPPPIMScheduleRequest.ps1 new file mode 100644 index 0000000000000..97d507b38fc55 --- /dev/null +++ b/Modules/CIPPCore/Public/PIM/New-CIPPPIMScheduleRequest.ps1 @@ -0,0 +1,204 @@ +function New-CIPPPIMScheduleRequest { + <# + .SYNOPSIS + Builds the body for a PIM role eligibility / role assignment schedule request. + + .DESCRIPTION + The ONLY place in CIPP that constructs roleEligibilityScheduleRequests and + roleAssignmentScheduleRequests bodies. It exists so that the security rule "CIPP can never + create a permanent (no-expiration) assignment or eligibility" is enforced in one function + that every caller - endpoints, standards, scheduled tasks - has to go through. + + Every request that creates, updates, extends or renews a schedule MUST carry an expiration, + expressed either as an ISO 8601 duration (-Duration) or an absolute end (-EndDateTime). + There is deliberately no parameter that produces scheduleInfo.expiration.type + 'noExpiration'; asking for it by any spelling throws. adminRemove is the one action that + needs no schedule. + + -MaxDuration caps the effective lifetime. Callers pass the tightest applicable limit + (role policy maximum, JIT maximum duration setting, the PIM ceiling) and the builder refuses + anything longer rather than clamping it, so the user sees why a request was rejected. + + .PARAMETER Kind + Eligibility -> roleEligibilityScheduleRequests; Assignment -> roleAssignmentScheduleRequests. + + .PARAMETER Action + adminAssign | adminUpdate | adminExtend | adminRenew | adminRemove. Self-service actions + (selfActivate, selfDeactivate, ...) are not offered: they can only be performed by the + principal, so CIPP uses time-bound active assignments instead. + + .PARAMETER Duration + ISO 8601 duration, e.g. PT8H, P1D, P6M, P1Y. Mutually exclusive with -EndDateTime. + + .PARAMETER EndDateTime + Absolute end. Must be in the future. Mutually exclusive with -Duration. + + .PARAMETER StartDateTime + Optional start; defaults to now (UTC). + + .PARAMETER MaxDuration + ISO 8601 duration cap. The effective lifetime (Duration, or EndDateTime - start) may not + exceed it. + + .OUTPUTS + PSCustomObject: Uri (v1.0 Graph endpoint), Body (ordered hashtable ready for ConvertTo-Json), + Kind, Action, ExpirationType, StartDateTime, EndDateTime (UTC, $null for adminRemove). + + .EXAMPLE + $Req = New-CIPPPIMScheduleRequest -Kind Assignment -Action adminAssign -PrincipalId $Id -RoleDefinitionId $Role -Duration 'PT4H' -Justification 'Ticket 1234' -MaxDuration 'PT8H' + New-GraphPOSTRequest -uri $Req.Uri -body ($Req.Body | ConvertTo-Json -Depth 10) -tenantid $Tenant -AsApp $true + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [ValidateSet('Eligibility', 'Assignment')] + [string]$Kind, + + [Parameter(Mandatory = $true)] + [ValidateSet('adminAssign', 'adminUpdate', 'adminExtend', 'adminRenew', 'adminRemove')] + [string]$Action, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$PrincipalId, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$RoleDefinitionId, + + [string]$DirectoryScopeId = '/', + + [string]$Justification, + + [string]$Duration, + + [datetime]$EndDateTime, + + [datetime]$StartDateTime, + + [string]$MaxDuration, + + [string]$TicketNumber, + + [string]$TicketSystem + ) + + $Uri = if ($Kind -eq 'Eligibility') { + 'https://graph.microsoft.com/v1.0/roleManagement/directory/roleEligibilityScheduleRequests' + } else { + 'https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignmentScheduleRequests' + } + + if ([string]::IsNullOrWhiteSpace($DirectoryScopeId)) { $DirectoryScopeId = '/' } + + $Body = [ordered]@{ + action = $Action + principalId = $PrincipalId + roleDefinitionId = $RoleDefinitionId + directoryScopeId = $DirectoryScopeId + } + if (-not [string]::IsNullOrWhiteSpace($Justification)) { + $Body.justification = $Justification + } + if (-not [string]::IsNullOrWhiteSpace($TicketNumber)) { + $Body.ticketInfo = @{ ticketNumber = $TicketNumber; ticketSystem = $TicketSystem } + } + + if ($Action -eq 'adminRemove') { + return [PSCustomObject]@{ + Uri = $Uri + Body = $Body + Kind = $Kind + Action = $Action + ExpirationType = $null + StartDateTime = $null + EndDateTime = $null + } + } + + # Everything below creates or changes a schedule, so it has to end. + $PermanentPattern = '^\s*(noExpiration|permanent|never|none|unlimited)\s*$' + if ($Duration -match $PermanentPattern) { + throw "Refusing to build a $Kind $Action request: '$Duration' asks for a permanent (no-expiration) schedule, which CIPP never creates. Supply an ISO 8601 duration such as PT8H or P1Y." + } + + $HasDuration = -not [string]::IsNullOrWhiteSpace($Duration) + $HasEnd = $PSBoundParameters.ContainsKey('EndDateTime') -and $null -ne $EndDateTime + if (-not $HasDuration -and -not $HasEnd) { + throw "Refusing to build a $Kind $Action request without an expiration: CIPP never creates permanent (no-expiration) role schedules. Supply -Duration (ISO 8601) or -EndDateTime." + } + if ($HasDuration -and $HasEnd) { + throw 'Specify either -Duration or -EndDateTime, not both.' + } + + $NowUtc = [datetime]::UtcNow + $Start = if ($PSBoundParameters.ContainsKey('StartDateTime') -and $null -ne $StartDateTime) { + $StartDateTime.ToUniversalTime() + } else { + $NowUtc + } + + if ($HasDuration) { + try { + $DurationSpan = [System.Xml.XmlConvert]::ToTimeSpan($Duration) + } catch { + throw "'$Duration' is not a valid ISO 8601 duration (expected a value such as PT8H, P1D, P6M or P1Y)." + } + if ($DurationSpan -le [timespan]::Zero) { + throw "Duration '$Duration' must be greater than zero." + } + $EffectiveSpan = $DurationSpan + $ComputedEnd = $Start.Add($DurationSpan) + $Expiration = [ordered]@{ + type = 'afterDuration' + duration = $Duration + } + $ExpirationType = 'afterDuration' + } else { + $EndUtc = $EndDateTime.ToUniversalTime() + if ($EndUtc -le $NowUtc) { + throw "EndDateTime $($EndUtc.ToString('o')) is not in the future." + } + if ($EndUtc -le $Start) { + throw "EndDateTime $($EndUtc.ToString('o')) is not after the start $($Start.ToString('o'))." + } + $EffectiveSpan = $EndUtc - $Start + $ComputedEnd = $EndUtc + $Expiration = [ordered]@{ + type = 'afterDateTime' + endDateTime = $EndUtc.ToString('yyyy-MM-ddTHH:mm:ssZ') + } + $ExpirationType = 'afterDateTime' + } + + if (-not [string]::IsNullOrWhiteSpace($MaxDuration)) { + try { + $MaxSpan = [System.Xml.XmlConvert]::ToTimeSpan($MaxDuration) + } catch { + throw "MaxDuration '$MaxDuration' is not a valid ISO 8601 duration." + } + if ($EffectiveSpan -gt $MaxSpan) { + $Requested = [math]::Round($EffectiveSpan.TotalHours, 2) + $Allowed = [math]::Round($MaxSpan.TotalHours, 2) + throw "Requested $Kind lifetime ($Requested hours) exceeds the maximum allowed ($MaxDuration = $Allowed hours). Shorten the request; CIPP does not extend limits." + } + } + + $Body.scheduleInfo = [ordered]@{ + startDateTime = $Start.ToString('yyyy-MM-ddTHH:mm:ssZ') + expiration = $Expiration + } + + return [PSCustomObject]@{ + Uri = $Uri + Body = $Body + Kind = $Kind + Action = $Action + ExpirationType = $ExpirationType + StartDateTime = $Start + EndDateTime = $ComputedEnd + } +} diff --git a/Modules/CIPPCore/Public/PIM/Repair-CIPPPIMRoleSettingsFloor.ps1 b/Modules/CIPPCore/Public/PIM/Repair-CIPPPIMRoleSettingsFloor.ps1 new file mode 100644 index 0000000000000..6af085a2205ce --- /dev/null +++ b/Modules/CIPPCore/Public/PIM/Repair-CIPPPIMRoleSettingsFloor.ps1 @@ -0,0 +1,118 @@ +function Repair-CIPPPIMRoleSettingsFloor { + <# + .SYNOPSIS + Raises captured PIM role settings to CIPP's secure floor, reporting every change. + + .DESCRIPTION + Used when a template is created from a role's current settings in a tenant + (ConvertFrom-CIPPPIMPolicyRules output). A tenant's live policy may sit below the secure + floor - permanent eligibility or active assignments, activation without MFA - and a + template must never store that, so each offending value is replaced with the closest value + the floor allows and the change is returned as an adjustment for the caller to surface. + Settings already at or above the floor pass through untouched, so capturing a compliant + role is an exact copy. + + This is the one deliberate exception to "reject, never adjust": templates typed in by an + administrator are still rejected outright (Test-CIPPPIMRoleSettingsFloor); only a capture + of what a tenant already has is raised, because the tenant's own values are the input and + refusing them would make capture useless on any tenant still on Entra's defaults. + + .PARAMETER Settings + The canonical settings object, as returned by ConvertFrom-CIPPPIMPolicyRules. + + .OUTPUTS + PSCustomObject: Settings (the repaired copy), Adjustments (string[] describing each raise). + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + $Adjustments = [System.Collections.Generic.List[string]]::new() + + function Repair-Duration { + param([string]$Value, [string]$Max, [string]$Describe) + if ([string]::IsNullOrWhiteSpace($Value)) { + $Adjustments.Add("$Describe did not require an expiration (permanent allowed); set to $Max.") + return $Max + } + $Span = try { [System.Xml.XmlConvert]::ToTimeSpan($Value) } catch { $null } + if ($null -eq $Span -or $Span -le [timespan]::Zero) { + $Adjustments.Add("$Describe had an unusable duration '$Value'; set to $Max.") + return $Max + } + if ($Span -gt [System.Xml.XmlConvert]::ToTimeSpan($Max)) { + $Adjustments.Add("$Describe allowed '$Value', above the floor maximum; lowered to $Max.") + return $Max + } + return $Value + } + + $ActivationRequires = "$($Settings.activationRequires)" + $ClaimValue = "$($Settings.authenticationContextClaimValue)" + if ($ActivationRequires -eq 'AuthenticationContext' -and $ClaimValue -notmatch '^c\d{1,2}$') { + $Adjustments.Add("Activation used an authentication context without a usable claim value ('$ClaimValue'); switched to requiring MFA.") + $ActivationRequires = 'MFA' + $ClaimValue = '' + } elseif ($ActivationRequires -notin @('MFA', 'AuthenticationContext')) { + $Adjustments.Add('Activation did not require MFA or an authentication context; set to require MFA.') + $ActivationRequires = 'MFA' + $ClaimValue = '' + } + + $ActivationJustification = $Settings.activationRequiresJustification -eq $true + if (-not $ActivationJustification) { + $Adjustments.Add('Activation did not require a justification; enabled it.') + $ActivationJustification = $true + } + + $RequiresApproval = $Settings.activationRequiresApproval -eq $true + $Approvers = "$($Settings.approvers)" + if ($RequiresApproval -and [string]::IsNullOrWhiteSpace($Approvers)) { + $Adjustments.Add('Activation required approval but no approver could be captured; approval disabled.') + $RequiresApproval = $false + } + + $ActiveJustification = $Settings.activeAssignmentRequiresJustification -eq $true + if (-not $ActiveJustification) { + $Adjustments.Add('Creating an active assignment did not require a justification; enabled it.') + $ActiveJustification = $true + } + + $Recipients = @("$($Settings.notificationRecipients)" -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + $ValidRecipients = @($Recipients | Where-Object { $_ -match '^[^@\s]+@[^@\s]+\.[^@\s]+$' }) + if ($ValidRecipients.Count -lt $Recipients.Count) { + $Dropped = @($Recipients | Where-Object { $_ -notin $ValidRecipients }) + $Adjustments.Add("Dropped notification recipient(s) that are not e-mail addresses: $($Dropped -join ', ').") + } + $NotificationLevel = "$($Settings.notificationLevel)" + if ($ValidRecipients.Count -gt 0 -and $NotificationLevel -notin @('All', 'Critical')) { + $Adjustments.Add("Notification level '$NotificationLevel' is not valid; set to 'All'.") + $NotificationLevel = 'All' + } + + $Repaired = [PSCustomObject]@{ + activationMaxDuration = Repair-Duration -Value $Settings.activationMaxDuration -Max 'PT24H' -Describe 'Role activation' + activationRequires = $ActivationRequires + authenticationContextClaimValue = $ClaimValue + activationRequiresJustification = $ActivationJustification + activationRequiresTicket = $Settings.activationRequiresTicket -eq $true + activationRequiresApproval = $RequiresApproval + approvers = if ($RequiresApproval) { $Approvers } else { '' } + eligibilityMaxDuration = Repair-Duration -Value $Settings.eligibilityMaxDuration -Max 'P365D' -Describe 'Eligible assignments' + activeAssignmentMaxDuration = Repair-Duration -Value $Settings.activeAssignmentMaxDuration -Max 'P365D' -Describe 'Active assignments' + activeAssignmentRequiresMfa = $Settings.activeAssignmentRequiresMfa -eq $true + activeAssignmentRequiresJustification = $ActiveJustification + notificationRecipients = ($ValidRecipients -join ', ') + notificationLevel = if ([string]::IsNullOrWhiteSpace($NotificationLevel)) { 'All' } else { $NotificationLevel } + } + + return [PSCustomObject]@{ + Settings = $Repaired + Adjustments = @($Adjustments) + } +} diff --git a/Modules/CIPPCore/Public/PIM/Set-CIPPPIMRoleSettings.ps1 b/Modules/CIPPCore/Public/PIM/Set-CIPPPIMRoleSettings.ps1 new file mode 100644 index 0000000000000..214fa45159630 --- /dev/null +++ b/Modules/CIPPCore/Public/PIM/Set-CIPPPIMRoleSettings.ps1 @@ -0,0 +1,74 @@ +function Set-CIPPPIMRoleSettings { + <# + .SYNOPSIS + Applies desired PIM policy rules to a role's management policy, one PATCH per differing rule. + + .DESCRIPTION + Compares first (Compare-CIPPPIMRoleSettings) and only writes the rules that differ, so a + compliant tenant sees no writes and the logbook records exactly what changed. Callers must + have validated the desired settings against the secure floor; this function does not + weaken or strengthen anything on its own. + + .PARAMETER PolicyId + The unifiedRoleManagementPolicy id (from policies/roleManagementPolicyAssignments.policyId). + + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $true)] + [string]$PolicyId, + + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [array]$DesiredRules, + + [Parameter(Mandatory = $true)] + [AllowNull()] + [AllowEmptyCollection()] + $CurrentRules, + + [string]$RoleName = '', + + $Headers, + + [string]$APIName = 'Standards' + ) + + $Differences = Compare-CIPPPIMRoleSettings -DesiredRules $DesiredRules -CurrentRules $CurrentRules -RoleName $RoleName + $Results = [System.Collections.Generic.List[object]]::new() + + if ($Differences.Count -eq 0) { + return @($Results) + } + + $RuleIds = @($Differences.Rule | Sort-Object -Unique) + foreach ($RuleId in $RuleIds) { + $Rule = $DesiredRules | Where-Object { $_['id'] -eq $RuleId } | Select-Object -First 1 + if (-not $Rule) { continue } + + $Changed = @($Differences | Where-Object { $_.Rule -eq $RuleId } | ForEach-Object { "$($_.Property): $($_.Current) -> $($_.Expected)" }) -join '; ' + $Uri = "https://graph.microsoft.com/beta/policies/roleManagementPolicies/$PolicyId/rules/$RuleId" + $Body = ConvertTo-Json -InputObject $Rule -Depth 20 -Compress + + if (-not $PSCmdlet.ShouldProcess("$RoleName ($PolicyId) rule $RuleId", 'PATCH')) { continue } + + try { + $null = New-GraphPOSTRequest -type PATCH -uri $Uri -body $Body -tenantid $TenantFilter -AsApp $true + $Message = "Updated PIM role setting $RuleId for $RoleName`: $Changed" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Message -Sev 'Info' -LogData @{ Role = $RoleName; PolicyId = $PolicyId; Rule = $RuleId; Changes = $Changed } + $Results.Add([PSCustomObject]@{ Rule = $RuleId; Success = $true; Message = $Message }) + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Message = "Failed to update PIM role setting $RuleId for $RoleName`: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Message -Sev 'Error' -LogData $ErrorMessage + $Results.Add([PSCustomObject]@{ Rule = $RuleId; Success = $false; Message = $Message }) + } + } + + return @($Results) +} diff --git a/Modules/CIPPCore/Public/PIM/Test-CIPPPIMRoleSettingsFloor.ps1 b/Modules/CIPPCore/Public/PIM/Test-CIPPPIMRoleSettingsFloor.ps1 new file mode 100644 index 0000000000000..edb9b7252c12e --- /dev/null +++ b/Modules/CIPPCore/Public/PIM/Test-CIPPPIMRoleSettingsFloor.ps1 @@ -0,0 +1,134 @@ +function Test-CIPPPIMRoleSettingsFloor { + <# + .SYNOPSIS + Validates PIM role settings against CIPP's secure floor. + + .DESCRIPTION + Pure validation shared by the PIM role-settings template endpoints (a template below the + floor is rejected, not clamped), the PIMRoleSettings standard (a stored template is + re-checked at run time so a hand-edited table row cannot weaken a tenant) and the policy + summary shown on the Roles pages (a tenant's live policy is graded against the same floor). + + The floor: + - activation (Expiration_EndUser_Assignment) must expire; maximum PT24H. Above PT8H is + allowed but reported as a warning so the override is visible in the logbook. + - activation must require MFA, or an authentication context (the two are mutually + exclusive in Entra, so one of them is enough). + - activation must require a justification. + - eligibility (Expiration_Admin_Eligibility) must expire; maximum P365D. + - active assignments (Expiration_Admin_Assignment) must expire; maximum P365D. This is what + stops permanent active assignments being created in the portal as well as in CIPP. + - active assignments must require a justification. + - approval is optional, but when required at least one approver must be named. + - notification recipients, when given, must be e-mail addresses with a valid level. + + .PARAMETER Settings + The canonical settings object (see ConvertTo-CIPPPIMRoleSettings). A $null duration means + "no expiration" and fails the floor. + + .OUTPUTS + PSCustomObject: Valid (bool), Errors (string[]), Warnings (string[]). + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + $Settings + ) + + $Errors = [System.Collections.Generic.List[string]]::new() + $Warnings = [System.Collections.Generic.List[string]]::new() + + if ($null -eq $Settings) { + $Errors.Add('No settings supplied.') + return [PSCustomObject]@{ Valid = $false; Errors = @($Errors); Warnings = @($Warnings) } + } + + function Test-FloorBool { + param($Value) + if ($Value -is [bool]) { return $Value } + if ($null -eq $Value) { return $false } + return ("$Value" -match '^(true|1|yes)$') + } + + function Get-FloorSpan { + param([string]$Value, [string]$Name, [string]$Max, [string]$Describe) + if ([string]::IsNullOrWhiteSpace($Value)) { + $Errors.Add("$Describe must expire ($Name is empty - a permanent/no-expiration setting is below the secure floor).") + return $null + } + try { + $Span = [System.Xml.XmlConvert]::ToTimeSpan($Value) + } catch { + $Errors.Add("$Describe`: '$Value' is not a valid ISO 8601 duration ($Name).") + return $null + } + if ($Span -le [timespan]::Zero) { + $Errors.Add("$Describe`: '$Value' must be greater than zero ($Name).") + return $null + } + $MaxSpan = [System.Xml.XmlConvert]::ToTimeSpan($Max) + if ($Span -gt $MaxSpan) { + $Errors.Add("$Describe`: '$Value' exceeds the maximum of $Max ($Name).") + return $null + } + return $Span + } + + # Activation (end-user assignment) + $ActivationSpan = Get-FloorSpan -Value $Settings.activationMaxDuration -Name 'activationMaxDuration' -Max 'PT24H' -Describe 'Role activation' + if ($ActivationSpan -and $ActivationSpan -gt [System.Xml.XmlConvert]::ToTimeSpan('PT8H')) { + $Warnings.Add("Role activation maximum '$($Settings.activationMaxDuration)' exceeds the recommended PT8H.") + } + + $Requires = "$($Settings.activationRequires)" + switch ($Requires) { + 'MFA' { } + 'AuthenticationContext' { + if ("$($Settings.authenticationContextClaimValue)" -notmatch '^c\d{1,2}$') { + $Errors.Add("Activation with an authentication context needs a claim value such as 'c1' (authenticationContextClaimValue).") + } + } + default { + $Errors.Add("Role activation must require MFA or an authentication context (activationRequires is '$Requires').") + } + } + + if (-not (Test-FloorBool $Settings.activationRequiresJustification)) { + $Errors.Add('Role activation must require a justification (activationRequiresJustification).') + } + + if ((Test-FloorBool $Settings.activationRequiresApproval) -and [string]::IsNullOrWhiteSpace("$($Settings.approvers)")) { + $Errors.Add('Approval is required but no approvers are named (approvers).') + } + + # Admin eligibility / assignment + $null = Get-FloorSpan -Value $Settings.eligibilityMaxDuration -Name 'eligibilityMaxDuration' -Max 'P365D' -Describe 'Eligible assignments' + $null = Get-FloorSpan -Value $Settings.activeAssignmentMaxDuration -Name 'activeAssignmentMaxDuration' -Max 'P365D' -Describe 'Active assignments' + + if (-not (Test-FloorBool $Settings.activeAssignmentRequiresJustification)) { + $Errors.Add('Creating an active assignment must require a justification (activeAssignmentRequiresJustification).') + } + + # Notifications + $Recipients = @("$($Settings.notificationRecipients)" -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + if ($Recipients.Count -gt 0) { + foreach ($Recipient in $Recipients) { + if ($Recipient -notmatch '^[^@\s]+@[^@\s]+\.[^@\s]+$') { + $Errors.Add("'$Recipient' is not a valid notification e-mail address (notificationRecipients).") + } + } + if ("$($Settings.notificationLevel)" -notin @('All', 'Critical')) { + $Errors.Add("notificationLevel must be 'All' or 'Critical' when recipients are set (found '$($Settings.notificationLevel)').") + } + } + + return [PSCustomObject]@{ + Valid = ($Errors.Count -eq 0) + Errors = @($Errors) + Warnings = @($Warnings) + } +} diff --git a/Modules/CIPPCore/Public/Remove-CIPPDbItem.ps1 b/Modules/CIPPCore/Public/Remove-CIPPDbItem.ps1 index 056edfc4a5aac..4386ac7a30b50 100644 --- a/Modules/CIPPCore/Public/Remove-CIPPDbItem.ps1 +++ b/Modules/CIPPCore/Public/Remove-CIPPDbItem.ps1 @@ -4,7 +4,9 @@ function Remove-CIPPDbItem { Remove an item from the CIPP Reporting database .DESCRIPTION - Removes a specific item from the CippReportingDB table using partition key (tenant) and row key (item ID) + Removes a specific item from the CippReportingDB table using partition key (tenant) + and either Type+ItemId or an explicit RowKey. Decrements the matching {Type}-Count + row when a data row (not the Count row itself) is removed. .PARAMETER TenantFilter The tenant domain or GUID (partition key) @@ -15,10 +17,19 @@ function Remove-CIPPDbItem { .PARAMETER ItemId The item ID or identifier to remove (used in row key) + .PARAMETER RowKey + Explicit table RowKey (preferred when the caller already has storage keys) + + .PARAMETER ETag + Optional ETag when deleting by RowKey + .EXAMPLE Remove-CIPPDbItem -TenantFilter 'contoso.onmicrosoft.com' -Type 'MailboxRules' -ItemId 'rule-id-123' + + .EXAMPLE + Remove-CIPPDbItem -TenantFilter 'contoso.onmicrosoft.com' -Type 'Users' -RowKey 'Users-abc-123' #> - [CmdletBinding()] + [CmdletBinding(DefaultParameterSetName = 'ByItemId')] param( [Parameter(Mandatory = $true)] [string]$TenantFilter, @@ -26,27 +37,52 @@ function Remove-CIPPDbItem { [Parameter(Mandatory = $true)] [string]$Type, - [Parameter(Mandatory = $true)] - [string]$ItemId + [Parameter(Mandatory = $true, ParameterSetName = 'ByItemId')] + [string]$ItemId, + + [Parameter(Mandatory = $true, ParameterSetName = 'ByRowKey')] + [string]$RowKey, + + [Parameter(ParameterSetName = 'ByRowKey')] + [string]$ETag ) try { $Table = Get-CippTable -tablename 'CippReportingDB' - # Sanitize the ItemId for RowKey (same as in Add-CIPPDbItem) - $SanitizedId = $ItemId -replace '[/\\#?]', '_' -replace '[\u0000-\u001F\u007F-\u009F]', '' - $RowKey = "$Type-$SanitizedId" + if ($TenantFilter -match '^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$') { + $TenantLookup = Get-Tenants -TenantFilter $TenantFilter + if ($TenantLookup) { + $TenantFilter = $TenantLookup.defaultDomainName + } + } + + if ($PSCmdlet.ParameterSetName -eq 'ByItemId') { + # Sanitize the ItemId for RowKey (same as in Add-CIPPDbItem) + $SanitizedId = $ItemId -replace '[/\\#?]', '_' -replace '[\u0000-\u001F\u007F-\u009F]', '' + $RowKey = "$Type-$SanitizedId" + } else { + $ExpectedPrefix = "$Type-" + if (-not $RowKey.StartsWith($ExpectedPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "RowKey '$RowKey' does not match type '$Type'" + } + } - # Try to get the entity $Filter = "PartitionKey eq '$TenantFilter' and RowKey eq '$RowKey'" $Entity = Get-CIPPAzDataTableEntity @Table -Filter $Filter if ($Entity) { - # Remove the entity + if ($ETag) { + $Entity | Add-Member -MemberType NoteProperty -Name 'ETag' -Value $ETag -Force + } Remove-CIPPAzDataTableEntity @Table -Entity $Entity -Force - Write-LogMessage -API 'CIPPDbItem' -tenant $TenantFilter -message "Removed $Type item with ID: $ItemId" -sev Debug + Write-LogMessage -API 'CIPPDbItem' -tenant $TenantFilter -message "Removed $Type row: $RowKey" -sev Debug + + # Do not decrement when removing the Count row itself + if ($RowKey -eq "$Type-Count") { + return + } - # Always decrement count try { $CountRowKey = "$Type-Count" $CountFilter = "PartitionKey eq '$TenantFilter' and RowKey eq '$CountRowKey'" @@ -66,7 +102,7 @@ function Remove-CIPPDbItem { Write-LogMessage -API 'CIPPDbItem' -tenant $TenantFilter -message "Failed to decrement count for $Type : $($_.Exception.Message)" -sev Warning } } else { - Write-LogMessage -API 'CIPPDbItem' -tenant $TenantFilter -message "Item not found for removal: $Type with ID $ItemId" -sev Debug + Write-LogMessage -API 'CIPPDbItem' -tenant $TenantFilter -message "Item not found for removal: $Type row $RowKey" -sev Debug } } catch { diff --git a/Modules/CIPPCore/Public/Remove-CIPPMobileDevice.ps1 b/Modules/CIPPCore/Public/Remove-CIPPMobileDevice.ps1 index e436fce09d5c6..3663920260845 100644 --- a/Modules/CIPPCore/Public/Remove-CIPPMobileDevice.ps1 +++ b/Modules/CIPPCore/Public/Remove-CIPPMobileDevice.ps1 @@ -14,10 +14,14 @@ function Remove-CIPPMobileDevice { $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-MobileDevice' -Anchor $Username -cmdParams @{mailbox = $Username } | ForEach-Object { try { $MobileDevice = $_ + # FriendlyName is usually empty; fall back like the ActiveSync device list. + $DeviceName = @($MobileDevice.FriendlyName, $MobileDevice.DeviceModel, $MobileDevice.DeviceOS, $MobileDevice.DeviceId) | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -First 1 + if (-not $DeviceName) { $DeviceName = 'Unknown device' } $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-MobileDevice' -Anchor $Username -cmdParams @{Identity = $MobileDevice.Identity } - $RemovedDevices.Add("$($MobileDevice.FriendlyName)") + $RemovedDevices.Add([string]$DeviceName) } catch { - $ErrorDevices.Add("$($MobileDevice.FriendlyName)") + $ErrorDevices.Add([string]$DeviceName) } } if ($ErrorDevices.Count -eq 0) { diff --git a/Modules/CIPPCore/Public/Remove-CIPPSPOSiteUser.ps1 b/Modules/CIPPCore/Public/Remove-CIPPSPOSiteUser.ps1 index f62defdce7a55..7ddeb0b1ff9ef 100644 --- a/Modules/CIPPCore/Public/Remove-CIPPSPOSiteUser.ps1 +++ b/Modules/CIPPCore/Public/Remove-CIPPSPOSiteUser.ps1 @@ -44,7 +44,7 @@ function Remove-CIPPSPOSiteUser { foreach ($SiteUrl in $SiteUrls) { if (-not $PSCmdlet.ShouldProcess($SiteUrl, "Remove $LoginName")) { continue } - $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + $BaseUri = (Resolve-CIPPSharePointRestContext -TenantFilter $TenantFilter -SiteUrl $SiteUrl -SharePointInfo $SharePointInfo).BaseUri try { try { $EnsureBody = ConvertTo-Json -Compress -InputObject @{ logonName = $LoginName } diff --git a/Modules/CIPPCore/Public/Remove-CIPPUserTeamsPhoneDIDs.ps1 b/Modules/CIPPCore/Public/Remove-CIPPUserTeamsPhoneDIDs.ps1 index 420e41e86ea63..fc7f7db2f8c6c 100644 --- a/Modules/CIPPCore/Public/Remove-CIPPUserTeamsPhoneDIDs.ps1 +++ b/Modules/CIPPCore/Public/Remove-CIPPUserTeamsPhoneDIDs.ps1 @@ -13,6 +13,8 @@ function Remove-CIPPUserTeamsPhoneDIDs { $TenantFilter ) + $BaseUri = 'https://graph.microsoft.com/v1.0/admin/teams/telephoneNumberManagement/numberAssignments' + try { # Set Username to UserID if not provided @@ -25,61 +27,41 @@ function Remove-CIPPUserTeamsPhoneDIDs { $SuccessCount = 0 $ErrorCount = 0 - # Get all tenant DIDs - $TeamsPhoneDIDs = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/admin/teams/telephoneNumberManagement/numberAssignments" -tenant $TenantFilter + $TeamsPhoneDIDs = New-GraphGetRequest -uri $BaseUri -tenantid $TenantFilter if (-not $TeamsPhoneDIDs -or $TeamsPhoneDIDs.Count -eq 0) { - $Result = "No Teams Phone DIDs found in tenant" + $Result = 'No Teams Phone DIDs found in tenant' $Results.Add($Result) return $Results.ToArray() } # Filter DIDs assigned to the specific user - $UserDIDs = $TeamsPhoneDIDs | Where-Object { $_.assignmentTargetId -eq $UserID -and $_.assignmentStatus -ne 'unassigned' } + $UserDIDs = @($TeamsPhoneDIDs | Where-Object { $_.assignmentTargetId -eq $UserID -and $_.assignmentStatus -ne 'unassigned' }) - if (-not $UserDIDs -or $UserDIDs.Count -eq 0) { + if ($UserDIDs.Count -eq 0) { $Result = "No Teams Phone DIDs found assigned to user: '$Username' - '$UserID'" $Results.Add($Result) return $Results.ToArray() } - # Prepare bulk requests for all DIDs - $RemoveRequests = foreach ($DID in $UserDIDs) { - @{ - id = $DID.telephoneNumber - method = 'POST' - url = "admin/teams/telephoneNumberManagement/numberAssignments/unassignNumber" - headers = @{ - 'Content-Type' = 'application/json' - } - body = @{ - telephoneNumber = $DID.telephoneNumber - numberType = $DID.numberType + # One POST per number: $batch fails with an IIS 'Request Too Long' page. + foreach ($DID in $UserDIDs) { + $PhoneNumber = $DID.telephoneNumber + try { + $Body = @{ + telephoneNumber = $PhoneNumber + numberType = Get-CippTeamsNumberType -NumberType $DID.numberType } - } - } + $null = New-GraphPOSTRequest -uri "$BaseUri/unassignNumber" -tenantid $TenantFilter -body ($Body | ConvertTo-Json -Compress) -type POST - # Execute bulk request - $RemoveResults = New-GraphBulkRequest -tenantid $TenantFilter -requests @($RemoveRequests) - - # Process results - $RemoveResults | ForEach-Object { - $PhoneNumber = $_.id - - if ($_.status -in (202, 204)) { $SuccessResult = "Successfully removed Teams Phone DID: '$PhoneNumber' from: '$Username' - '$UserID'" Write-LogMessage -headers $Headers -API $APIName -message $SuccessResult -Sev 'Info' -tenant $TenantFilter $Results.Add($SuccessResult) $SuccessCount++ - } else { - $ErrorMessage = if ($_.body.error.message) { - $_.body.error.message - } else { - "HTTP Status: $($_.status)" - } - - $ErrorResult = "Failed to remove Teams Phone DID: '$PhoneNumber' from: '$Username' - '$UserID'. Error: $ErrorMessage" - Write-LogMessage -headers $Headers -API $APIName -message $ErrorResult -Sev 'Error' -tenant $TenantFilter + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $ErrorResult = "Failed to remove Teams Phone DID: '$PhoneNumber' from: '$Username' - '$UserID'. Error: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -message $ErrorResult -Sev 'Error' -tenant $TenantFilter -LogData $ErrorMessage $Results.Add($ErrorResult) $ErrorCount++ } diff --git a/Modules/CIPPCore/Public/Request-CIPPSPOPersonalSite.ps1 b/Modules/CIPPCore/Public/Request-CIPPSPOPersonalSite.ps1 index 46afa2f3d3333..13816a670d220 100644 --- a/Modules/CIPPCore/Public/Request-CIPPSPOPersonalSite.ps1 +++ b/Modules/CIPPCore/Public/Request-CIPPSPOPersonalSite.ps1 @@ -39,13 +39,30 @@ function Request-CIPPSPOPersonalSite { $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter try { - $Request = New-GraphPostRequest -scope "$($SharePointInfo.AdminUrl)/.default" -tenantid $TenantFilter -Uri "$($SharePointInfo.AdminUrl)/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' - if (!$Request.IsComplete) { throw } + # OneDrive pre-provisioning (ProfileLoader.RequestPersonalSites) is a User Profile Service + # operation, so it must run app-only with the SAM certificate: a GDAP delegated token has no + # licensed user object in the customer tenant and SharePoint refuses it with a bare 401. App-only + # here needs the SharePoint 'User.ReadWrite.All' application permission (declared in + # SAMManifest.json, applied on CPV consent) on top of Sites.FullControl.All. + $Request = New-GraphPostRequest -scope "$($SharePointInfo.AdminUrl)/.default" -tenantid $TenantFilter -Uri "$($SharePointInfo.AdminUrl)/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AsApp $true -UseCertificate + + # ProcessQuery answers HTTP 200 even when the request was refused - the reason rides in the CSOM + # ErrorInfo node (e.g. an "access to profile information" denial when the permission above is not + # yet consented). Surface it so a refusal is not reported back as success. + $CsomError = ($Request | Where-Object { $_.ErrorInfo } | Select-Object -First 1).ErrorInfo.ErrorMessage + if ($CsomError) { throw $CsomError } + if (!$Request.IsComplete) { throw 'SharePoint did not confirm the personal site request.' } Write-LogMessage -headers $Headers -API $APIName -message "Requested personal site for $($UserEmails -join ', ')" -Sev 'Info' -tenant $TenantFilter return "Successfully requested personal site for $($UserEmails -join ', ')" } catch { $ErrorMessage = Get-CippException -Exception $_ - $Result = "Failed to request personal site for $($UserEmails -join ', '). Error: $($ErrorMessage.NormalizedError)" + $Detail = $ErrorMessage.NormalizedError + # A "profile information" denial means the SAM app has not been granted the SharePoint + # User.ReadWrite.All application permission in this tenant yet - refreshing CPV consent fixes it. + if ($Detail -match 'profile information') { + $Detail = "$Detail - CIPP is missing the SharePoint 'User.ReadWrite.All' application permission in $TenantFilter. Refresh the tenant's CPV permissions and try again." + } + $Result = "Failed to request personal site for $($UserEmails -join ', '). Error: $Detail" Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Error' -tenant $TenantFilter -LogData $ErrorMessage throw $Result } diff --git a/Modules/CIPPCore/Public/Resolve-CIPPIntuneTargetedMobileApps.ps1 b/Modules/CIPPCore/Public/Resolve-CIPPIntuneTargetedMobileApps.ps1 new file mode 100644 index 0000000000000..bae616ac6ac35 --- /dev/null +++ b/Modules/CIPPCore/Public/Resolve-CIPPIntuneTargetedMobileApps.ps1 @@ -0,0 +1,105 @@ +function Resolve-CIPPIntuneTargetedMobileApps { + <# + .SYNOPSIS + Resolves an app configuration template's targeted apps to the target tenant's mobile app ids. + .DESCRIPTION + A managed-device app configuration policy (deviceAppManagement/mobileAppConfigurations) names + the apps it applies to by mobileApp id, and those ids exist only in the tenant the policy was + captured from. Deploying the template's ids into another tenant fails with an unknown app. + + New-CIPPIntuneTemplate records each targeted app's identity alongside the ids + (targetedMobileAppsDetails: bundle id, package id, display name, type). This function turns + that into the matching app ids in the tenant being deployed to: bundle id or package id first, + then display name plus app type. Any app that cannot be found is reported by name rather than + silently dropped, because a configuration policy that targets nothing is not the policy that + was asked for. + + Templates captured before the identity was recorded carry only ids. Those are kept when the + target tenant has an app with that id (the same tenant, or a re-deploy), and rejected with an + explanation otherwise. + .PARAMETER PolicyFile + The template payload, parsed from the template's RAWJson. + .PARAMETER TenantFilter + The tenant the policy is being deployed to. + .OUTPUTS + The resolved mobileApp ids for the target tenant. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + $PolicyFile, + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + $Headers, + $APIName = 'Resolve-CIPPIntuneTargetedMobileApps' + ) + + $TemplateAppIds = @($PolicyFile.targetedMobileApps | Where-Object { $_ }) + $Details = @($PolicyFile.targetedMobileAppsDetails | Where-Object { $_ }) + + if ($TemplateAppIds.Count -eq 0 -and $Details.Count -eq 0) { + return @() + } + + $TenantApps = @(New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceAppManagement/mobileApps?$top=999' -tenantid $TenantFilter) + $TenantAppIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($App in $TenantApps) { if ($App.id) { [void]$TenantAppIds.Add([string]$App.id) } } + + $Resolved = [System.Collections.Generic.List[string]]::new() + $Unresolved = [System.Collections.Generic.List[string]]::new() + + if ($Details.Count -gt 0) { + foreach ($Detail in $Details) { + # Same id present in the target tenant: the template came from this tenant. + if ($Detail.id -and $TenantAppIds.Contains([string]$Detail.id)) { + if (-not $Resolved.Contains([string]$Detail.id)) { $Resolved.Add([string]$Detail.id) } + continue + } + + $Match = $null + foreach ($IdentityProperty in 'bundleId', 'packageId', 'packageIdentifier') { + $Identity = [string]$Detail.$IdentityProperty + if ([string]::IsNullOrWhiteSpace($Identity)) { continue } + $Match = $TenantApps | Where-Object { [string]$_.$IdentityProperty -eq $Identity } | Select-Object -First 1 + if ($Match) { break } + } + if (-not $Match -and $Detail.displayName) { + # Name plus type: a store app and a line-of-business app can share a name and + # cannot substitute for one another in a configuration policy. + $Match = $TenantApps | Where-Object { + $_.displayName -eq $Detail.displayName -and (-not $Detail.'@odata.type' -or $_.'@odata.type' -eq $Detail.'@odata.type') + } | Select-Object -First 1 + } + + if ($Match.id) { + if (-not $Resolved.Contains([string]$Match.id)) { $Resolved.Add([string]$Match.id) } + } else { + $Identifier = $Detail.bundleId ?? $Detail.packageId ?? $Detail.packageIdentifier ?? $Detail.id + $Unresolved.Add("'$($Detail.displayName ?? 'unknown app')' ($Identifier)") + } + } + } else { + foreach ($AppId in $TemplateAppIds) { + if ($TenantAppIds.Contains([string]$AppId)) { + if (-not $Resolved.Contains([string]$AppId)) { $Resolved.Add([string]$AppId) } + } else { + $Unresolved.Add("app id $AppId") + } + } + if ($Unresolved.Count -gt 0) { + $Message = "App configuration '$($PolicyFile.displayName)' targets apps that do not exist in $TenantFilter ($($Unresolved -join ', ')). This template was captured before CIPP recorded which apps those ids belong to, so they cannot be matched to this tenant's apps. Re-create the template from the source tenant and deploy it again." + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Message -Sev Error + throw $Message + } + } + + if ($Unresolved.Count -gt 0) { + $Message = "App configuration '$($PolicyFile.displayName)' targets apps that are not present in $TenantFilter : $($Unresolved -join ', '). Add these apps to Intune in this tenant, then deploy the template again." + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Message -Sev Error + throw $Message + } + + return @($Resolved) +} diff --git a/Modules/CIPPCore/Public/Resolve-CIPPSharePointLibraryRootUri.ps1 b/Modules/CIPPCore/Public/Resolve-CIPPSharePointLibraryRootUri.ps1 new file mode 100644 index 0000000000000..b77073fd614a8 --- /dev/null +++ b/Modules/CIPPCore/Public/Resolve-CIPPSharePointLibraryRootUri.ps1 @@ -0,0 +1,52 @@ +function Resolve-CIPPSharePointLibraryRootUri { + <# + .SYNOPSIS + Resolves a document library list GUID to its absolute root folder URI via SPO REST. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $true)] + [string]$ListId, + + [string]$SiteUrl, + [string]$SiteId + ) + + if ([string]::IsNullOrWhiteSpace($SiteUrl)) { + if ([string]::IsNullOrWhiteSpace($SiteId)) { + throw 'SiteUrl or SiteId is required.' + } + $SiteMeta = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/sites/$SiteId`?`$select=webUrl" -tenantid $TenantFilter -asapp $true + if ([string]::IsNullOrWhiteSpace($SiteMeta.webUrl)) { + throw "Could not resolve webUrl for site id $SiteId." + } + $SiteUrl = $SiteMeta.webUrl + } + + $SiteUrl = $SiteUrl.TrimEnd('/') + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $Scope = "$($SharePointInfo.SharePointUrl)/.default" + $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } + $BaseUri = "$SiteUrl/_api" + $SafeListId = $ListId -replace "'", "''" + + $List = New-GraphGetRequest -uri "$BaseUri/web/lists(guid'$SafeListId')?`$select=RootFolder/ServerRelativeUrl&`$expand=RootFolder" ` + -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true + + $ServerRelativeUrl = $List.RootFolder.ServerRelativeUrl + if ([string]::IsNullOrWhiteSpace($ServerRelativeUrl)) { + throw 'Could not resolve library root folder ServerRelativeUrl.' + } + + $Origin = ([System.Uri]$SiteUrl).GetLeftPart([System.UriPartial]::Authority) + $AbsoluteUri = "$Origin$ServerRelativeUrl" + + [PSCustomObject]@{ + SiteUrl = $SiteUrl + LibraryRootUri = $AbsoluteUri + ServerRelativeUrl = $ServerRelativeUrl + } +} diff --git a/Modules/CIPPCore/Public/Resolve-CIPPSharePointPermissionScope.ps1 b/Modules/CIPPCore/Public/Resolve-CIPPSharePointPermissionScope.ps1 index d0d00d140e68e..63135102c8eee 100644 --- a/Modules/CIPPCore/Public/Resolve-CIPPSharePointPermissionScope.ps1 +++ b/Modules/CIPPCore/Public/Resolve-CIPPSharePointPermissionScope.ps1 @@ -39,10 +39,10 @@ function Resolve-CIPPSharePointPermissionScope { [switch]$EnsureUniqueRoleAssignments ) - $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter - $Scope = "$($SharePointInfo.SharePointUrl)/.default" - $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } - $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + $RestContext = Resolve-CIPPSharePointRestContext -TenantFilter $TenantFilter -SiteUrl $SiteUrl + $Scope = $RestContext.Scope + $JsonAccept = $RestContext.Headers + $BaseUri = $RestContext.BaseUri $IsLibrary = -not [string]::IsNullOrWhiteSpace($ListId) $ScopeUri = if ($IsLibrary) { "$BaseUri/web/lists(guid'$ListId')" } else { "$BaseUri/web" } @@ -50,7 +50,18 @@ function Resolve-CIPPSharePointPermissionScope { $BrokeInheritance = $false if ($IsLibrary) { $ListInfo = New-GraphGetRequest -uri "$ScopeUri`?`$select=HasUniqueRoleAssignments,Title" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true - $HasUnique = [bool]$ListInfo.HasUniqueRoleAssignments + # [bool]$null is $false in PowerShell, which falsely reports inheriting libraries when + # HasUniqueRoleAssignments is not projected. Probe the scalar property in that case. + $HasUniqueRaw = $ListInfo.HasUniqueRoleAssignments + if ($null -eq $HasUniqueRaw) { + try { + $Probe = New-GraphGetRequest -uri "$ScopeUri/HasUniqueRoleAssignments" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true + $HasUniqueRaw = if ($null -ne $Probe.PSObject.Properties['value']) { $Probe.value } else { $Probe } + } catch { + $HasUniqueRaw = $null + } + } + $HasUnique = $HasUniqueRaw -eq $true -or "$HasUniqueRaw" -eq 'true' $TargetLabel = if ($ListInfo.Title) { "library '$($ListInfo.Title)'" } else { "library $ListId" } if (-not $HasUnique -and $EnsureUniqueRoleAssignments.IsPresent) { diff --git a/Modules/CIPPCore/Public/Resolve-CIPPSharePointRestContext.ps1 b/Modules/CIPPCore/Public/Resolve-CIPPSharePointRestContext.ps1 new file mode 100644 index 0000000000000..b297dc54cfef7 --- /dev/null +++ b/Modules/CIPPCore/Public/Resolve-CIPPSharePointRestContext.ps1 @@ -0,0 +1,49 @@ +function Resolve-CIPPSharePointRestContext { + <# + .SYNOPSIS + Resolve the SharePoint REST context for a site-scoped API call + + .DESCRIPTION + Builds the certificate-authenticated SharePoint REST plumbing shared by site-scoped + endpoints: the token scope, odata headers, normalized site URL and the /_api base URI. + Pass -SharePointInfo when resolving several sites in one operation to avoid repeated + admin-link lookups. + + .PARAMETER TenantFilter + The tenant the site belongs to + + .PARAMETER SiteUrl + The full URL of the site + + .PARAMETER SharePointInfo + Optional output from Get-SharePointAdminLink to reuse across multiple sites + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $true)] + [string]$SiteUrl, + + [object]$SharePointInfo + ) + + if (-not $SharePointInfo) { + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + } + + $NormalizedSiteUrl = $SiteUrl.TrimEnd('/') + $Scope = "$($SharePointInfo.SharePointUrl)/.default" + $Headers = @{ Accept = 'application/json;odata=nometadata' } + $BaseUri = "$NormalizedSiteUrl/_api" + + return [PSCustomObject]@{ + SharePointInfo = $SharePointInfo + SiteUrl = $NormalizedSiteUrl + Scope = $Scope + Headers = $Headers + BaseUri = $BaseUri + WebUri = "$BaseUri/web" + } +} diff --git a/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 b/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 index 52cefebb84d97..961cd378c84cf 100644 --- a/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 +++ b/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 @@ -17,6 +17,9 @@ function Send-CIPPAlert { $RowKey = [string][guid]::NewGuid(), $Attachments, $AffectedUser, + $PsaTicketPriority, + $PSAReference, + $PSATicketId, [switch]$UseStandardizedSchema ) Write-Information 'Shipping Alert' @@ -343,8 +346,10 @@ function Send-CIPPAlert { if ($Type -eq 'psa') { Write-Information 'Trying to send to PSA' if (-not $config.sendtoIntegration) { + # The extension test button bypasses this gate, so log the skip where the operator looks. Write-Information 'PSA delivery skipped: sendtoIntegration is disabled in CippNotifications config. Enable it under Settings -> Notifications to route alerts to your PSA.' - return + Write-LogMessage -API 'Webhook Alerts' -tenant $TenantFilter -message "PSA delivery skipped for '$Title': 'Send to integration' is off under Settings > Notifications, so no PSA ticket was raised." -sev Warning + return 'Skipped: PSA delivery is disabled in the notification settings' } if ($PSCmdlet.ShouldProcess('PSA', 'Sending alert')) { try { @@ -358,20 +363,40 @@ function Send-CIPPAlert { AlertText = "$HTMLContent" AlertTitle = "$PsaTitle" } + if ($PSAReference) { + # Passed through verbatim - what a reference means is the PSA extension's call. + $Alert.Reference = $PSAReference + Write-Information "PSA alert reference: $PSAReference" + } + if ($PSATicketId) { + $Alert.PsaTicketId = $PSATicketId + Write-Information "PSA alert target ticket: $PSATicketId" + } if ($AffectedUser) { $Alert.AffectedUser = $AffectedUser $UserLabel = if ($AffectedUser.UPN) { $AffectedUser.UPN } elseif ($AffectedUser.AzureOID) { "OID:$($AffectedUser.AzureOID)" } else { 'unknown' } Write-Information "PSA alert AffectedUser: $UserLabel" } - $PsaResult = New-CippExtAlert -Alert $Alert - if ($PsaResult) { - Write-Information "PSA result: $PsaResult" + if ($PsaTicketPriority) { + $Alert.PsaTicketPriority = $PsaTicketPriority + Write-Information "PSA alert priority override: $PsaTicketPriority" + } + # Extensions report failure in their return value, one line per extension. + $PsaOutput = @(New-CippExtAlert -Alert $Alert) + $PsaResult = ($PsaOutput -join ' ').Trim() + $Failure = (@($PsaOutput | Where-Object { "$_" -match '^(Failed|Error)' }) -join ' ').Trim() + if ($Failure) { + Write-LogMessage -API 'Webhook Alerts' -tenant $TenantFilter -message "PSA delivery failed for '$Title': $Failure" -sev Error + return "Error: $Failure" } Write-LogMessage -API 'Webhook Alerts' -tenant $TenantFilter -message "Sent PSA alert $title" -sev info + # Same shape as the email and webhook branches; the text carries the ticket id. + return "Sent PSA alert: $title$(if ($PsaResult) { " - $PsaResult" })" } catch { $ErrorMessage = Get-CippException -Exception $_ Write-Information "Could not send alerts to ticketing system: $($ErrorMessage.NormalizedError)" Write-LogMessage -API 'Webhook Alerts' -tenant $TenantFilter -message "Could not send alerts to ticketing system: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + return "Error: Could not send alerts to ticketing system: $($ErrorMessage.NormalizedError)" } } } diff --git a/Modules/CIPPCore/Public/Send-CIPPCustomTestAlert.ps1 b/Modules/CIPPCore/Public/Send-CIPPCustomTestAlert.ps1 index 172fc50111403..07ef814787cb9 100644 --- a/Modules/CIPPCore/Public/Send-CIPPCustomTestAlert.ps1 +++ b/Modules/CIPPCore/Public/Send-CIPPCustomTestAlert.ps1 @@ -59,8 +59,10 @@ function Send-CIPPCustomTestAlert { # Email — Send-CIPPAlert no-ops if no notification email is configured. $null = Send-CIPPAlert -Type 'email' -Title $Title -HTMLContent $Template.htmlcontent -TenantFilter $TenantFilter -APIName 'CustomTests' - # PSA — Send-CIPPAlert no-ops unless config.sendtoIntegration is set. - $null = Send-CIPPAlert -Type 'psa' -Title $Title -HTMLContent $Template.htmlcontent -TenantFilter $TenantFilter -APIName 'CustomTests' + # Gate here so Send-CIPPAlert's skip warning only fires for deliveries someone asked for. + if ($Config.sendtoIntegration) { + $null = Send-CIPPAlert -Type 'psa' -Title $Title -HTMLContent $Template.htmlcontent -TenantFilter $TenantFilter -APIName 'CustomTests' + } # Webhook — hand-built payload, Send-CIPPAlert no-ops if no webhook is configured. $WebhookData = [PSCustomObject]@{ diff --git a/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 b/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 index 029e99ce54c0f..a5c29324aa3b5 100644 --- a/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 +++ b/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 @@ -126,6 +126,9 @@ function Send-CIPPScheduledTaskAlert { } } + # One outcome per delivery attempt (Channel, Result), returned so the caller can keep it with the task. + $Outcomes = [System.Collections.Generic.List[object]]::new() + try { Write-Information "Sending post-execution alerts for task $($TaskInfo.Name)" @@ -149,13 +152,45 @@ function Send-CIPPScheduledTaskAlert { $EncodedTaskName = [System.Web.HttpUtility]::HtmlEncode($TaskInfo.Name) $EncodedTenantName = [System.Web.HttpUtility]::HtmlEncode($TenantFilter) $AlertHeader = "

$EncodedTaskName

Tenant: $EncodedTenantName

" - $FinalResults = if ($Results -is [array] -and $Results[0] -is [string]) { + # Commands that also serve an HTTP caller return a single row carrying the result lines plus + # the extras that caller needs - New-CIPPUserTask hands back Results alongside Username, + # Password, CopyFrom and the whole Graph user object. Rendered as one row that becomes a + # column per key, so the readable lines end up squeezed beside a flattened Graph object. + # Split it instead: the result lines become the table, the rest follows underneath. Nothing + # is dropped, and $Results itself is untouched so the webhook payload and the stored task + # results keep the exact shape they have always had. + $EnvelopeRow = $null + if (@($Results).Count -eq 1) { + $SingleRow = @($Results)[0] + if ($null -ne $SingleRow -and $SingleRow -isnot [string]) { + $RowProps = @($SingleRow.PSObject.Properties) + if ($RowProps.Count -gt 1 -and $RowProps.Name -contains 'Results') { $EnvelopeRow = $SingleRow } + } + } + + $FinalResults = if ($EnvelopeRow) { + @($EnvelopeRow.Results) | ConvertTo-Html -Fragment -Property @{ l = 'Results'; e = { $_ } } + } elseif ($Results -is [array] -and $Results[0] -is [string]) { $Results | ConvertTo-Html -Fragment -Property @{ l = 'Text'; e = { $_ } } } else { $Results | ForEach-Object { ConvertTo-AlertDisplayRow -Row $_ } | ConvertTo-Html -Fragment } $HTML = $FinalResults -replace '\[\[BR\]\]', '
' -replace '
', "$AlertHeader $TableDesign
" | Out-String + # Everything the envelope carried besides the result lines, as field/value rows below the + # table rather than as extra columns beside it. + if ($EnvelopeRow) { + $ExtraRows = foreach ($Prop in $EnvelopeRow.PSObject.Properties) { + if ($Prop.Name -eq 'Results') { continue } + [pscustomobject]@{ Field = $Prop.Name; Value = (Format-AlertCellValue -Value $Prop.Value -Depth 1) } + } + if ($ExtraRows) { + $ExtraHtml = @($ExtraRows) | ConvertTo-Html -Fragment + $ExtraHtml = $ExtraHtml -replace '\[\[BR\]\]', '
' -replace '
', '
' | Out-String + $HTML += "

Additional detail

$ExtraHtml" + } + } + # For alert tasks, add per-row snooze links. The resolved URL is kept in scope so the # per-user PSA split below can build the same block from each user's own rows. $SnoozeCIPPURL = $null @@ -215,6 +250,19 @@ function Send-CIPPScheduledTaskAlert { '*psa*' { $PsaSplitSent = $false $TaskAffectedUser = $null + # Per-task PSA ticket priority (configured on the alert) overrides the global + # HaloPSA.DefaultPriority. Empty on tasks saved before this field existed, in which + # case New-HaloPSATicket falls back to the integration default. Read here rather + # than inside the try so the consolidated fallback path below can use it even when + # the affected-user resolution throws. + $TaskPsaPriority = $TaskInfo.PsaTicketPriority + # A task can name the ticket the work came from, either explicitly (PsaTicketId, set + # from the ticket box on the wizards) or inside its free-text reference. Both travel + # with the alert so a PSA that recognises them can add the result to that ticket + # rather than opening a new one; the extension decides which wins. Every PSA call + # below carries them, so a split task's per-user notes land on the same ticket. + $PsaReference = $TaskInfo.Reference + $PsaTicketId = $TaskInfo.PsaTicketId try { $ExtConfigTable = Get-CIPPTable -TableName Extensionsconfig $ExtConfig = (Get-CIPPAzDataTableEntity @ExtConfigTable).config | ConvertFrom-Json -ErrorAction SilentlyContinue @@ -302,9 +350,10 @@ function Send-CIPPScheduledTaskAlert { if ([string]::IsNullOrWhiteSpace($GroupKey)) { # Rows without a usable user identifier - fall back to the # task-level affected user if one was resolved. - $GroupParams = @{ Type = 'psa'; Title = $title; HTMLContent = $GroupHTML; TenantFilter = $TenantFilter } + $GroupParams = @{ Type = 'psa'; Title = $title; HTMLContent = $GroupHTML; TenantFilter = $TenantFilter; PSAReference = $PsaReference; PSATicketId = $PsaTicketId } if ($TaskAffectedUser) { $GroupParams.AffectedUser = $TaskAffectedUser } - Send-CIPPAlert @GroupParams + if ($TaskPsaPriority) { $GroupParams.PsaTicketPriority = $TaskPsaPriority } + $Outcomes.Add([pscustomobject]@{ Channel = 'PSA'; Result = [string]((Send-CIPPAlert @GroupParams) -join ' ') }) } else { $GroupDisplayName = if ($DisplayField) { $Group.Group[0].$DisplayField } else { $null } $UserLabel = if ($GroupDisplayName) { "$GroupDisplayName ($GroupKey)" } else { $GroupKey } @@ -313,7 +362,9 @@ function Send-CIPPScheduledTaskAlert { UPN = $GroupKey DisplayName = $GroupDisplayName } - Send-CIPPAlert -Type 'psa' -Title $UserTitle -HTMLContent $GroupHTML -TenantFilter $TenantFilter -AffectedUser $AffectedUser + $UserParams = @{ Type = 'psa'; Title = $UserTitle; HTMLContent = $GroupHTML; TenantFilter = $TenantFilter; AffectedUser = $AffectedUser; PSAReference = $PsaReference; PSATicketId = $PsaTicketId } + if ($TaskPsaPriority) { $UserParams.PsaTicketPriority = $TaskPsaPriority } + $Outcomes.Add([pscustomobject]@{ Channel = 'PSA'; Result = [string]((Send-CIPPAlert @UserParams) -join ' ') }) } } $PsaSplitSent = $true @@ -325,12 +376,17 @@ function Send-CIPPScheduledTaskAlert { } if (-not $PsaSplitSent) { - $PsaParams = @{ Type = 'psa'; Title = $title; HTMLContent = (ConvertTo-PSAHtml -Html $HTML); TenantFilter = $TenantFilter } + $PsaParams = @{ Type = 'psa'; Title = $title; HTMLContent = (ConvertTo-PSAHtml -Html $HTML); TenantFilter = $TenantFilter; PSAReference = $PsaReference; PSATicketId = $PsaTicketId } if ($TaskAffectedUser) { $PsaParams.AffectedUser = $TaskAffectedUser } - Send-CIPPAlert @PsaParams + if ($TaskPsaPriority) { $PsaParams.PsaTicketPriority = $TaskPsaPriority } + $Outcomes.Add([pscustomobject]@{ Channel = 'PSA'; Result = [string]((Send-CIPPAlert @PsaParams) -join ' ') }) } } '*email*' { + # Deliberately untouched by PsaTicketId: that field drives the PSA note only. What a + # mail-ingesting PSA threads on is whatever the operator put in Reference, which is + # already carried into the title above - stamping a ticket token onto every subject + # would push CIPP's own convention onto recipients who never asked for it. $EmailParams = @{ Type = 'email' Title = $title @@ -340,7 +396,7 @@ function Send-CIPPScheduledTaskAlert { if ($TaskAttachments) { $EmailParams.Attachments = $TaskAttachments } - Send-CIPPAlert @EmailParams + $Outcomes.Add([pscustomobject]@{ Channel = 'Email'; Result = [string]((Send-CIPPAlert @EmailParams) -join ' ') }) } '*webhook*' { # Build per-item snooze metadata for alert tasks @@ -398,7 +454,7 @@ function Send-CIPPScheduledTaskAlert { if ($SnoozeInfo) { $obj | Add-Member -NotePropertyName 'Snooze' -NotePropertyValue $SnoozeInfo } $obj } - Send-CIPPAlert -Type 'webhook' -Title $title -TenantFilter $TenantFilter -JSONContent $($Webhook | ConvertTo-Json -Depth 20) -APIName 'Scheduled Task Alerts' -SchemaSource $TaskType -InvokingCommand $TaskInfo.Command -UseStandardizedSchema:$UseStandardizedSchema + $Outcomes.Add([pscustomobject]@{ Channel = 'Webhook'; Result = [string]((Send-CIPPAlert -Type 'webhook' -Title $title -TenantFilter $TenantFilter -JSONContent $($Webhook | ConvertTo-Json -Depth 20) -APIName 'Scheduled Task Alerts' -SchemaSource $TaskType -InvokingCommand $TaskInfo.Command -UseStandardizedSchema:$UseStandardizedSchema) -join ' ') }) } } @@ -407,5 +463,7 @@ function Send-CIPPScheduledTaskAlert { } catch { Write-Warning "Failed to send scheduled task alerts: $($_.Exception.Message)" Write-LogMessage -API 'Scheduler_Alerts' -tenant $TenantFilter -message "Failed to send alerts for task $($TaskInfo.Name): $($_.Exception.Message)" -sev Error + $Outcomes.Add([pscustomobject]@{ Channel = 'All'; Result = "Error: $($_.Exception.Message)" }) } + return @($Outcomes) } diff --git a/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 index 7eb6edbd418db..c6229afc715f4 100644 --- a/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 @@ -1,7 +1,9 @@ function Set-CIPPAuthenticationPolicy { [CmdletBinding(SupportsShouldProcess = $true)] param( - [Parameter(Mandatory = $true)]$Tenant, + # TenantFilter (not Tenant) so the scheduler treats this as the protected tenant scope; + # the alias keeps existing -Tenant callers working + [Parameter(Mandatory = $true)][Alias('Tenant')]$TenantFilter, [Parameter(Mandatory = $true)][ValidateSet('FIDO2', 'MicrosoftAuthenticator', 'SMS', 'TemporaryAccessPass', 'HardwareOATH', 'softwareOath', 'Voice', 'Email', 'x509Certificate', 'QRCodePin')]$AuthenticationMethodId, [Parameter(Mandatory = $true)][bool]$Enabled, # true = enabled or false = disabled $MicrosoftAuthenticatorSoftwareOathEnabled, @@ -30,11 +32,11 @@ function Set-CIPPAuthenticationPolicy { $State = if ($Enabled) { 'enabled' } else { 'disabled' } # Get current state of the called authentication method and Set state of authentication method to input state try { - $CurrentInfo = New-GraphGetRequest -Uri "https://graph.microsoft.com/beta/policies/authenticationmethodspolicy/authenticationMethodConfigurations/$AuthenticationMethodId" -tenantid $Tenant -AsApp $True + $CurrentInfo = New-GraphGetRequest -Uri "https://graph.microsoft.com/beta/policies/authenticationmethodspolicy/authenticationMethodConfigurations/$AuthenticationMethodId" -tenantid $TenantFilter -AsApp $True $CurrentInfo.state = $State } catch { $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -headers $Headers -API $APIName -tenant $Tenant -message "Could not get CurrentInfo for $AuthenticationMethodId. Error:$($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Could not get CurrentInfo for $AuthenticationMethodId. Error:$($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage # Throw rather than return: callers treat any returned string as a successful write, so returning # here made a failed read look like a completed remediation in both the audit log and the API response. throw "Could not get CurrentInfo for $AuthenticationMethodId. Error:$($ErrorMessage.NormalizedError)" @@ -180,7 +182,7 @@ function Set-CIPPAuthenticationPolicy { } } default { - Write-LogMessage -headers $Headers -API $APIName -tenant $Tenant -message "Somehow you hit the default case with an input of $AuthenticationMethodId . You probably made a typo in the input for AuthenticationMethodId. It`'s case sensitive." -sev Error + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Somehow you hit the default case with an input of $AuthenticationMethodId . You probably made a typo in the input for AuthenticationMethodId. It`'s case sensitive." -sev Error throw "Somehow you hit the default case with an input of $AuthenticationMethodId . You probably made a typo in the input for AuthenticationMethodId. It`'s case sensitive." } } @@ -202,14 +204,14 @@ function Set-CIPPAuthenticationPolicy { try { if ($PSCmdlet.ShouldProcess($AuthenticationMethodId, "Set state to $State $OptionalLogMessage")) { # Convert body to JSON and send request - $null = New-GraphPostRequest -tenantid $Tenant -Uri "https://graph.microsoft.com/beta/policies/authenticationmethodspolicy/authenticationMethodConfigurations/$AuthenticationMethodId" -Type PATCH -Body (ConvertTo-Json -InputObject $CurrentInfo -Compress -Depth 10) -ContentType 'application/json' -AsApp $True - Write-LogMessage -headers $Headers -API $APIName -tenant $Tenant -message "Set $AuthenticationMethodId state to $State $OptionalLogMessage" -sev Info + $null = New-GraphPostRequest -tenantid $TenantFilter -Uri "https://graph.microsoft.com/beta/policies/authenticationmethodspolicy/authenticationMethodConfigurations/$AuthenticationMethodId" -Type PATCH -Body (ConvertTo-Json -InputObject $CurrentInfo -Compress -Depth 10) -ContentType 'application/json' -AsApp $True + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Set $AuthenticationMethodId state to $State $OptionalLogMessage" -sev Info } return "Set $AuthenticationMethodId state to $State $OptionalLogMessage" } catch { $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -headers $Headers -API $APIName -tenant $Tenant -message "Failed to $State $AuthenticationMethodId Support: $ErrorMessage" -sev Error -LogData $ErrorMessage + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to $State $AuthenticationMethodId Support: $ErrorMessage" -sev Error -LogData $ErrorMessage throw "Failed to $State $AuthenticationMethodId Support. Error: $($ErrorMessage.NormalizedError)" } } diff --git a/Modules/CIPPCore/Public/Set-CIPPDefenderASRPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPDefenderASRPolicy.ps1 index 0163793499830..5a67256124a57 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDefenderASRPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDefenderASRPolicy.ps1 @@ -87,7 +87,9 @@ function Set-CIPPDefenderASRPolicy { $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies('$($ASRRequest.id)')/assign" -tenantid $TenantFilter -type POST -body $AssignBody Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Assigned policy $($DisplayName) to $($ASR.AssignTo)" -Sev 'Info' } - "$($TenantFilter): Successfully added ASR Settings" + $Result = "$($TenantFilter): Successfully added ASR Settings" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev 'Info' + $Result } } } diff --git a/Modules/CIPPCore/Public/Set-CIPPDefenderAVPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPDefenderAVPolicy.ps1 index df3b6b816440c..780c90a057398 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDefenderAVPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDefenderAVPolicy.ps1 @@ -182,6 +182,8 @@ function Set-CIPPDefenderAVPolicy { $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies('$($PolicyRequest.id)')/assign" -tenantid $TenantFilter -type POST -body $AssignBody Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Assigned AV policy to $($PolicySettings.AssignTo)" -Sev 'Info' } - "$($TenantFilter): Successfully set Default AV Policy settings" + $Result = "$($TenantFilter): Successfully set Default AV Policy settings" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev 'Info' + $Result } } diff --git a/Modules/CIPPCore/Public/Set-CIPPDefenderCompliancePolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPDefenderCompliancePolicy.ps1 index 5384b63995be1..e01634a87ea1c 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDefenderCompliancePolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDefenderCompliancePolicy.ps1 @@ -64,9 +64,13 @@ function Set-CIPPDefenderCompliancePolicy { "Defender Intune Configuration already correct and active for $($TenantFilter). Skipping" } elseif ($ConnectorExists) { $null = New-GraphPOSTRequest -uri $ConnectorUri -tenantid $TenantFilter -type PATCH -body $SettingsObj -AsApp $true - "$($TenantFilter): Successfully updated Defender Compliance and Reporting settings." + $Result = "$($TenantFilter): Successfully updated Defender Compliance and Reporting settings." + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev 'Info' + $Result } else { $null = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/mobileThreatDefenseConnectors/' -tenantid $TenantFilter -type POST -body $SettingsObj -AsApp $true - "$($TenantFilter): Successfully created Defender Compliance and Reporting settings." + $Result = "$($TenantFilter): Successfully created Defender Compliance and Reporting settings." + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev 'Info' + $Result } } diff --git a/Modules/CIPPCore/Public/Set-CIPPDefenderEDRPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPDefenderEDRPolicy.ps1 index 511afe6f4ae0d..81296de2ba95f 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDefenderEDRPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDefenderEDRPolicy.ps1 @@ -80,7 +80,9 @@ function Set-CIPPDefenderEDRPolicy { $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies('$($EDRRequest.id)')/assign" -tenantid $TenantFilter -type POST -body $AssignBody Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Assigned EDR policy $($DisplayName) to $($EDR.AssignTo)" -Sev 'Info' } - "$($TenantFilter): Successfully added EDR Settings" + $Result = "$($TenantFilter): Successfully added EDR Settings" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev 'Info' + $Result } } } diff --git a/Modules/CIPPCore/Public/Set-CIPPDefenderExclusionPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPDefenderExclusionPolicy.ps1 index b602784d34d07..09f99e9f150cc 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDefenderExclusionPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDefenderExclusionPolicy.ps1 @@ -90,7 +90,9 @@ function Set-CIPPDefenderExclusionPolicy { $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies('$($ExclusionRequest.id)')/assign" -tenantid $TenantFilter -type POST -body $AssignBody Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Assigned Exclusion policy to $($ExclusionAssignTo)" -Sev 'Info' } - "$($TenantFilter): Successfully set Default AV Exclusion Policy settings" + $Result = "$($TenantFilter): Successfully set Default AV Exclusion Policy settings" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev 'Info' + $Result } } } diff --git a/Modules/CIPPCore/Public/Set-CIPPFeatureFlag.ps1 b/Modules/CIPPCore/Public/Set-CIPPFeatureFlag.ps1 index 61a480b828c2a..f6bf1aafccce7 100644 --- a/Modules/CIPPCore/Public/Set-CIPPFeatureFlag.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPFeatureFlag.ps1 @@ -8,6 +8,9 @@ function Set-CIPPFeatureFlag { The ID of the feature flag to update .PARAMETER Enabled The new enabled state for the feature flag (true/false) + .PARAMETER Force + Set the flag even when AllowUserToggle is false. For system-driven flags that are + managed by a specific flow (e.g. the Setup Wizard) rather than the user settings page. .FUNCTIONALITY Internal #> @@ -17,7 +20,9 @@ function Set-CIPPFeatureFlag { [string]$Id, [Parameter(Mandatory = $true)] - [bool]$Enabled + [bool]$Enabled, + + [switch]$Force ) try { @@ -32,8 +37,8 @@ function Set-CIPPFeatureFlag { return $false } - # Check if user toggle is allowed - if (-not $FeatureFlag.AllowUserToggle) { + # -Force bypasses the user-toggle guard for system-managed flags (e.g. set by the Setup Wizard). + if (-not $FeatureFlag.AllowUserToggle -and -not $Force) { Write-Warning "Feature flag '$Id' does not allow user toggling" return $false } diff --git a/Modules/CIPPCore/Public/Set-CIPPIntunePolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPIntunePolicy.ps1 index e368fe27802b1..22b7abd94fd20 100644 --- a/Modules/CIPPCore/Public/Set-CIPPIntunePolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPIntunePolicy.ps1 @@ -79,14 +79,20 @@ function Set-CIPPIntunePolicy { if ($FuzzyResult.MatchType -eq 'fuzzy') { Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Fuzzy matched policy '$($FuzzyResult.OriginalName)' for template '$DisplayName' (distance=$($FuzzyResult.Distance))" -Sev Info } - $PolicyFile = $PolicyFile | Select-Object * -ExcludeProperty id, createdDateTime, lastModifiedDateTime, version, '@odata.context', targetedMobileApps + $PolicyFile = $PolicyFile | Select-Object * -ExcludeProperty id, createdDateTime, lastModifiedDateTime, version, '@odata.context', targetedMobileApps, targetedMobileAppsDetails $RawJSON = ConvertTo-Json -InputObject $PolicyFile -Depth 20 -Compress $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL/$($ExistingID.Id)" -tenantid $TenantFilter -type PATCH -body $RawJSON -AddedHeaders $ApprovalHeaders Write-LogMessage -headers $Headers -API $APIName -tenant $($TenantFilter) -message "Updated policy $($DisplayName) to template defaults" -Sev Info $CreateRequest = $FuzzyResult.Policy } else { $PostType = 'added' - $PolicyFile = $PolicyFile | Select-Object * -ExcludeProperty id, createdDateTime, lastModifiedDateTime, version, '@odata.context' + # The template's targetedMobileApps are ids from the tenant it was captured in. + # Resolve them to this tenant's apps (or fail with the app named) before creating. + $ResolvedApps = @(Resolve-CIPPIntuneTargetedMobileApps -PolicyFile $PolicyFile -TenantFilter $TenantFilter -Headers $Headers -APIName $APIName) + $PolicyFile = $PolicyFile | Select-Object * -ExcludeProperty id, createdDateTime, lastModifiedDateTime, version, '@odata.context', targetedMobileAppsDetails + if ($ResolvedApps.Count -gt 0 -or $PolicyFile.PSObject.Properties['targetedMobileApps']) { + $PolicyFile | Add-Member -NotePropertyName 'targetedMobileApps' -NotePropertyValue @($ResolvedApps) -Force + } $RawJSON = ConvertTo-Json -InputObject $PolicyFile -Depth 20 -Compress $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $TenantFilter -type POST -body $RawJSON -AddedHeaders $ApprovalHeaders Write-LogMessage -headers $Headers -API $APIName -tenant $($TenantFilter) -message "Added policy $($DisplayName) via template" -Sev Info @@ -103,7 +109,15 @@ function Set-CIPPIntunePolicy { $ComplianceODataType = ($RawJSON | ConvertFrom-Json).'@odata.type' $FuzzyResult = Find-CIPPFuzzyPolicyMatch -DisplayName $DisplayName -ExistingPolicies $CheckExististing -MaxDistance $LevenshteinDistance -ODataType $ComplianceODataType if ($FuzzyResult) { - $RawJSON = ConvertTo-Json -InputObject ($PolicyFile | Select-Object * -ExcludeProperty 'scheduledActionsForRule') -Depth 20 -Compress + $EditPolicy = $PolicyFile | Select-Object * -ExcludeProperty 'scheduledActionsForRule' + # deviceCompliancePolicies is a polymorphic collection with an abstract base type. A PATCH + # carrying derived-type properties (osMinimumVersion, workProfile*, ...) with no @odata.type + # fails Graph model validation. Templates imported or captured without it (RAWJson has no + # @odata.type) hit this, so borrow the concrete type from the matched policy. + if (-not $EditPolicy.'@odata.type' -and $FuzzyResult.Policy.'@odata.type') { + $null = $EditPolicy | Add-Member -MemberType NoteProperty -Name '@odata.type' -Value $FuzzyResult.Policy.'@odata.type' -Force + } + $RawJSON = ConvertTo-Json -InputObject $EditPolicy -Depth 20 -Compress $PostType = 'edited' $ExistingID = $FuzzyResult.Policy if ($FuzzyResult.MatchType -eq 'fuzzy') { diff --git a/Modules/CIPPCore/Public/Set-CIPPNotificationConfig.ps1 b/Modules/CIPPCore/Public/Set-CIPPNotificationConfig.ps1 index 2d28de0649392..b43366c6fd633 100644 --- a/Modules/CIPPCore/Public/Set-CIPPNotificationConfig.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPNotificationConfig.ps1 @@ -16,8 +16,7 @@ function Set-CIPPNotificationConfig { $logsToInclude, $sendtoIntegration, $sev, - [boolean]$UseStandardizedSchema, - $APIName = 'Set Notification Config' + [boolean]$UseStandardizedSchema ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPPerUserMFA.ps1 b/Modules/CIPPCore/Public/Set-CIPPPerUserMFA.ps1 index e06a75b7b070c..7f1822dcdd765 100644 --- a/Modules/CIPPCore/Public/Set-CIPPPerUserMFA.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPPerUserMFA.ps1 @@ -36,7 +36,7 @@ function Set-CIPPPerUserMFA { try { $int = 0 $Body = @{ - perUserMFAstate = $State + perUserMfaState = $State } $Requests = foreach ($id in $userId) { @{ diff --git a/Modules/CIPPCore/Public/Set-CIPPRegistrationCampaign.ps1 b/Modules/CIPPCore/Public/Set-CIPPRegistrationCampaign.ps1 index 52b7e0e29544f..31397a57cc3ac 100644 --- a/Modules/CIPPCore/Public/Set-CIPPRegistrationCampaign.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPRegistrationCampaign.ps1 @@ -16,7 +16,7 @@ function Set-CIPPRegistrationCampaign { #> [CmdletBinding(SupportsShouldProcess = $true)] param( - [Parameter(Mandatory = $true)]$Tenant, + [Parameter(Mandatory = $true)][Alias('Tenant')]$TenantFilter, $State, $TargetedAuthenticationMethod, $SnoozeDurationInDays, @@ -28,11 +28,11 @@ function Set-CIPPRegistrationCampaign { ) try { - $CurrentPolicy = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/policies/authenticationMethodsPolicy' -tenantid $Tenant + $CurrentPolicy = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/policies/authenticationMethodsPolicy' -tenantid $TenantFilter $CurrentCampaign = $CurrentPolicy.registrationEnforcement.authenticationMethodsRegistrationCampaign } catch { $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -headers $Headers -API $APIName -tenant $Tenant -message "Could not get the current registration campaign. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Could not get the current registration campaign. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage throw "Could not get the current registration campaign. Error: $($ErrorMessage.NormalizedError)" } @@ -81,13 +81,13 @@ function Set-CIPPRegistrationCampaign { try { $Result = "Set the registration campaign state to $DesiredState targeting $DesiredMethod with a snooze duration of $DesiredSnooze day(s), $($DesiredIncludeTargets.Count) include target(s) and $($DesiredExcludeTargets.Count) exclude target(s)" if ($PSCmdlet.ShouldProcess('Registration campaign', "Set state to $DesiredState")) { - $null = New-GraphPostRequest -tenantid $Tenant -Uri 'https://graph.microsoft.com/beta/policies/authenticationMethodsPolicy' -Type PATCH -Body $Body -ContentType 'application/json' -AsApp $false - Write-LogMessage -headers $Headers -API $APIName -tenant $Tenant -message $Result -sev Info + $null = New-GraphPostRequest -tenantid $TenantFilter -Uri 'https://graph.microsoft.com/beta/policies/authenticationMethodsPolicy' -Type PATCH -Body $Body -ContentType 'application/json' -AsApp $false + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -sev Info } return $Result } catch { $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -headers $Headers -API $APIName -tenant $Tenant -message "Failed to update the registration campaign. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to update the registration campaign. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage throw "Failed to update the registration campaign. Error: $($ErrorMessage.NormalizedError)" } } diff --git a/Modules/CIPPCore/Public/Set-CIPPSAMCertificate.ps1 b/Modules/CIPPCore/Public/Set-CIPPSAMCertificate.ps1 index 1fc745e0ca115..9ecf1fba6edf8 100644 --- a/Modules/CIPPCore/Public/Set-CIPPSAMCertificate.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPSAMCertificate.ps1 @@ -40,8 +40,9 @@ function Set-CIPPSAMCertificate { if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { $Table = Get-CIPPTable -tablename 'DevSecrets' $Secret = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'Secret' and RowKey eq 'Secret'" + # A certificate-only First Setup registers the certificate before the Secret row exists; create it. if (!$Secret) { - throw 'DevSecrets table row not found. Cannot store SAM certificate in dev mode.' + $Secret = [PSCustomObject]@{ PartitionKey = 'Secret'; RowKey = 'Secret' } } $Secret | Add-Member -MemberType NoteProperty -Name $Name -Value $PfxBase64 -Force Add-AzDataTableEntity @Table -Entity $Secret -Force diff --git a/Modules/CIPPCore/Public/Set-CIPPSPOSite.ps1 b/Modules/CIPPCore/Public/Set-CIPPSPOSite.ps1 index df35ab4fd187e..aef08ce135a80 100644 --- a/Modules/CIPPCore/Public/Set-CIPPSPOSite.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPSPOSite.ps1 @@ -37,12 +37,19 @@ function Set-CIPPSPOSite { [Parameter(Mandatory = $true)] [string]$SiteUrl, [Parameter(Mandatory = $true)] - [hashtable]$Properties + [hashtable]$Properties, + # SharePoint app-only auth requires a certificate (secret app-only is rejected by SPO). When + # set, authenticate app-only with the SAM certificate instead of the delegated context. + [switch]$UseCertificate ) $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter $AdminUrl = $SharePointInfo.AdminUrl + # Threaded onto the ProcessQuery call below; empty = unchanged delegated behaviour. + $AuthSplat = @{} + if ($UseCertificate) { $AuthSplat['AsApp'] = $true; $AuthSplat['UseCertificate'] = $true } + $AllowedTypes = @('Boolean', 'String', 'Int32', 'Int64') # Properties that are CSOM enums; their (numeric) value must be sent as Type="Enum". $EnumProperties = @('SharingCapability', 'DefaultSharingLinkType', 'DefaultLinkPermission', 'SharingDomainRestrictionMode', 'ConditionalAccessPolicy') @@ -75,6 +82,6 @@ function Set-CIPPSPOSite { } if ($PSCmdlet.ShouldProcess($SiteUrl, 'Set Site Properties')) { - New-GraphPostRequest -scope "$AdminUrl/.default" -tenantid $TenantFilter -Uri "$AdminUrl/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AddedHeaders $AdditionalHeaders + New-GraphPostRequest -scope "$AdminUrl/.default" -tenantid $TenantFilter -Uri "$AdminUrl/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AddedHeaders $AdditionalHeaders @AuthSplat } } diff --git a/Modules/CIPPCore/Public/Set-CIPPSPOSiteBulk.ps1 b/Modules/CIPPCore/Public/Set-CIPPSPOSiteBulk.ps1 new file mode 100644 index 0000000000000..9544835df633c --- /dev/null +++ b/Modules/CIPPCore/Public/Set-CIPPSPOSiteBulk.ps1 @@ -0,0 +1,128 @@ +function Set-CIPPSPOSiteBulk { + <# + .SYNOPSIS + Set properties on many SharePoint sites concurrently via CSOM + + .DESCRIPTION + Batched counterpart to Set-CIPPSPOSite. SharePoint executes each site's Update serially inside a + single ProcessQuery (and caps a few per request), so batching into one request gives no speedup; + the win is CONCURRENCY. This builds one per-site ProcessQuery (identical shape to Set-CIPPSPOSite) + and hands them all to CIPP.CIPPRestClient.SendConcurrent, which fans them out asynchronously in + .NET (bounded by MaxConcurrency and the SPO admin host's connection-pool cap) with Retry-After / + backoff on 429. The SPO admin token is acquired once and reused across every request. + + Returns one object per site: @{ SiteUrl; Success; Error }. A single site's failure never aborts + the batch. + + .PARAMETER TenantFilter + Tenant to apply settings to + + .PARAMETER Sites + Array of per-site specs, each @{ SiteUrl = ''; Properties = @{ = ; ... } }. + Supported value types match Set-CIPPSPOSite: Boolean, String, Int32, Int64, and the CSOM enum + properties (SharingCapability, DefaultSharingLinkType, DefaultLinkPermission, + SharingDomainRestrictionMode, ConditionalAccessPolicy). + + .PARAMETER MaxConcurrency + Upper bound on in-flight requests (default 5, matching the SPO connection-pool cap in CIPPSharp). + Measured on a 526-site tenant, 5 in flight throttled far less than 8-10 - SharePoint's CSOM-admin + throttle is a sustained-rate limit, so a lower ceiling keeps the sweep under it. + + .PARAMETER UseCertificate + Authenticate app-only with the SAM certificate (SharePoint app-only requires it). + + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [Parameter(Mandatory = $true)] + [array]$Sites, + [int]$MaxConcurrency = 5, + [int]$MaxRetries = 3, + [switch]$UseCertificate + ) + + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $AdminUrl = $SharePointInfo.AdminUrl + $RequestUri = "$AdminUrl/_vti_bin/client.svc/ProcessQuery" + + # Acquire the SPO admin token ONCE and reuse it across every concurrent request. + $TokenSplat = @{ tenantid = $TenantFilter; scope = "$AdminUrl/.default" } + if ($UseCertificate) { $TokenSplat['AsApp'] = $true; $TokenSplat['UseCertificate'] = $true } + $Authorization = (Get-GraphToken @TokenSplat).Authorization + + $AllowedTypes = @('Boolean', 'String', 'Int32', 'Int64') + $EnumProperties = @('SharingCapability', 'DefaultSharingLinkType', 'DefaultLinkPermission', 'SharingDomainRestrictionMode', 'ConditionalAccessPolicy') + + $Requests = [System.Collections.Generic.List[CIPP.CIPPConcurrentRequest]]::new() + $RequestSites = [System.Collections.Generic.List[object]]::new() + + foreach ($Site in $Sites) { + if ([string]::IsNullOrWhiteSpace($Site.SiteUrl) -or -not $Site.Properties) { continue } + + $SetProperty = [System.Collections.Generic.List[string]]::new() + $x = 106 + foreach ($Property in $Site.Properties.Keys) { + $Value = $Site.Properties[$Property] + $PropertyType = $Value.GetType().Name + if ($Property -in $EnumProperties) { + $SetProperty.Add("$([int]$Value)") + $x++ + } elseif ($PropertyType -in $AllowedTypes) { + $PropertyToSet = if ($PropertyType -eq 'Boolean') { $Value.ToString().ToLower() } else { [System.Security.SecurityElement]::Escape([string]$Value) } + $SetProperty.Add("$PropertyToSet") + $x++ + } + } + if ($SetProperty.Count -eq 0) { continue } + + $XML = @" +$($SetProperty -join '')$([System.Security.SecurityElement]::Escape($Site.SiteUrl))false +"@ + + $Request = [CIPP.CIPPConcurrentRequest]::new() + $Request.Uri = $RequestUri + $Request.Method = 'POST' + $Request.Body = $XML + $Request.ContentType = 'text/xml' + $Headers = [System.Collections.Generic.Dictionary[string, string]]::new() + $Headers['Authorization'] = $Authorization + $Headers['Accept'] = 'application/json;odata=verbose' + $Request.Headers = $Headers + + $Requests.Add($Request) + $RequestSites.Add($Site) + } + + if ($Requests.Count -eq 0) { return @() } + + if (-not $PSCmdlet.ShouldProcess("$($Requests.Count) sites", 'Set Site Properties (bulk)')) { return @() } + + $Results = [CIPP.CIPPRestClient]::SendConcurrent($Requests, $MaxConcurrency, $MaxRetries) + + @(foreach ($Result in $Results) { + $Site = $RequestSites[$Result.Index] + $ErrorMessage = $null + if ($Result.Error) { + $ErrorMessage = $Result.Error + } elseif ($Result.StatusCode -ne 200) { + $ErrorMessage = "HTTP $($Result.StatusCode): $($Result.Result.Content)" + } else { + # CSOM returns 200 even for per-site failures; the error is in the body's ErrorInfo. + try { + $CsomError = ($Result.Result.Content | ConvertFrom-Json | Where-Object { $_.ErrorInfo } | Select-Object -First 1).ErrorInfo.ErrorMessage + if ($CsomError) { $ErrorMessage = $CsomError } + } catch { + $ErrorMessage = "Could not parse CSOM response: $($_.Exception.Message)" + } + } + [PSCustomObject]@{ + SiteUrl = $Site.SiteUrl + Success = [string]::IsNullOrEmpty($ErrorMessage) + Error = $ErrorMessage + } + }) +} diff --git a/Modules/CIPPCore/Public/Set-CIPPSPOTenant.ps1 b/Modules/CIPPCore/Public/Set-CIPPSPOTenant.ps1 index fe1188e1ca7cf..94ae84b874593 100644 --- a/Modules/CIPPCore/Public/Set-CIPPSPOTenant.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPSPOTenant.ps1 @@ -68,10 +68,19 @@ function Set-CIPPSPOTenant { [string]$SharepointPrefix, [Parameter(ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Properties')] [Parameter(ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Method')] - [string]$SharepointDomain + [string]$SharepointDomain, + # SharePoint app-only auth requires a certificate (secret app-only is rejected by SPO). When + # set, authenticate app-only with the SAM certificate instead of the delegated context. + [Parameter(ParameterSetName = 'Properties')] + [Parameter(ParameterSetName = 'Method')] + [switch]$UseCertificate ) process { + # Threaded onto the ProcessQuery call below; empty = unchanged delegated behaviour. + $AuthSplat = @{} + if ($UseCertificate) { $AuthSplat['AsApp'] = $true; $AuthSplat['UseCertificate'] = $true } + if (!$SharepointPrefix) { # get sharepoint admin site $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter @@ -135,7 +144,7 @@ function Set-CIPPSPOTenant { } if ($PSCmdlet.ShouldProcess($Description, 'Set Tenant Properties')) { - New-GraphPostRequest -scope "$AdminURL/.default" -tenantid $TenantFilter -Uri "$AdminURL/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AddedHeaders $AdditionalHeaders + New-GraphPostRequest -scope "$AdminURL/.default" -tenantid $TenantFilter -Uri "$AdminURL/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AddedHeaders $AdditionalHeaders @AuthSplat # Invalidate cached tenant data so subsequent reads reflect the change $Table = Get-CIPPTable -tablename 'cachespotenant' diff --git a/Modules/CIPPCore/Public/Set-CIPPSharePointLibraryCopyOperation.ps1 b/Modules/CIPPCore/Public/Set-CIPPSharePointLibraryCopyOperation.ps1 new file mode 100644 index 0000000000000..19316eab085ed --- /dev/null +++ b/Modules/CIPPCore/Public/Set-CIPPSharePointLibraryCopyOperation.ps1 @@ -0,0 +1,93 @@ +function Set-CIPPSharePointLibraryCopyOperation { + <# + .SYNOPSIS + Persists a SharePointLibraryCopy operation, splitting large handle payloads across rows. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $true)] + [string]$OperationId, + + [Parameter(Mandatory = $true)] + [hashtable]$Entity + ) + + $Table = Get-CIPPTable -TableName 'SharePointLibraryCopy' + + $SafeTenant = $TenantFilter -replace "'", "''" + $SafeOp = $OperationId -replace "'", "''" + $PrimaryExisting = @(Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq '$SafeTenant' and RowKey eq '$SafeOp'") | Select-Object -First 1 + + # Status-only updates must not rewrite CopyJobInfos (avoids delete/recreate races). + $PreserveHandles = -not $Entity.ContainsKey('CopyJobInfos') + if ($PreserveHandles -and $PrimaryExisting) { + $MergeEntity = [ordered]@{ + PartitionKey = $TenantFilter + RowKey = $OperationId + } + foreach ($Key in $Entity.Keys) { + $MergeEntity[$Key] = $Entity[$Key] + } + Add-CIPPAzDataTableEntity @Table -Entity ([hashtable]$MergeEntity) -OperationType UpsertMerge + return + } + + $ChunkRows = @(Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq '$SafeTenant' and startswith(RowKey, '$SafeOp`_')") + $Existing = @($PrimaryExisting) + @($ChunkRows) | Where-Object { $_ } + + foreach ($Row in $Existing) { + Remove-CIPPAzDataTableEntity @Table -Entity $Row -Force -ErrorAction SilentlyContinue + } + + $Table.Force = $true + + $CopyJobInfos = $null + if ($Entity.ContainsKey('CopyJobInfos')) { + $CopyJobInfos = $Entity.CopyJobInfos + $Entity.Remove('CopyJobInfos') | Out-Null + } + + $BaseEntity = [ordered]@{ + PartitionKey = $TenantFilter + } + + foreach ($Key in $Entity.Keys) { + $BaseEntity[$Key] = $Entity[$Key] + } + + if ($null -ne $CopyJobInfos) { + $HandlesJson = ConvertTo-Json -InputObject @($CopyJobInfos) -Depth 8 -Compress + $MaxChunk = 60000 + if ($HandlesJson.Length -le $MaxChunk) { + $BaseEntity.CopyJobInfos = $HandlesJson + $BaseEntity.RowKey = $OperationId + Add-CIPPAzDataTableEntity @Table -Entity ([hashtable]$BaseEntity) + } else { + $ChunkIndex = 0 + for ($i = 0; $i -lt $HandlesJson.Length; $i += $MaxChunk) { + $Len = [Math]::Min($MaxChunk, $HandlesJson.Length - $i) + $Slice = $HandlesJson.Substring($i, $Len) + if ($ChunkIndex -eq 0) { + $PrimaryEntity = [hashtable]$BaseEntity.Clone() + $PrimaryEntity.CopyJobInfos = $Slice + $PrimaryEntity.RowKey = $OperationId + if (-not $PrimaryEntity.Status) { $PrimaryEntity.Status = 'Queued' } + Add-CIPPAzDataTableEntity @Table -Entity $PrimaryEntity + } else { + Add-CIPPAzDataTableEntity @Table -Entity @{ + PartitionKey = $TenantFilter + RowKey = "${OperationId}_$ChunkIndex" + CopyJobInfos = $Slice + } + } + $ChunkIndex++ + } + } + } else { + $BaseEntity.RowKey = $OperationId + Add-CIPPAzDataTableEntity @Table -Entity ([hashtable]$BaseEntity) + } +} diff --git a/Modules/CIPPCore/Public/Set-CIPPSharePointObjectPermission.ps1 b/Modules/CIPPCore/Public/Set-CIPPSharePointObjectPermission.ps1 index 213e6ac713ea4..e4ff59066cf7b 100644 --- a/Modules/CIPPCore/Public/Set-CIPPSharePointObjectPermission.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPSharePointObjectPermission.ps1 @@ -112,10 +112,10 @@ function Set-CIPPSharePointObjectPermission { throw "None of the groups ($($GroupNames -join ', ')) could be found in $TenantFilter by display name." } - $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter - $Scope = "$($SharePointInfo.SharePointUrl)/.default" - $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } - $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + $RestContext = Resolve-CIPPSharePointRestContext -TenantFilter $TenantFilter -SiteUrl $SiteUrl + $Scope = $RestContext.Scope + $JsonAccept = $RestContext.Headers + $BaseUri = $RestContext.BaseUri $TargetLabel = if ($ListId) { "library $ListId" } else { 'site root' } if (-not $PSCmdlet.ShouldProcess($SiteUrl, "Grant $PermissionLevel on $TargetLabel")) { return } diff --git a/Modules/CIPPCore/Public/Set-CIPPStandardsCompareField.ps1 b/Modules/CIPPCore/Public/Set-CIPPStandardsCompareField.ps1 index 2aff76ce9ac19..ca1d31525a234 100644 --- a/Modules/CIPPCore/Public/Set-CIPPStandardsCompareField.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPStandardsCompareField.ps1 @@ -123,10 +123,12 @@ function Set-CIPPStandardsCompareField { if ($ExistingHash.ContainsKey($Field.FieldName)) { $Entity = $ExistingHash[$Field.FieldName] $Entity.Value = $NormalizedValue - $Entity | Add-Member -NotePropertyName TemplateId -NotePropertyValue ([string]$script:CippStandardInfoStorage.Value.StandardTemplateId) -Force - $Entity | Add-Member -NotePropertyName LicenseAvailable -NotePropertyValue ([bool]$Field.LicenseAvailable) -Force - $Entity | Add-Member -NotePropertyName CurrentValue -NotePropertyValue ([string]$Field.CurrentValue) -Force - $Entity | Add-Member -NotePropertyName ExpectedValue -NotePropertyValue ([string]$Field.ExpectedValue) -Force + $Entity | Add-Member -NotePropertyMembers ([ordered]@{ + TemplateId = ([string]$script:CippStandardInfoStorage.Value.StandardTemplateId) + LicenseAvailable = ([bool]$Field.LicenseAvailable) + CurrentValue = ([string]$Field.CurrentValue) + ExpectedValue = ([string]$Field.ExpectedValue) + }) -Force } else { $Entity = [PSCustomObject]@{ PartitionKey = [string]$TenantName.defaultDomainName diff --git a/Modules/CIPPCore/Public/Standards/Get-CIPPStandards.ps1 b/Modules/CIPPCore/Public/Standards/Get-CIPPStandards.ps1 index 1cdafbdaff969..1a08d78164a8b 100644 --- a/Modules/CIPPCore/Public/Standards/Get-CIPPStandards.ps1 +++ b/Modules/CIPPCore/Public/Standards/Get-CIPPStandards.ps1 @@ -73,11 +73,13 @@ function Get-CIPPStandards { $TemplateLabel = if ($TemplateJSON.displayName) { $TemplateJSON.displayName } else { "$($TemplateItem.RowKey)" } $NewItem = $Item.PSObject.Copy() $NewItem.PSObject.Properties.Remove('TemplateList-Tags') - $NewItem | Add-Member -NotePropertyName TemplateList -NotePropertyValue ([pscustomobject]@{ - label = $TemplateLabel - value = "$($TemplateItem.RowKey)" + $NewItem | Add-Member -NotePropertyMembers ([ordered]@{ + TemplateList = ([pscustomobject]@{ + label = $TemplateLabel + value = "$($TemplateItem.RowKey)" + }) + TemplateId = $Template.GUID }) -Force - $NewItem | Add-Member -NotePropertyName TemplateId -NotePropertyValue $Template.GUID -Force $NewItem } } else { @@ -105,11 +107,13 @@ function Get-CIPPStandards { $TemplateLabel = if ($TemplateJSON.displayName) { $TemplateJSON.displayName } else { "$($TemplateItem.RowKey)" } $NewItem = $StandardValue.PSObject.Copy() $NewItem.PSObject.Properties.Remove('TemplateList-Tags') - $NewItem | Add-Member -NotePropertyName TemplateList -NotePropertyValue ([pscustomobject]@{ - label = $TemplateLabel - value = "$($TemplateItem.RowKey)" + $NewItem | Add-Member -NotePropertyMembers ([ordered]@{ + TemplateList = ([pscustomobject]@{ + label = $TemplateLabel + value = "$($TemplateItem.RowKey)" + }) + TemplateId = $Template.GUID }) -Force - $NewItem | Add-Member -NotePropertyName TemplateId -NotePropertyValue $Template.GUID -Force $NewItem } $ExpandedStandards[$StandardName] = $NewArray diff --git a/Modules/CIPPCore/Public/Standards/Get-CIPPStandardsTemplateScope.ps1 b/Modules/CIPPCore/Public/Standards/Get-CIPPStandardsTemplateScope.ps1 new file mode 100644 index 0000000000000..6a56bd9118966 --- /dev/null +++ b/Modules/CIPPCore/Public/Standards/Get-CIPPStandardsTemplateScope.ps1 @@ -0,0 +1,74 @@ +function Get-CIPPStandardsTemplateScope { + <# + .SYNOPSIS + Resolves what a standards template covers, straight from its stored definition. + .DESCRIPTION + Get-CIPPStandards only emits the template that won the three-tier merge for each standard, so a + report row written by a tenant-specific template still belongs to an AllTenants template that + carries the same standard. This reads the selected template's own definition and returns the + standard keys, Intune/CA template ids (package members included), quarantine policy names and + reusable settings template ids it references, in the shapes the CippStandardsReports RowKeys use. + .PARAMETER TemplateId + RowKey of the StandardsTemplateV2 row. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TemplateId + ) + + $Scope = [PSCustomObject]@{ + StandardKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + TemplateGuids = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + QuarantineNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + } + + $TemplatesTable = Get-CIPPTable -TableName 'templates' + $SafeTemplateId = ConvertTo-CIPPODataFilterValue -Value $TemplateId -Type String + $SelectedTemplate = Get-CIPPAzDataTableEntity @TemplatesTable -Filter "PartitionKey eq 'StandardsTemplateV2' and RowKey eq '$SafeTemplateId'" + $SelectedStandards = try { ($SelectedTemplate.JSON | ConvertFrom-Json -Depth 100 -ErrorAction Stop).standards } catch { $null } + if ($null -eq $SelectedStandards) { return $Scope } + + $PackageRows = @{} + foreach ($Property in @($SelectedStandards.PSObject.Properties)) { + $StandardName = $Property.Name + $Config = $Property.Value + switch ($StandardName) { + { $_ -in @('IntuneTemplate', 'ConditionalAccessTemplate') } { + $Partition = if ($_ -eq 'IntuneTemplate') { 'IntuneTemplate' } else { 'CATemplate' } + foreach ($Item in @($Config)) { + if ($Item.TemplateList.value) { $null = $Scope.TemplateGuids.Add([string]$Item.TemplateList.value) } + foreach ($Tag in @($Item.'TemplateList-Tags')) { + $TagValue = if ($Tag.value) { [string]$Tag.value } else { [string]$Tag } + if (-not $TagValue) { continue } + if (-not $PackageRows.ContainsKey($Partition)) { + $PackageRows[$Partition] = @(Get-CIPPAzDataTableEntity @TemplatesTable -Filter "PartitionKey eq '$Partition'" | Where-Object { $_.Package }) + } + foreach ($Row in ($PackageRows[$Partition] | Where-Object { $_.Package -eq $TagValue })) { + $null = $Scope.TemplateGuids.Add([string]$Row.RowKey) + } + } + } + } + 'QuarantineTemplate' { + foreach ($Item in @($Config)) { + $DisplayName = $Item.displayName.value ?? $Item.displayName + if ($DisplayName) { $null = $Scope.QuarantineNames.Add([string]$DisplayName) } + } + } + 'ReusableSettingsTemplate' { + foreach ($Item in @($Config)) { + foreach ($Ref in @($Item.TemplateList)) { + $Id = if ($Ref.value) { [string]$Ref.value } else { [string]$Ref } + if ($Id) { $null = $Scope.StandardKeys.Add("standards.ReusableSettingsTemplate.$Id") } + } + } + } + default { + $null = $Scope.StandardKeys.Add("standards.$StandardName") + } + } + } + + return $Scope +} diff --git a/Modules/CIPPCore/Public/Start-CIPPSharePointLibraryCopy.ps1 b/Modules/CIPPCore/Public/Start-CIPPSharePointLibraryCopy.ps1 new file mode 100644 index 0000000000000..e1518bd51de92 --- /dev/null +++ b/Modules/CIPPCore/Public/Start-CIPPSharePointLibraryCopy.ps1 @@ -0,0 +1,159 @@ +function Start-CIPPSharePointLibraryCopy { + <# + .SYNOPSIS + Preflights or starts a SharePoint library-to-library copy operation. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [ValidateSet('PreflightLibraryCopy', 'StartLibraryCopy')] + [string]$Mode, + + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $true)] + [string]$SourceSiteId, + + [string]$SourceSiteUrl, + [Parameter(Mandatory = $true)] + [string]$SourceListId, + [string]$SourceSiteName, + [string]$SourceLibraryName, + + [Parameter(Mandatory = $true)] + [string]$DestSiteId, + + [string]$DestSiteUrl, + [Parameter(Mandatory = $true)] + [string]$DestListId, + [string]$DestSiteName, + [string]$DestLibraryName, + + [int]$NameConflictBehavior = 1, + [string]$StartedBy, + $Headers, + $APIName = 'SharePoint Library Copy' + ) + + $ResolveLibraryMeta = { + param([string]$SiteId, [string]$SiteUrl, [string]$ListId) + if ([string]::IsNullOrWhiteSpace($SiteId)) { + if ([string]::IsNullOrWhiteSpace($SiteUrl)) { + throw 'SourceSiteId or SourceSiteUrl is required.' + } + $ParsedUrl = [System.Uri]$SiteUrl + $SiteSegment = if ($ParsedUrl.AbsolutePath -in @('', '/')) { + $ParsedUrl.Host + } else { + "$($ParsedUrl.Host):$($ParsedUrl.AbsolutePath):" + } + $SiteMeta = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/sites/$SiteSegment`?`$select=id,webUrl,displayName" -tenantid $TenantFilter -asapp $true + $SiteId = $SiteMeta.id + $SiteUrl = $SiteMeta.webUrl + } else { + $SiteMeta = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/sites/$SiteId`?`$select=id,webUrl,displayName" -tenantid $TenantFilter -asapp $true + if (-not $SiteUrl) { $SiteUrl = $SiteMeta.webUrl } + } + $List = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/sites/$SiteId/lists/$ListId`?`$select=id,displayName,name,list" -tenantid $TenantFilter -asapp $true + [PSCustomObject]@{ + SiteId = $SiteId + SiteUrl = $SiteUrl + SiteDisplayName = $SiteMeta.displayName + ListId = $List.id + Title = $List.displayName + Name = $List.name + Template = $List.list.template + } + } + + $SourceMeta = & $ResolveLibraryMeta -SiteId $SourceSiteId -SiteUrl $SourceSiteUrl -ListId $SourceListId + $DestMeta = & $ResolveLibraryMeta -SiteId $DestSiteId -SiteUrl $DestSiteUrl -ListId $DestListId + + foreach ($Meta in @($SourceMeta, $DestMeta)) { + $Eligible = Test-CIPPSharePointLibraryCopyEligible -Template $Meta.Template -Title $Meta.Title -Name $Meta.Name + if (-not $Eligible.Eligible) { + throw $Eligible.Reason + } + } + + if ($SourceMeta.SiteId -eq $DestMeta.SiteId -and $SourceMeta.ListId -eq $DestMeta.ListId) { + throw 'Source and destination library must be different.' + } + + $Enumerate = Get-CIPPSharePointLibraryRootChildUris -TenantFilter $TenantFilter -SiteId $SourceMeta.SiteId ` + -SiteUrl $SourceMeta.SiteUrl -ListId $SourceMeta.ListId + + $Count = $Enumerate.EligibleRootCount + if ($Count -eq 0) { + throw 'Source library has no eligible content to copy.' + } + if ($Count -gt 1000) { + throw "Source library has $Count eligible root items (limit 1,000). Group files into folders and try again." + } + + $WarnLevel = 'none' + if ($Count -gt 200) { $WarnLevel = 'strong' } + elseif ($Count -gt 50) { $WarnLevel = 'soft' } + + if ($Mode -eq 'PreflightLibraryCopy') { + return [PSCustomObject]@{ + EligibleRootCount = $Count + WarnLevel = $WarnLevel + Message = "Estimated SharePoint jobs: $Count." + } + } + + $SourceRoot = Resolve-CIPPSharePointLibraryRootUri -TenantFilter $TenantFilter -SiteUrl $SourceMeta.SiteUrl ` + -SiteId $SourceMeta.SiteId -ListId $SourceMeta.ListId + $DestRoot = Resolve-CIPPSharePointLibraryRootUri -TenantFilter $TenantFilter -SiteUrl $DestMeta.SiteUrl ` + -SiteId $DestMeta.SiteId -ListId $DestMeta.ListId + + $SameWeb = $SourceMeta.SiteId -eq $DestMeta.SiteId + $CopyJobs = Invoke-CIPPSharePointCreateCopyJobs -TenantFilter $TenantFilter -SourceSiteUrl $SourceRoot.SiteUrl ` + -ExportObjectUris $Enumerate.ChildUris -DestinationUri $DestRoot.LibraryRootUri ` + -NameConflictBehavior $NameConflictBehavior -SameWebCopyMoveOptimization $SameWeb + + $OperationId = (New-Guid).Guid + $Expiry = ([DateTime]::UtcNow.AddDays(7)).ToString('o') + $SrcSiteName = if ($SourceSiteName) { $SourceSiteName } else { $SourceMeta.SiteDisplayName ?? 'Source site' } + $SrcLibName = if ($SourceLibraryName) { $SourceLibraryName } else { $SourceMeta.Title } + $DstSiteName = if ($DestSiteName) { $DestSiteName } else { $DestMeta.SiteDisplayName ?? 'Destination site' } + $DstLibName = if ($DestLibraryName) { $DestLibraryName } else { $DestMeta.Title } + + $HandleStates = @($CopyJobs | ForEach-Object { + [PSCustomObject]@{ Status = 'Queued'; IsComplete = $false } + }) + + Set-CIPPSharePointLibraryCopyOperation -TenantFilter $TenantFilter -OperationId $OperationId -Entity @{ + SourceSiteUrl = $SourceRoot.SiteUrl + SourceSiteName = $SrcSiteName + SourceLibraryName = $SrcLibName + DestSiteName = $DstSiteName + DestLibraryName = $DstLibName + StartedBy = $StartedBy + Status = 'Processing' + JobHandleCount = $CopyJobs.Count + Expiry = $Expiry + CopyJobInfos = @($CopyJobs) + HandleStates = (ConvertTo-Json -InputObject @($HandleStates) -Compress -Depth 4) + SanitizedSnapshot = (ConvertTo-Json -InputObject @{ + OperationId = $OperationId + Status = 'Processing' + JobsComplete = 0 + JobsTotal = $CopyJobs.Count + TotalErrors = 0 + TotalWarnings = 0 + Message = 'Copy queued.' + } -Compress) + } + + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter ` + -message "Started library copy $OperationId ($SrcLibName -> $DstLibName, $($CopyJobs.Count) jobs)" -sev Info + + [PSCustomObject]@{ + OperationId = $OperationId + JobHandleCount = $CopyJobs.Count + Message = 'Library copy started.' + } +} diff --git a/Modules/CIPPCore/Public/Test-CIPPGDAPGroupMappings.ps1 b/Modules/CIPPCore/Public/Test-CIPPGDAPGroupMappings.ps1 index e3f7b357491e6..d1c1d1b684b12 100644 --- a/Modules/CIPPCore/Public/Test-CIPPGDAPGroupMappings.ps1 +++ b/Modules/CIPPCore/Public/Test-CIPPGDAPGroupMappings.ps1 @@ -186,6 +186,7 @@ function Test-CIPPGDAPGroupMappings { roleDefinitionId = [string]$Mapping.roleDefinitionId } -Force } + Write-LogMessage -headers $Headers -API $APIName -message "Wrote back $($Corrections.Count) corrected GDAP group mapping(s) to GDAPRoles" -Sev 'Info' } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -headers $Headers -API $APIName -message "Failed to write corrected GDAP group mappings to GDAPRoles: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage @@ -197,6 +198,7 @@ function Test-CIPPGDAPGroupMappings { if ($TemplateId) { try { Add-CIPPGDAPRoleTemplate -TemplateId $TemplateId -RoleMappings ($Mappings | Select-Object -Property RoleName, GroupName, GroupId, roleDefinitionId) -Overwrite + Write-LogMessage -headers $Headers -API $APIName -message "Wrote corrected GDAP group mappings to template '$TemplateId'" -Sev 'Info' } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -headers $Headers -API $APIName -message "Failed to write corrected GDAP group mappings to template '$TemplateId': $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage @@ -209,6 +211,7 @@ function Test-CIPPGDAPGroupMappings { if ($Invite) { $Invite.RoleMappings = [string](@($Mappings | Select-Object -Property RoleName, GroupName, GroupId, roleDefinitionId) | ConvertTo-Json -Depth 10 -Compress) Add-CIPPAzDataTableEntity @InviteTable -Entity $Invite -Force + Write-LogMessage -headers $Headers -API $APIName -message "Wrote corrected GDAP group mappings to invite '$InviteRowKey'" -Sev 'Info' } } catch { $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPCore/Public/Test-CIPPOffboardingRequest.ps1 b/Modules/CIPPCore/Public/Test-CIPPOffboardingRequest.ps1 index 0f9b81fa6b275..2fbdf33b1acc5 100644 --- a/Modules/CIPPCore/Public/Test-CIPPOffboardingRequest.ps1 +++ b/Modules/CIPPCore/Public/Test-CIPPOffboardingRequest.ps1 @@ -67,7 +67,7 @@ function Test-CIPPOffboardingRequest { # Keep this list in sync with the conditions in Invoke-CIPPOffboardingJob. $BooleanActions = @( 'ConvertToShared', 'HideFromGAL', 'removeCalendarInvites', 'removePermissions', 'removeCalendarPermissions', - 'RemoveRules', 'RemoveMobile', 'RemoveGroups', 'RemoveLicenses', 'RevokeSessions', 'DisableSignIn', + 'RemoveRules', 'RemoveMobile', 'WipeMobile', 'RemoveGroups', 'RemoveLicenses', 'RevokeSessions', 'DisableSignIn', 'ClearImmutableId', 'ResetPass', 'RemoveMFADevices', 'RemoveTeamsPhoneDID', 'DeleteUser', 'DisableOneDriveSharing', 'disableForwarding' ) diff --git a/Modules/CIPPCore/Public/Test-CIPPSharePointLibraryCopyEligible.ps1 b/Modules/CIPPCore/Public/Test-CIPPSharePointLibraryCopyEligible.ps1 new file mode 100644 index 0000000000000..75a8100e9d4c4 --- /dev/null +++ b/Modules/CIPPCore/Public/Test-CIPPSharePointLibraryCopyEligible.ps1 @@ -0,0 +1,40 @@ +function Test-CIPPSharePointLibraryCopyEligible { + <# + .SYNOPSIS + Returns whether a document library is eligible as source or destination for library copy. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$Template, + + [string]$Title, + [string]$Name + ) + + if ($Template -ne 'documentLibrary') { + return [PSCustomObject]@{ Eligible = $false; Reason = 'Not a document library.' } + } + + $TitleNorm = ([string]$Title).Trim() + $NameNorm = ([string]$Name).Trim() + + $DeniedTitles = @( + 'Form Templates' + 'Style Library' + 'Preservation Hold Library' + 'Site Assets' + 'Site Pages' + ) + foreach ($Denied in $DeniedTitles) { + if ($TitleNorm -eq $Denied) { + return [PSCustomObject]@{ Eligible = $false; Reason = "System library '$Denied' is not eligible." } + } + } + + if ($NameNorm -eq 'SiteAssets') { + return [PSCustomObject]@{ Eligible = $false; Reason = 'Site Assets library is not eligible.' } + } + + return [PSCustomObject]@{ Eligible = $true; Reason = $null } +} diff --git a/Modules/CIPPCore/Public/Tools/Import-CommunityTemplate.ps1 b/Modules/CIPPCore/Public/Tools/Import-CommunityTemplate.ps1 index 0170cdd70aa66..9a76171a1ae7e 100644 --- a/Modules/CIPPCore/Public/Tools/Import-CommunityTemplate.ps1 +++ b/Modules/CIPPCore/Public/Tools/Import-CommunityTemplate.ps1 @@ -150,6 +150,8 @@ function Import-CommunityTemplate { RowKey = if ($Duplicate) { $Duplicate.RowKey } else { $id } Source = $Source } + # Full replace: keep the CIPP-assigned Package. + if ($Duplicate -and $Duplicate.Package) { $entity.Package = $Duplicate.Package } Add-CIPPAzDataTableEntity @Table -Entity $entity -Force break } @@ -222,6 +224,8 @@ function Import-CommunityTemplate { RowKey = if ($Duplicate) { $Duplicate.RowKey } else { $id } Source = $Source } + # Full replace: keep the CIPP-assigned Package. + if ($Duplicate -and $Duplicate.Package) { $entity.Package = $Duplicate.Package } Write-Information "Final entity: $($entity | ConvertTo-Json -Depth 10)" Add-CIPPAzDataTableEntity @Table -Entity $entity -Force @@ -312,10 +316,6 @@ function Import-CommunityTemplate { Source = $Source } - if ($Existing -and $Existing.Package) { - $entity.Package = $Existing.Package - } - if ($Duplicate -and $Duplicate.Package) { $entity.Package = $Duplicate.Package } diff --git a/Modules/CIPPCore/Public/Tools/Push-ExecGenerateReportBuilderReport.ps1 b/Modules/CIPPCore/Public/Tools/Push-ExecGenerateReportBuilderReport.ps1 index ee55408feb75d..6235acfa63c4a 100644 --- a/Modules/CIPPCore/Public/Tools/Push-ExecGenerateReportBuilderReport.ps1 +++ b/Modules/CIPPCore/Public/Tools/Push-ExecGenerateReportBuilderReport.ps1 @@ -42,16 +42,23 @@ function Push-ExecGenerateReportBuilderReport { $ParsedBlocks = @($Blocks) } } elseif ($TemplateGUID) { + # A schedule that references a template by GUID follows the template: blocks, page + # setup and name are read fresh on every run, so edits made to the template after the + # schedule was created are picked up without recreating the schedule. $TemplateTable = Get-CippTable -tablename 'templates' $Template = Get-CIPPAzDataTableEntity @TemplateTable -Filter "PartitionKey eq 'ReportBuilderTemplate' and RowKey eq '$($TemplateGUID)'" - if ($Template -and $Template.JSON) { - $TemplateData = ConvertFrom-Json -InputObject $Template.JSON - $ParsedBlocks = @($TemplateData.Blocks) - # A schedule created before page setup existed passes no Settings, so fall back to - # whatever the template itself was saved with. - if (-not $ParsedSettings -and $TemplateData.Settings) { - $ParsedSettings = $TemplateData.Settings - } + if (-not $Template -or -not $Template.JSON) { + throw "Report template $TemplateGUID was not found. It may have been deleted; recreate the schedule from a saved template." + } + $TemplateData = ConvertFrom-Json -InputObject $Template.JSON + $ParsedBlocks = @($TemplateData.Blocks) + if ($TemplateData.Name) { + $TemplateName = $TemplateData.Name + } + # A schedule created before page setup existed passes no Settings, so fall back to + # whatever the template itself was saved with. + if (-not $ParsedSettings -and $TemplateData.Settings) { + $ParsedSettings = $TemplateData.Settings } } @@ -166,8 +173,10 @@ function Push-ExecGenerateReportBuilderReport { (@($HeaderLine, $SeparatorLine) + $DataLines) -join "`n" } } - $Block | Add-Member -NotePropertyName 'content' -NotePropertyValue $BlockContent -Force - $Block | Add-Member -NotePropertyName 'static' -NotePropertyValue $true -Force + $Block | Add-Member -NotePropertyMembers ([ordered]@{ + content = $BlockContent + static = $true + }) -Force } else { $Block | Add-Member -NotePropertyName 'content' -NotePropertyValue 'No data available for this data source.' -Force } diff --git a/Modules/CIPPCore/Public/Update-CIPPSharePointLibraryCopyStatus.ps1 b/Modules/CIPPCore/Public/Update-CIPPSharePointLibraryCopyStatus.ps1 new file mode 100644 index 0000000000000..cf9e9b93a1645 --- /dev/null +++ b/Modules/CIPPCore/Public/Update-CIPPSharePointLibraryCopyStatus.ps1 @@ -0,0 +1,178 @@ +function Update-CIPPSharePointLibraryCopyStatus { + <# + .SYNOPSIS + Polls unfinished copy job handles and returns a sanitized operation-level status snapshot. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $true)] + [string]$OperationId + ) + + $CollectIssueMessages = { + param([array]$HandleStates, [ValidateSet('Error', 'Warning')][string]$Kind) + + $Property = if ($Kind -eq 'Error') { 'ErrorMessages' } else { 'WarningMessages' } + $Seen = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + $Results = [System.Collections.Generic.List[object]]::new() + + foreach ($State in @($HandleStates)) { + if ($null -eq $State) { continue } + foreach ($Message in @($State.$Property)) { + if ([string]::IsNullOrWhiteSpace($Message)) { continue } + $Text = [string]$Message + if ($Seen.Add($Text)) { + [void]$Results.Add([PSCustomObject]@{ Severity = $Kind; Message = $Text }) + } + } + } + + return @($Results) + } + + $Operation = Get-CIPPSharePointLibraryCopyOperation -TenantFilter $TenantFilter -OperationId $OperationId + if (-not $Operation) { + throw 'Library copy operation not found.' + } + + if ($Operation.Status -in @('Completed', 'CompletedWithErrors', 'Failed') -and $Operation.SanitizedSnapshot) { + $Snapshot = $Operation.SanitizedSnapshot + $Snapshot | Add-Member -NotePropertyName Errors -NotePropertyValue ( + & $CollectIssueMessages -HandleStates $Operation.HandleStates -Kind Error + ) -Force + $Snapshot | Add-Member -NotePropertyName Warnings -NotePropertyValue ( + & $CollectIssueMessages -HandleStates $Operation.HandleStates -Kind Warning + ) -Force + return $Snapshot + } + + $JobsTotal = [Math]::Max([int]$Operation.JobHandleCount, @($Operation.CopyJobInfos).Count) + $HandleStates = @($Operation.HandleStates) + if ($HandleStates.Count -lt $JobsTotal) { + $HandleStates = @(1..$JobsTotal | ForEach-Object { + [PSCustomObject]@{ Status = 'Queued'; IsComplete = $false } + }) + } + + for ($Index = 0; $Index -lt $JobsTotal; $Index++) { + $State = $HandleStates[$Index] + if ($State.IsComplete) { continue } + + try { + $Progress = Get-CIPPSharePointCopyJobProgress -TenantFilter $TenantFilter ` + -SourceSiteUrl $Operation.SourceSiteUrl -CopyJobInfo $Operation.CopyJobInfos[$Index] + $HandleStates[$Index] = [PSCustomObject]@{ + Status = $Progress.Status + IsComplete = $Progress.IsComplete + ObjectsProcessed = $Progress.ObjectsProcessed + TotalExpectedObjects = $Progress.TotalExpectedObjects + FilesCreated = $Progress.FilesCreated + BytesProcessed = $Progress.BytesProcessed + TotalErrors = $Progress.TotalErrors + TotalWarnings = $Progress.TotalWarnings + ErrorMessages = @($Progress.ErrorMessages) + WarningMessages = @($Progress.WarningMessages) + } + } catch { + $Detail = [string]$_.Exception.Message + if ([string]::IsNullOrWhiteSpace($Detail)) { + $Detail = 'Failed to retrieve copy job progress from SharePoint.' + } else { + $Detail = [regex]::Replace($Detail, 'https?://[^\s''"]+', '[url redacted]', 'IgnoreCase') + $Detail = ($Detail -replace '\s{2,}', ' ').Trim() + if ($Detail.Length -gt 280) { + $Detail = $Detail.Substring(0, 280).Trim() + '…' + } + } + $HandleStates[$Index] = [PSCustomObject]@{ + Status = 'Failed' + IsComplete = $true + TotalErrors = 1 + ErrorMessages = @($Detail) + } + } + } + + $JobsComplete = @($HandleStates | Where-Object { $_.IsComplete }).Count + $ObjectsProcessed = ($HandleStates | ForEach-Object { [int64]($_.ObjectsProcessed ?? 0) } | Measure-Object -Sum).Sum + $TotalExpected = ($HandleStates | ForEach-Object { $_.TotalExpectedObjects } | Where-Object { $null -ne $_ } | Measure-Object -Sum).Sum + $FilesCreated = ($HandleStates | ForEach-Object { [int64]($_.FilesCreated ?? 0) } | Measure-Object -Sum).Sum + $BytesProcessed = ($HandleStates | ForEach-Object { [int64]($_.BytesProcessed ?? 0) } | Measure-Object -Sum).Sum + $TotalErrors = ($HandleStates | ForEach-Object { [int64]($_.TotalErrors ?? 0) } | Measure-Object -Sum).Sum + $TotalWarnings = ($HandleStates | ForEach-Object { [int64]($_.TotalWarnings ?? 0) } | Measure-Object -Sum).Sum + + $ProgressPercent = $null + if ($TotalExpected -gt 0) { + $ProgressPercent = [Math]::Round(100.0 * $ObjectsProcessed / $TotalExpected, 1) + } elseif ($JobsTotal -gt 0) { + $ProgressPercent = [Math]::Round(100.0 * $JobsComplete / $JobsTotal, 1) + } + + $AnyFailed = @($HandleStates | Where-Object { $_.Status -eq 'Failed' }).Count -gt 0 + $AllComplete = $JobsComplete -ge $JobsTotal + + $Status = if ($AllComplete -and $TotalErrors -gt 0) { 'CompletedWithErrors' } + elseif ($AllComplete -and -not $AnyFailed) { 'Completed' } + elseif ($AnyFailed -and $AllComplete) { 'CompletedWithErrors' } + elseif ($AnyFailed) { 'Failed' } + else { 'Processing' } + + $Message = switch ($Status) { + 'Completed' { 'Library copy completed.' } + 'CompletedWithErrors' { 'Library copy completed with errors.' } + 'Failed' { 'One or more copy jobs failed.' } + default { 'Copy in progress.' } + } + + $Errors = @(& $CollectIssueMessages -HandleStates $HandleStates -Kind Error) + $Warnings = @(& $CollectIssueMessages -HandleStates $HandleStates -Kind Warning) + if ($TotalErrors -gt 0 -and $Errors.Count -eq 0) { + $Errors = @([PSCustomObject]@{ + Severity = 'Error' + Message = 'SharePoint reported copy errors, but CIPP could not read detailed messages from the job log or queue.' + }) + } + + $Snapshot = [PSCustomObject]@{ + OperationId = $OperationId + Status = $Status + JobsComplete = $JobsComplete + JobsTotal = $JobsTotal + ObjectsProcessed = $ObjectsProcessed + TotalExpectedObjects = if ($TotalExpected -gt 0) { $TotalExpected } else { $null } + ProgressPercent = $ProgressPercent + FilesCreated = $FilesCreated + BytesProcessed = $BytesProcessed + TotalErrors = $TotalErrors + TotalWarnings = $TotalWarnings + Errors = $Errors + Warnings = $Warnings + LastUpdatedUtc = ([DateTime]::UtcNow).ToString('o') + Message = $Message + SourceSiteName = $Operation.SourceSiteName + SourceLibraryName = $Operation.SourceLibraryName + DestSiteName = $Operation.DestSiteName + DestLibraryName = $Operation.DestLibraryName + } + + $Expiry = if ($AllComplete) { ([DateTime]::UtcNow.AddHours(48)).ToString('o') } else { $Operation.Expiry } + + Set-CIPPSharePointLibraryCopyOperation -TenantFilter $TenantFilter -OperationId $OperationId -Entity @{ + SourceSiteUrl = $Operation.SourceSiteUrl + SourceSiteName = $Operation.SourceSiteName + SourceLibraryName = $Operation.SourceLibraryName + DestSiteName = $Operation.DestSiteName + DestLibraryName = $Operation.DestLibraryName + StartedBy = $Operation.StartedBy + Status = $Status + JobHandleCount = $JobsTotal + Expiry = $Expiry + HandleStates = (ConvertTo-Json -InputObject @($HandleStates) -Compress -Depth 6) + SanitizedSnapshot = (ConvertTo-Json -InputObject $Snapshot -Compress -Depth 6) + } + + return $Snapshot +} diff --git a/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 b/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 index aaefa7f488a42..f5067a0a840be 100644 --- a/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 +++ b/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 @@ -221,6 +221,12 @@ function Invoke-CippWebhookProcessing { if ($AffectedUser) { $CIPPAlert.AffectedUser = $AffectedUser } + # Per-alert priority rides on the record rather than a function parameter, the same + # way CustomSubject does above - this function has a second caller + # (Push-PublicWebhookProcess) that has no alert config to pass. + if ($Data.CIPPPsaTicketPriority) { + $CIPPAlert.PsaTicketPriority = $Data.CIPPPsaTicketPriority + } Send-CIPPAlert @CIPPAlert } 'generateWebhook' { diff --git a/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 b/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 index 935ff56ac883a..e9bf9d96dd71a 100644 --- a/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 +++ b/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 @@ -244,13 +244,14 @@ function Test-CIPPAuditLogRules { $ExcludedTenants = @(Expand-CIPPTenantGroups -TenantFilter $ExcludedTenants) } [pscustomobject]@{ - Tenants = $Tenants - Excluded = $ExcludedTenants - Conditions = $ConfigEntry.Conditions - Actions = $ConfigEntry.Actions - LogType = $ConfigEntry.Type - AlertComment = $ConfigEntry.AlertComment - CustomSubject = $ConfigEntry.CustomSubject + Tenants = $Tenants + Excluded = $ExcludedTenants + Conditions = $ConfigEntry.Conditions + Actions = $ConfigEntry.Actions + LogType = $ConfigEntry.Type + AlertComment = $ConfigEntry.AlertComment + CustomSubject = $ConfigEntry.CustomSubject + PsaTicketPriority = $ConfigEntry.PsaTicketPriority } } }) @@ -839,13 +840,14 @@ function Test-CIPPAuditLogRules { } [PSCustomObject]@{ - conditions = $conditions - expectedAction = $actions - CIPPClause = $CIPPClause - AlertComment = $Config.AlertComment - CustomSubject = $Config.CustomSubject - HasGeoCondition = $HasGeoCondition - ExcludedUserKeys = $LocationExcludedUserKeys + conditions = $conditions + expectedAction = $actions + CIPPClause = $CIPPClause + AlertComment = $Config.AlertComment + CustomSubject = $Config.CustomSubject + PsaTicketPriority = $Config.PsaTicketPriority + HasGeoCondition = $HasGeoCondition + ExcludedUserKeys = $LocationExcludedUserKeys } } } catch { @@ -904,8 +906,11 @@ function Test-CIPPAuditLogRules { $ReturnedData = foreach ($item in $ReturnedData) { $item.CIPPAction = $clause.expectedAction $item.CIPPClause = $clause.CIPPClause -join ' and ' - $item | Add-Member -NotePropertyName 'CIPPAlertComment' -NotePropertyValue $clause.AlertComment -Force -ErrorAction SilentlyContinue - $item | Add-Member -NotePropertyName 'CIPPCustomSubject' -NotePropertyValue $clause.CustomSubject -Force -ErrorAction SilentlyContinue + $item | Add-Member -NotePropertyMembers ([ordered]@{ + CIPPAlertComment = $clause.AlertComment + CIPPCustomSubject = $clause.CustomSubject + CIPPPsaTicketPriority = $clause.PsaTicketPriority + }) -Force -ErrorAction SilentlyContinue $MatchedRules.Add($clause.CIPPClause -join ' and ') $item } diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheActiveUserDetail.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheActiveUserDetail.ps1 new file mode 100644 index 0000000000000..2050891087c92 --- /dev/null +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheActiveUserDetail.ps1 @@ -0,0 +1,38 @@ +function Set-CIPPDBCacheActiveUserDetail { + <# + .SYNOPSIS + Caches the Microsoft 365 active-user detail report for a tenant + + .DESCRIPTION + Stores getOffice365ActiveUserDetail(period='D90') - one row per user carrying per-service + last-activity dates (Exchange, OneDrive, SharePoint, Teams, Yammer) and the products + assigned. Rows are keyed by userPrincipalName so they join to the cached Users dataset. + + Note: when the tenant conceals usage-report names, userPrincipalName is anonymized and the + rows cannot be joined to users. The license optimization report detects this and points to + the Anonymous Reports Disable standard. + + .PARAMETER TenantFilter + The tenant to cache active-user detail for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching active user detail' -sev Debug + + New-GraphGetRequest -uri "https://graph.microsoft.com/beta/reports/getOffice365ActiveUserDetail(period='D90')?`$format=application%2fjson" -tenantid $TenantFilter -Stream | + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ActiveUserDetail' -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached active user detail successfully' -sev Debug + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache active user detail: $($_.Exception.Message)" -sev Error + } +} diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAppRoleAssignments.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAppRoleAssignments.ps1 index 6db0d82a87690..870f6caf8874e 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAppRoleAssignments.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAppRoleAssignments.ps1 @@ -29,8 +29,10 @@ function Set-CIPPDBCacheAppRoleAssignments { $AppRoleAssignments = $SP.appRoleAssignments foreach ($Assignment in $AppRoleAssignments) { # Enrich with service principal info - $Assignment | Add-Member -NotePropertyName 'servicePrincipalDisplayName' -NotePropertyValue $SP.displayName -Force - $Assignment | Add-Member -NotePropertyName 'servicePrincipalAppId' -NotePropertyValue $SP.appId -Force + $Assignment | Add-Member -NotePropertyMembers ([ordered]@{ + servicePrincipalDisplayName = $SP.displayName + servicePrincipalAppId = $SP.appId + }) -Force $AllAppRoleAssignments.Add($Assignment) } } catch { @@ -41,6 +43,11 @@ function Set-CIPPDBCacheAppRoleAssignments { if ($AllAppRoleAssignments.Count -gt 0) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'AppRoleAssignments' -Data $AllAppRoleAssignments -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($AllAppRoleAssignments.Count) app role assignments" -sev Debug + } else { + # The service principal read succeeded and no assignments exist: write the authoritative + # empty set so the Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'AppRoleAssignments' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 app role assignments (none found)' -sev Debug } $AllAppRoleAssignments = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAuthenticationFlowsPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAuthenticationFlowsPolicy.ps1 index d25be4186873d..476fb63b4c91b 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAuthenticationFlowsPolicy.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAuthenticationFlowsPolicy.ps1 @@ -24,6 +24,11 @@ function Set-CIPPDBCacheAuthenticationFlowsPolicy { if ($AuthFlowPolicy) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'AuthenticationFlowsPolicy' -Data @($AuthFlowPolicy) -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached authentication flows policy successfully' -sev Debug + } else { + # The request succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'AuthenticationFlowsPolicy' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 authentication flows policies (none found)' -sev Debug } } catch { diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAuthenticationMethodsPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAuthenticationMethodsPolicy.ps1 index 3f26788dbc30a..f718d3991229c 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAuthenticationMethodsPolicy.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAuthenticationMethodsPolicy.ps1 @@ -34,6 +34,11 @@ function Set-CIPPDBCacheAuthenticationMethodsPolicy { if ($Fido2Configuration) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Fido2Configuration' -Data @($Fido2Configuration) -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached FIDO2 authentication method configuration successfully' -sev Debug + } else { + # The request succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Fido2Configuration' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 FIDO2 authentication method configurations (none found)' -sev Debug } $Fido2Configuration = $null } catch { diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAutopilotDeploymentProfiles.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAutopilotDeploymentProfiles.ps1 index 2dec2aee679a5..36c9266bf29ba 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAutopilotDeploymentProfiles.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAutopilotDeploymentProfiles.ps1 @@ -20,6 +20,9 @@ function Set-CIPPDBCacheAutopilotDeploymentProfiles { $TestResult = Test-CIPPStandardLicense -StandardName 'AutopilotDeploymentProfilesCache' -TenantFilter $TenantFilter -Preset Intune -SkipLog if ($TestResult -eq $false) { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have Intune license, skipping Autopilot deployment profiles cache' -sev Debug + # A license skip is still a completed collection: record the authoritative empty set + # so collect-on-miss does not re-run this collector forever on unlicensed tenants. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'AutopilotDeploymentProfiles' -Data @() -AddCount -ClearOnEmpty return } diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheB2BManagementPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheB2BManagementPolicy.ps1 index 36255c5e966de..710768604455d 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheB2BManagementPolicy.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheB2BManagementPolicy.ps1 @@ -34,10 +34,12 @@ function Set-CIPPDBCacheB2BManagementPolicy { $DomainPolicy = $ParsedDefinition.B2BManagementPolicy.InvitationsAllowedAndBlockedDomainsPolicy $AllowedDomains = @($DomainPolicy.AllowedDomains) $BlockedDomains = @($DomainPolicy.BlockedDomains) - $Policy | Add-Member -NotePropertyName 'parsedDefinition' -NotePropertyValue $ParsedDefinition -Force - $Policy | Add-Member -NotePropertyName 'allowedDomains' -NotePropertyValue $AllowedDomains -Force - $Policy | Add-Member -NotePropertyName 'blockedDomains' -NotePropertyValue $BlockedDomains -Force - $Policy | Add-Member -NotePropertyName 'hasRestrictions' -NotePropertyValue (($AllowedDomains.Count -gt 0) -or ($BlockedDomains.Count -gt 0)) -Force + $Policy | Add-Member -NotePropertyMembers ([ordered]@{ + parsedDefinition = $ParsedDefinition + allowedDomains = $AllowedDomains + blockedDomains = $BlockedDomains + hasRestrictions = (($AllowedDomains.Count -gt 0) -or ($BlockedDomains.Count -gt 0)) + }) -Force $Policy } @@ -45,7 +47,12 @@ function Set-CIPPDBCacheB2BManagementPolicy { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'B2BManagementPolicy' -Data @($B2BManagementPolicy) -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached B2B management policy successfully' -sev Debug } else { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'No B2B management policy found' -sev Debug + # The read succeeded and the tenant genuinely has no legacy B2B policy: write the + # authoritative empty set so the Count marker records a completed collection and + # stale rows are cleared, instead of leaving the type indistinguishable from + # "collector never ran". + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'B2BManagementPolicy' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'No B2B management policy found - cached authoritative empty set' -sev Debug } } catch { diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheComplianceRetentionPolicies.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheComplianceRetentionPolicies.ps1 index a51e14ccc9bb2..0870d2840796a 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheComplianceRetentionPolicies.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheComplianceRetentionPolicies.ps1 @@ -26,6 +26,9 @@ function Set-CIPPDBCacheComplianceRetentionPolicies { if ($LicenseCheck -eq $false) { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have a Purview/AIP license, skipping retention compliance policies' -sev Debug + # A license skip is still a completed collection: record the authoritative empty set + # so collect-on-miss does not re-run this collector forever on unlicensed tenants. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ComplianceRetentionPolicies' -Data @() -AddCount -ClearOnEmpty return } @@ -37,6 +40,11 @@ function Set-CIPPDBCacheComplianceRetentionPolicies { if ($Policies) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ComplianceRetentionPolicies' -Data @($Policies) -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $(@($Policies).Count) retention compliance policies" -sev Debug + } else { + # The read succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ComplianceRetentionPolicies' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 retention compliance policies (none found)' -sev Debug } } catch { diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheComplianceRetentionRules.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheComplianceRetentionRules.ps1 index 712ad8a419b30..2261ecbdbfcc6 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheComplianceRetentionRules.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheComplianceRetentionRules.ps1 @@ -26,6 +26,9 @@ function Set-CIPPDBCacheComplianceRetentionRules { if ($LicenseCheck -eq $false) { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have a Purview/AIP license, skipping retention compliance rules' -sev Debug + # A license skip is still a completed collection: record the authoritative empty set + # so collect-on-miss does not re-run this collector forever on unlicensed tenants. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ComplianceRetentionRules' -Data @() -AddCount -ClearOnEmpty return } @@ -37,6 +40,11 @@ function Set-CIPPDBCacheComplianceRetentionRules { if ($Rules) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ComplianceRetentionRules' -Data @($Rules) -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $(@($Rules).Count) retention compliance rules" -sev Debug + } else { + # The read succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ComplianceRetentionRules' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 retention compliance rules (none found)' -sev Debug } } catch { diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheConditionalAccessPolicies.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheConditionalAccessPolicies.ps1 index b1c9b2bfb455d..9acf4ac8e8cf8 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheConditionalAccessPolicies.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheConditionalAccessPolicies.ps1 @@ -21,6 +21,13 @@ function Set-CIPPDBCacheConditionalAccessPolicies { if ($TestResult -eq $false) { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have Azure AD Premium license, skipping CA' -sev Debug + # A license skip is still a completed collection: record authoritative empty sets for + # the CA types this collector writes so collect-on-miss does not re-run it forever. + # SecurityDefaults is deliberately NOT emptied here - it applies exactly to tenants + # without Entra Premium and its ungated collector keeps that cache populated. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ConditionalAccessPolicies' -Data @() -AddCount -ClearOnEmpty + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'NamedLocations' -Data @() -AddCount -ClearOnEmpty + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'AuthenticationStrengths' -Data @() -AddCount -ClearOnEmpty return } diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCopilotAdminSettings.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCopilotAdminSettings.ps1 index ae45e1b064372..be2452b193074 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCopilotAdminSettings.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCopilotAdminSettings.ps1 @@ -21,12 +21,22 @@ function Set-CIPPDBCacheCopilotAdminSettings { # The Copilot admin settings API currently requires delegated auth (no -AsApp) $LimitedMode = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/copilot/admin/settings/limitedMode' -tenantid $TenantFilter - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CopilotAdminSettings' -Data @($LimitedMode) -AddCount - $LimitedMode = $null - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached Copilot admin settings successfully' -sev Debug + # Only write when the fetch actually returned the settings object. limitedMode always + # exists on a tenant that answers this endpoint, so an empty result means the request + # failed without throwing - writing it anyway would reset the Count marker (and rotate + # out the previous good row) on a transient Graph error. + if ($LimitedMode) { + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CopilotAdminSettings' -Data @($LimitedMode) -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached Copilot admin settings successfully' -sev Debug + } else { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Copilot admin settings fetch returned no data - leaving the existing cache untouched' -sev Warning + } + $LimitedMode = $null } catch { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Copilot admin settings: $($_.Exception.Message)" -sev Error + # A transient Graph error must not touch the cache: the last good settings row stays in + # place and the next successful run replaces it. + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Copilot admin settings: $($_.Exception.Message)" -sev Debug } } diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCopilotPolicySettings.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCopilotPolicySettings.ps1 index 0bdfae63d3772..a8a7b0f042586 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCopilotPolicySettings.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCopilotPolicySettings.ps1 @@ -39,6 +39,7 @@ function Set-CIPPDBCacheCopilotPolicySettings { # -SkipValueExtraction returns the entity intact. $Values = [ordered]@{} $PolicyIds = [ordered]@{} + $SucceededSettings = 0 foreach ($Key in $SettingMap.Keys) { try { $Current = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/copilot/admin/policySettings/$($SettingMap[$Key])" -tenantid $TenantFilter -SkipValueExtraction @@ -46,12 +47,23 @@ function Set-CIPPDBCacheCopilotPolicySettings { # never turns "0" into a number and stops matching the configured value. $Values[$Key] = if ($null -eq $Current.value) { $null } else { [string]$Current.value } $PolicyIds[$Key] = $Current.policyId + $SucceededSettings++ } catch { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to get Copilot policy setting '$($SettingMap[$Key])': $($_.Exception.Message)" -sev Warning $Values[$Key] = $null $PolicyIds[$Key] = $null } } + + # When EVERY per-setting fetch failed this was a tenant-wide failure (auth, throttling, + # a transient Graph error), not five genuine null values: writing the all-null row would + # replace the previous good row via orphan cleanup. Leave the existing cache untouched + # and let the next successful run refresh it. + if ($SucceededSettings -eq 0) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'All Copilot policy setting fetches failed - leaving the existing cache untouched' -sev Warning + return + } + $Values['policyIds'] = [PSCustomObject]$PolicyIds Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CopilotPolicySettings' -Data @([PSCustomObject]$Values) -AddCount @@ -61,6 +73,6 @@ function Set-CIPPDBCacheCopilotPolicySettings { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached Copilot policy settings successfully' -sev Debug } catch { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Copilot policy settings: $($_.Exception.Message)" -sev Error + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Copilot policy settings: $($_.Exception.Message)" -sev Debug } } diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsExternalAccessPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsExternalAccessPolicy.ps1 index baf9ca9be9e2c..501d0eca33ed4 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsExternalAccessPolicy.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsExternalAccessPolicy.ps1 @@ -30,6 +30,11 @@ function Set-CIPPDBCacheCsExternalAccessPolicy { $Data = @($ExternalAccess) Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CsExternalAccessPolicy' -Data $Data -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached Teams External Access Policy' -sev Debug + } else { + # The request succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CsExternalAccessPolicy' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 Teams External Access Policies (none found)' -sev Debug } $ExternalAccess = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsAppPermissionPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsAppPermissionPolicy.ps1 index a7e3eda73371a..fd62167d29046 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsAppPermissionPolicy.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsAppPermissionPolicy.ps1 @@ -30,6 +30,11 @@ function Set-CIPPDBCacheCsTeamsAppPermissionPolicy { $Data = @($AppPermissionPolicies) Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CsTeamsAppPermissionPolicy' -Data $Data -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($Data.Count) Teams App Permission Policies" -sev Debug + } else { + # The request succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CsTeamsAppPermissionPolicy' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 Teams App Permission Policies (none found)' -sev Debug } $AppPermissionPolicies = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsClientConfiguration.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsClientConfiguration.ps1 index 8e5ef591c4a23..21e59b1ce827b 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsClientConfiguration.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsClientConfiguration.ps1 @@ -31,6 +31,11 @@ function Set-CIPPDBCacheCsTeamsClientConfiguration { $Data = @($ClientConfig) Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CsTeamsClientConfiguration' -Data $Data -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached Teams Client Configuration' -sev Debug + } else { + # The request succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CsTeamsClientConfiguration' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 Teams Client Configurations (none found)' -sev Debug } $ClientConfig = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMeetingPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMeetingPolicy.ps1 index 33bdc29669c56..b045dd64f9caa 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMeetingPolicy.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMeetingPolicy.ps1 @@ -30,6 +30,11 @@ function Set-CIPPDBCacheCsTeamsMeetingPolicy { $Data = @($MeetingPolicy) Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CsTeamsMeetingPolicy' -Data $Data -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached Teams Meeting Policy' -sev Debug + } else { + # The request succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CsTeamsMeetingPolicy' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 Teams Meeting Policies (none found)' -sev Debug } $MeetingPolicy = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMessagingConfiguration.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMessagingConfiguration.ps1 index bff91cce2f2f6..886848e9d297e 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMessagingConfiguration.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMessagingConfiguration.ps1 @@ -31,6 +31,11 @@ function Set-CIPPDBCacheCsTeamsMessagingConfiguration { $Data = @($MessagingConfig) Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CsTeamsMessagingConfiguration' -Data $Data -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached Teams Messaging Configuration' -sev Debug + } else { + # The request succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CsTeamsMessagingConfiguration' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 Teams Messaging Configurations (none found)' -sev Debug } $MessagingConfig = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMessagingPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMessagingPolicy.ps1 index a843c6cfa7b52..860b056b5d2fa 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMessagingPolicy.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMessagingPolicy.ps1 @@ -31,6 +31,11 @@ function Set-CIPPDBCacheCsTeamsMessagingPolicy { $Data = @($MessagingPolicy) Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CsTeamsMessagingPolicy' -Data $Data -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached Teams Messaging Policy' -sev Debug + } else { + # The request succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CsTeamsMessagingPolicy' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 Teams Messaging Policies (none found)' -sev Debug } $MessagingPolicy = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTenantFederationConfiguration.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTenantFederationConfiguration.ps1 index a27c559fcaf88..5afb630bdc417 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTenantFederationConfiguration.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTenantFederationConfiguration.ps1 @@ -31,6 +31,11 @@ function Set-CIPPDBCacheCsTenantFederationConfiguration { $Data = @($Federation) Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CsTenantFederationConfiguration' -Data $Data -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached Teams Tenant Federation Configuration' -sev Debug + } else { + # The request succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CsTenantFederationConfiguration' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 Teams Tenant Federation Configurations (none found)' -sev Debug } $Federation = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDefenderCVEs.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDefenderCVEs.ps1 index 5434c6c6a2f8b..9a2a1bc8e99b7 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDefenderCVEs.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDefenderCVEs.ps1 @@ -44,22 +44,29 @@ function Set-CIPPDBCacheDefenderCVEs { try { $CveId = $Vuln.cveId + # TVM also returns software-inventory rows with no CVE. Skip them before the + # hashtable lookup: ContainsKey($null) throws, which was caught per-record and + # logged as an 'Allover Build' error for every such row. + if ([string]::IsNullOrWhiteSpace($CveId)) { $SkippedCount++; return } if (-not $CveAggregator.ContainsKey($CveId)) { # Establish global CVE & software properties for this specific tenant $CveAggregator[$CveId] = @{ - cveId = $CveId - customerId = $TenantFilter - softwareVendor = $Vuln.softwareVendor ?? '' - softwareName = $Vuln.softwareName ?? '' - vulnerabilitySeverityLevel = $Vuln.vulnerabilitySeverityLevel ?? '' - recommendedSecurityUpdate = $Vuln.recommendedSecurityUpdate ?? '' - recommendedSecurityUpdateUrl = $Vuln.recommendedSecurityUpdateUrl ?? '' - exploitabilityLevel = $Vuln.exploitabilityLevel ?? '' + cveId = $CveId + customerId = $TenantFilter + softwareVendor = $Vuln.softwareVendor ?? '' + softwareName = $Vuln.softwareName ?? '' + softwareVersion = $Vuln.softwareVersion ?? '' + vulnerabilitySeverityLevel = $Vuln.vulnerabilitySeverityLevel ?? '' + exploitabilityLevel = $Vuln.exploitabilityLevel ?? '' # Device metadata as the JSON text it will be stored as, not as objects. - DeviceJson = [System.Text.StringBuilder]::new() - DeviceCount = 0 + DeviceJson = [System.Text.StringBuilder]::new() + DeviceCount = 0 + # Dedupe devices by id so DeviceCount is a unique-device count and the + # stored list carries each affected device once, however many software + # packages reported the same CVE on it. + SeenDevices = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) } } @@ -78,23 +85,28 @@ function Set-CIPPDBCacheDefenderCVEs { # second copy that used to exist at emit time, where a CVE's whole device List and # the JSON produced from it were both live at once. # - # ConvertTo-Json builds the fragment rather than string interpolation, so escaping - # of device names and registry paths stays correct. - $Fragment = @{ - deviceId = ($Vuln.deviceId -join ',') ?? '' - deviceName = ($Vuln.deviceName -join ',') ?? '' - osVersion = $Vuln.osVersion ?? '' - softwareVersion = ($Vuln.softwareVersion -join ',') ?? '' - diskPaths = if ($Vuln.diskPaths) { $Vuln.diskPaths -join ';' } else { '' } - registryPaths = if ($Vuln.registryPaths) { $Vuln.registryPaths -join ';' } else { '' } - } | ConvertTo-Json -Compress - - # Appended only after the fragment is fully built, so a record that fails - # mid-extraction cannot leave a partial payload attached to the wrong CVE. + # Minimal per-device payload: only the id and name are consumed downstream. + $DeviceId = ($Vuln.deviceId -join ',') ?? '' + $DeviceName = ($Vuln.deviceName -join ',') ?? '' + + # Dedupe on the device id (falling back to the name) so one device that reports + # the same CVE across several software packages is stored and counted once. + $DeviceKey = if ($DeviceId) { $DeviceId } else { $DeviceName } $Bucket = $CveAggregator[$CveId] - if ($Bucket.DeviceCount -gt 0) { [void]$Bucket.DeviceJson.Append(',') } - [void]$Bucket.DeviceJson.Append($Fragment) - $Bucket.DeviceCount++ + if ($DeviceKey -and $Bucket.SeenDevices.Add($DeviceKey)) { + # ConvertTo-Json builds the fragment rather than string interpolation, so + # escaping of device names stays correct. + $Fragment = @{ + deviceId = $DeviceId + deviceName = $DeviceName + } | ConvertTo-Json -Compress + + # Appended only after the fragment is fully built, so a record that fails + # mid-extraction cannot leave a partial payload attached to the wrong CVE. + if ($Bucket.DeviceCount -gt 0) { [void]$Bucket.DeviceJson.Append(',') } + [void]$Bucket.DeviceJson.Append($Fragment) + $Bucket.DeviceCount++ + } } catch { $SkippedCount++ $ErrorMessage = Get-CippException -Exception $_ @@ -155,24 +167,27 @@ function Set-CIPPDBCacheDefenderCVEs { } @{ - PartitionKey = $CveKey - RowKey = $TenantFilter # RowKey becomes just the Tenant, ensuring 1 row per CVE per Tenant - customerId = $TenantFilter - cveId = $CveKey - softwareVendor = $CveData.softwareVendor - softwareName = $CveData.softwareName - vulnerabilitySeverityLevel = $CveData.vulnerabilitySeverityLevel - recommendedSecurityUpdate = $CveData.recommendedSecurityUpdate - recommendedSecurityUpdateUrl = $CveData.recommendedSecurityUpdateUrl - exploitabilityLevel = $CveData.exploitabilityLevel - - # Meta aggregation counts - deviceCount = $CveData.DeviceCount - - # All individual device variations compressed safely inside a single field - deviceDetailsJson = $CompactDeviceJson - - lastUpdated = $LastUpdated + PartitionKey = $CveKey + RowKey = $TenantFilter # blob field only; the table RowKey is derived from 'id' below + # Stable table RowKey: Add-CIPPDbItem derives "$Type-$id", so this makes + # writes idempotent (DefenderCVEs-) instead of a random GUID per + # run - which also stopped every run rewriting the whole tenant's rows. + id = $CveKey + customerId = $TenantFilter + cveId = $CveKey + softwareVendor = $CveData.softwareVendor + softwareName = $CveData.softwareName + softwareVersion = $CveData.softwareVersion + vulnerabilitySeverityLevel = $CveData.vulnerabilitySeverityLevel + exploitabilityLevel = $CveData.exploitabilityLevel + + # Unique affected-device count for this CVE in this tenant. + deviceCount = $CveData.DeviceCount + + # Minimal per-device detail ({deviceId, deviceName}) as one JSON string. + deviceDetailsJson = $CompactDeviceJson + + lastUpdated = $LastUpdated } # The row is built; drop the bucket so its device list is collectable @@ -187,6 +202,10 @@ function Set-CIPPDBCacheDefenderCVEs { } catch { $ErrorMessage = Get-CippException -Exception $_ + if (Test-CIPPCacheCapabilityError -Message $_.Exception.Message) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Skipping Defender CVE cache - tenant not onboarded to Defender for Endpoint: $($ErrorMessage.NormalizedError)" -sev 'Debug' -LogData $ErrorMessage + return + } Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "CVE Cache Refresh failed: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage throw } diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDeviceEnrollmentConfigurations.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDeviceEnrollmentConfigurations.ps1 index fb23fea07a7c2..ffd4054738b88 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDeviceEnrollmentConfigurations.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDeviceEnrollmentConfigurations.ps1 @@ -26,6 +26,9 @@ function Set-CIPPDBCacheDeviceEnrollmentConfigurations { $TestResult = Test-CIPPStandardLicense -StandardName 'DeviceEnrollmentConfigurationsCache' -TenantFilter $TenantFilter -Preset Intune -SkipLog if ($TestResult -eq $false) { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have Intune license, skipping device enrollment configurations cache' -sev Debug + # A license skip is still a completed collection: record the authoritative empty set + # so collect-on-miss does not re-run this collector forever on unlicensed tenants. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DeviceEnrollmentConfigurations' -Data @() -AddCount -ClearOnEmpty return } diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDeviceRegistrationPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDeviceRegistrationPolicy.ps1 index 876244bae3dc9..24224b44a6312 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDeviceRegistrationPolicy.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDeviceRegistrationPolicy.ps1 @@ -24,6 +24,11 @@ function Set-CIPPDBCacheDeviceRegistrationPolicy { if ($DeviceRegistrationPolicy) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DeviceRegistrationPolicy' -Data @($DeviceRegistrationPolicy) -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached device registration policy successfully' -sev Debug + } else { + # The request succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DeviceRegistrationPolicy' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 device registration policies (none found)' -sev Debug } } catch { diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDlpCompliancePolicies.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDlpCompliancePolicies.ps1 index 2186b844e1953..8fe8a6fd58f3a 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDlpCompliancePolicies.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDlpCompliancePolicies.ps1 @@ -27,6 +27,10 @@ function Set-CIPPDBCacheDlpCompliancePolicies { if ($LicenseCheck -eq $false) { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have a Purview/AIP license, skipping DLP compliance policies' -sev Debug + # A license skip is still a completed collection: record authoritative empty sets for + # both types this collector writes so collect-on-miss does not re-run it forever. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DlpCompliancePolicies' -Data @() -AddCount -ClearOnEmpty + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DlpComplianceRules' -Data @() -AddCount -ClearOnEmpty return } @@ -40,6 +44,11 @@ function Set-CIPPDBCacheDlpCompliancePolicies { if ($Policies) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DlpCompliancePolicies' -Data @($Policies) -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $(@($Policies).Count) DLP compliance policies" -sev Debug + } else { + # The read succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DlpCompliancePolicies' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 DLP compliance policies (none found)' -sev Debug } # Full rule objects: the compare needs the Rule allowlist fields (AdvancedRule, conditions, @@ -49,6 +58,11 @@ function Set-CIPPDBCacheDlpCompliancePolicies { if ($Rules) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DlpComplianceRules' -Data @($Rules) -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $(@($Rules).Count) DLP compliance rules" -sev Debug + } else { + # The read succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DlpComplianceRules' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 DLP compliance rules (none found)' -sev Debug } } catch { diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoAcceptedDomains.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoAcceptedDomains.ps1 index be47cf4c37141..3965d7e22abf9 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoAcceptedDomains.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoAcceptedDomains.ps1 @@ -24,6 +24,11 @@ function Set-CIPPDBCacheExoAcceptedDomains { if ($AcceptedDomains) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoAcceptedDomains' -Data $AcceptedDomains -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($AcceptedDomains.Count) Accepted Domains" -sev Debug + } else { + # The cmdlet succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoAcceptedDomains' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 Accepted Domains (none found)' -sev Debug } $AcceptedDomains = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoAdminAuditLogConfig.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoAdminAuditLogConfig.ps1 index d51a81c26ba25..88f08cdf12185 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoAdminAuditLogConfig.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoAdminAuditLogConfig.ps1 @@ -26,6 +26,11 @@ function Set-CIPPDBCacheExoAdminAuditLogConfig { $AuditConfigArray = @($AuditConfig) Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoAdminAuditLogConfig' -Data $AuditConfigArray -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached Exchange Admin Audit Log configuration' -sev Debug + } else { + # The cmdlet succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoAdminAuditLogConfig' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 Admin Audit Log configurations (none found)' -sev Debug } $AuditConfig = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoAntiPhishPolicies.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoAntiPhishPolicies.ps1 index 2e7fa2dbee1bc..8d7d1de93c095 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoAntiPhishPolicies.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoAntiPhishPolicies.ps1 @@ -24,6 +24,11 @@ function Set-CIPPDBCacheExoAntiPhishPolicies { if ($AntiPhishPolicies) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoAntiPhishPolicies' -Data $AntiPhishPolicies -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($AntiPhishPolicies.Count) Anti-Phishing policies" -sev Debug + } else { + # The cmdlet succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoAntiPhishPolicies' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 Anti-Phishing policies (none found)' -sev Debug } $AntiPhishPolicies = $null @@ -32,6 +37,11 @@ function Set-CIPPDBCacheExoAntiPhishPolicies { if ($AntiPhishRules) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoAntiPhishRules' -Data $AntiPhishRules -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($AntiPhishRules.Count) Anti-Phishing rules" -sev Debug + } else { + # The cmdlet succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoAntiPhishRules' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 Anti-Phishing rules (none found)' -sev Debug } $AntiPhishRules = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoAtpPolicyForO365.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoAtpPolicyForO365.ps1 index b93320868475e..dd39419d63db9 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoAtpPolicyForO365.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoAtpPolicyForO365.ps1 @@ -19,14 +19,19 @@ function Set-CIPPDBCacheExoAtpPolicyForO365 { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Exchange ATP policies for Office 365' -sev Debug - $AtpPolicies = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-AtpPolicyForO365' - if ($AtpPolicies) { - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoAtpPolicyForO365' -Data $AtpPolicies -AddCount - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($AtpPolicies.Count) ATP policies for Office 365" -sev Debug - } + # Write unconditionally with -ClearOnEmpty so a successful but empty result records an + # authoritative Count=0 marker. That marker is what lets the CIS test tell "collected, no + # policy" (a real Failed) apart from "never collected" (a Skip) instead of both surfacing + # as "cache not found". + $AtpPolicies = @(New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-AtpPolicyForO365') + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoAtpPolicyForO365' -Data $AtpPolicies -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($AtpPolicies.Count) ATP policies for Office 365" -sev Debug $AtpPolicies = $null } catch { + # Rethrow so the collection runner records a real failure, instead of the producer silently + # reporting success with an empty cache (which surfaced to tests as "cache not found"). Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache ATP policy data: $($_.Exception.Message)" -sev Error + throw } } diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoDkimSigningConfig.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoDkimSigningConfig.ps1 index a0e3b6b2350a8..358a4d48e9664 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoDkimSigningConfig.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoDkimSigningConfig.ps1 @@ -24,6 +24,11 @@ function Set-CIPPDBCacheExoDkimSigningConfig { if ($DkimConfig) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoDkimSigningConfig' -Data $DkimConfig -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($DkimConfig.Count) DKIM configurations" -sev Debug + } else { + # The cmdlet succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoDkimSigningConfig' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 DKIM configurations (none found)' -sev Debug } $DkimConfig = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoDlpSensitiveInfoTypes.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoDlpSensitiveInfoTypes.ps1 index dd640f3440725..98eb5647704f5 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoDlpSensitiveInfoTypes.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoDlpSensitiveInfoTypes.ps1 @@ -7,7 +7,8 @@ function Set-CIPPDBCacheExoDlpSensitiveInfoTypes { Calls Get-DlpSensitiveInformationTypeRulePackage against the Security & Compliance endpoint and writes the raw rule packages (including the ClassificationRuleCollectionXml the SIT drift comparer parses via ConvertTo-CIPPSitComparable) into the CIPP database under Type - 'ExoDlpSensitiveInfoTypes'. + 'ExoDlpSensitiveInfoTypes'. Only custom/tenant-authored rule packages are cached; the + Microsoft built-in catalog is huge, identical across every tenant, and never read from this cache. .PARAMETER TenantFilter The tenant to cache SIT rule packages for @@ -27,6 +28,9 @@ function Set-CIPPDBCacheExoDlpSensitiveInfoTypes { if ($LicenseCheck -eq $false) { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have a Purview/AIP license, skipping sensitive information type rule packages' -sev Debug + # A license skip is still a completed collection: record the authoritative empty set + # so collect-on-miss does not re-run this collector forever on unlicensed tenants. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoDlpSensitiveInfoTypes' -Data @() -AddCount -ClearOnEmpty return } @@ -34,10 +38,19 @@ function Set-CIPPDBCacheExoDlpSensitiveInfoTypes { $Tenant = Get-Tenants -TenantFilter $TenantFilter | Select-Object -First 1 $RulePackages = New-ExoRequest -TenantId $Tenant.customerId -cmdlet 'Get-DlpSensitiveInformationTypeRulePackage' -Compliance | Select-Object * -ExcludeProperty '*odata*', '*data.type*' + # Drop Microsoft's built-in catalog packages, keeping only custom/tenant-authored ones. Fail-open: + # a package with no Publisher is kept rather than risk losing a real custom pack. + $RulePackages = @($RulePackages | Where-Object { $_.Publisher -notlike 'Microsoft*' }) if ($RulePackages) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoDlpSensitiveInfoTypes' -Data @($RulePackages) -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $(@($RulePackages).Count) sensitive information type rule packages" -sev Debug + } else { + # The read succeeded and no custom rule packages exist (the common case): write the + # authoritative empty set so the Count marker records a completed collection and stale + # rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoDlpSensitiveInfoTypes' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 sensitive information type rule packages (no custom packages found)' -sev Debug } } catch { diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoExternalInOutlook.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoExternalInOutlook.ps1 index 14236ee775a4f..bc5313e9e0c7a 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoExternalInOutlook.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoExternalInOutlook.ps1 @@ -28,6 +28,11 @@ function Set-CIPPDBCacheExoExternalInOutlook { $ExternalInOutlookArray = @($ExternalInOutlook) Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoExternalInOutlook' -Data $ExternalInOutlookArray -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached Exchange ExternalInOutlook configuration' -sev Debug + } else { + # The cmdlet succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoExternalInOutlook' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 ExternalInOutlook configurations (none found)' -sev Debug } $ExternalInOutlook = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoGlobalQuarantinePolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoGlobalQuarantinePolicy.ps1 index 67617a57577c0..9f67f0e148e19 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoGlobalQuarantinePolicy.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoGlobalQuarantinePolicy.ps1 @@ -26,6 +26,11 @@ function Set-CIPPDBCacheExoGlobalQuarantinePolicy { $GlobalQuarantinePolicyArray = @($GlobalQuarantinePolicy) Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoGlobalQuarantinePolicy' -Data $GlobalQuarantinePolicyArray -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached Exchange global quarantine policy' -sev Debug + } else { + # The cmdlet succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoGlobalQuarantinePolicy' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 global quarantine policies (none found)' -sev Debug } $GlobalQuarantinePolicy = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoHostedConnectionFilterPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoHostedConnectionFilterPolicy.ps1 index ec9531d11227e..799381f45edc6 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoHostedConnectionFilterPolicy.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoHostedConnectionFilterPolicy.ps1 @@ -23,6 +23,11 @@ function Set-CIPPDBCacheExoHostedConnectionFilterPolicy { if ($ConnectionFilterPolicies) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoHostedConnectionFilterPolicy' -Data $ConnectionFilterPolicies -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($ConnectionFilterPolicies.Count) hosted connection filter policies" -sev Debug + } else { + # The cmdlet succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoHostedConnectionFilterPolicy' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 hosted connection filter policies (none found)' -sev Debug } $ConnectionFilterPolicies = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoHostedContentFilterPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoHostedContentFilterPolicy.ps1 index 958648690c0e1..19087288cbe32 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoHostedContentFilterPolicy.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoHostedContentFilterPolicy.ps1 @@ -22,6 +22,11 @@ function Set-CIPPDBCacheExoHostedContentFilterPolicy { if ($HostedContentFilterPolicies) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoHostedContentFilterPolicy' -Data $HostedContentFilterPolicies -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($HostedContentFilterPolicies.Count) Hosted Content Filter policies" -sev Debug + } else { + # The cmdlet succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoHostedContentFilterPolicy' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 Hosted Content Filter policies (none found)' -sev Debug } $HostedContentFilterPolicies = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoHostedContentFilterRule.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoHostedContentFilterRule.ps1 index a0834481eeea7..c93d433d0ef9e 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoHostedContentFilterRule.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoHostedContentFilterRule.ps1 @@ -23,6 +23,11 @@ function Set-CIPPDBCacheExoHostedContentFilterRule { if ($HostedContentFilterRules) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoHostedContentFilterRule' -Data $HostedContentFilterRules -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($HostedContentFilterRules.Count) hosted content filter rules" -sev Debug + } else { + # The cmdlet succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoHostedContentFilterRule' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 hosted content filter rules (none found)' -sev Debug } $HostedContentFilterRules = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoHostedOutboundSpamFilterPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoHostedOutboundSpamFilterPolicy.ps1 index 2a53545a2405c..d3fae972d9f84 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoHostedOutboundSpamFilterPolicy.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoHostedOutboundSpamFilterPolicy.ps1 @@ -23,6 +23,11 @@ function Set-CIPPDBCacheExoHostedOutboundSpamFilterPolicy { if ($HostedOutboundSpamFilterPolicies) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoHostedOutboundSpamFilterPolicy' -Data $HostedOutboundSpamFilterPolicies -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($HostedOutboundSpamFilterPolicies.Count) Hosted Outbound Spam Filter policies" -sev Debug + } else { + # The cmdlet succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoHostedOutboundSpamFilterPolicy' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 Hosted Outbound Spam Filter policies (none found)' -sev Debug } $HostedOutboundSpamFilterPolicies = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoInboundConnector.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoInboundConnector.ps1 index e4ba018b2bfe2..180edeb121504 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoInboundConnector.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoInboundConnector.ps1 @@ -23,6 +23,11 @@ function Set-CIPPDBCacheExoInboundConnector { if ($InboundConnectors) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoInboundConnector' -Data $InboundConnectors -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($InboundConnectors.Count) inbound connectors" -sev Debug + } else { + # The cmdlet succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoInboundConnector' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 inbound connectors (none found)' -sev Debug } $InboundConnectors = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoLabels.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoLabels.ps1 index 93a551ae3fe96..6c647bbe827f2 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoLabels.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoLabels.ps1 @@ -26,7 +26,13 @@ function Set-CIPPDBCacheExoLabels { $LicenseCheck = Test-CIPPStandardLicense -StandardName 'ExoLabelsCache' -TenantFilter $TenantFilter -Preset Compliance -SkipLog if ($LicenseCheck -eq $false) { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have a Purview/AIP license, skipping compliance labels' -sev Debug + # Warning, not Debug: Test-CIPPStandardLicense also returns $false when the capability + # lookup itself errors, so a wrong gate on a tenant that DOES have Purview must be + # visible in the logs rather than silently parking the standard at No Data. + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Purview/AIP capability check returned '$LicenseCheck' (requires one of RMS_S_PREMIUM, RMS_S_PREMIUM2, MIP_S_CLP1, MIP_S_CLP2) - skipping compliance labels and recording an authoritative empty set" -sev Warning + # A license skip is still a completed collection: record the authoritative empty set + # so collect-on-miss does not re-run this collector forever on unlicensed tenants. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoLabels' -Data @() -AddCount -ClearOnEmpty return } @@ -38,6 +44,11 @@ function Set-CIPPDBCacheExoLabels { if ($Labels) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoLabels' -Data @($Labels) -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $(@($Labels).Count) compliance labels" -sev Debug + } else { + # The cmdlet succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoLabels' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 compliance labels (none found)' -sev Debug } } catch { diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoMailContacts.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoMailContacts.ps1 index 424f41cf9d54a..feb04b572f59b 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoMailContacts.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoMailContacts.ps1 @@ -8,7 +8,7 @@ function Set-CIPPDBCacheExoMailContacts { baselines. Get-MailContact carries the mail-specific properties (ExternalEmailAddress, MailTip, HiddenFromAddressListsEnabled) while the extended directory properties (FirstName, Company, City, Phone, etc.) only exist on Get-Contact, so both are fetched - in one bulk request and merged per contact. + (each $select-projected to only the stored fields) and merged per contact. ExternalEmailAddress is normalized: the 'SMTP:'/'smtp:' prefix is stripped and the value lowercased, because Exchange re-cases the domain part when it creates a contact @@ -31,17 +31,11 @@ function Set-CIPPDBCacheExoMailContacts { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Mail Contacts' -sev Debug - $BulkRequests = @( - @{ CmdletInput = @{ CmdletName = 'Get-MailContact'; Parameters = @{ ResultSize = 'Unlimited' } } } - @{ CmdletInput = @{ CmdletName = 'Get-Contact'; Parameters = @{ ResultSize = 'Unlimited' } } } - ) - $BulkResults = New-ExoBulkRequest -tenantid $TenantFilter -cmdletArray $BulkRequests -useSystemMailbox $true -ReturnWithCommand $true - - # Build lookups from Get-Contact results: primary key ExternalDirectoryObjectId, - # fallback Identity for contacts without a directory object id. + $MailContactResults = @(New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-MailContact' -cmdParams @{ ResultSize = 'Unlimited' } -Select 'Identity,Guid,ExternalDirectoryObjectId,DisplayName,ExternalEmailAddress,MailTip,HiddenFromAddressListsEnabled') + $ContactResults = @(New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-Contact' -cmdParams @{ ResultSize = 'Unlimited' } -Select 'Identity,FirstName,LastName,Company,StateOrProvince,StreetAddress,Phone,WebPage,Title,City,PostalCode,CountryOrRegion,MobilePhone') $ContactByDirectoryId = @{} $ContactByIdentity = @{} - foreach ($Contact in @($BulkResults.'Get-Contact')) { + foreach ($Contact in $ContactResults) { if ($Contact.ExternalDirectoryObjectId) { $ContactByDirectoryId[[string]$Contact.ExternalDirectoryObjectId] = $Contact } @@ -51,7 +45,7 @@ function Set-CIPPDBCacheExoMailContacts { } $MailContacts = [System.Collections.Generic.List[PSObject]]::new() - foreach ($MailContact in @($BulkResults.'Get-MailContact')) { + foreach ($MailContact in $MailContactResults) { $MatchedContact = $null if ($MailContact.ExternalDirectoryObjectId -and $ContactByDirectoryId.ContainsKey([string]$MailContact.ExternalDirectoryObjectId)) { $MatchedContact = $ContactByDirectoryId[[string]$MailContact.ExternalDirectoryObjectId] @@ -85,7 +79,8 @@ function Set-CIPPDBCacheExoMailContacts { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoMailContacts' -Data @($MailContacts) -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($MailContacts.Count) Mail Contacts" -sev Debug - $BulkResults = $null + $MailContactResults = $null + $ContactResults = $null $ContactByDirectoryId = $null $ContactByIdentity = $null $MailContacts = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoMalwareFilterPolicies.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoMalwareFilterPolicies.ps1 index 7d71ab954e5e0..9c7430564252e 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoMalwareFilterPolicies.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoMalwareFilterPolicies.ps1 @@ -24,6 +24,11 @@ function Set-CIPPDBCacheExoMalwareFilterPolicies { if ($MalwarePolicies) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoMalwareFilterPolicies' -Data $MalwarePolicies -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($MalwarePolicies.Count) Malware Filter policies" -sev Debug + } else { + # The cmdlet succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoMalwareFilterPolicies' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 Malware Filter policies (none found)' -sev Debug } $MalwarePolicies = $null @@ -32,6 +37,11 @@ function Set-CIPPDBCacheExoMalwareFilterPolicies { if ($MalwareRules) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoMalwareFilterRules' -Data $MalwareRules -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($MalwareRules.Count) Malware Filter rules" -sev Debug + } else { + # The cmdlet succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoMalwareFilterRules' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 Malware Filter rules (none found)' -sev Debug } $MalwareRules = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoOMEConfiguration.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoOMEConfiguration.ps1 index 0d5524c56d747..c9a64520e64b5 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoOMEConfiguration.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoOMEConfiguration.ps1 @@ -24,6 +24,11 @@ function Set-CIPPDBCacheExoOMEConfiguration { $OMEConfigurationArray = @($OMEConfigurations) Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoOMEConfiguration' -Data $OMEConfigurationArray -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($OMEConfigurationArray.Count) OME configurations" -sev Debug + } else { + # The cmdlet succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoOMEConfiguration' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 OME configurations (none found)' -sev Debug } $OMEConfigurations = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoOrganizationConfig.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoOrganizationConfig.ps1 index c07c203edaef9..036d049a4c457 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoOrganizationConfig.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoOrganizationConfig.ps1 @@ -26,6 +26,11 @@ function Set-CIPPDBCacheExoOrganizationConfig { $OrgConfigArray = @($OrgConfig) Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoOrganizationConfig' -Data $OrgConfigArray -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached Exchange Organization configuration' -sev Debug + } else { + # The cmdlet succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoOrganizationConfig' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 Organization configurations (none found)' -sev Debug } $OrgConfig = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoOutboundConnector.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoOutboundConnector.ps1 index 0253c3d512a5f..7f8dce099e6ba 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoOutboundConnector.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoOutboundConnector.ps1 @@ -23,6 +23,11 @@ function Set-CIPPDBCacheExoOutboundConnector { if ($OutboundConnectors) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoOutboundConnector' -Data $OutboundConnectors -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($OutboundConnectors.Count) outbound connectors" -sev Debug + } else { + # The cmdlet succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoOutboundConnector' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 outbound connectors (none found)' -sev Debug } $OutboundConnectors = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoPresetSecurityPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoPresetSecurityPolicy.ps1 index 7930ec943f18e..71ec4f87d7c64 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoPresetSecurityPolicy.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoPresetSecurityPolicy.ps1 @@ -22,6 +22,9 @@ function Set-CIPPDBCacheExoPresetSecurityPolicy { $MDOTestResult = Test-CIPPStandardLicense -StandardName 'ExoPresetSecurityPolicy' -TenantFilter $TenantFilter -Preset DefenderForOffice365 -SkipLog if ($MDOTestResult -eq $false) { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Skipping Preset Security Policy cache: tenant lacks Microsoft Defender for Office 365' -sev Debug + # A license skip is still a completed collection: record the authoritative empty set + # so collect-on-miss does not re-run this collector forever on unlicensed tenants. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoPresetSecurityPolicy' -Data @() -AddCount -ClearOnEmpty return } @@ -40,6 +43,11 @@ function Set-CIPPDBCacheExoPresetSecurityPolicy { if ($AllRules.Count -gt 0) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoPresetSecurityPolicy' -Data $AllRules -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($AllRules.Count) Preset Security Policy rules" -sev Debug + } else { + # Both cmdlets succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoPresetSecurityPolicy' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 Preset Security Policy rules (none found)' -sev Debug } $EOPRules = $null $ATPRules = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoProtectionAlert.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoProtectionAlert.ps1 index f0cb8cfcf8fc7..fc9d8f736bcfe 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoProtectionAlert.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoProtectionAlert.ps1 @@ -27,6 +27,11 @@ function Set-CIPPDBCacheExoProtectionAlert { if ($ProtectionAlerts) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoProtectionAlert' -Data $ProtectionAlerts -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($ProtectionAlerts.Count) protection alerts" -sev Debug + } else { + # The cmdlet succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoProtectionAlert' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 protection alerts (none found)' -sev Debug } $ProtectionAlerts = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoQuarantinePolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoQuarantinePolicy.ps1 index 63cb47149ae29..b648c5315bb22 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoQuarantinePolicy.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoQuarantinePolicy.ps1 @@ -23,6 +23,11 @@ function Set-CIPPDBCacheExoQuarantinePolicy { if ($QuarantinePolicies) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoQuarantinePolicy' -Data $QuarantinePolicies -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($QuarantinePolicies.Count) Quarantine policies" -sev Debug + } else { + # The cmdlet succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoQuarantinePolicy' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 Quarantine policies (none found)' -sev Debug } $QuarantinePolicies = $null @@ -37,6 +42,11 @@ function Set-CIPPDBCacheExoQuarantinePolicy { if ($GlobalQuarantinePolicy) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoGlobalQuarantinePolicy' -Data $GlobalQuarantinePolicy -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached Global Quarantine policy' -sev Debug + } else { + # The cmdlet succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoGlobalQuarantinePolicy' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 Global Quarantine policies (none found)' -sev Debug } $GlobalQuarantinePolicy = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoRemoteDomain.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoRemoteDomain.ps1 index dd9fb277b99ab..b1935ff7a19a1 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoRemoteDomain.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoRemoteDomain.ps1 @@ -23,6 +23,11 @@ function Set-CIPPDBCacheExoRemoteDomain { if ($RemoteDomains) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoRemoteDomain' -Data $RemoteDomains -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($RemoteDomains.Count) Remote Domains" -sev Debug + } else { + # The cmdlet succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoRemoteDomain' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 Remote Domains (none found)' -sev Debug } $RemoteDomains = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoRoleAssignmentPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoRoleAssignmentPolicy.ps1 index 93b57c264e616..271668d0a8ad7 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoRoleAssignmentPolicy.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoRoleAssignmentPolicy.ps1 @@ -23,6 +23,11 @@ function Set-CIPPDBCacheExoRoleAssignmentPolicy { if ($RoleAssignmentPolicies) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoRoleAssignmentPolicy' -Data $RoleAssignmentPolicies -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($RoleAssignmentPolicies.Count) role assignment policies" -sev Debug + } else { + # The cmdlet succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoRoleAssignmentPolicy' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 role assignment policies (none found)' -sev Debug } $RoleAssignmentPolicies = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoSafeAttachmentPolicies.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoSafeAttachmentPolicies.ps1 index 07867a4caff3a..8b318dd4687b6 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoSafeAttachmentPolicies.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoSafeAttachmentPolicies.ps1 @@ -19,23 +19,25 @@ function Set-CIPPDBCacheExoSafeAttachmentPolicies { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Exchange Safe Attachment policies and rules' -sev Debug - # Get Safe Attachment policies - $SafeAttachmentPolicies = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-SafeAttachmentPolicy' - if ($SafeAttachmentPolicies) { - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoSafeAttachmentPolicies' -Data $SafeAttachmentPolicies -AddCount - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($SafeAttachmentPolicies.Count) Safe Attachment policies" -sev Debug - } + # Write unconditionally with -ClearOnEmpty so a successful but empty result records an + # authoritative Count=0 marker. That marker is what lets the CIS tests tell "collected, no + # policies" (a real Failed) apart from "never collected" (a Skip) instead of both surfacing + # as "cache not found". + $SafeAttachmentPolicies = @(New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-SafeAttachmentPolicy') + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoSafeAttachmentPolicies' -Data $SafeAttachmentPolicies -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($SafeAttachmentPolicies.Count) Safe Attachment policies" -sev Debug $SafeAttachmentPolicies = $null # Get Safe Attachment rules - $SafeAttachmentRules = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-SafeAttachmentRule' - if ($SafeAttachmentRules) { - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoSafeAttachmentRules' -Data $SafeAttachmentRules -AddCount - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($SafeAttachmentRules.Count) Safe Attachment rules" -sev Debug - } + $SafeAttachmentRules = @(New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-SafeAttachmentRule') + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoSafeAttachmentRules' -Data $SafeAttachmentRules -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($SafeAttachmentRules.Count) Safe Attachment rules" -sev Debug $SafeAttachmentRules = $null } catch { + # Rethrow so the collection runner records a real failure, instead of the producer silently + # reporting success with an empty cache (which surfaced to tests as "cache not found"). Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Safe Attachment data: $($_.Exception.Message)" -sev Error + throw } } diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoSafeLinksPolicies.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoSafeLinksPolicies.ps1 index 65403b1872684..897e15f5c9451 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoSafeLinksPolicies.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoSafeLinksPolicies.ps1 @@ -16,26 +16,44 @@ function Set-CIPPDBCacheExoSafeLinksPolicies { [string]$QueueId ) + # Safe Links is a Defender for Office 365 feature. On tenants without it Exchange rejects + # Get-SafeLinksPolicy, which surfaced as an Error log for every unlicensed tenant on every + # collection. Skip those tenants and leave the cache untouched, so the tests report the + # collection as not run (Skipped) rather than as a failure. + try { + $Capabilities = Get-CIPPTenantCapabilities -TenantFilter $TenantFilter + $MDOCapabilities = @('ATP_ENTERPRISE', 'ATP_ENTERPRISE_GOV', 'THREAT_INTELLIGENCE', 'THREAT_INTELLIGENCE_GOV') + $HasMDO = @($MDOCapabilities | Where-Object { $Capabilities.$_ -eq $true }).Count -gt 0 + if (-not $HasMDO) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Skipping the Safe Links cache: this tenant is not licensed for Defender for Office 365' -sev Info + return + } + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Could not determine Defender for Office 365 licensing, attempting the Safe Links cache anyway: $($_.Exception.Message)" -sev Debug + } + try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Exchange Safe Links policies and rules' -sev Debug - # Get Safe Links policies - $SafeLinksPolicies = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-SafeLinksPolicy' - if ($SafeLinksPolicies) { - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoSafeLinksPolicies' -Data $SafeLinksPolicies -AddCount - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($SafeLinksPolicies.Count) Safe Links policies" -sev Debug - } + # Write unconditionally with -ClearOnEmpty so a successful but empty result records an + # authoritative Count=0 marker. That marker is what lets the CIS tests tell "collected, no + # policies" (a real Failed) apart from "never collected" (a Skip) instead of both surfacing + # as "cache not found". + $SafeLinksPolicies = @(New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-SafeLinksPolicy') + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoSafeLinksPolicies' -Data $SafeLinksPolicies -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($SafeLinksPolicies.Count) Safe Links policies" -sev Debug $SafeLinksPolicies = $null # Get Safe Links rules - $SafeLinksRules = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-SafeLinksRule' - if ($SafeLinksRules) { - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoSafeLinksRules' -Data $SafeLinksRules -AddCount - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($SafeLinksRules.Count) Safe Links rules" -sev Debug - } + $SafeLinksRules = @(New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-SafeLinksRule') + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoSafeLinksRules' -Data $SafeLinksRules -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($SafeLinksRules.Count) Safe Links rules" -sev Debug $SafeLinksRules = $null } catch { + # Rethrow so the collection runner records a real failure, instead of the producer silently + # reporting success with an empty cache (which surfaced to tests as "cache not found"). Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Safe Links data: $($_.Exception.Message)" -sev Error + throw } } diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoSharingPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoSharingPolicy.ps1 index 368c1dca953ca..cda29daeeadc1 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoSharingPolicy.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoSharingPolicy.ps1 @@ -24,6 +24,11 @@ function Set-CIPPDBCacheExoSharingPolicy { if ($SharingPolicies) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoSharingPolicy' -Data $SharingPolicies -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($SharingPolicies.Count) Sharing Policies" -sev Debug + } else { + # The cmdlet succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoSharingPolicy' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 Sharing Policies (none found)' -sev Debug } $SharingPolicies = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoTeamsProtectionPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoTeamsProtectionPolicy.ps1 index 1b32996aa916c..b64142e6f03d4 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoTeamsProtectionPolicy.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoTeamsProtectionPolicy.ps1 @@ -24,6 +24,11 @@ function Set-CIPPDBCacheExoTeamsProtectionPolicy { $TeamsProtectionPolicyArray = @($TeamsProtectionPolicies) Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoTeamsProtectionPolicy' -Data $TeamsProtectionPolicyArray -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($TeamsProtectionPolicyArray.Count) Teams protection policies" -sev Debug + } else { + # The cmdlet succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoTeamsProtectionPolicy' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 Teams protection policies (none found)' -sev Debug } $TeamsProtectionPolicies = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoTransportConfig.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoTransportConfig.ps1 index f606c288f0203..dfcf10af829f3 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoTransportConfig.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoTransportConfig.ps1 @@ -26,6 +26,11 @@ function Set-CIPPDBCacheExoTransportConfig { $TransportConfigArray = @($TransportConfig) Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoTransportConfig' -Data $TransportConfigArray -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached Exchange Transport configuration' -sev Debug + } else { + # The cmdlet succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoTransportConfig' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 Transport configurations (none found)' -sev Debug } $TransportConfig = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoTransportRules.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoTransportRules.ps1 index a557947d4ce0d..836fffcad4323 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoTransportRules.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoTransportRules.ps1 @@ -24,6 +24,11 @@ function Set-CIPPDBCacheExoTransportRules { if ($TransportRules) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoTransportRules' -Data $TransportRules -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($TransportRules.Count) Transport Rules" -sev Debug + } else { + # The cmdlet succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoTransportRules' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 Transport Rules (none found)' -sev Debug } $TransportRules = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheGroupUsage.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheGroupUsage.ps1 new file mode 100644 index 0000000000000..8e16932a26590 --- /dev/null +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheGroupUsage.ps1 @@ -0,0 +1,51 @@ +function Set-CIPPDBCacheGroupUsage { + <# + .SYNOPSIS + Refreshes every reporting DB cache that feeds the group usage report + + .DESCRIPTION + The group usage report is compiled at read time from existing cache types, so this + collector writes no rows of its own — it runs the source collectors sequentially so + a single on-demand sync refreshes all of them. + + .PARAMETER TenantFilter + The tenant to refresh the source caches for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + $SourceTypes = @( + 'Groups' + 'ConditionalAccessPolicies' + 'IntunePolicies' + 'IntuneApplications' + 'IntuneAppProtectionPolicies' + 'IntuneScripts' + 'AutopilotDeploymentProfiles' + 'DeviceEnrollmentConfigurations' + 'Roles' + 'RoleAssignmentScheduleInstances' + 'RoleEligibilitySchedules' + 'AppRoleAssignments' + 'LicenseOverview' + 'ExoTransportRules' + ) + + foreach ($SourceType in $SourceTypes) { + $FunctionName = "Set-CIPPDBCache$SourceType" + try { + $Params = @{ TenantFilter = $TenantFilter } + if ($QueueId) { $Params.QueueId = $QueueId } + & $FunctionName @Params + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Group usage sync: failed to refresh $SourceType : $($_.Exception.Message)" -sev Warning + } + } +} diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheGroups.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheGroups.ps1 index b02fcc0d54fc5..857c167953ba0 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheGroups.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheGroups.ps1 @@ -19,49 +19,50 @@ function Set-CIPPDBCacheGroups { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching groups' -sev Debug + $MemberBatchSize = 50 $GroupSelect = 'id,createdDateTime,displayName,description,mail,mailEnabled,mailNickname,resourceProvisioningOptions,securityEnabled,visibility,organizationId,onPremisesSamAccountName,membershipRule,groupTypes,onPremisesSyncEnabled,assignedLicenses,licenseProcessingState' - $Groups = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/groups?`$top=999&`$select=$GroupSelect&`$expand=owners(`$select=id,displayName,userPrincipalName)" -tenantid $TenantFilter - - # Build bulk request for group members - $MemberRequests = $Groups | ForEach-Object { - if ($_.id) { - [PSCustomObject]@{ - id = $_.id - method = 'GET' - url = "/groups/$($_.id)/members?`$top=999&`$select=id,displayName,userPrincipalName" + $GroupUri = "https://graph.microsoft.com/beta/groups?`$top=999&`$select=$GroupSelect&`$expand=owners(`$select=id,displayName,userPrincipalName)" + + # Stream groups in batches of $MemberBatchSize so peak memory is one batch of rows + # plus their member lists, not the whole tenant. The writer is opened before the + # pipeline on purpose: GetSteppablePipeline() captures whichever scope is live, so + # opening it inside ForEach-Object captures the Graph call's scope, which is gone + # by End() - the end block then fails with "is not recognized". + $CachedCount = 0 + $PendingBatch = [System.Collections.Generic.List[object]]::new() + $Writer = { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Groups' -AddCount }.GetSteppablePipeline() + $Writer.Begin($true) + + function Write-GroupBatch { + param( + [System.Collections.Generic.List[object]]$Batch, + $PipelineWriter, + [ref]$Count + ) + + if ($Batch.Count -eq 0) { return } + + $MemberRequests = $Batch | ForEach-Object { + if ($_.id -and $_.groupTypes -notcontains 'DynamicMembership') { + [PSCustomObject]@{ + id = $_.id + method = 'GET' + url = "/groups/$($_.id)/members?`$top=999&`$select=id,displayName,userPrincipalName" + } } } - } - # Index the member responses by group id. The previous per-group - # 'Where-Object { $_.id -eq $Group.id }' rescanned the whole response array for every - # group, which is O(groups x groups) - 100M comparisons on a 10k-group tenant. - $MembersByGroupId = @{} - # Tracks which shape the rows take: groups fetched with a member lookup carry a - # 'members' property (null when the lookup returned nothing for that group), groups - # fetched without one omit the property entirely. Keyed off whether the lookup ran, - # not off whether it returned anything, so an empty response still yields the - # members-shaped row the previous implementation produced. - $HasMembers = [bool]$MemberRequests - if ($HasMembers) { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Fetching group members' -sev Debug - $MemberResults = New-GraphBulkRequest -Requests @($MemberRequests) -tenantid $TenantFilter - foreach ($Result in $MemberResults) { - if ($Result.id) { $MembersByGroupId[$Result.id] = $Result.body.value } + $MembersByGroupId = @{} + if ($MemberRequests) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Fetching group members for batch of $($Batch.Count)" -sev Debug + $MemberResults = New-GraphBulkRequest -Requests @($MemberRequests) -tenantid $TenantFilter + foreach ($Result in $MemberResults) { + if ($Result.id) { $MembersByGroupId[$Result.id] = $Result.body.value } + } + $MemberResults = $null } - $MemberResults = $null - } - $MemberRequests = $null - - # Project and emit one group at a time: Add-CIPPDbItem batches internally, so peak - # retention is a batch of rows rather than every group (with its whole member list) - # plus a second materialised array. Each group's members are dropped from the index - # once written, so membership becomes collectable as the run progresses. - # Properties are applied in a single Add-Member call - adding them one at a time - # rebuilds the object's property bag on every call. The [ordered] dictionary keeps - # the emitted JSON property order identical to the previous sequential adds. - & { - foreach ($Group in $Groups) { + + foreach ($Group in $Batch) { $groupType = if ($Group.groupTypes -contains 'Unified') { 'Microsoft 365' } elseif ($Group.mailEnabled -and $Group.securityEnabled) { 'Mail-Enabled Security' } elseif (-not $Group.mailEnabled -and $Group.securityEnabled) { 'Security' } @@ -74,10 +75,19 @@ function Set-CIPPDBCacheGroups { else { 'unknown' } $NoteProperties = [ordered]@{} - if ($HasMembers) { + if ($Group.id -and $Group.groupTypes -notcontains 'DynamicMembership') { $NoteProperties['members'] = $MembersByGroupId[$Group.id] - $MembersByGroupId.Remove($Group.id) + # Precompute the UPN CSV so the paged list read can stream the stored blob + # verbatim instead of parsing every member array (heavy on 50k-member groups). + $NoteProperties['membersCsv'] = ($MembersByGroupId[$Group.id].userPrincipalName -join ',') } + if ($Group.owners) { + $NoteProperties['ownersCsv'] = ($Group.owners.userPrincipalName -join ',') + } + # Set unconditionally (unlike ownersCsv above) so a genuinely owner-less group + # still gets an explicit false baked into the blob - the AsRawJson paged read + # streams this stored blob verbatim and never recomputes it. + $NoteProperties['hasOwner'] = [bool]($Group.owners -and $Group.owners.Count -gt 0) $NoteProperties['primDomain'] = ($Group.mail -split '@' | Select-Object -Last 1) $NoteProperties['teamsEnabled'] = ($Group.resourceProvisioningOptions -contains 'Team') $NoteProperties['dynamicGroupBool'] = ($Group.groupTypes -contains 'DynamicMembership') @@ -85,14 +95,31 @@ function Set-CIPPDBCacheGroups { $NoteProperties['calculatedGroupType'] = $calculatedGroupType $Group | Add-Member -NotePropertyMembers $NoteProperties -Force - $Group + $Count.Value++ + $PipelineWriter.Process($Group) + } + + $Batch.Clear() + $MembersByGroupId = $null + } + + try { + New-GraphGetRequest -uri $GroupUri -tenantid $TenantFilter -Stream | ForEach-Object { + $PendingBatch.Add($_) + if ($PendingBatch.Count -ge $MemberBatchSize) { + Write-GroupBatch -Batch $PendingBatch -PipelineWriter $Writer -Count ([ref]$CachedCount) + } } - } | Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Groups' -AddCount - $Groups = $null - $MembersByGroupId = $null + Write-GroupBatch -Batch $PendingBatch -PipelineWriter $Writer -Count ([ref]$CachedCount) - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached groups with members and owners successfully' -sev Debug + if ($CachedCount -gt 0) { + $Writer.End() + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $CachedCount groups with members and owners successfully" -sev Debug + } + } finally { + $Writer.Dispose() + } } catch { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter ` diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheHVEAccounts.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheHVEAccounts.ps1 index 8f84024e824c3..2ca38e78dc00b 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheHVEAccounts.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheHVEAccounts.ps1 @@ -41,7 +41,7 @@ function Set-CIPPDBCacheHVEAccounts { $BulkResults = New-ExoBulkRequest -tenantid $TenantFilter -cmdletArray @($BulkCmdlets) for ($i = 0; $i -lt $HVEAccounts.Count; $i++) { $Result = $BulkResults[$i] - if ($Result.body -and -not $Result.body.error -and $Result.body.value) { + if ($Result.body -and -not $Result.body.error -and $Result.body.value -and $HVEAccounts[$i].PrimarySmtpAddress) { $PolicyData = $Result.body.value $BillingPolicyMap[$HVEAccounts[$i].PrimarySmtpAddress] = @{ BillingPolicyId = $PolicyData.BillingPolicyId @@ -55,7 +55,9 @@ function Set-CIPPDBCacheHVEAccounts { } foreach ($HVE in $HVEAccounts) { - $Policy = $BillingPolicyMap[$HVE.PrimarySmtpAddress] + # PrimarySmtpAddress can be null for a partially-provisioned HVE account - same + # null-key-indexing hazard as the Mailboxes cache (see Set-CIPPDBCacheMailboxes.ps1). + $Policy = if ($HVE.PrimarySmtpAddress) { $BillingPolicyMap[$HVE.PrimarySmtpAddress] } else { $null } $Transformed.Add(($HVE | Select-Object ` @{ Name = 'displayName'; Expression = { $_.DisplayName } }, diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneAppProtectionPolicies.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneAppProtectionPolicies.ps1 index 6f77c3330d162..e8678f7b7e60f 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneAppProtectionPolicies.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneAppProtectionPolicies.ps1 @@ -10,6 +10,11 @@ function Set-CIPPDBCacheIntuneAppProtectionPolicies { $TestResult = Test-CIPPStandardLicense -StandardName 'IntuneAppProtectionPoliciesCache' -TenantFilter $TenantFilter -Preset Intune -SkipLog if ($TestResult -eq $false) { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have Intune license, skipping app protection policies cache' -sev Debug + # A license skip is still a completed collection: record authoritative empty sets for + # every type this collector writes so collect-on-miss does not re-run it forever. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'IntuneAppProtectionPolicyGroups' -Data @() -AddCount -ClearOnEmpty + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'IntuneAppProtectionManagedAppPolicies' -Data @() -AddCount -ClearOnEmpty + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'IntuneAppProtectionMobileAppConfigurations' -Data @() -AddCount -ClearOnEmpty return } diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneAppleUserInitiatedEnrollmentProfiles.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneAppleUserInitiatedEnrollmentProfiles.ps1 index 5b7f149e15b3a..e07e42cee41d2 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneAppleUserInitiatedEnrollmentProfiles.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneAppleUserInitiatedEnrollmentProfiles.ps1 @@ -24,6 +24,9 @@ function Set-CIPPDBCacheIntuneAppleUserInitiatedEnrollmentProfiles { $TestResult = Test-CIPPStandardLicense -StandardName 'IntuneAppleEnrollmentProfilesCache' -TenantFilter $TenantFilter -Preset Intune -SkipLog if ($TestResult -eq $false) { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have Intune license, skipping Apple enrollment type profiles cache' -sev Debug + # A license skip is still a completed collection: record the authoritative empty set + # so collect-on-miss does not re-run this collector forever on unlicensed tenants. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'IntuneAppleUserInitiatedEnrollmentProfiles' -Data @() -AddCount -ClearOnEmpty return } diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneApplications.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneApplications.ps1 index 4218dfdc137ea..880b1007c6140 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneApplications.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneApplications.ps1 @@ -10,6 +10,11 @@ function Set-CIPPDBCacheIntuneApplications { $TestResult = Test-CIPPStandardLicense -StandardName 'IntuneApplicationsCache' -TenantFilter $TenantFilter -Preset Intune -SkipLog if ($TestResult -eq $false) { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have Intune license, skipping applications cache' -sev Debug + # A license skip is still a completed collection: record authoritative empty sets for + # every type this collector writes so collect-on-miss does not re-run it forever. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'IntuneApplicationGroups' -Data @() -AddCount -ClearOnEmpty + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'IntuneApplications' -Data @() -AddCount -ClearOnEmpty + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'IntuneMobileAppsAll' -Data @() -AddCount -ClearOnEmpty return } diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneAssignmentFilters.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneAssignmentFilters.ps1 index e6037b1f10611..2528eee0c1d50 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneAssignmentFilters.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneAssignmentFilters.ps1 @@ -10,6 +10,9 @@ function Set-CIPPDBCacheIntuneAssignmentFilters { $TestResult = Test-CIPPStandardLicense -StandardName 'IntuneAssignmentFiltersCache' -TenantFilter $TenantFilter -Preset Intune -SkipLog if ($TestResult -eq $false) { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have Intune license, skipping assignment filters cache' -sev Debug + # A license skip is still a completed collection: record the authoritative empty set + # so collect-on-miss does not re-run this collector forever on unlicensed tenants. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'IntuneAssignmentFilters' -Data @() -AddCount -ClearOnEmpty return } diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneBrandingProfile.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneBrandingProfile.ps1 index 60c054954e2bd..30137eeeb6781 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneBrandingProfile.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneBrandingProfile.ps1 @@ -20,6 +20,9 @@ function Set-CIPPDBCacheIntuneBrandingProfile { $TestResult = Test-CIPPStandardLicense -StandardName 'IntuneBrandingProfileCache' -TenantFilter $TenantFilter -Preset Intune -SkipLog if ($TestResult -eq $false) { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have Intune license, skipping Intune branding profile cache' -sev Debug + # A license skip is still a completed collection: record the authoritative empty set + # so collect-on-miss does not re-run this collector forever on unlicensed tenants. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'IntuneBrandingProfile' -Data @() -AddCount -ClearOnEmpty return } diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneCompliancePolicies.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneCompliancePolicies.ps1 index 06f43b240a03e..6e3f43915218d 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneCompliancePolicies.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneCompliancePolicies.ps1 @@ -10,6 +10,10 @@ function Set-CIPPDBCacheIntuneCompliancePolicies { $TestResult = Test-CIPPStandardLicense -StandardName 'IntuneCompliancePoliciesCache' -TenantFilter $TenantFilter -Preset Intune -SkipLog if ($TestResult -eq $false) { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have Intune license, skipping compliance policies cache' -sev Debug + # A license skip is still a completed collection: record authoritative empty sets for + # both types this collector writes so collect-on-miss does not re-run it forever. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'IntuneCompliancePolicyGroups' -Data @() -AddCount -ClearOnEmpty + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'IntuneDeviceCompliancePolicies' -Data @() -AddCount -ClearOnEmpty return } diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneConfigurationPolicies.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneConfigurationPolicies.ps1 index 36ba1b935a8c8..c8e858b7a57af 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneConfigurationPolicies.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneConfigurationPolicies.ps1 @@ -24,6 +24,9 @@ function Set-CIPPDBCacheIntuneConfigurationPolicies { $TestResult = Test-CIPPStandardLicense -StandardName 'IntuneConfigurationPoliciesCache' -TenantFilter $TenantFilter -Preset Intune -SkipLog if ($TestResult -eq $false) { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have Intune license, skipping configuration policies cache' -sev Debug + # A license skip is still a completed collection: record the authoritative empty set + # so collect-on-miss does not re-run this collector forever on unlicensed tenants. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'IntuneConfigurationPolicies' -Data @() -AddCount -ClearOnEmpty return } diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneDataProcessorOnboarding.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneDataProcessorOnboarding.ps1 index 904e274db725d..cd0cc55cb8212 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneDataProcessorOnboarding.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneDataProcessorOnboarding.ps1 @@ -25,6 +25,9 @@ function Set-CIPPDBCacheIntuneDataProcessorOnboarding { $TestResult = Test-CIPPStandardLicense -StandardName 'IntuneDataProcessorOnboardingCache' -TenantFilter $TenantFilter -Preset Intune -SkipLog if ($TestResult -eq $false) { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have Intune license, skipping data processor onboarding cache' -sev Debug + # A license skip is still a completed collection: record the authoritative empty set + # so collect-on-miss does not re-run this collector forever on unlicensed tenants. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'IntuneDataProcessorOnboarding' -Data @() -AddCount -ClearOnEmpty return } diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneDeviceEnrollmentConfigurations.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneDeviceEnrollmentConfigurations.ps1 index a7416e164a3dd..0e9e540eb4ade 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneDeviceEnrollmentConfigurations.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneDeviceEnrollmentConfigurations.ps1 @@ -26,6 +26,9 @@ function Set-CIPPDBCacheIntuneDeviceEnrollmentConfigurations { $TestResult = Test-CIPPStandardLicense -StandardName 'IntuneDeviceEnrollmentConfigurationsCache' -TenantFilter $TenantFilter -Preset Intune -SkipLog if ($TestResult -eq $false) { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have Intune license, skipping Intune device enrollment configurations cache' -sev Debug + # A license skip is still a completed collection: record the authoritative empty set + # so collect-on-miss does not re-run this collector forever on unlicensed tenants. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'IntuneDeviceEnrollmentConfigurations' -Data @() -AddCount -ClearOnEmpty return } diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneDeviceManagementSettings.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneDeviceManagementSettings.ps1 index e55bad9d2f381..ff3254c2c8312 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneDeviceManagementSettings.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneDeviceManagementSettings.ps1 @@ -25,6 +25,9 @@ function Set-CIPPDBCacheIntuneDeviceManagementSettings { $TestResult = Test-CIPPStandardLicense -StandardName 'IntuneDeviceManagementSettingsCache' -TenantFilter $TenantFilter -Preset Intune -SkipLog if ($TestResult -eq $false) { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have Intune license, skipping Intune device management settings cache' -sev Debug + # A license skip is still a completed collection: record the authoritative empty set + # so collect-on-miss does not re-run this collector forever on unlicensed tenants. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'IntuneDeviceManagementSettings' -Data @() -AddCount -ClearOnEmpty return } diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneMobileApps.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneMobileApps.ps1 index 70593f46d0327..50465f7f3e6d7 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneMobileApps.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneMobileApps.ps1 @@ -25,6 +25,9 @@ function Set-CIPPDBCacheIntuneMobileApps { $TestResult = Test-CIPPStandardLicense -StandardName 'IntuneMobileAppsCache' -TenantFilter $TenantFilter -Preset Intune -SkipLog if ($TestResult -eq $false) { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have Intune license, skipping mobile apps cache' -sev Debug + # A license skip is still a completed collection: record the authoritative empty set + # so collect-on-miss does not re-run this collector forever on unlicensed tenants. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'IntuneMobileApps' -Data @() -AddCount -ClearOnEmpty return } diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneReusableSettings.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneReusableSettings.ps1 index 437bcd6607db4..065e579e936eb 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneReusableSettings.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneReusableSettings.ps1 @@ -10,6 +10,9 @@ function Set-CIPPDBCacheIntuneReusableSettings { $TestResult = Test-CIPPStandardLicense -StandardName 'IntuneReusableSettingsCache' -TenantFilter $TenantFilter -Preset Intune -SkipLog if ($TestResult -eq $false) { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have Intune license, skipping reusable settings cache' -sev Debug + # A license skip is still a completed collection: record the authoritative empty set + # so collect-on-miss does not re-run this collector forever on unlicensed tenants. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'IntuneReusableSettings' -Data @() -AddCount -ClearOnEmpty return } diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneScripts.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneScripts.ps1 index 1052c9505b9d5..9f5746b472894 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneScripts.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneScripts.ps1 @@ -10,6 +10,11 @@ function Set-CIPPDBCacheIntuneScripts { $TestResult = Test-CIPPStandardLicense -StandardName 'IntuneScriptsCache' -TenantFilter $TenantFilter -Preset Intune -SkipLog if ($TestResult -eq $false) { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have Intune license, skipping scripts cache' -sev Debug + # A license skip is still a completed collection: record authoritative empty sets for + # every type this collector writes so collect-on-miss does not re-run it forever. + foreach ($SkippedType in @('IntuneScriptGroups', 'IntuneWindowsScripts', 'IntuneMacOSScripts', 'IntuneRemediationScripts', 'IntuneLinuxScripts')) { + Add-CIPPDbItem -TenantFilter $TenantFilter -Type $SkippedType -Data @() -AddCount -ClearOnEmpty + } return } diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneWindowsAutopilotDeploymentProfiles.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneWindowsAutopilotDeploymentProfiles.ps1 index 03f966b3571ce..86f2997b4f506 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneWindowsAutopilotDeploymentProfiles.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneWindowsAutopilotDeploymentProfiles.ps1 @@ -23,6 +23,9 @@ function Set-CIPPDBCacheIntuneWindowsAutopilotDeploymentProfiles { $TestResult = Test-CIPPStandardLicense -StandardName 'IntuneAutopilotProfilesCache' -TenantFilter $TenantFilter -Preset Intune -SkipLog if ($TestResult -eq $false) { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have Intune license, skipping Autopilot profiles cache' -sev Debug + # A license skip is still a completed collection: record the authoritative empty set + # so collect-on-miss does not re-run this collector forever on unlicensed tenants. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'IntuneWindowsAutopilotDeploymentProfiles' -Data @() -AddCount -ClearOnEmpty return } diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMailboxes.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMailboxes.ps1 index 74f56ffc3e599..09346031d3420 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMailboxes.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMailboxes.ps1 @@ -27,7 +27,7 @@ function Set-CIPPDBCacheMailboxes { # Get mailboxes and user details in a single bulk request $ZeroArchiveGuid = '00000000-0000-0000-0000-000000000000' - $Select = 'id,ExchangeGuid,ArchiveGuid,UserPrincipalName,DisplayName,PrimarySMTPAddress,RecipientType,RecipientTypeDetails,EmailAddresses,WhenSoftDeleted,IsInactiveMailbox,ForwardingSmtpAddress,DeliverToMailboxAndForward,ForwardingAddress,HiddenFromAddressListsEnabled,ExternalDirectoryObjectId,MessageCopyForSendOnBehalfEnabled,MessageCopyForSentAsEnabled,GrantSendOnBehalfTo,PersistedCapabilities,LitigationHoldEnabled,LitigationHoldDate,LitigationHoldDuration,ComplianceTagHoldApplied,RetentionHoldEnabled,InPlaceHolds,RetentionPolicy,RemotePowerShellEnabled,Guid,Identity,AutoExpandingArchiveEnabled,IsExchangeCloudManaged,IsDirSynced,MailboxPlan,MailboxPlanId,RecipientLimits,AccountDisabled' + $Select = 'id,ExchangeGuid,ArchiveGuid,UserPrincipalName,DisplayName,PrimarySMTPAddress,RecipientType,RecipientTypeDetails,EmailAddresses,WhenSoftDeleted,IsInactiveMailbox,ForwardingSmtpAddress,DeliverToMailboxAndForward,ForwardingAddress,HiddenFromAddressListsEnabled,ExternalDirectoryObjectId,MessageCopyForSendOnBehalfEnabled,MessageCopyForSentAsEnabled,GrantSendOnBehalfTo,PersistedCapabilities,LitigationHoldEnabled,LitigationHoldDate,LitigationHoldDuration,ComplianceTagHoldApplied,RetentionHoldEnabled,InPlaceHolds,RetentionPolicy,RemotePowerShellEnabled,Guid,Identity,AutoExpandingArchiveEnabled,ArchiveQuota,IsExchangeCloudManaged,IsDirSynced,MailboxPlan,MailboxPlanId,RecipientLimits,AccountDisabled,AuditEnabled,AuditOwner,AuditDelegate,AuditAdmin,DefaultAuditSet' $BulkRequests = @( @{ CmdletInput = @{ CmdletName = 'Get-Mailbox'; Parameters = @{} } } @{ CmdletInput = @{ CmdletName = 'Get-User'; Parameters = @{} } } @@ -54,13 +54,19 @@ function Set-CIPPDBCacheMailboxes { # Transform Get-Mailbox results and merge Get-User properties $Mailboxes = [System.Collections.Generic.List[PSObject]]::new() foreach ($Mailbox in @($BulkResults.'Get-Mailbox')) { - $MatchedUser = $UserLookup[$Mailbox.ExternalDirectoryObjectId] + # ExternalDirectoryObjectId can be null for a mailbox that has no linked Entra ID + # directory object (the $UserLookup population above already guards against this on + # the write side - see the -and check a few lines up). Indexing a hashtable with a + # null key throws "Index operation failed; the array index evaluated to null." and + # aborts the whole cache run for the tenant, so the read side needs the same guard. + $MatchedUser = if ($Mailbox.ExternalDirectoryObjectId) { $UserLookup[$Mailbox.ExternalDirectoryObjectId] } else { $null } $AutoExpandingArchiveState = Get-CIPPAutoExpandingArchiveState -MailboxAutoExpandingArchiveEnabled $Mailbox.AutoExpandingArchiveEnabled -OrgAutoExpandingArchiveEnabled $OrgAutoExpandingArchiveEnabled $Mailboxes.Add(($Mailbox | Select-Object id, ExchangeGuid, ArchiveGuid, WhenSoftDeleted, @{ Name = 'UPN'; Expression = { $_.'UserPrincipalName' } }, @{ Name = 'displayName'; Expression = { $_.'DisplayName' } }, @{ Name = 'primarySmtpAddress'; Expression = { $_.'PrimarySMTPAddress' } }, @{ Name = 'ArchiveEnabled'; Expression = { $_.ArchiveGuid -and $_.ArchiveGuid.ToString() -ne $ZeroArchiveGuid } }, + @{ Name = 'ArchiveQuota'; Expression = { try { Get-ExoOnlineStringBytes -SizeString ([string]$_.ArchiveQuota) } catch { 0 } } }, @{ Name = 'AutoExpandingArchive'; Expression = { $AutoExpandingArchiveState.AutoExpandingArchive } }, @{ Name = 'AutoExpandingArchiveScope'; Expression = { $AutoExpandingArchiveState.AutoExpandingArchiveScope } }, @{ Name = 'ArchiveSize'; Expression = { 0 } }, @@ -94,6 +100,11 @@ function Set-CIPPDBCacheMailboxes { PersistedCapabilities, RecipientLimits, AccountDisabled, + AuditEnabled, + AuditOwner, + AuditDelegate, + AuditAdmin, + DefaultAuditSet, @{ Name = 'RemotePowerShellEnabled'; Expression = { $MatchedUser.RemotePowerShellEnabled } }, @{ Name = 'Guid'; Expression = { $MatchedUser.Guid } }, @{ Name = 'Identity'; Expression = { $MatchedUser.Identity } })) diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheManagedDeviceCleanupRules.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheManagedDeviceCleanupRules.ps1 index 225a36899f5bb..7d85f2412df93 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheManagedDeviceCleanupRules.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheManagedDeviceCleanupRules.ps1 @@ -24,6 +24,9 @@ function Set-CIPPDBCacheManagedDeviceCleanupRules { $TestResult = Test-CIPPStandardLicense -StandardName 'ManagedDeviceCleanupRulesCache' -TenantFilter $TenantFilter -Preset Intune -SkipLog if ($TestResult -eq $false) { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have Intune license, skipping managed device cleanup rules cache' -sev Debug + # A license skip is still a completed collection: record the authoritative empty set + # so collect-on-miss does not re-run this collector forever on unlicensed tenants. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ManagedDeviceCleanupRules' -Data @() -AddCount -ClearOnEmpty return } diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMobileDeviceManagementPolicies.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMobileDeviceManagementPolicies.ps1 index 969f496cb5089..4d741e4a98092 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMobileDeviceManagementPolicies.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMobileDeviceManagementPolicies.ps1 @@ -26,7 +26,7 @@ function Set-CIPPDBCacheMobileDeviceManagementPolicies { # Full entity (no $select) so isMdmEnrollmentDuringRegistrationDisabled, appliesTo and the # termsOfUseUrl/discoveryUrl/complianceUrl properties are all included, plus included groups - $MDMPolicy = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/policies/mobileDeviceManagementPolicies/0000000a-0000-0000-c000-000000000000?$expand=includedGroups($select=displayName)' -tenantid $TenantFilter + $MDMPolicy = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/policies/mobileDeviceManagementPolicies/0000000a-0000-0000-c000-000000000000?$expand=includedGroups($select=id,displayName)' -tenantid $TenantFilter Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'MobileDeviceManagementPolicies' -Data @($MDMPolicy) -AddCount $MDMPolicy = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOAuth2PermissionGrants.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOAuth2PermissionGrants.ps1 index 404d7c7189ad7..2ab748e665707 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOAuth2PermissionGrants.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOAuth2PermissionGrants.ps1 @@ -24,6 +24,11 @@ function Set-CIPPDBCacheOAuth2PermissionGrants { if ($OAuth2PermissionGrants) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'OAuth2PermissionGrants' -Data $OAuth2PermissionGrants -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($OAuth2PermissionGrants.Count) OAuth2 permission grants" -sev Debug + } else { + # The request succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'OAuth2PermissionGrants' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 OAuth2 permission grants (none found)' -sev Debug } $OAuth2PermissionGrants = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOneDriveLongPaths.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOneDriveLongPaths.ps1 new file mode 100644 index 0000000000000..6578cf1fed0b5 --- /dev/null +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOneDriveLongPaths.ps1 @@ -0,0 +1,155 @@ +function Set-CIPPDBCacheOneDriveLongPaths { + <# + .SYNOPSIS + Fans out OneDrive long-path recount, one resumable activity per personal site. + + .DESCRIPTION + Adhoc-only (not part of nightly SharePoint collection). Enumerates personal sites, resolves + owner UPN from OneDriveUsage cache (merging a live usage report for gaps), and starts + per-site activities that full-recount path-length counts without storing paths or names. + + Clears the previous OneDriveLongPaths cache at fan-out start so departed users cannot + leave stale alert counts. Mid-scan the cache may be partial until activities finish. + + Trigger: /api/ExecCIPPDBCache?Name=OneDriveLongPaths&TenantFilter=... + Alert Get-CIPPAlertOneDriveLongPaths reads the resulting cache — run this collection first. + + .PARAMETER TenantFilter + The tenant to scan + + .PARAMETER QueueId + Optional queue ID for progress tracking + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Starting OneDrive long-path collection (per-site fan-out)' -sev Debug + + $OrgDisplayName = 'Organization' + try { + $Org = New-GraphGetRequest -uri 'https://graph.microsoft.com/v1.0/organization?$select=displayName' -tenantid $TenantFilter -asapp $true + $OrgRow = @($Org)[0] + if ($OrgRow.displayName) { $OrgDisplayName = [string]$OrgRow.displayName } + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "OneDrive long-paths: could not read organization displayName; using default: $($_.Exception.Message)" -sev Warning + } + # Default sync root: C:\Users\{upnLocal}\OneDrive - {org}\ — org segment once per tenant; UPN local-part added per site. + $InferredLocalRootFixedLength = ('C:\Users\').Length + ("\OneDrive - $OrgDisplayName\").Length + + $RawSites = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/sites/getAllSites?`$filter=isPersonalSite eq true&`$select=id,webUrl,displayName,sharepointIds&`$top=999" -tenantid $TenantFilter -asapp $true) + $SeenSiteIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $Sites = foreach ($Site in $RawSites) { + $GraphSiteId = [string]$Site.id + if ([string]::IsNullOrWhiteSpace($GraphSiteId)) { continue } + if (-not $SeenSiteIds.Add($GraphSiteId)) { continue } + $Site + } + $Sites = @($Sites) + + # siteId -> ownerPrincipalName (usage report siteId is the SPO GUID) + $UpnBySiteId = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase) + try { + $UsageItems = @(Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'OneDriveUsage' | Where-Object { $_.RowKey -ne 'OneDriveUsage-Count' }) + foreach ($UsageItem in $UsageItems) { + $UsageRow = $null + try { $UsageRow = $UsageItem.Data | ConvertFrom-Json -Depth 5 } catch { continue } + if ($UsageRow.siteId -and $UsageRow.ownerPrincipalName) { + $UpnBySiteId[[string]$UsageRow.siteId] = [string]$UsageRow.ownerPrincipalName + } + } + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "OneDrive long-paths: OneDriveUsage cache read failed: $($_.Exception.Message)" -sev Debug + } + + $NeedsLiveUsage = ($UpnBySiteId.Count -eq 0) + if (-not $NeedsLiveUsage) { + foreach ($Site in $Sites) { + $SpoSiteId = [string]($Site.sharepointIds.siteId ?? '') + if ($SpoSiteId -and -not $UpnBySiteId.ContainsKey($SpoSiteId)) { + $NeedsLiveUsage = $true + break + } + } + } + + if ($NeedsLiveUsage) { + try { + $Usage = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/reports/getOneDriveUsageAccountDetail(period='D7')?`$format=application/json&`$top=999" -tenantid $TenantFilter -asapp $true) + foreach ($UsageRow in $Usage) { + if ($UsageRow.siteId -and $UsageRow.ownerPrincipalName) { + $UpnBySiteId[[string]$UsageRow.siteId] = [string]$UsageRow.ownerPrincipalName + } + } + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "OneDrive long-paths: live usage report failed: $($_.Exception.Message)" -sev Warning + } + } + + if ($Sites.Count -eq 0) { + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'OneDriveLongPaths' -Data @() -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'OneDrive long-paths: no personal sites; wrote empty cache' -sev Debug + return + } + + $ScanId = [guid]::NewGuid().ToString() + $StateTable = Get-CippTable -tablename 'CippOneDriveLongPathsState' + Add-CIPPAzDataTableEntity @StateTable -Entity @{ + PartitionKey = $TenantFilter + RowKey = 'scan' + ScanId = $ScanId + StartedUtc = [string]([DateTimeOffset]::UtcNow.ToString('o')) + } -Force + + # Drop prior scan results so departed users cannot leave false alert counts. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'OneDriveLongPaths' -Data @() -ClearOnEmpty + + $Batch = [System.Collections.Generic.List[object]]::new() + foreach ($Site in $Sites) { + $SpoSiteId = [string]($Site.sharepointIds.siteId ?? '') + $GraphSiteId = [string]$Site.id + $Upn = $null + if ($SpoSiteId -and $UpnBySiteId.ContainsKey($SpoSiteId)) { + $Upn = $UpnBySiteId[$SpoSiteId] + } elseif ($GraphSiteId -and $UpnBySiteId.ContainsKey($GraphSiteId)) { + $Upn = $UpnBySiteId[$GraphSiteId] + } + + $QueueLabel = if ($Upn) { $Upn } else { $GraphSiteId } + $Batch.Add([PSCustomObject]@{ + FunctionName = 'DBCacheOneDriveLongPaths' + TenantFilter = $TenantFilter + SiteId = $GraphSiteId + SpoSiteId = $SpoSiteId + OwnerPrincipalName = $Upn + OrgDisplayName = $OrgDisplayName + InferredLocalRootFixedLength = $InferredLocalRootFixedLength + ScanId = $ScanId + QueueId = $QueueId + QueueName = "OneDrive Long Paths - $QueueLabel" + }) + } + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "OneDrive long-paths: dispatching $($Batch.Count) sites, scan $ScanId" -sev Debug + + if ($QueueId) { + try { + Update-CippQueueEntry -RowKey $QueueId -TotalTasks $Batch.Count -IncrementTotalTasks + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "OneDrive long-paths: could not update queue ${QueueId}: $($_.Exception.Message)" -sev Warning + } + } + + $null = Start-CIPPOrchestrator -InputObject ([PSCustomObject]@{ + Batch = @($Batch) + OrchestratorName = "OneDriveLongPaths_$TenantFilter" + SkipLog = $true + }) + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to start OneDrive long-path collection: $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) + } +} diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOneDriveRootPermissions.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOneDriveRootPermissions.ps1 index 6d8aefb5ffbc9..2f3fd77516440 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOneDriveRootPermissions.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOneDriveRootPermissions.ps1 @@ -53,6 +53,9 @@ function Set-CIPPDBCacheOneDriveRootPermissions { $LicenseCheck = Test-CIPPStandardLicense -StandardName 'OneDriveRootPermissionsCache' -TenantFilter $TenantFilter -Preset SharePoint -SkipLog if ($LicenseCheck -eq $false) { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have SharePoint/OneDrive license, skipping OneDrive root permissions cache' -sev Debug + # A license skip is still a completed collection: record the authoritative empty set + # so collect-on-miss does not re-run this collector forever on unlicensed tenants. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'OneDriveRootPermissions' -Data @() -AddCount -ClearOnEmpty return } diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOneDriveUsage.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOneDriveUsage.ps1 index d0f3cc2c58f1a..3110c08c12124 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOneDriveUsage.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOneDriveUsage.ps1 @@ -49,8 +49,10 @@ function Set-CIPPDBCacheOneDriveUsage { foreach ($UsageRow in $OneDriveUsage) { if ($null -eq $UsageRow) { continue } - $UsageRow | Add-Member -NotePropertyName 'id' -NotePropertyValue $UsageRow.siteId -Force - $UsageRow | Add-Member -NotePropertyName 'userPrincipalName' -NotePropertyValue $UsageRow.ownerPrincipalName -Force + $UsageRow | Add-Member -NotePropertyMembers ([ordered]@{ + id = $UsageRow.siteId + userPrincipalName = $UsageRow.ownerPrincipalName + }) -Force } $OneDriveListing = [System.Collections.Generic.List[object]]::new() diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOwaMailboxPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOwaMailboxPolicy.ps1 index b29ad6bfa0e9b..9a74445d198e6 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOwaMailboxPolicy.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOwaMailboxPolicy.ps1 @@ -30,6 +30,11 @@ function Set-CIPPDBCacheOwaMailboxPolicy { if ($OwaMailboxPolicies) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'OwaMailboxPolicy' -Data $OwaMailboxPolicies -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($OwaMailboxPolicies.Count) OWA Mailbox Policies" -sev Debug + } else { + # The cmdlet succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'OwaMailboxPolicy' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 OWA Mailbox Policies (none found)' -sev Debug } $OwaMailboxPolicies = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCachePIMSettings.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCachePIMSettings.ps1 index 01057595b165f..74895f9de85ac 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCachePIMSettings.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCachePIMSettings.ps1 @@ -21,6 +21,10 @@ function Set-CIPPDBCachePIMSettings { if ($TestResult -eq $false) { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have Azure AD Premium P2 license, skipping PIM' -sev Debug + # A license skip is still a completed collection: record authoritative empty sets for + # both types this collector writes so collect-on-miss does not re-run it forever. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'PIMRoleSettings' -Data @() -AddCount -ClearOnEmpty + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'PIMAssignments' -Data @() -AddCount -ClearOnEmpty return } @@ -32,6 +36,11 @@ function Set-CIPPDBCachePIMSettings { if ($PIMRoleSettings) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'PIMRoleSettings' -Data $PIMRoleSettings -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($PIMRoleSettings.Count) PIM role settings" -sev Debug + } else { + # The request succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'PIMRoleSettings' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 PIM role settings (none found)' -sev Debug } $PIMRoleSettings = $null } catch { @@ -44,6 +53,11 @@ function Set-CIPPDBCachePIMSettings { if ($PIMAssignments) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'PIMAssignments' -Data $PIMAssignments -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($PIMAssignments.Count) PIM assignments" -sev Debug + } else { + # The request succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'PIMAssignments' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 PIM assignments (none found)' -sev Debug } $PIMAssignments = $null } catch { diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCachePermissionGrantPolicies.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCachePermissionGrantPolicies.ps1 index aa88aae3855d9..6b58076d61268 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCachePermissionGrantPolicies.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCachePermissionGrantPolicies.ps1 @@ -19,7 +19,8 @@ function Set-CIPPDBCachePermissionGrantPolicies { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching permission grant policies' -sev Debug - $PermissionGrantPolicies = @(New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/policies/permissionGrantPolicies?$expand=includes' -tenantid $TenantFilter) + # includes/excludes are auto-expanded on GET; $expand is rejected by Graph + $PermissionGrantPolicies = @(New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/policies/permissionGrantPolicies' -tenantid $TenantFilter) Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'PermissionGrantPolicies' -Data @($PermissionGrantPolicies) -AddCount $PermissionGrantPolicies = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheReportSubmissionPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheReportSubmissionPolicy.ps1 index fb0e24beae0cc..4ebf1d77220d2 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheReportSubmissionPolicy.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheReportSubmissionPolicy.ps1 @@ -30,6 +30,11 @@ function Set-CIPPDBCacheReportSubmissionPolicy { $Data = @($ReportSubmissionPolicies) Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ReportSubmissionPolicy' -Data $Data -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($Data.Count) Report Submission Policies" -sev Debug + } else { + # The cmdlet succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ReportSubmissionPolicy' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 Report Submission Policies (none found)' -sev Debug } $ReportSubmissionPolicies = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheReportSubmissionRule.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheReportSubmissionRule.ps1 index 5a4ae9e018643..df15e3e7ae78b 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheReportSubmissionRule.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheReportSubmissionRule.ps1 @@ -24,6 +24,11 @@ function Set-CIPPDBCacheReportSubmissionRule { $ReportSubmissionRuleArray = @($ReportSubmissionRules) Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ReportSubmissionRule' -Data $ReportSubmissionRuleArray -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($ReportSubmissionRuleArray.Count) report submission rules" -sev Debug + } else { + # The cmdlet succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ReportSubmissionRule' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 report submission rules (none found)' -sev Debug } $ReportSubmissionRules = $null diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRiskDetections.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRiskDetections.ps1 index a2590aa4d2f91..6066afb22a0f8 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRiskDetections.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRiskDetections.ps1 @@ -26,6 +26,9 @@ function Set-CIPPDBCacheRiskDetections { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'RiskDetections' -Data $RiskDetections -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($RiskDetections.Count) risk detections successfully" -sev Debug } else { + # The request succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'RiskDetections' -Data @() -AddCount -ClearOnEmpty Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'No risk detections found or Identity Protection not available' -sev Debug } diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRiskyServicePrincipals.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRiskyServicePrincipals.ps1 index 921208f6fff3e..36349c3a7e7ee 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRiskyServicePrincipals.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRiskyServicePrincipals.ps1 @@ -26,6 +26,9 @@ function Set-CIPPDBCacheRiskyServicePrincipals { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'RiskyServicePrincipals' -Data $RiskyServicePrincipals -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($RiskyServicePrincipals.Count) risky service principals successfully" -sev Debug } else { + # The request succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'RiskyServicePrincipals' -Data @() -AddCount -ClearOnEmpty Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'No risky service principals found or Workload Identity Protection not available' -sev Debug } diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRiskyUsers.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRiskyUsers.ps1 index 7e47e3b66ba5d..acd135036d559 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRiskyUsers.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRiskyUsers.ps1 @@ -26,6 +26,9 @@ function Set-CIPPDBCacheRiskyUsers { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'RiskyUsers' -Data $RiskyUsers -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($RiskyUsers.Count) risky users successfully" -sev Debug } else { + # The request succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'RiskyUsers' -Data @() -AddCount -ClearOnEmpty Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'No risky users found or Identity Protection not available' -sev Debug } diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSPOSites.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSPOSites.ps1 new file mode 100644 index 0000000000000..65b297e64a02c --- /dev/null +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSPOSites.ps1 @@ -0,0 +1,115 @@ +function Set-CIPPDBCacheSPOSites { + <# + .SYNOPSIS + Caches per-site SharePoint Online admin settings for every site collection + + .DESCRIPTION + Generic per-site SharePoint cache (Type 'SPOSites'): one row per site collection with the + admin-manageable settings (site owner, sharing controls, lifecycle, version policy, People + Picker, unmanaged-device access), keyed by site id. Any site-level standard or report can read + it. The tenant-wide enumeration (Get-CIPPSPOSite) supplies the site list and the fields it is + accurate for; the ~19 fields it only returns as defaults (owner, per-site sharing, People + Picker, ...) are filled from an authoritative per-site read (Get-CIPPSPOSiteBulk, batched and + concurrency-capped). SharePoint app-only requires the certificate (delegated is not available on + every tenant), so both reads use -UseCertificate. + + .PARAMETER TenantFilter + The tenant to cache SharePoint site settings for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching SharePoint site settings' -sev Debug + + # The tenant-wide enumeration is fast but returns DEFAULT/empty values for ~19 per-site fields + # (site owner, per-site sharing controls, ShowPeoplePickerSuggestionsForGuestUsers, ...) - only + # the per-site GetSitePropertiesByUrl returns them (confirmed on a live tenant, and confirmed no + # Graph/enumeration variant supplies them). So the enumeration gives us the URL list + the fields + # it IS accurate for, and one authoritative per-site read - batched and concurrency-capped to stay + # under SharePoint's CSOM throttle - fills in the rest. A per-site read failure falls back to the + # enumeration value for that site. + $Sites = @(Get-CIPPSPOSite -TenantFilter $TenantFilter -UseCertificate | Where-Object { $_ -and $_.Url }) + + $AuthByUrl = @{} + if ($Sites.Count -gt 0) { + try { + foreach ($Result in @(Get-CIPPSPOSiteBulk -TenantFilter $TenantFilter -SiteUrls @($Sites.Url) -MaxConcurrency 4 -BatchSize 5 -UseCertificate)) { + if ($Result.Success -and $Result.Site) { $AuthByUrl["$($Result.SiteUrl)"] = $Result.Site } + } + $Missing = $Sites.Count - $AuthByUrl.Count + if ($Missing -gt 0) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "SPOSites: $Missing of $($Sites.Count) sites fell back to enumeration values (authoritative per-site read did not return them)" -sev Debug + } + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "SPOSites: authoritative per-site read failed; falling back to the enumeration values for this run: $($_.Exception.Message)" -sev Warning + } + } + + # Fields the enumeration only returns as defaults - take the authoritative per-site value, and + # fall back to the (less accurate) enumeration value when the per-site read did not return. + $AuthoritativeFields = @( + 'ShowPeoplePickerSuggestionsForGuestUsers', 'OwnerName', 'OwnerEmail', 'OwnerLoginName', + 'GroupOwnerLoginName', 'IsGroupOwnerSiteAdmin', 'AllowEditing', 'AllowFileArchive', + 'DefaultShareLinkScope', 'DefaultMainLinkScope', 'DisableCompanyWideSharingLinks', + 'LoopDefaultSharingLinkScope', 'LoopDefaultSharingLinkRole', 'BlockDownloadLinksFileType', + 'LimitedAccessFileType', 'RequestFilesLinkEnabled', 'RequestFilesLinkExpirationInDays', + 'SharingLockDownEnabled', 'SharingLockDownCanBeCleared', 'ReadOnlyForUnmanagedDevices', + 'RestrictedAccessControl', 'DisableAppViews', 'DisableFlows', 'SandboxedCodeActivationCapability', + 'IsHubSite', 'IsTeamsConnected', 'IsTeamsChannelConnected', 'WebsCount', 'Status' + ) + + $Rows = @(foreach ($Site in $Sites) { + $Auth = $AuthByUrl["$($Site.Url)"] + $Source = if ($Auth) { $Auth } else { $Site } + # Accurate from the enumeration (kept as-is to preserve id/format stability). Enum values + # stay numeric as CSOM returns them. + $Row = [ordered]@{ + id = "$($Site.SiteId)" + Url = $Site.Url + Title = $Site.Title + Template = $Site.Template + GroupId = "$($Site.GroupId)" + SharingCapability = $Site.SharingCapability + DefaultSharingLinkType = $Site.DefaultSharingLinkType + DefaultLinkPermission = $Site.DefaultLinkPermission + SharingDomainRestrictionMode = $Site.SharingDomainRestrictionMode + SharingAllowedDomainList = $Site.SharingAllowedDomainList + SharingBlockedDomainList = $Site.SharingBlockedDomainList + OverrideTenantAnonymousLinkExpirationPolicy = $Site.OverrideTenantAnonymousLinkExpirationPolicy + AnonymousLinkExpirationInDays = $Site.AnonymousLinkExpirationInDays + LockState = $Site.LockState + StorageMaximumLevel = $Site.StorageMaximumLevel + StorageWarningLevel = $Site.StorageWarningLevel + StorageUsage = $Site.StorageUsage + InheritVersionPolicyFromTenant = $Site.InheritVersionPolicyFromTenant + EnableAutoExpirationVersionTrim = $Site.EnableAutoExpirationVersionTrim + MajorVersionLimit = $Site.MajorVersionLimit + ExpireVersionsAfterDays = $Site.ExpireVersionsAfterDays + ConditionalAccessPolicy = $Site.ConditionalAccessPolicy + } + foreach ($Field in $AuthoritativeFields) { $Row[$Field] = $Source.$Field } + [PSCustomObject]$Row + }) + + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'SPOSites' -Data $Rows -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($Rows.Count) SharePoint site settings" -sev Debug + + } catch { + # A tenant with no SharePoint app-only consent answers 401 every run until that changes; + # record it and move on rather than failing the whole collection for it. + if ($_.Exception.Data['SPOAccessDenied'] -or $_.Exception.Message -match '\b401\b|unauthorized') { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Skipped SharePoint site cache: $($_.Exception.Message)" -sev Warning + return + } + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache SharePoint site settings: $($_.Exception.Message)" -sev Error + throw + } +} diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSPOTenant.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSPOTenant.ps1 index b3c720bcfe7e1..0b7acb8191d7a 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSPOTenant.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSPOTenant.ps1 @@ -20,13 +20,18 @@ function Set-CIPPDBCacheSPOTenant { param( [Parameter(Mandatory = $true)] [string]$TenantFilter, - [string]$QueueId + [string]$QueueId, + # SharePoint app-only requires the SAM certificate. Opt-in per caller (e.g. a baseline's + # read.collectorArgs) so this shared collector's default (delegated) is unchanged. + [switch]$UseCertificate ) try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching SharePoint Online tenant configuration' -sev Debug - $SPOTenant = Get-CIPPSPOTenant -TenantFilter $TenantFilter -SkipCache + $AuthSplat = @{} + if ($UseCertificate) { $AuthSplat['UseCertificate'] = $true } + $SPOTenant = Get-CIPPSPOTenant -TenantFilter $TenantFilter -SkipCache @AuthSplat # An empty response is a failure too: this collection only runs for SharePoint-licensed # tenants, so there is always a configuration object to return. Falling through quietly diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSensitivityLabels.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSensitivityLabels.ps1 index 307bc5e879139..6891d3c87c0c6 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSensitivityLabels.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSensitivityLabels.ps1 @@ -21,6 +21,9 @@ function Set-CIPPDBCacheSensitivityLabels { if ($LicenseCheck -eq $false) { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have a Purview/AIP license, skipping sensitivity labels' -sev Debug + # A license skip is still a completed collection: record the authoritative empty set + # so collect-on-miss does not re-run this collector forever on unlicensed tenants. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'SensitivityLabels' -Data @() -AddCount -ClearOnEmpty return } @@ -31,6 +34,11 @@ function Set-CIPPDBCacheSensitivityLabels { if ($Labels) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'SensitivityLabels' -Data $Labels -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($Labels.Count) sensitivity labels" -sev Debug + } else { + # The read succeeded with nothing returned: write the authoritative empty set so the + # Count marker records a completed collection and stale rows are cleared. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'SensitivityLabels' -Data @() -AddCount -ClearOnEmpty + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached 0 sensitivity labels (none found)' -sev Debug } } catch { diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointPermissions.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointPermissions.ps1 index 28d80fe2f4286..85f50a6609b7e 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointPermissions.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointPermissions.ps1 @@ -37,6 +37,9 @@ function Set-CIPPDBCacheSharePointPermissions { $LicenseCheck = Test-CIPPStandardLicense -StandardName 'SharePointPermissionsCache' -TenantFilter $TenantFilter -Preset SharePoint -SkipLog if ($LicenseCheck -eq $false) { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have a SharePoint license, skipping SharePoint permissions cache' -sev Debug + # A license skip is still a completed collection: record the authoritative empty set + # so collect-on-miss does not re-run this collector forever on unlicensed tenants. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'SharePointPermissions' -Data @() -AddCount -ClearOnEmpty return } diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSiteUsage.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSiteUsage.ps1 index f29626c038f22..6ca73597ac6c5 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSiteUsage.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSiteUsage.ps1 @@ -3,6 +3,12 @@ function Set-CIPPDBCacheSharePointSiteUsage { .SYNOPSIS Caches SharePoint site listing and site usage details for a tenant + .DESCRIPTION + Active sites + usage from SPO admin RenderAdminListData (same source as the site browser), + Graph getAllSites for Graph ids / sharepointIds, Get-CIPPSPOSite for file-level archive + metrics, and a Graph lists bulk pass for AutoMapUrl. Writes the same SharePointSiteListing + and SharePointSiteUsage property shapes consumed by Get-CIPPSharePointSiteUsageReport. + .PARAMETER TenantFilter The tenant to cache SharePoint site usage for @@ -19,105 +25,10 @@ function Set-CIPPDBCacheSharePointSiteUsage { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching SharePoint site listing and usage' -sev Debug - $Tenant = Get-Tenants -TenantFilter $TenantFilter - $TenantId = $Tenant.customerId - - $BulkRequests = @( - @{ - id = 'listAllSites' - method = 'GET' - url = "sites/getAllSites?`$filter=isPersonalSite eq false&`$select=id,createdDateTime,description,name,displayName,isPersonalSite,lastModifiedDateTime,webUrl,siteCollection,sharepointIds&`$top=999" - } - @{ - id = 'usage' - method = 'GET' - url = "reports/getSharePointSiteUsageDetail(period='D7')?`$format=application/json&`$top=999" - } - ) - - $Result = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($BulkRequests) -asapp $true - $Sites = @(($Result | Where-Object { $_.id -eq 'listAllSites' }).body.value) - $UsageResponse = $Result | Where-Object { $_.id -eq 'usage' } - if ($UsageResponse.status -and $UsageResponse.status -ne 200) { - throw ($UsageResponse.body.error.message ?? "Usage report request failed with status $($UsageResponse.status)") - } - $UsageBody = $UsageResponse.body - if ($UsageBody -is [string]) { - $UsageJson = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($UsageBody)) - $UsageRows = @(($UsageJson | ConvertFrom-Json).value) - } else { - $UsageRows = @($UsageBody.value) - } - - # Ensure a stable row key for usage rows. - foreach ($UsageRow in $UsageRows) { - if ($null -eq $UsageRow) { continue } - $UsageRow | Add-Member -NotePropertyName 'id' -NotePropertyValue $UsageRow.siteId -Force - } - - $SiteListing = [System.Collections.Generic.List[object]]::new() - foreach ($Site in $Sites) { - $SiteListing.Add([PSCustomObject]@{ - id = $Site.id - sharepointIds = $Site.sharepointIds - createdDateTime = $Site.createdDateTime - displayName = $Site.displayName - webUrl = $Site.webUrl - isPersonalSite = $Site.isPersonalSite - AutoMapUrl = '' - }) - } - - $RequestId = 0 - $ListRequests = foreach ($Site in $SiteListing) { - @{ - id = $RequestId++ - method = 'GET' - url = "sites/$($Site.sharepointIds.siteId)/lists?`$select=id,name,list,parentReference" - } - } - - # Reduce the library responses to one list id per site key as they are read, rather than - # holding every document library object for the whole tenant. The previous version kept - # them all and rescanned the array for each site, which is O(sites x libraries). - # parentReference.siteId is a composite ('hostname,siteCollectionId,webId'), so each - # comma-separated component is indexed - that is what the old '-like "*$siteId*"' test - # matched, since a site GUID can only occur as a whole component. First writer wins, - # matching the previous 'Select-Object -First 1'. - $ListIdBySiteKey = @{} - # A site with no sharepointIds.siteId produced the wildcard pattern '**' in the old - # filter, which matches every library, so the first one won. Kept so those rows are - # unchanged. - $FirstLibraryListId = $null - if ($ListRequests.Count -gt 0) { - try { - $LibraryLists = (New-GraphBulkRequest -tenantid $TenantFilter -scope 'https://graph.microsoft.com/.default' -Requests @($ListRequests) -asapp $true).body.value - foreach ($List in $LibraryLists) { - if ($List.list.template -ne 'DocumentLibrary') { continue } - if ($null -eq $FirstLibraryListId) { $FirstLibraryListId = $List.id } - $ParentSiteId = $List.parentReference.siteId - if (-not $ParentSiteId) { continue } - foreach ($Key in ([string]$ParentSiteId -split ',')) { - if ($Key -and -not $ListIdBySiteKey.ContainsKey($Key)) { $ListIdBySiteKey[$Key] = $List.id } - } - } - $LibraryLists = $null - } catch { - Write-LogMessage -Message "Error getting auto map urls for SharePoint cache: $($_.Exception.Message)" -Sev 'Error' -tenant $TenantFilter -API 'CIPPDBCache' -LogData (Get-CippException -Exception $_) - } - } - $ListRequests = $null - - foreach ($Site in $SiteListing) { - $SiteKey = [string]$Site.sharepointIds.siteId - $ListId = if ($SiteKey) { $ListIdBySiteKey[$SiteKey] } else { $FirstLibraryListId } - $Site.AutoMapUrl = "tenantId=$($TenantId)&webId={$($Site.sharepointIds.webId)}&siteid={$($Site.sharepointIds.siteId)}&webUrl=$($Site.webUrl)&listId={$($ListId)}" - } - $ListIdBySiteKey = $null - - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'SharePointSiteListing' -Data @($SiteListing) -AddCount + $Built = Get-CIPPSharePointSiteUsageRows -TenantFilter $TenantFilter -IncludeArchive -LogApi 'CIPPDBCache' - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'SharePointSiteUsage' -Data @($UsageRows) -AddCount + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'SharePointSiteListing' -Data @($Built.SiteListing) -AddCount + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'SharePointSiteUsage' -Data @($Built.UsageRows) -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached SharePoint site listing and usage successfully' -sev Debug diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheStorageCleanupScan.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheStorageCleanupScan.ps1 new file mode 100644 index 0000000000000..7a3eef812a1bb --- /dev/null +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheStorageCleanupScan.ps1 @@ -0,0 +1,151 @@ +function Set-CIPPDBCacheStorageCleanupScan { + <# + .SYNOPSIS + Fans out SharePoint storage cleanup signal collection, batched by site. + + .DESCRIPTION + Enumerates every non-personal, non-system SharePoint site and starts a child orchestration + with one activity per batch of 20 sites (Push-DBCacheStorageCleanupScanBatch). A single + PostExecution (Push-StoreStorageCleanupScan) aggregates every batch and writes the + StorageCleanupScan cache once. + + Hold-only / report-private: only the storage report reads this cache. It is not part of + nightly CIPPDB collection and is not consumed by ListSites or other List APIs. + + Per site the batch collects library StorageMetrics (versionEstimateBytes) and an aggregate + recycle-bin summary (sizes only — no item titles or paths). + + .PARAMETER TenantFilter + The tenant to cache storage cleanup signals for + + .PARAMETER QueueId + Optional queue ID for progress tracking + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + $BatchSize = 20 + $SitesToLeaveOut = @( + 'search' + 'contentTypeHub' + 'appcatalog' + 'portals/hub' + 'portals/community' + ) + + function Test-CIPPStorageCleanupLeaveOut { + param( + [string]$Name, + [string]$WebUrl, + [string[]]$LeaveOut + ) + $SitePath = $null + $SitePathLeaf = $null + if (-not [string]::IsNullOrWhiteSpace($WebUrl)) { + try { + $SitePath = ([System.Uri]$WebUrl).AbsolutePath.Trim('/') + if (-not [string]::IsNullOrWhiteSpace($SitePath)) { + $SitePathLeaf = $SitePath.Split('/')[-1] + } + } catch { + $SitePath = $null + $SitePathLeaf = $null + } + } + foreach ($LeaveOutName in $LeaveOut) { + if ( + ([string]::Equals($Name, $LeaveOutName, [System.StringComparison]::OrdinalIgnoreCase)) -or + ([string]::Equals($SitePath, $LeaveOutName, [System.StringComparison]::OrdinalIgnoreCase)) -or + ([string]::Equals($SitePathLeaf, $LeaveOutName, [System.StringComparison]::OrdinalIgnoreCase)) + ) { + return $true + } + } + return $false + } + + try { + $LicenseCheck = Test-CIPPStandardLicense -StandardName 'StorageCleanupScanCache' -TenantFilter $TenantFilter -Preset SharePoint -SkipLog + if ($LicenseCheck -eq $false) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have a SharePoint license, skipping StorageCleanupScan cache' -sev Debug + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'StorageCleanupScan' -Data @() -AddCount -ClearOnEmpty + return + } + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Starting StorageCleanupScan collection' -sev Debug + + $RawSites = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/sites/getAllSites?`$select=id,displayName,name,webUrl,isPersonalSite&`$top=999" -tenantid $TenantFilter -asapp $true) + + $SiteById = @{} + foreach ($Site in $RawSites) { + if (-not $Site.id -or $Site.isPersonalSite) { continue } + if (Test-CIPPStorageCleanupLeaveOut -Name $Site.name -WebUrl $Site.webUrl -LeaveOut $SitesToLeaveOut) { continue } + $SiteById[$Site.id] = $Site + } + $Sites = @($SiteById.Values) + $ExpectedSiteCount = $Sites.Count + + if ($ExpectedSiteCount -eq 0) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'No SharePoint sites found; writing empty StorageCleanupScan cache' -sev Debug + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'StorageCleanupScan' -Data @() -AddCount + return + } + + $Batches = [System.Collections.Generic.List[object]]::new() + $TotalBatches = [Math]::Ceiling($Sites.Count / $BatchSize) + for ($i = 0; $i -lt $Sites.Count; $i += $BatchSize) { + $BatchSites = $Sites[$i..[Math]::Min($i + $BatchSize - 1, $Sites.Count - 1)] + $BatchNumber = [Math]::Floor($i / $BatchSize) + 1 + $SiteSeeds = foreach ($Site in $BatchSites) { + [PSCustomObject]@{ + id = $Site.id + webUrl = $Site.webUrl + displayName = $Site.displayName ?? $Site.name + } + } + $BatchItem = [PSCustomObject]@{ + FunctionName = 'DBCacheStorageCleanupScanBatch' + TenantFilter = $TenantFilter + QueueName = "Storage Cleanup Scan Batch $BatchNumber/$TotalBatches - $TenantFilter" + BatchNumber = $BatchNumber + TotalBatches = $TotalBatches + Sites = @($SiteSeeds) + } + if ($QueueId) { + $BatchItem | Add-Member -NotePropertyName 'QueueId' -NotePropertyValue $QueueId -Force + } + [void]$Batches.Add($BatchItem) + } + + if ($QueueId -and $Batches.Count -gt 0) { + try { + Update-CippQueueEntry -RowKey $QueueId -TotalTasks $Batches.Count -IncrementTotalTasks + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Could not update queue $QueueId with StorageCleanupScan batch tasks: $($_.Exception.Message)" -sev Warning + } + } + + $InputObject = [PSCustomObject]@{ + Batch = @($Batches) + OrchestratorName = "StorageCleanupScan_$TenantFilter" + SkipLog = $true + PostExecution = @{ + FunctionName = 'StoreStorageCleanupScan' + Parameters = @{ + TenantFilter = $TenantFilter + ExpectedSiteCount = $ExpectedSiteCount + } + } + } + + $null = Start-CIPPOrchestrator -InputObject $InputObject + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Started StorageCleanupScan collection across $ExpectedSiteCount sites in $($Batches.Count) batches" -sev Debug + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to start StorageCleanupScan collection: $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) + } +} diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheTeamsResourceAccounts.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheTeamsResourceAccounts.ps1 index 6ae50e8ecfea3..021a0d1c81148 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheTeamsResourceAccounts.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheTeamsResourceAccounts.ps1 @@ -27,6 +27,9 @@ function Set-CIPPDBCacheTeamsResourceAccounts { if ($LicenseCheck -eq $false) { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have a Teams license, skipping Teams resource accounts' -sev Debug + # A license skip is still a completed collection: record the authoritative empty set + # so collect-on-miss does not re-run this collector forever on unlicensed tenants. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'TeamsResourceAccounts' -Data @() -AddCount -ClearOnEmpty return } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecAzBobbyTables.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecAzBobbyTables.ps1 index 4c7e5a7a4a7bf..287a6ef9714a0 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecAzBobbyTables.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecAzBobbyTables.ps1 @@ -12,6 +12,9 @@ function Invoke-ExecAzBobbyTables { #> [CmdletBinding()] param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers $AllowList = @( 'Add-AzDataTableEntity' 'Add-CIPPAzDataTableEntity' @@ -26,17 +29,20 @@ function Invoke-ExecAzBobbyTables { ) $Function = $Request.Body.FunctionName + $TableName = $Request.Body.TableName $Params = if ($Request.Body.Parameters) { $Request.Body.Parameters | ConvertTo-Json -Compress -ErrorAction Stop | ConvertFrom-Json -AsHashtable } else { @{} } + $ParamKeys = if ($Params.Keys) { @($Params.Keys) -join ', ' } else { 'none' } + $TableNote = if ($TableName) { " on table '$TableName'" } else { '' } if ($Function -in $AllowList) { if ($Function -eq 'Get-AzDataTable') { $Context = New-AzDataTableContext -ConnectionString $env:AzureWebJobsStorage } else { - $Context = New-AzDataTableContext -ConnectionString $env:AzureWebJobsStorage -TableName $Request.Body.TableName + $Context = New-AzDataTableContext -ConnectionString $env:AzureWebJobsStorage -TableName $TableName } try { $Results = & $Function -Context $Context @Params @@ -46,19 +52,22 @@ function Invoke-ExecAzBobbyTables { # Drop it from the Get-CIPPTable cache so it gets recreated on next use. The table # name comes from the request, so clear everything when it was not supplied. if ($Function -eq 'Remove-AzDataTable') { - if ($Request.Body.TableName) { - Unregister-CIPPTable -TableName $Request.Body.TableName + if ($TableName) { + Unregister-CIPPTable -TableName $TableName } else { Unregister-CIPPTable -All } } + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "SuperAdmin AzBobbyTables ran '$Function'$TableNote (param keys: $ParamKeys)" -Sev 'Info' $StatusCode = [HttpStatusCode]::OK } catch { $Results = $_.Exception.Message + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "SuperAdmin AzBobbyTables '$Function' failed: $Results" -Sev 'Error' -LogData (Get-CippException -Exception $_) $StatusCode = [HttpStatusCode]::InternalServerError } } else { $Results = "Function $Function not found or not allowed" + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "SuperAdmin AzBobbyTables blocked: $Results" -Sev 'Error' $StatusCode = [HttpStatusCode]::NotFound } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCIPPDBCache.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCIPPDBCache.ps1 index 0a4d9b5e1fe2b..bc8cb47b20358 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCIPPDBCache.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCIPPDBCache.ps1 @@ -29,6 +29,19 @@ function Invoke-ExecCIPPDBCache { throw 'TenantFilter parameter is required' } + # A derived cache type has no collector of its own — it is produced as a side-effect of + # another collector (e.g. SharePointSiteListing by Set-CIPPDBCacheSharePointSiteUsage). The + # registry's 'collectedBy' names that producing collector, so a run of the derived type runs + # it and populates the derived data. + $CacheTypesPath = Join-Path $env:CIPPRootPath 'Config/CIPPDBCacheTypes.json' + if (Test-Path $CacheTypesPath) { + $CollectedBy = ((Get-Content $CacheTypesPath -Raw | ConvertFrom-Json) | Where-Object { $_.type -eq $Name }).collectedBy + if ($CollectedBy) { + Write-Information "ExecCIPPDBCache: '$Name' is a derived cache type; running its producing collector '$CollectedBy'" + $Name = "$CollectedBy" + } + } + # Validate the function exists — on HttpOnly workers CIPPDB module isn't loaded, # so import it temporarily for validation (the actual execution runs on activity workers) $FunctionName = "Set-CIPPDBCache$Name" diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCIPPDBCacheAdmin.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCIPPDBCacheAdmin.ps1 new file mode 100644 index 0000000000000..691764831cfb4 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCIPPDBCacheAdmin.ps1 @@ -0,0 +1,149 @@ +function Invoke-ExecCIPPDBCacheAdmin { + <# + .SYNOPSIS + SuperAdmin browse / remove / empty for CIPPDB (CippReportingDB) cache collections. + + .DESCRIPTION + Typed alternative to Table Maintenance for the reporting cache. List returns decoded + cache objects stamped with CIPPPartitionKey / CIPPRowKey / CIPPETag so the UI can + delete by storage key. Empty clears an entire type for a tenant (or AllTenants). + + .FUNCTIONALITY + Entrypoint + .ROLE + CIPP.SuperAdmin.ReadWrite + #> + [CmdletBinding()] + param ( + $Request, + $TriggerMetadata + ) + + $APIName = $TriggerMetadata.FunctionName + $Body = $Request.Body + $Action = [string]$Body.Action + $TenantFilter = [string]$Body.TenantFilter + $Type = [string]$Body.Type + + if ([string]::IsNullOrWhiteSpace($Action)) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = 'Error: Action is required (List, Remove, Empty)' } + }) + } + + try { + switch ($Action) { + 'List' { + if ([string]::IsNullOrWhiteSpace($TenantFilter) -or [string]::IsNullOrWhiteSpace($Type)) { + throw 'List requires TenantFilter and Type' + } + + $IsAllTenants = $TenantFilter -eq 'AllTenants' + $DbTenant = if ($IsAllTenants) { 'allTenants' } else { $TenantFilter } + $Rows = @(Get-CIPPDbItem -TenantFilter $DbTenant -Type $Type) + $CountRowKey = "$Type-Count" + + $Results = foreach ($Row in $Rows) { + if ($Row.RowKey -eq $CountRowKey) { continue } + if ([string]::IsNullOrWhiteSpace($Row.Data)) { continue } + + try { + $Parsed = [CIPP.CippJson]::ConvertFromJson($Row.Data, $null) + } catch { + Write-Information "Skipping unparseable CippReportingDB row for '$($Row.PartitionKey)'/'$Type': $($_.Exception.Message)" + continue + } + + foreach ($Record in @($Parsed)) { + if ($Record -isnot [System.Management.Automation.PSObject] -and $Record -isnot [PSCustomObject]) { + $Record = [PSCustomObject]@{ Value = $Record } + } + $RecordProps = [ordered]@{ + CIPPPartitionKey = $Row.PartitionKey + CIPPRowKey = $Row.RowKey + CIPPETag = $Row.ETag + } + if ($IsAllTenants) { + $RecordProps['Tenant'] = $Row.PartitionKey + } + $Record | Add-Member -NotePropertyMembers $RecordProps -Force + $Record + } + } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @{ Results = @($Results) } + }) + } + + 'Remove' { + if ([string]::IsNullOrWhiteSpace($Type)) { + throw 'Remove requires Type' + } + + $Rows = @($Body.Rows) + if ($Rows.Count -eq 0) { + throw 'Remove requires Rows with CIPPPartitionKey/CIPPRowKey (or PartitionKey/RowKey)' + } + + # Deduplicate by partition+row so array-unrolled List rows sharing one entity delete once + $Seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $Removed = 0 + foreach ($Row in $Rows) { + $PartitionKey = [string]($Row.CIPPPartitionKey ?? $Row.PartitionKey ?? $TenantFilter) + $RowKey = [string]($Row.CIPPRowKey ?? $Row.RowKey) + $ETag = [string]($Row.CIPPETag ?? $Row.ETag) + if ([string]::IsNullOrWhiteSpace($PartitionKey) -or [string]::IsNullOrWhiteSpace($RowKey)) { + continue + } + $DedupKey = "$PartitionKey|$RowKey" + if (-not $Seen.Add($DedupKey)) { continue } + + $RemoveParams = @{ + TenantFilter = $PartitionKey + Type = $Type + RowKey = $RowKey + } + if ($ETag) { $RemoveParams.ETag = $ETag } + Remove-CIPPDbItem @RemoveParams + $Removed++ + } + + Write-LogMessage -API $APIName -tenant $TenantFilter -message "Removed $Removed $Type cache row(s)" -sev Warning + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @{ Results = "Removed $Removed $Type cache row(s)" } + }) + } + + 'Empty' { + if ([string]::IsNullOrWhiteSpace($TenantFilter) -or [string]::IsNullOrWhiteSpace($Type)) { + throw 'Empty requires TenantFilter and Type' + } + + $ClearResult = Clear-CIPPDbCache -TenantFilter $TenantFilter -Type $Type + Write-LogMessage -API $APIName -tenant $TenantFilter -message "Emptied $Type cache for $($ClearResult.Tenant): $($ClearResult.RemovedCount) row(s)" -sev Warning + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @{ + Results = "Emptied $Type cache for $($ClearResult.Tenant): $($ClearResult.RemovedCount) row(s) removed" + Details = $ClearResult + } + }) + } + + default { + throw "Unknown Action '$Action'. Use List, Remove, or Empty." + } + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -tenant $TenantFilter -message "CIPPDB cache admin failed: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = $ErrorMessage.NormalizedError } + }) + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCippFunction.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCippFunction.ps1 index 867ce9a67cb0d..4ec20a3a4521f 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCippFunction.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCippFunction.ps1 @@ -11,6 +11,9 @@ function Invoke-ExecCippFunction { #> [CmdletBinding()] param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers $BlockList = @( 'Get-GraphToken' 'Get-GraphTokenFromCert' @@ -28,6 +31,7 @@ function Invoke-ExecCippFunction { } else { @{} } + $ParamKeys = if ($Params.Keys) { @($Params.Keys) -join ', ' } else { 'none' } if (Get-Command -Module CIPPCore -Name $Function -and $BlockList -notcontains $Function) { try { @@ -35,13 +39,16 @@ function Invoke-ExecCippFunction { if (!$Results) { $Results = "Function $Function executed successfully" } + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "SuperAdmin CippFunction ran '$Function' (param keys: $ParamKeys)" -Sev 'Info' $StatusCode = [HttpStatusCode]::OK } catch { $Results = $_.Exception.Message + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "SuperAdmin CippFunction '$Function' failed: $Results" -Sev 'Error' -LogData (Get-CippException -Exception $_) $StatusCode = [HttpStatusCode]::InternalServerError } } else { $Results = "Function $Function not found or not allowed" + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "SuperAdmin CippFunction blocked: $Results" -Sev 'Error' $StatusCode = [HttpStatusCode]::NotFound } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCloneTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCloneTemplate.ps1 index 03489fb5602fe..6706fbeba1f4d 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCloneTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCloneTemplate.ps1 @@ -10,6 +10,9 @@ function Invoke-ExecCloneTemplate { $TriggerMetadata ) + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $GUID = $Request.Query.GUID ?? $Request.Body.GUID $Type = $Request.Query.Type ?? $Request.Body.Type @@ -31,6 +34,8 @@ function Invoke-ExecCloneTemplate { } try { Add-CIPPAzDataTableEntity @Table -Entity $Template + $Result = "Template cloned successfully (Type=$Type, NewGuid=$NewGuid)" + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Info' $body = @{ Results = @{ state = 'success' @@ -38,11 +43,14 @@ function Invoke-ExecCloneTemplate { } } } catch { + $ErrorMessage = Get-CIPPException -Exception $_ + $Result = "Failed to clone template (Type=$Type, GUID=$GUID): $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Error' -LogData $ErrorMessage $body = @{ Results = @{ state = 'error' resultText = 'Failed to clone template' - details = Get-CIPPException -Exception $_ + details = $ErrorMessage } } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecDiagnosticsPresets.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecDiagnosticsPresets.ps1 index b7503b35dd12b..8241a2765d4e8 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecDiagnosticsPresets.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecDiagnosticsPresets.ps1 @@ -11,6 +11,9 @@ function Invoke-ExecDiagnosticsPresets { $TriggerMetadata ) + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + try { $Table = Get-CIPPTable -TableName 'DiagnosticsPresets' $Action = $Request.Body.action @@ -31,6 +34,9 @@ function Invoke-ExecDiagnosticsPresets { RowKey = $GUID } + $Result = "Diagnostics preset deleted successfully (GUID=$GUID)" + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Info' + return [HttpResponseContext]@{ StatusCode = [HttpStatusCode]::OK Body = @{ @@ -68,6 +74,9 @@ function Invoke-ExecDiagnosticsPresets { Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force + $Result = "Diagnostics preset saved successfully (Name=$Name, GUID=$GUID)" + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Info' + return [HttpResponseContext]@{ StatusCode = [HttpStatusCode]::OK Body = @{ @@ -79,10 +88,12 @@ function Invoke-ExecDiagnosticsPresets { } } } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "Failed to manage diagnostics preset: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage return [HttpResponseContext]@{ StatusCode = [HttpStatusCode]::InternalServerError Body = @{ - Error = "Failed to manage diagnostics preset: $($_.Exception.Message)" + Error = "Failed to manage diagnostics preset: $($ErrorMessage.NormalizedError)" } } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecEditTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecEditTemplate.ps1 index 4a3076e6aef86..8b89cc8960380 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecEditTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecEditTemplate.ps1 @@ -88,12 +88,12 @@ function Invoke-ExecEditTemplate { SHA = '' } Add-CIPPAzDataTableEntity @Table -Entity $Entity -OperationType 'UpsertMerge' - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Edited template $($Request.Body.name) with GUID $GUID" -Sev 'Debug' + Write-LogMessage -headers $Request.Headers -API $APINAME -tenant 'Global' -message "Edited template $($Request.Body.name) with GUID $GUID" -Sev 'Info' } $body = [pscustomobject]@{ 'Results' = 'Successfully saved the template' } } catch { - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Failed to edit template: $($_.Exception.Message)" -Sev 'Error' + Write-LogMessage -headers $Request.Headers -API $APINAME -tenant 'Global' -message "Failed to edit template: $($_.Exception.Message)" -Sev 'Error' $body = [pscustomobject]@{'Results' = "Editing template failed: $($_.Exception.Message)" } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecPartnerWebhook.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecPartnerWebhook.ps1 index 82a6bc823ad30..ba953bef9c01a 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecPartnerWebhook.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecPartnerWebhook.ps1 @@ -82,6 +82,8 @@ function Invoke-ExecPartnerWebhook { StandardsExcludeAllTenants = $Request.Body.standardsExcludeAllTenants } Add-CIPPAzDataTableEntity @ConfigTable -Entity $PartnerWebhookOnboarding -Force | Out-Null + # Subscription create/update is logged by New-CIPPGraphSubscription; log the onboarding config write here. + Write-LogMessage -headers $Request.Headers -API ($Request.Params.CIPPEndpoint) -tenant 'Global' -message "Partner webhook onboarding config saved (Enabled=$([bool]$Request.Body.enabled))" -Sev 'Info' } 'SendTest' { $Results = New-GraphPOSTRequest -uri 'https://api.partnercenter.microsoft.com/webhooks/v1/registration/validationEvents' -tenantid $env:TenantID -NoAuthCheck $true -scope 'https://api.partnercenter.microsoft.com/.default' @@ -106,3 +108,4 @@ function Invoke-ExecPartnerWebhook { Body = $Body } } + diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecServicePrincipals.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecServicePrincipals.ps1 index 1c3fd744bf4c9..0e2a9a82225cc 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecServicePrincipals.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecServicePrincipals.ps1 @@ -7,6 +7,8 @@ function Invoke-ExecServicePrincipals { #> [CmdletBinding()] param($Request, $TriggerMetadata) + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers $TenantFilter = $env:TenantID $Success = $true @@ -30,6 +32,7 @@ function Invoke-ExecServicePrincipals { if ($BlockList -contains $Request.Query.AppId) { $Results = 'Service Principal creation is blocked for this AppId' $Success = $false + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Results -Sev 'Error' } else { $Body = @{ 'appId' = $Request.Query.AppId @@ -37,14 +40,18 @@ function Invoke-ExecServicePrincipals { try { $ServicePrincipal = New-GraphPostRequest -Uri 'https://graph.microsoft.com/beta/servicePrincipals' -tenantid $TenantFilter -type POST -body $Body -NoAuthCheck $true $Results = "Created service principal for $($ServicePrincipal.displayName) ($($ServicePrincipal.appId))" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Results -Sev 'Info' } catch { - $Results = "Unable to create service principal: $($_.Exception.Message)" + $ErrorMessage = Get-CippException -Exception $_ + $Results = "Unable to create service principal: $($ErrorMessage.NormalizedError)" $Success = $false + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Results -Sev 'Error' -LogData $ErrorMessage } } } else { $Results = 'Invalid AppId' $Success = $false + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Results -Sev 'Error' } } default { @@ -66,8 +73,12 @@ function Invoke-ExecServicePrincipals { } } } catch { - $Results = $_.Exception.Message + $ErrorMessage = Get-CippException -Exception $_ + $Results = $ErrorMessage.NormalizedError $Success = $false + if ($Action -eq 'Create') { + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to create service principal: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + } } $Metadata = @{ diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListGraphRequest.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListGraphRequest.ps1 index d67a467f99665..b9e71e5062c74 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListGraphRequest.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListGraphRequest.ps1 @@ -99,6 +99,13 @@ function Invoke-ListGraphRequest { $GraphRequestParams.ManualPagination = [System.Convert]::ToBoolean($Request.Query.manualPagination) } + # $top is Graph's page size, so $top=1 with pagination fetches the entire collection one + # record per round trip. A caller asking for 1 wants one record; only an explicit + # NoPagination/manualPagination overrides this. + if ($Parameters.'$top' -eq '1' -and $null -eq $Request.Query.NoPagination -and $null -eq $Request.Query.manualPagination) { + $GraphRequestParams.NoPagination = $true + } + # Continue a manualPagination walk: pass back the @odata.nextLink returned with the # previous page. Endpoint is still required, and the other query options are already # encoded in the link. @@ -106,6 +113,12 @@ function Invoke-ListGraphRequest { $GraphRequestParams.nextLink = $Request.Query.nextLink } + # Paged AllTenants cache reads only: target page size in bytes of raw JSON, clamped + # between 262144 and 8388608 (default 4000000). Pages always hold at least one whole tenant. + if ($Request.Query.maxPageBytes -as [int]) { + $GraphRequestParams.MaxPageBytes = [int]$Request.Query.maxPageBytes + } + # Return just the number of matching records instead of the records themselves. The # cheapest way to size a collection before deciding whether to fetch it. if ($Request.Query.CountOnly) { @@ -158,6 +171,27 @@ function Invoke-ListGraphRequest { $Metadata.GraphHeaders = $script:LastGraphResponseHeaders } + # Paged AllTenants cache serve: one page of tenant blobs plus Metadata.nextLink. + if ($UseRawJson -and $Results -isnot [string] -and $Results.PSObject.Properties.Name -contains 'CippPagedJson') { + if ($Request.Headers.'x-ms-coldstart' -eq 1) { + $Metadata.ColdStart = $true + } + if ($Results.CippNextLink) { + $Metadata.nextLink = $Results.CippNextLink + } else { + # Do not echo the incoming token back on the final page. + $Metadata.Remove('nextLink') + } + $MetadataJson = ConvertTo-Json -InputObject $Metadata -Depth 5 -Compress + $GraphRequestData = '{"Results":' + $Results.CippPagedJson + ',"Metadata":' + $MetadataJson + '}' + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + ContentType = 'application/json' + Body = $GraphRequestData + }) + } + # RawJsonArray returns a JSON string directly — skip object-level processing if ($UseRawJson -and $Results -is [string] -and $Results.StartsWith('[')) { if ($Request.Headers.'x-ms-coldstart' -eq 1) { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListLogs.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListLogs.ps1 index 204640c5f11b1..9a9df64ef972a 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListLogs.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListLogs.ps1 @@ -5,64 +5,91 @@ function Invoke-ListLogs { .ROLE CIPP.Core.Read .DESCRIPTION - Lists CIPP platform audit logs with filtering by severity, date range, tenant, and user. Supports listing available log categories. + Lists CIPP platform audit logs with filtering by severity, date range, tenant, and user. Supports listing available log categories, fetching a single entry, and server-side pagination via manualPagination/nextLink. #> [CmdletBinding()] param($Request, $TriggerMetadata) $Table = Get-CIPPTable $TzId = if ($env:CIPP_TIMEZONE) { $env:CIPP_TIMEZONE } else { 'UTC' } + $LocalNow = [TimeZoneInfo]::ConvertTimeBySystemTimeZoneId([DateTime]::UtcNow, $TzId) - $TemplatesTable = Get-CIPPTable -tablename 'templates' - $Templates = Get-CIPPAzDataTableEntity @TemplatesTable + function Get-LogStandardInfo { + param($Row, $Templates) + if (-not $Row.StandardTemplateId) { return @{} } + $Standard = ($Templates | Where-Object { $_.RowKey -eq $Row.StandardTemplateId }).JSON | ConvertFrom-Json - $ReturnedLog = if ($Request.Query.ListLogs) { - Get-AzDataTableEntity @Table -Property PartitionKey | Sort-Object -Unique PartitionKey | Select-Object PartitionKey | ForEach-Object { + $StandardInfo = @{ + Template = $Standard.templateName + Standard = $Row.Standard + } + + if ($Row.IntuneTemplateId) { + $IntuneTemplate = ($Templates | Where-Object { $_.RowKey -eq $Row.IntuneTemplateId }).JSON | ConvertFrom-Json + $StandardInfo.IntunePolicy = $IntuneTemplate.displayName + } + if ($Row.ConditionalAccessTemplateId) { + $ConditionalAccessTemplate = ($Templates | Where-Object { $_.RowKey -eq $Row.ConditionalAccessTemplateId }).JSON | ConvertFrom-Json + $StandardInfo.ConditionalAccessPolicy = $ConditionalAccessTemplate.displayName + } + return $StandardInfo + } + + if ($Request.Query.ListLogs) { + $ReturnedLog = Get-CIPPAzDataTableEntity @Table -Property PartitionKey | Sort-Object -Unique PartitionKey | Select-Object PartitionKey | ForEach-Object { @{ value = $_.PartitionKey label = $_.PartitionKey } } - } elseif ($Request.Query.logentryid) { - # Return single log entry by RowKey - $LocalNow = [TimeZoneInfo]::ConvertTimeBySystemTimeZoneId([DateTime]::UtcNow, $TzId) - $DateFilter = ConvertTo-CIPPODataFilterValue -Value ($Request.Query.DateFilter ?? $LocalNow.ToString('yyyyMMdd')) -Type Date - $SafeLogEntryId = ConvertTo-CIPPODataFilterValue -Value $Request.Query.logentryid -Type Guid - $Filter = "RowKey eq '{0}' and PartitionKey eq '{1}'" -f $SafeLogEntryId, $DateFilter + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @($ReturnedLog) + } + } + + if ($Request.Query.logentryid) { + # Return single log entry by RowKey. RowKeys are either legacy GUIDs or the + # inverted-ticks format Write-LogMessage writes; both use only hex digits and hyphens. + $LogEntryId = [string]$Request.Query.logentryid + if ($LogEntryId -notmatch '^[0-9a-fA-F-]{1,64}$') { + throw "Invalid log entry id format: '$LogEntryId'" + } + $DateFilter = if ($Request.Query.DateFilter) { + ConvertTo-CIPPODataFilterValue -Value $Request.Query.DateFilter -Type Date + } elseif ($LogEntryId -match '^(?\d{19})-') { + # New-format RowKeys embed the write time, so links without a dateFilter (e.g. from + # the API logs drawer) resolve regardless of which day the entry was written. + try { + $Ticks = [DateTime]::MaxValue.Ticks - [long]$Matches.Inverted + [TimeZoneInfo]::ConvertTimeBySystemTimeZoneId([DateTime]::new($Ticks, [DateTimeKind]::Utc), $TzId).ToString('yyyyMMdd') + } catch { + $LocalNow.ToString('yyyyMMdd') + } + } else { + $LocalNow.ToString('yyyyMMdd') + } + $Filter = "RowKey eq '{0}' and PartitionKey eq '{1}'" -f $LogEntryId, $DateFilter $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList - Write-Host "Getting single log entry for RowKey: $($Request.Query.logentryid)" + Write-Host "Getting single log entry for RowKey: $LogEntryId" - $Row = Get-AzDataTableEntity @Table -Filter $Filter + $Row = Get-CIPPAzDataTableEntity @Table -Filter $Filter - if ($Row) { + $ReturnedLog = if ($Row) { if ($AllowedTenants -notcontains 'AllTenants') { $TenantList = Get-Tenants -IncludeErrors | Where-Object { $_.customerId -in $AllowedTenants } } if ($AllowedTenants -contains 'AllTenants' -or ($AllowedTenants -notcontains 'AllTenants' -and ($TenantList.defaultDomainName -contains $Row.Tenant -or $Row.Tenant -eq 'CIPP' -or $TenantList.customerId -contains $Row.TenantId -or $TenantList.initialDomainName -contains $Row.Tenant)) ) { - if ($Row.StandardTemplateId) { - $Standard = ($Templates | Where-Object { $_.RowKey -eq $Row.StandardTemplateId }).JSON | ConvertFrom-Json - - $StandardInfo = @{ - Template = $Standard.templateName - Standard = $Row.Standard - } - - if ($Row.IntuneTemplateId) { - $IntuneTemplate = ($Templates | Where-Object { $_.RowKey -eq $Row.IntuneTemplateId }).JSON | ConvertFrom-Json - $StandardInfo.IntunePolicy = $IntuneTemplate.displayName - } - if ($Row.ConditionalAccessTemplateId) { - $ConditionalAccessTemplate = ($Templates | Where-Object { $_.RowKey -eq $Row.ConditionalAccessTemplateId }).JSON | ConvertFrom-Json - $StandardInfo.ConditionalAccessPolicy = $ConditionalAccessTemplate.displayName - } - - } else { - $StandardInfo = @{} - } - + $StandardInfo = if ($Row.StandardTemplateId) { + $TemplatesTable = Get-CIPPTable -tablename 'templates' + $Templates = Get-CIPPAzDataTableEntity @TemplatesTable + Get-LogStandardInfo -Row $Row -Templates $Templates + } else { @{} } $LogData = if ($Row.LogData -and (Test-Json -Json $Row.LogData -ErrorAction SilentlyContinue)) { $Row.LogData | ConvertFrom-Json } else { $Row.LogData } + # Same record shape as the list paths below, except the log entry page reads + # the template info as 'Standard' rather than 'StandardInfo'. [PSCustomObject]@{ DateTime = $Row.Timestamp Tenant = $Row.Tenant @@ -84,118 +111,235 @@ function Invoke-ListLogs { } } } - } else { - if ($request.Query.Filter -eq $true) { - $LogLevel = if ($Request.Query.Severity) { ($Request.query.Severity).split(',') } else { 'Info', 'Warn', 'Warning', 'Error', 'Critical', 'Alert' } - $PartitionKey = $Request.Query.DateFilter - $username = $Request.Query.User ?? '*' - $TenantFilter = $Request.Query.Tenant - $ApiFilter = $Request.Query.API - $StandardFilter = $Request.Query.StandardTemplateId - $ScheduledTaskFilter = $Request.Query.ScheduledTaskId - $BaselineRunFilter = $Request.Query.BaselineRunId - - $StartDate = if ($Request.Query.StartDate ?? $Request.Query.DateFilter) { ConvertTo-CIPPODataFilterValue -Value ($Request.Query.StartDate ?? $Request.Query.DateFilter) -Type Date } else { $null } - $EndDate = if ($Request.Query.EndDate ?? $Request.Query.DateFilter) { ConvertTo-CIPPODataFilterValue -Value ($Request.Query.EndDate ?? $Request.Query.DateFilter) -Type Date } else { $null } - - if ($StartDate -and $EndDate) { - $Filter = "PartitionKey ge '$StartDate' and PartitionKey le '$EndDate'" - } elseif ($StartDate) { - $Filter = "PartitionKey eq '{0}'" -f $StartDate - } else { - $Filter = "PartitionKey eq '{0}'" -f [TimeZoneInfo]::ConvertTimeBySystemTimeZoneId([DateTime]::UtcNow, $TzId).ToString('yyyyMMdd') - } - } else { - $LogLevel = 'Info', 'Warn', 'Warning', 'Error', 'Critical', 'Alert' - $PartitionKey = [TimeZoneInfo]::ConvertTimeBySystemTimeZoneId([DateTime]::UtcNow, $TzId).ToString('yyyyMMdd') - $username = '*' - $TenantFilter = $null - $ApiFilter = $null - $StandardFilter = $null - $ScheduledTaskFilter = $null - $BaselineRunFilter = $null - $Filter = "PartitionKey eq '{0}'" -f $PartitionKey + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @($ReturnedLog) } + } - # Severity stays client-side: Azurite/Azure Table OData has been unreliable - # on long OR chains. Per-partition row counts are small enough that this is fine. - if ($StandardFilter) { - $SafeStd = ConvertTo-CIPPODataFilterValue -Value $StandardFilter -Type Guid - $Filter = "$Filter and StandardTemplateId eq '$SafeStd'" + # When true, the Severity/User/Tenant/API/StartDate/EndDate query filters are applied; otherwise the current day is returned unfiltered. + if ($Request.Query.Filter -eq $true) { + $LogLevel = if ($Request.Query.Severity) { ($Request.Query.Severity).split(',') } else { 'Info', 'Warn', 'Warning', 'Error', 'Critical', 'Alert' } + $Username = $Request.Query.User ?? '*' + $TenantFilter = $Request.Query.Tenant + $ApiFilter = $Request.Query.API + $StandardFilter = $Request.Query.StandardTemplateId + $ScheduledTaskFilter = $Request.Query.ScheduledTaskId + $BaselineRunFilter = $Request.Query.BaselineRunId + $StartDateRaw = $Request.Query.StartDate ?? $Request.Query.DateFilter + $EndDateRaw = $Request.Query.EndDate ?? $Request.Query.DateFilter + } else { + $LogLevel = 'Info', 'Warn', 'Warning', 'Error', 'Critical', 'Alert' + $Username = '*' + $TenantFilter = $null + $ApiFilter = $null + $StandardFilter = $null + $ScheduledTaskFilter = $null + $BaselineRunFilter = $null + $StartDateRaw = $null + $EndDateRaw = $null + } + + $StartDate = if ($StartDateRaw) { ConvertTo-CIPPODataFilterValue -Value $StartDateRaw -Type Date } else { $null } + $EndDate = if ($EndDateRaw) { ConvertTo-CIPPODataFilterValue -Value $EndDateRaw -Type Date } else { $null } + + # Days=N widens a filtered query to the last N calendar days (in the instance timezone), + # so the scoped drawers still show a run that finished last night. Ignored when dates are given. + $Days = if ($Request.Query.Filter -eq $true) { $Request.Query.Days -as [int] } else { 0 } + if (-not $StartDate -and -not $EndDate -and $Days -gt 0) { + $StartDate = $LocalNow.Date.AddDays(-([Math]::Min($Days, 90) - 1)).ToString('yyyyMMdd') + $EndDate = $LocalNow.ToString('yyyyMMdd') + } + + # Severity stays client-side: Azurite/Azure Table OData has been unreliable + # on long OR chains. Per-partition row counts are small enough that this is fine. + $ServerSideFilter = [System.Collections.Generic.List[string]]::new() + if ($StandardFilter) { + $SafeStd = ConvertTo-CIPPODataFilterValue -Value $StandardFilter -Type Guid + $ServerSideFilter.Add("StandardTemplateId eq '$SafeStd'") + } + if ($ScheduledTaskFilter) { + $SafeSched = ConvertTo-CIPPODataFilterValue -Value $ScheduledTaskFilter -Type Guid + $ServerSideFilter.Add("ScheduledTaskId eq '$SafeSched'") + } + if ($BaselineRunFilter) { + $SafeRun = ConvertTo-CIPPODataFilterValue -Value $BaselineRunFilter -Type Guid + $ServerSideFilter.Add("BaselineRunId eq '$SafeRun'") + } + + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + if ($AllowedTenants -notcontains 'AllTenants') { + $TenantList = Get-Tenants -IncludeErrors | Where-Object { $_.customerId -in $AllowedTenants } + } + + # Templates are only needed to resolve StandardInfo names, and only the scheduled-task + # view renders those - skip the extra table read everywhere else. + $Templates = if ($ScheduledTaskFilter) { + $TemplatesTable = Get-CIPPTable -tablename 'templates' + Get-CIPPAzDataTableEntity @TemplatesTable + } else { $null } + + # The row-level filters that cannot (or should not) go into the table query. + $RowFilter = { + param($Row) + $Row.Severity -in $LogLevel -and + ($Username -eq '*' -or $Row.Username -like $Username) -and + ([string]::IsNullOrEmpty($TenantFilter) -or $TenantFilter -eq 'AllTenants' -or $Row.Tenant -like "*$TenantFilter*" -or $Row.TenantID -eq $TenantFilter) -and + ([string]::IsNullOrEmpty($ApiFilter) -or $Row.API -match "$ApiFilter") -and + ($AllowedTenants -contains 'AllTenants' -or $TenantList.defaultDomainName -contains $Row.Tenant -or $Row.Tenant -eq 'CIPP' -or $TenantList.customerId -contains $Row.TenantId) + } + + # Return one page per request plus a continuation token in Metadata.nextLink, which the + # frontend passes back as nextLink to fetch the next page. Pages walk the requested date + # range newest-day-first and, within a day, in RowKey order (newest-first for entries + # written with the inverted-ticks RowKey scheme). + if ($Request.Query.manualPagination -and [System.Convert]::ToBoolean($Request.Query.manualPagination)) { + $PageSize = 400 + # Rows to return per page, clamped between 50 and 1000. Defaults to 400. + if ($Request.Query.PageSize -as [int]) { + $PageSize = [Math]::Min([Math]::Max([int]$Request.Query.PageSize, 50), 1000) } - if ($ScheduledTaskFilter) { - $SafeSched = ConvertTo-CIPPODataFilterValue -Value $ScheduledTaskFilter -Type Guid - $Filter = "$Filter and ScheduledTaskId eq '$SafeSched'" + # Bound the table round trips a single request can make, so a filter that matches + # nothing across many partitions returns a short (possibly empty) page with a + # nextLink instead of scanning until the gateway times out. + $MaxQueries = 10 + + $ParseDay = { + param($Value) + # ConvertTo-CIPPODataFilterValue has already validated the shape; normalize + # yyyy-MM-dd / ISO datetime forms down to a date. + [datetime]::ParseExact(($Value -replace '-', '').Substring(0, 8), 'yyyyMMdd', [cultureinfo]::InvariantCulture) } - if ($BaselineRunFilter) { - $SafeRun = ConvertTo-CIPPODataFilterValue -Value $BaselineRunFilter -Type Guid - $Filter = "$Filter and BaselineRunId eq '$SafeRun'" + $EndDay = if ($EndDate) { & $ParseDay $EndDate } elseif ($StartDate) { & $ParseDay $StartDate } else { $LocalNow.Date } + $StartDay = if ($StartDate) { & $ParseDay $StartDate } else { $EndDay } + if (($EndDay - $StartDay).TotalDays -gt 366) { $StartDay = $EndDay.AddDays(-366) } + + $CursorDay = $EndDay + $LastRowKey = $null + # Continuation token from the previous page's Metadata.nextLink; opaque to callers. + if ($Request.Query.nextLink) { + $TokenParts = ([string]$Request.Query.nextLink).Split('|', 2) + $CursorDay = [datetime]::ParseExact($TokenParts[0], 'yyyyMMdd', [cultureinfo]::InvariantCulture) + if ($CursorDay -gt $EndDay) { $CursorDay = $EndDay } + if ($TokenParts.Count -eq 2 -and $TokenParts[1]) { + $LastRowKey = $TokenParts[1] + } } - $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList - Write-Host "Getting logs for filter: $Filter, LogLevel: $LogLevel, Username: $username" - - if ($AllowedTenants -notcontains 'AllTenants') { - $TenantList = Get-Tenants -IncludeErrors | Where-Object { $_.customerId -in $AllowedTenants } - } - - $ReturnedLog = Get-AzDataTableEntity @Table -Filter $Filter | Where-Object { - $_.Severity -in $LogLevel -and - ($username -eq '*' -or $_.Username -like $username) -and - ([string]::IsNullOrEmpty($TenantFilter) -or $TenantFilter -eq 'AllTenants' -or $_.Tenant -like "*$TenantFilter*" -or $_.TenantID -eq $TenantFilter) -and - ([string]::IsNullOrEmpty($ApiFilter) -or $_.API -match "$ApiFilter") -and - ($AllowedTenants -contains 'AllTenants' -or $TenantList.defaultDomainName -contains $_.Tenant -or $_.Tenant -eq 'CIPP' -or $TenantList.customerId -contains $_.TenantId) - } | ForEach-Object { - $Row = $_ - if ($ScheduledTaskFilter -and $Row.StandardTemplateId) { - $Standard = ($Templates | Where-Object { $_.RowKey -eq $Row.StandardTemplateId }).JSON | ConvertFrom-Json - - $StandardInfo = @{ - Template = $Standard.templateName - Standard = $Row.Standard - } + $Rows = [System.Collections.Generic.List[object]]::new() + $Queries = 0 + $Exhausted = $CursorDay -lt $StartDay + while (-not $Exhausted -and $Queries -lt $MaxQueries -and $Rows.Count -lt $PageSize) { + $Filter = "PartitionKey eq '{0}'" -f $CursorDay.ToString('yyyyMMdd') + if ($LastRowKey) { + $SafeRowKey = ConvertTo-CIPPODataFilterValue -Value $LastRowKey -Type String + # '~' sorts after every character valid in these RowKeys, so this resumes + # strictly after the last returned entity including any of its '-partN' + # split-entity continuation rows. + $Filter = "$Filter and RowKey gt '$SafeRowKey~'" + } + foreach ($Clause in $ServerSideFilter) { $Filter = "$Filter and $Clause" } - if ($Row.IntuneTemplateId) { - $IntuneTemplate = ($Templates | Where-Object { $_.RowKey -eq $Row.IntuneTemplateId }).JSON | ConvertFrom-Json - $StandardInfo.IntunePolicy = $IntuneTemplate.displayName - } - if ($Row.ConditionalAccessTemplateId) { - $ConditionalAccessTemplate = ($Templates | Where-Object { $_.RowKey -eq $Row.ConditionalAccessTemplateId }).JSON | ConvertFrom-Json - $StandardInfo.ConditionalAccessPolicy = $ConditionalAccessTemplate.displayName + $Chunk = @(Get-CIPPAzDataTableEntity @Table -Filter $Filter -First $PageSize) + $Queries++ + if ($Chunk.Count -gt 0) { + $LastRowKey = $Chunk[-1].RowKey + foreach ($Row in $Chunk) { + if (& $RowFilter $Row) { + $StandardInfo = if ($ScheduledTaskFilter) { Get-LogStandardInfo -Row $Row -Templates $Templates } else { @{} } + $LogData = if ($Row.LogData -and (Test-Json -Json $Row.LogData -ErrorAction SilentlyContinue)) { + $Row.LogData | ConvertFrom-Json + } else { $Row.LogData } + # Keep this record shape identical to the legacy list path below - the + # frontend treats paged and unpaged rows interchangeably. + $Rows.Add([PSCustomObject]@{ + DateTime = $Row.Timestamp + Tenant = $Row.Tenant + API = $Row.API + Message = $Row.Message + User = $Row.Username + Severity = $Row.Severity + LogData = $LogData + TenantID = if ($null -ne $Row.TenantID) { + $Row.TenantID + } else { + 'None' + } + AppId = $Row.AppId + IP = $Row.IP + RowKey = $Row.RowKey + StandardInfo = $StandardInfo + DateFilter = $Row.PartitionKey + }) + } } } else { - $StandardInfo = @{} + # -First counts physical rows before split-entity reassembly, so a short + # chunk does not prove the partition is drained; only an empty one does. + $CursorDay = $CursorDay.AddDays(-1) + $LastRowKey = $null + if ($CursorDay -lt $StartDay) { $Exhausted = $true } } + } - $LogData = if ($Row.LogData -and (Test-Json -Json $Row.LogData -ErrorAction SilentlyContinue)) { - $Row.LogData | ConvertFrom-Json - } else { $Row.LogData } - [PSCustomObject]@{ - DateTime = $Row.Timestamp - Tenant = $Row.Tenant - API = $Row.API - Message = $Row.Message - User = $Row.Username - Severity = $Row.Severity - LogData = $LogData - TenantID = if ($null -ne $Row.TenantID) { - $Row.TenantID - } else { - 'None' - } - AppId = $Row.AppId - IP = $Row.IP - RowKey = $Row.RowKey - StandardInfo = $StandardInfo - DateFilter = $Row.PartitionKey + $Metadata = @{} + if (-not $Exhausted) { + $Metadata.nextLink = '{0}|{1}' -f $CursorDay.ToString('yyyyMMdd'), $LastRowKey + } + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = [PSCustomObject]@{ + # Walk order is already newest-first for inverted-ticks RowKeys, and more + # truthful than re-sorting on the table Timestamp, which jitters at ms + # granularity for near-simultaneous writes. Rows from pre-scheme GUID-keyed + # partitions arrive unordered; the logs table sorts by DateTime client-side. + Results = @($Rows) + Metadata = $Metadata } } - $ReturnedLog + } + + # Legacy unpaginated path: fetch the whole requested range in one go and return a bare + # array. Kept for callers that do not speak the Results/Metadata pagination contract. + if ($StartDate -and $EndDate) { + $Filter = "PartitionKey ge '$StartDate' and PartitionKey le '$EndDate'" + } elseif ($StartDate) { + $Filter = "PartitionKey eq '{0}'" -f $StartDate + } else { + $Filter = "PartitionKey eq '{0}'" -f $LocalNow.ToString('yyyyMMdd') + } + foreach ($Clause in $ServerSideFilter) { $Filter = "$Filter and $Clause" } + Write-Host "Getting logs for filter: $Filter, LogLevel: $LogLevel, Username: $Username" + + $ReturnedLog = Get-CIPPAzDataTableEntity @Table -Filter $Filter | Where-Object { & $RowFilter $_ } | ForEach-Object { + $Row = $_ + $StandardInfo = if ($ScheduledTaskFilter) { Get-LogStandardInfo -Row $Row -Templates $Templates } else { @{} } + $LogData = if ($Row.LogData -and (Test-Json -Json $Row.LogData -ErrorAction SilentlyContinue)) { + $Row.LogData | ConvertFrom-Json + } else { $Row.LogData } + [PSCustomObject]@{ + DateTime = $Row.Timestamp + Tenant = $Row.Tenant + API = $Row.API + Message = $Row.Message + User = $Row.Username + Severity = $Row.Severity + LogData = $LogData + TenantID = if ($null -ne $Row.TenantID) { + $Row.TenantID + } else { + 'None' + } + AppId = $Row.AppId + IP = $Row.IP + RowKey = $Row.RowKey + StandardInfo = $StandardInfo + DateFilter = $Row.PartitionKey + } } return [HttpResponseContext]@{ StatusCode = [HttpStatusCode]::OK Body = @($ReturnedLog | Sort-Object -Property DateTime -Descending) } - } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-RemoveCippQueue.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-RemoveCippQueue.ps1 index 6b664705e8fb3..77b78d5ca6189 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-RemoveCippQueue.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-RemoveCippQueue.ps1 @@ -8,7 +8,17 @@ function Invoke-RemoveCippQueue { [CmdletBinding()] param($Request, $TriggerMetadata) - $Results = Clear-CIPPQueueData -Request $Request -TriggerMetadata $TriggerMetadata + $APIName = $Request.Params.CIPPEndpoint ?? 'RemoveCippQueue' + $Headers = $Request.Headers + + try { + $Results = Clear-CIPPQueueData -Request $Request -TriggerMetadata $TriggerMetadata + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message 'History cleared' -Sev 'Info' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "Failed to clear queue history: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + $Results = @{Results = @("Failed to clear queue history: $($ErrorMessage.NormalizedError)") } + } return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::OK diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionClearHIBPKey.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionClearHIBPKey.ps1 index c7a6f656047dd..5bde54b7b03ae 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionClearHIBPKey.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionClearHIBPKey.ps1 @@ -8,11 +8,18 @@ function Invoke-ExecExtensionClearHIBPKey { [CmdletBinding()] param($Request, $TriggerMetadata) + $APIName = $Request.Params.CIPPEndpoint ?? 'ExtensionClearHIBPKey' + $Headers = $Request.Headers + $Results = try { Remove-ExtensionAPIKey -Extension 'HIBP' | Out-Null - 'Successfully cleared the HIBP API key.' + $Result = 'Successfully cleared the HIBP API key.' + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "Cleared API key for extension 'HIBP'" -Sev 'Info' + $Result } catch { - "Failed to clear the HIBP API key" + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "Failed to clear API key for extension 'HIBP': $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + 'Failed to clear the HIBP API key' } return ([HttpResponseContext]@{ diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionMapping.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionMapping.ps1 index 5179bfdc3d0ee..65929d8da7930 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionMapping.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionMapping.ps1 @@ -38,10 +38,13 @@ Function Invoke-ExecExtensionMapping { # Outcomes and priorities are scoped to a ticket type. The settings page sends the # ticket type currently selected in the form so the lists follow the dropdown; without # it both fall back to whatever ticket type was last saved. + # @() on each: PowerShell unrolls single-element output, so a ticket type with one outcome + # (or a lookup that answers with a single explanatory row) would otherwise serialise as a + # bare object and break callers that expect a list. $SelectedTicketType = $Request.Query.TicketType - $TicketTypes = Get-HaloTicketType - $Outcomes = Get-HaloTicketOutcome -TicketType $SelectedTicketType - $Priorities = Get-HaloPriority -TicketType $SelectedTicketType + $TicketTypes = @(Get-HaloTicketType) + $Outcomes = @(Get-HaloTicketOutcome -TicketType $SelectedTicketType) + $Priorities = @(Get-HaloPriority -TicketType $SelectedTicketType) $Result = @{ 'TicketTypes' = $TicketTypes 'Outcomes' = $Outcomes @@ -110,7 +113,7 @@ Function Invoke-ExecExtensionMapping { catch { $ErrorMessage = Get-CippException -Exception $_ $Result = "Mapping API failed. $($ErrorMessage.NormalizedError)" - Write-LogMessage -API $APIName -headers $Headers -message $Result -Sev 'Error' -LogData $ErrorMessage + Write-LogMessage -API $APIName -tenant 'Global' -headers $Headers -message $Result -Sev 'Error' -LogData $ErrorMessage $StatusCode = [HttpStatusCode]::InternalServerError } @@ -130,6 +133,7 @@ Function Invoke-ExecExtensionMapping { $InstanceId = Start-CIPPOrchestrator -InputObject $InputObject Write-Host "Started permissions orchestration with ID = '$InstanceId'" $Result = 'AutoMapping Request has been queued. Exact name matches will appear first and matches on device names and serials will take longer. Please check the CIPP Logbook and refresh the page once complete.' + Write-LogMessage -API $APIName -tenant 'Global' -headers $Headers -message $Result -Sev 'Info' } 'HaloPSA' { $Result = Invoke-HaloAutoMap -CIPPMapping $Table @@ -141,7 +145,7 @@ Function Invoke-ExecExtensionMapping { catch { $ErrorMessage = Get-CippException -Exception $_ $Result = "Mapping API failed. $($ErrorMessage.NormalizedError)" - Write-LogMessage -API $APIName -headers $Headers -message $Result -Sev 'Error' -LogData $ErrorMessage + Write-LogMessage -API $APIName -tenant 'Global' -headers $Headers -message $Result -Sev 'Error' -LogData $ErrorMessage $StatusCode = [HttpStatusCode]::InternalServerError } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionNinjaOneQueue.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionNinjaOneQueue.ps1 index b9b5724e33104..102f767ce1fe4 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionNinjaOneQueue.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionNinjaOneQueue.ps1 @@ -7,10 +7,32 @@ function Invoke-ExecExtensionNinjaOneQueue { #> [CmdletBinding()] param($Request, $TriggerMetadata) - switch ($QueueItem.NinjaAction) { - 'StartAutoMapping' { Invoke-NinjaOneOrgMapping } - 'AutoMapTenant' { Invoke-NinjaOneOrgMappingTenant -QueueItem $QueueItem } - 'SyncTenant' { Invoke-NinjaOneTenantSync -QueueItem $QueueItem } + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $NinjaAction = $QueueItem.NinjaAction + + try { + switch ($NinjaAction) { + 'StartAutoMapping' { + Invoke-NinjaOneOrgMapping + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message 'NinjaOne StartAutoMapping completed' -Sev 'Info' + } + 'AutoMapTenant' { + Invoke-NinjaOneOrgMappingTenant -QueueItem $QueueItem + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message 'NinjaOne AutoMapTenant completed' -Sev 'Info' + } + 'SyncTenant' { + Invoke-NinjaOneTenantSync -QueueItem $QueueItem + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message 'NinjaOne SyncTenant completed' -Sev 'Info' + } + default { + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "Unknown NinjaOne action: $NinjaAction" -Sev 'Error' + } + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "NinjaOne action '$NinjaAction' failed: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage } $Body = [PSCustomObject]@{ diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionSync.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionSync.ps1 index 797bf04ec555b..86456117cb391 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionSync.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionSync.ps1 @@ -55,6 +55,9 @@ Function Invoke-ExecExtensionSync { #Write-Host ($InputObject | ConvertTo-Json) $InstanceId = Start-CIPPOrchestrator -InputObject $InputObject + $SyncTenantFilter = if ($Request.Query.TenantFilter) { $Request.Query.TenantFilter } else { $Tenant.RowKey } + Write-LogMessage -API 'NinjaOneSync' -tenant $SyncTenantFilter -message "On-demand NinjaOne Synchronization queued for $($Tenant.IntegrationName)" -Sev 'Info' -Headers $Request.Headers + $Results = [pscustomobject]@{'Results' = "NinjaOne Synchronization Queued for $($Tenant.IntegrationName)" } } else { $Results = [pscustomobject]@{'Results' = 'Tenant was not found.' } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionsConfig.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionsConfig.ps1 index 79529e5b52916..e6f0a94bcaa26 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionsConfig.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionsConfig.ps1 @@ -7,6 +7,7 @@ function Invoke-ExecExtensionsConfig { #> [CmdletBinding()] param($Request, $TriggerMetadata) + $APIName = $Request.Params.CIPPEndpoint $Headers = $Request.Headers @@ -74,9 +75,13 @@ function Invoke-ExecExtensionsConfig { Add-AzDataTableEntity @ConfigTable -Entity $AddObject -Force Register-CIPPExtensionScheduledTasks - "Successfully saved the extension configuration. $AddedText" + $Result = "Successfully saved the extension configuration. $AddedText" + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result.Trim() -Sev 'Info' + $Result } catch { - "Failed to save the extensions configuration: $($_.Exception.message) Linenumber: $($_.InvocationInfo.ScriptLineNumber)" + $Result = "Failed to save the extensions configuration: $($_.Exception.message) Linenumber: $($_.InvocationInfo.ScriptLineNumber)" + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Error' + $Result } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ListAsyncDeployment.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ListAsyncDeployment.ps1 new file mode 100644 index 0000000000000..6f582d8f133d7 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ListAsyncDeployment.ps1 @@ -0,0 +1,33 @@ +function Invoke-ListAsyncDeployment { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + CIPP.Scheduler.Read + .SYNOPSIS + Get the live progress of a background job + .DESCRIPTION + Returns the status rows of a background job that reports progress while it runs, such as a + user offboarding started from the wizard: one row per target (the user) with its overall + status and the status and message of every step. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + # The job id handed back when the work was queued (e.g. DeploymentId from ExecOffboardUser) + $DeploymentId = $Request.Query.DeploymentId ?? $Request.Body.DeploymentId + if (-not $DeploymentId) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = 'DeploymentId is required' } + }) + } + + # Rows name their tenant (TenantFilter, or Name for tenant-keyed jobs such as SharePoint template + # deployments); a tenant-restricted caller only gets rows in scope. + $Rows = @(Get-CIPPAsyncDeployment -JobId $DeploymentId | Select-CippAllowedTenantData -TenantProperty @('TenantFilter', 'Name')) + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = ConvertTo-Json -Depth 10 -InputObject $Rows + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ListFunctionParameters.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ListFunctionParameters.ps1 index 8016cae01fd7e..250ef5046d78f 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ListFunctionParameters.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ListFunctionParameters.ps1 @@ -20,7 +20,9 @@ function Invoke-ListFunctionParameters { $CommandQuery.Name = $Function } $IgnoreList = 'entryPoint', 'internal' - $CommonParameters = @('Verbose', 'Debug', 'ErrorAction', 'WarningAction', 'InformationAction', 'ErrorVariable', 'WarningVariable', 'InformationVariable', 'OutVariable', 'OutBuffer', 'PipelineVariable', 'TenantFilter', 'APIName', 'Headers', 'ProgressAction', 'WhatIf', 'Confirm', 'Headers', 'NoAuthCheck') + # Tenant/TenantId are hidden alongside TenantFilter: the scheduler injects the authorized task + # tenant into whichever of these the command declares, so they are never user-editable. + $CommonParameters = @('Verbose', 'Debug', 'ErrorAction', 'WarningAction', 'InformationAction', 'ErrorVariable', 'WarningVariable', 'InformationVariable', 'OutVariable', 'OutBuffer', 'PipelineVariable', 'TenantFilter', 'Tenant', 'TenantId', 'APIName', 'Headers', 'ProgressAction', 'WhatIf', 'Confirm', 'Headers', 'NoAuthCheck') $TemporaryBlacklist = 'Get-CIPPAuthentication', 'Invoke-CippWebhookProcessing', 'Invoke-ListFunctionParameters', 'New-CIPPAPIConfig', 'New-CIPPGraphSubscription' if (-not $global:CIPPFunctionParameters) { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ListScheduledItemDetails.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ListScheduledItemDetails.ps1 index 88ee45df5ba2b..ca8374da80043 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ListScheduledItemDetails.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ListScheduledItemDetails.ps1 @@ -27,7 +27,8 @@ function Invoke-ListScheduledItemDetails { # Retrieve the task information $TaskTable = Get-CIPPTable -TableName 'ScheduledTasks' - $Task = Get-CIPPAzDataTableEntity @TaskTable -Filter "RowKey eq '$SafeRowKey' and PartitionKey eq 'ScheduledTask'" | Select-Object RowKey, Name, TaskState, Command, Parameters, Recurrence, ExecutedTime, ScheduledTime, PostExecution, Tenant, TenantGroup, Hidden, Results, Timestamp, Trigger + $Task = Get-CIPPAzDataTableEntity @TaskTable -Filter "RowKey eq '$SafeRowKey' and PartitionKey eq 'ScheduledTask'" | Select-Object RowKey, Name, TaskState, Command, Parameters, Recurrence, ExecutedTime, ScheduledTime, PostExecution, PostExecutionResults, Tenant, TenantGroup, Tenants, TenantSelectionVersion, excludedTenants, excludedTenantGroups, Hidden, Results, Timestamp, Trigger + if (-not $Task) { return ([HttpResponseContext]@{ @@ -72,7 +73,21 @@ function Invoke-ListScheduledItemDetails { } catch {} # Handle tenant group display information (similar to Invoke-ListScheduledItems) - if ($Task.TenantGroup) { + if ($Task.Tenants) { + # Tenant stays 'AllTenants' for the execution gates, so report the real scope from Tenants. + try { + $TenantsParsed = $Task.Tenants | ConvertFrom-Json -Depth 10 -ErrorAction Stop + $Task.Tenant = @($TenantsParsed | ForEach-Object { + [PSCustomObject]@{ + label = $_.label ?? $_.value + value = $_.value + type = $_.type ?? 'Tenant' + } + }) + } catch { + Write-Warning "Failed to parse tenant selection for task $($Task.RowKey): $($_.Exception.Message)" + } + } elseif ($Task.TenantGroup) { try { $TenantGroupObject = $Task.TenantGroup | ConvertFrom-Json -ErrorAction SilentlyContinue if ($TenantGroupObject) { @@ -112,6 +127,15 @@ function Invoke-ListScheduledItemDetails { } } + # Delivery outcomes of the post-execution notifications (one per channel attempt), stored as JSON + if ($Task.PostExecutionResults) { + try { + $Task.PostExecutionResults = @($Task.PostExecutionResults | ConvertFrom-Json -ErrorAction Stop) + } catch { + $Task.PostExecutionResults = @() + } + } + # Get the results if available $ResultsTable = Get-CIPPTable -TableName 'ScheduledTaskResults' $ResultsFilter = "PartitionKey eq '$SafeRowKey'" @@ -198,9 +222,11 @@ function Invoke-ListScheduledItemDetails { $TenantId = $Result.RowKey $TenantInfo = Get-Tenants -TenantFilter $TenantId -ErrorAction SilentlyContinue if ($TenantInfo) { - $Result | Add-Member -NotePropertyName TenantName -NotePropertyValue $TenantInfo.displayName -Force - $Result | Add-Member -NotePropertyName TenantDefaultDomain -NotePropertyValue $TenantInfo.defaultDomainName -Force - $Result | Add-Member -NotePropertyName TenantId -NotePropertyValue $TenantInfo.customerId -Force + $Result | Add-Member -NotePropertyMembers ([ordered]@{ + TenantName = $TenantInfo.displayName + TenantDefaultDomain = $TenantInfo.defaultDomainName + TenantId = $TenantInfo.customerId + }) -Force } } } catch { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ListScheduledItems.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ListScheduledItems.ps1 index 4729b984d1cfc..4044caf468d1d 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ListScheduledItems.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ListScheduledItems.ps1 @@ -45,10 +45,11 @@ function Invoke-ListScheduledItems { } if ($TenantFilter -and $TenantFilter -ne 'AllTenants') { - # Tasks are stored against either the customerId or the default domain name depending on - # what created them, so resolve the tenant up front and let storage match either. + # Tasks are stored against whichever tenant identifier the caller supplied - customerId, + # default domain, or the initial .onmicrosoft.com domain - so resolve the tenant up front + # and let storage match any of them. (Get-Tenants itself accepts all three as -TenantFilter.) $TenantObject = Get-Tenants -TenantFilter $TenantFilter | Select-Object -First 1 - $TenantIdentifiers = @($TenantObject.defaultDomainName, $TenantObject.customerId) | Where-Object { $_ } | Select-Object -Unique + $TenantIdentifiers = @($TenantObject.defaultDomainName, $TenantObject.initialDomainName, $TenantObject.customerId) | Where-Object { $_ } | Select-Object -Unique if (-not $TenantIdentifiers) { # Tenant could not be resolved (deleted, excluded, or not visible to the caller). # Fall back to the raw value so we filter on something rather than on nothing. @@ -76,9 +77,9 @@ function Invoke-ListScheduledItems { $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList $TenantLookup = @{} - foreach ($Tenant in (Get-Tenants -IncludeErrors | Select-Object customerId, defaultDomainName)) { + foreach ($Tenant in (Get-Tenants -IncludeErrors | Select-Object customerId, defaultDomainName, initialDomainName)) { if ($Tenant.customerId) { - $TenantLookup[[string]$Tenant.customerId] = $Tenant.defaultDomainName + $TenantLookup[[string]$Tenant.customerId] = $Tenant } } @@ -87,7 +88,11 @@ function Invoke-ListScheduledItems { foreach ($AllowedTenant in $AllowedTenants) { $null = $AllowedTenantIdentifiers.Add([string]$AllowedTenant) if ($TenantLookup.ContainsKey([string]$AllowedTenant)) { - $null = $AllowedTenantIdentifiers.Add($TenantLookup[[string]$AllowedTenant]) + # A task keyed on any of the tenant's identifiers must pass the access check, not just + # the default domain - otherwise a scoped user cannot see their own initial-domain tasks. + foreach ($Domain in @($TenantLookup[[string]$AllowedTenant].defaultDomainName, $TenantLookup[[string]$AllowedTenant].initialDomainName)) { + if ($Domain) { $null = $AllowedTenantIdentifiers.Add([string]$Domain) } + } } } $Tasks = $Tasks | Where-Object { $AllowedTenantIdentifiers.Contains([string]$_.Tenant) } @@ -147,7 +152,7 @@ function Invoke-ListScheduledItems { } else { $TenantValue = [string]$Task.Tenant if ($TenantLookup.ContainsKey($TenantValue)) { - $TenantValue = $TenantLookup[$TenantValue] + $TenantValue = $TenantLookup[$TenantValue].defaultDomainName } $Task.Tenant = [PSCustomObject]@{ label = $TenantValue diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecAddTrustedIP.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecAddTrustedIP.ps1 index 3aef21a287281..9a1fd93fc25a5 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecAddTrustedIP.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecAddTrustedIP.ps1 @@ -8,6 +8,9 @@ function Invoke-ExecAddTrustedIP { [CmdletBinding()] param($Request, $TriggerMetadata) + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $tenantfilter = $Request.Query.tenantfilter if (-not $tenantfilter) { return ([HttpResponseContext]@{ @@ -25,16 +28,28 @@ function Invoke-ExecAddTrustedIP { }) } - $Table = Get-CippTable -tablename 'trustedIps' - foreach ($IP in $Request.body.IP) { - Add-CIPPAzDataTableEntity @Table -Entity @{ - PartitionKey = $tenantDomain - RowKey = $IP - state = $Request.Body.State - } -Force + try { + $Table = Get-CippTable -tablename 'trustedIps' + foreach ($IP in $Request.body.IP) { + Add-CIPPAzDataTableEntity @Table -Entity @{ + PartitionKey = $tenantDomain + RowKey = $IP + state = $Request.Body.State + } -Force + } + $Result = "Added $($Request.Body.IP) to database with state $($Request.Body.State) for $($tenantDomain)" + Write-LogMessage -headers $Headers -API $APIName -tenant $tenantDomain -message $Result -Sev 'Info' + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @{ results = $Result } + }) + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to add trusted IP(s) for $($tenantDomain): $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant $tenantDomain -message $Result -Sev 'Error' -LogData $ErrorMessage + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::InternalServerError + Body = @{ results = $Result } + }) } - return ([HttpResponseContext]@{ - StatusCode = [HttpStatusCode]::OK - Body = @{ results = "Added $($Request.Body.IP) to database with state $($Request.Body.State) for $($tenantDomain)" } - }) } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecApiClient.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecApiClient.ps1 index 415e40089b889..4a346e1389765 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecApiClient.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecApiClient.ps1 @@ -221,13 +221,21 @@ function Invoke-ExecApiClient { Set-CippApiAuth -RGName $RGName -FunctionAppName $FunctionAppName -TenantId $TenantId -ClientIds $ClientIds -McpClientIds $McpClientIds if ($McpClientIds.Count -gt 0 -and $env:WEBSITE_HOSTNAME) { + # Advertise the OIDC scopes alongside the resource scope so discovery-based MCP + # clients (Copilot Studio, ChatGPT) request a refresh token. offline_access is + # what makes Entra issue one; without it the client re-consents every ~hour. + # Claude appends offline_access itself, but stricter clients only request what the + # metadata advertises, so it has to be in the protected-resource document and the + # EasyAuth challenge scope too - not just the authorization-server document. + $McpScope = "https://$($env:WEBSITE_HOSTNAME)/user_impersonation" + $McpScopesSupported = @('openid', 'profile', 'offline_access', $McpScope) + $McpDefaultScopeString = 'openid profile offline_access {0}' -f $McpScope if ($env:CIPPNG) { $TenantedLogin = "https://login.microsoftonline.com/$($env:TenantID)" - $McpScope = "https://$($env:WEBSITE_HOSTNAME)/user_impersonation" $PrmDocument = [ordered]@{ resource = '{origin}/api/ExecMcp' authorization_servers = @('{origin}') - scopes_supported = @($McpScope) + scopes_supported = $McpScopesSupported bearer_methods_supported = @('header') } | ConvertTo-Json -Compress $AsDocument = [ordered]@{ @@ -241,11 +249,11 @@ function Invoke-ExecApiClient { grant_types_supported = @('authorization_code', 'refresh_token') code_challenge_methods_supported = @('S256') token_endpoint_auth_methods_supported = @('none', 'client_secret_post', 'client_secret_basic') - scopes_supported = @('openid', 'profile', 'offline_access', $McpScope) + scopes_supported = $McpScopesSupported } | ConvertTo-Json -Compress - $null = Update-CIPPAzFunctionAppSetting -Name $FunctionAppName -ResourceGroupName $RGName -AppSetting @{ 'CRAFT_PRM' = "$PrmDocument"; 'CRAFT_PRM_AS' = "$AsDocument"; 'WEBSITE_AUTH_PRM_DEFAULT_WITH_SCOPES' = $McpScope } + $null = Update-CIPPAzFunctionAppSetting -Name $FunctionAppName -ResourceGroupName $RGName -AppSetting @{ 'CRAFT_PRM' = "$PrmDocument"; 'CRAFT_PRM_AS' = "$AsDocument"; 'WEBSITE_AUTH_PRM_DEFAULT_WITH_SCOPES' = $McpDefaultScopeString } } else { - $null = Update-CIPPAzFunctionAppSetting -Name $FunctionAppName -ResourceGroupName $RGName -AppSetting @{ 'WEBSITE_AUTH_PRM_DEFAULT_WITH_SCOPES' = "https://$($env:WEBSITE_HOSTNAME)/user_impersonation" } + $null = Update-CIPPAzFunctionAppSetting -Name $FunctionAppName -ResourceGroupName $RGName -AppSetting @{ 'WEBSITE_AUTH_PRM_DEFAULT_WITH_SCOPES' = $McpDefaultScopeString } } } else { $null = Update-CIPPAzFunctionAppSetting -Name $FunctionAppName -ResourceGroupName $RGName -AppSetting @{} -RemoveKeys @('WEBSITE_AUTH_PRM_DEFAULT_WITH_SCOPES', 'CRAFT_PRM', 'CRAFT_PRM_AS') diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecAppServiceDomains.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecAppServiceDomains.ps1 index effbebdbe4a6a..b815a2b6b6331 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecAppServiceDomains.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecAppServiceDomains.ps1 @@ -14,14 +14,18 @@ function Invoke-ExecAppServiceDomains { Actions (passed as Query.Action or Body.Action): List - Site metadata (default hostname, inbound IP) plus every hostname - binding and any App Service Managed Certificate that matches. + binding, any App Service Managed Certificate that matches, and the + state of a certificate job still running in the background. CheckDns - Live DoH lookup of the alias record a custom domain needs. CIPP no longer uses domain-verification TXT records, so a leftover asuid. record is detected and flagged for removal rather than requested. Powers wizard step 1 + resume. - AddBinding - Create the hostname binding (wizard step 2). Azure re-validates ownership. - AddCertificate - Create an App Service Managed Certificate and enable the SNI SSL binding - (wizard step 3). Safe to re-run — reuses an existing cert if present. + AddBinding - Create the hostname binding (wizard step 2). Azure validates ownership + through the alias record. + AddCertificate - Issue an App Service Managed Certificate and enable the SNI SSL binding + (wizard step 3) via Invoke-CIPPCustomDomainCertificate. Issuance that + outlives the request carries on as a hidden scheduled task that retries + every 15 minutes, a few times, then stops. Remove - Delete a custom hostname binding (and its managed cert, best effort). Every action is independently re-runnable so the wizard can resume a half-finished domain or @@ -33,24 +37,15 @@ function Invoke-ExecAppServiceDomains { $APIName = $Request.Params.CIPPEndpoint $Headers = $Request.Headers $Action = $Request.Query.Action ?? $Request.Body.Action - $ApiVersion = '2024-11-01' - - # Resolve the ARM coordinates of the App Service running this instance. Mirrors the resolution - # the Container Management endpoint uses (platform env + managed identity token), so a missing - # resource group fails loudly rather than guessing. - function Get-AppServiceSiteInfo { - $SiteName = $env:WEBSITE_SITE_NAME - $RGName = Get-CIPPFunctionAppResourceGroup -SiteName $SiteName - return @{ - Subscription = Get-CIPPAzFunctionAppSubId - SiteName = $SiteName - RGName = $RGName - } - } - function Get-SiteArmBase { - param($Site) - return "https://management.azure.com/subscriptions/$($Site.Subscription)/resourceGroups/$($Site.RGName)/providers/Microsoft.Web/sites/$($Site.SiteName)" + # Trim/lowercase the requested hostname and reject anything that is not a DNS name - it goes + # into ARM URIs and table filters verbatim. + function Get-CleanHostname { + param([string]$Value) + $Clean = ([string]$Value).Trim().ToLower() + if ([string]::IsNullOrWhiteSpace($Clean)) { throw 'Hostname is required' } + if ($Clean -notmatch '^(\*\.)?([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$') { throw "'$Clean' is not a valid hostname" } + return $Clean } # Work out which DNS record a given custom hostname needs. Azure accepts either a CNAME (to the @@ -68,7 +63,7 @@ function Invoke-ExecAppServiceDomains { $Labels = $BaseHost.Split('.') # 2-label names (contoso.com) are treated as apex → A record. Everything else is a subdomain # → CNAME. This is a heuristic (multi-part TLDs like co.uk can't be detected without a public - # suffix list); the UI lets the operator pick the other record type, and Azure accepts either. + # suffix list); CheckDns reports which record actually resolved and AddBinding honours that. $IsApex = -not $IsWildcard -and $Labels.Count -le 2 return [pscustomobject]@{ @@ -101,73 +96,77 @@ function Invoke-ExecAppServiceDomains { try { switch ($Action) { 'List' { - $Site = Get-AppServiceSiteInfo - $ArmBase = Get-SiteArmBase -Site $Site - - $SiteObj = New-CIPPAzRestRequest -Uri "$($ArmBase)?api-version=$ApiVersion" -Method GET - $DefaultHostName = $SiteObj.properties.defaultHostName - $InboundIp = $SiteObj.properties.inboundIpAddress - - $BindingResponse = New-CIPPAzRestRequest -Uri "$($ArmBase)/hostNameBindings?api-version=$ApiVersion" -Method GET - - # Pull managed certs in the RG once so we can attach expiry/thumbprint per domain. - $Certs = @() - try { - $CertResponse = New-CIPPAzRestRequest -Uri "https://management.azure.com/subscriptions/$($Site.Subscription)/resourceGroups/$($Site.RGName)/providers/Microsoft.Web/certificates?api-version=$ApiVersion" -Method GET - $Certs = @($CertResponse.value) - } catch { - Write-Information "Could not list certificates: $($_.Exception.Message)" - } + $AppService = Get-CIPPAppServiceSite + $Api = $AppService.ApiVersion + $BindingResponse = New-CIPPAzRestRequest -Uri "$($AppService.ArmBase)/hostNameBindings?api-version=$Api" -Method GET -ErrorAction Stop + $TaskTable = Get-CIPPTable -TableName 'ScheduledTasks' $Domains = foreach ($Binding in $BindingResponse.value) { - $HostName = $Binding.name # ARM returns bindings named "/"; keep just the hostname. - if ($HostName -match '/') { $HostName = ($HostName -split '/')[-1] } + $HostName = ($Binding.name -split '/')[-1] $IsDefault = $HostName -like '*.azurewebsites.net' - $Cert = $Certs | Where-Object { $_.properties.canonicalName -eq $HostName } | Select-Object -First 1 + $Secured = $Binding.properties.sslState -in @('SniEnabled', 'IpBasedEnabled') + $Cert = $AppService.Certificates | Where-Object { $_.properties.canonicalName -eq $HostName } | Select-Object -First 1 + + # A certificate still being issued in the background is a chain of hidden retry + # tasks: the planned one says which attempt is next, the last finished one why. + $Active = $null + $Finished = $null + if (-not $IsDefault -and -not $Secured) { + $Jobs = @(Get-CIPPAzDataTableEntity @TaskTable -Filter "PartitionKey eq 'ScheduledTask' and Reference eq 'CustomDomainCert-$HostName'") + $Active = $Jobs | Where-Object { $_.TaskState -in @('Planned', 'Pending', 'Running') } | Select-Object -First 1 + $Finished = $Jobs | Where-Object { $_.TaskState -in @('Completed', 'Failed') } | Sort-Object -Property Timestamp -Descending | Select-Object -First 1 + } + $JobParams = try { ($Active ?? $Finished).Parameters | ConvertFrom-Json } catch { $null } + $LastResult = try { ($Finished.Results | ConvertFrom-Json).Results } catch { [string]$Finished.Results } [pscustomobject]@{ - Hostname = $HostName - IsDefault = $IsDefault - HostNameType = $Binding.properties.hostNameType - SslState = $Binding.properties.sslState ?? 'Disabled' - Thumbprint = $Binding.properties.thumbprint - DnsRecordType = $Binding.properties.customHostNameDnsRecordType - Secured = ($Binding.properties.sslState -in @('SniEnabled', 'IpBasedEnabled')) - CertName = $Cert.name - CertThumbprint = $Cert.properties.thumbprint - CertExpiration = $Cert.properties.expirationDate - CertIssuer = $Cert.properties.issuer + Hostname = $HostName + IsDefault = $IsDefault + HostNameType = $Binding.properties.hostNameType + SslState = $Binding.properties.sslState ?? 'Disabled' + Thumbprint = $Binding.properties.thumbprint + DnsRecordType = $Binding.properties.customHostNameDnsRecordType + Secured = $Secured + CertName = $Cert.name + CertThumbprint = $Cert.properties.thumbprint + CertExpiration = $Cert.properties.expirationDate + CertIssuer = $Cert.properties.issuer + CertJobActive = [bool]$Active + CertJobAttempt = $JobParams ? [int]$JobParams.Attempt : $null + CertJobMaxAttempts = $JobParams ? [int]$JobParams.MaxAttempts : $null + CertJobNextRun = $Active ? [DateTimeOffset]::FromUnixTimeSeconds([int64]$Active.ScheduledTime).UtcDateTime.ToString('o') : $null + CertJobResult = $LastResult } } $Body = @{ Results = @{ - SiteName = $Site.SiteName - ResourceGroup = $Site.RGName - DefaultHostName = $DefaultHostName - InboundIpAddress = $InboundIp - Domains = @($Domains | Sort-Object -Property IsDefault, Hostname) + SiteName = $AppService.SiteName + ResourceGroup = $AppService.ResourceGroup + DefaultHostName = $AppService.Site.properties.defaultHostName + InboundIpAddress = $AppService.Site.properties.inboundIpAddress + # The App Service's own Custom domains blade - the fallback the wizard offers when Azure rejects a binding. + AzurePortalDomainsUrl = "https://portal.azure.com/#@/resource/subscriptions/$($AppService.SubscriptionId)/resourceGroups/$($AppService.ResourceGroup)/providers/Microsoft.Web/sites/$($AppService.SiteName)/customDomains" + Domains = @($Domains | Sort-Object -Property IsDefault, Hostname) } } } 'CheckDns' { $HostName = $Request.Body.Hostname ?? $Request.Query.Hostname - if (-not [string]::IsNullOrWhiteSpace($HostName)) { $HostName = ([string]$HostName).Trim().ToLower() } if ([string]::IsNullOrWhiteSpace($HostName)) { throw 'Hostname is required' } + $HostName = Get-CleanHostname $HostName # DoH resolver lives in the DNSHealth module; import + initialize it the same way the # domain health endpoint does before resolving. Import-Module DNSHealth -ErrorAction SilentlyContinue Set-DnsResolver -Resolver 'Google' -ErrorAction SilentlyContinue - $Site = Get-AppServiceSiteInfo - $ArmBase = Get-SiteArmBase -Site $Site - $SiteObj = New-CIPPAzRestRequest -Uri "$($ArmBase)?api-version=$ApiVersion" -Method GET + $AppService = Get-CIPPAppServiceSite $Plan = Get-DomainRecordPlan -Hostname $HostName ` - -DefaultHostName $SiteObj.properties.defaultHostName ` - -InboundIp $SiteObj.properties.inboundIpAddress + -DefaultHostName $AppService.Site.properties.defaultHostName ` + -InboundIp $AppService.Site.properties.inboundIpAddress # CIPP no longer asks for a domain-verification TXT record at asuid., but an # old one left behind by a previous setup is actively harmful: Azure hard-fails the @@ -180,6 +179,7 @@ function Invoke-ExecAppServiceDomains { # Wildcards can't be resolved directly, so they pass this check unconditionally — # Azure validates the wildcard alias when the binding is created. $AliasVerified = $false + $AliasType = $null $AliasDetail = $null if ($Plan.IsWildcard) { $AliasVerified = $true @@ -191,9 +191,11 @@ function Invoke-ExecAppServiceDomains { $AMatch = $AValues | Where-Object { $_ -eq $Plan.ARecordTarget } if ($CnameMatch) { $AliasVerified = $true + $AliasType = 'CNAME' $AliasDetail = "CNAME -> $($Plan.CnameTarget)" } elseif ($AMatch) { $AliasVerified = $true + $AliasType = 'A' $AliasDetail = "A -> $($Plan.ARecordTarget)" } else { $Found = @($CnameValues + $AValues) -join ', ' @@ -201,10 +203,6 @@ function Invoke-ExecAppServiceDomains { } } - # The alias is the only record the wizard gates on now — Azure makes the final - # ownership call when the binding is created. - $CanProceed = [bool]$AliasVerified - $Records = @( [pscustomobject]@{ Purpose = 'Alias' @@ -223,7 +221,8 @@ function Invoke-ExecAppServiceDomains { LegacyAsuid = $LegacyAsuid LegacyAsuidHost = $Plan.LegacyAsuidHost AliasVerified = $AliasVerified - CanProceed = $CanProceed + AliasType = $AliasType + CanProceed = [bool]$AliasVerified AliasDetail = $AliasDetail Records = @($Records) } @@ -232,143 +231,85 @@ function Invoke-ExecAppServiceDomains { 'AddBinding' { $HostName = $Request.Body.Hostname ?? $Request.Query.Hostname - if (-not [string]::IsNullOrWhiteSpace($HostName)) { $HostName = ([string]$HostName).Trim().ToLower() } if ([string]::IsNullOrWhiteSpace($HostName)) { throw 'Hostname is required' } + $HostName = Get-CleanHostname $HostName if ($HostName -like '*.azurewebsites.net') { throw 'The default *.azurewebsites.net hostname is managed by Azure and cannot be added.' } - $Site = Get-AppServiceSiteInfo - $ArmBase = Get-SiteArmBase -Site $Site - - # Azure enforces domain-ownership validation during this PUT, using the alias - # record. A leftover asuid TXT record from an older setup hard-fails validation - # when its value doesn't match this App Service — even if the alias is correct — - # so append removal guidance to that error. - $BindingUri = "$($ArmBase)/hostNameBindings/$HostName`?api-version=$ApiVersion" - $BindingBody = @{ - properties = @{ - siteName = $Site.SiteName - hostNameType = 'Verified' - } + $AppService = Get-CIPPAppServiceSite + $Plan = Get-DomainRecordPlan -Hostname $HostName ` + -DefaultHostName $AppService.Site.properties.defaultHostName ` + -InboundIp $AppService.Site.properties.inboundIpAddress + + # Which alias record Azure should validate against: the one CheckDns saw resolve (A or CNAME), else the recommended type for this hostname shape. + $DnsRecordType = switch ([string]$Request.Body.DnsRecordType) { + 'A' { 'A' } + 'CNAME' { 'CName' } + default { $Plan.IsApex ? 'A' : 'CName' } } + + # Azure validates ownership during this PUT through the alias record - but only when + # customHostNameDnsRecordType says which one to check. Without it ARM skips the + # CNAME/A check and demands an asuid TXT record instead, so a correct CNAME still + # fails with "A TXT record pointing from asuid. ... was not found". + $BindingUri = "$($AppService.ArmBase)/hostNameBindings/$HostName`?api-version=$($AppService.ApiVersion)" + $BindingBody = @{ properties = @{ customHostNameDnsRecordType = $DnsRecordType } } try { - New-CIPPAzRestRequest -Uri $BindingUri -Method PUT -Body $BindingBody -ContentType 'application/json' | Out-Null + $null = New-CIPPAzRestRequest -Uri $BindingUri -Method PUT -Body $BindingBody -ErrorAction Stop } catch { + # A leftover asuid TXT record from an older setup hard-fails validation when its + # value doesn't match this App Service — even if the alias is correct. $BindingError = $_.Exception.Message if ($BindingError -match 'TXT record|asuid|CanonicalName') { - $AsuidHint = $HostName.StartsWith('*.') ? "asuid.$($HostName.Substring(2))" : "asuid.$HostName" - throw "$BindingError — If a TXT record named '$AsuidHint' exists from a previous setup, remove it: CIPP no longer uses domain-verification TXT records, and a leftover one blocks validation even when the CNAME/A alias is correct." + throw "$BindingError — If a TXT record named '$($Plan.LegacyAsuidHost)' exists from a previous setup, remove it: CIPP no longer uses domain-verification TXT records, and a leftover one blocks validation even when the CNAME/A alias is correct." } throw } - Write-LogMessage -API $APIName -headers $Headers -message "Added custom domain binding '$HostName' to $($Site.SiteName)" -sev Info + Write-LogMessage -API $APIName -headers $Headers -message "Added custom domain binding '$HostName' ($DnsRecordType) to $($AppService.SiteName)" -sev Info $Body = @{ Results = "Custom domain '$HostName' bound to the App Service. You can now enable a managed certificate." } } 'AddCertificate' { $HostName = $Request.Body.Hostname ?? $Request.Query.Hostname - if (-not [string]::IsNullOrWhiteSpace($HostName)) { $HostName = ([string]$HostName).Trim().ToLower() } if ([string]::IsNullOrWhiteSpace($HostName)) { throw 'Hostname is required' } + $HostName = Get-CleanHostname $HostName if ($HostName -like '*.azurewebsites.net') { throw 'The default hostname is already secured by Azure.' } if ($HostName.StartsWith('*.')) { throw 'App Service Managed Certificates do not support wildcard domains. Upload your own certificate in the Azure Portal instead.' } - $Site = Get-AppServiceSiteInfo - $ArmBase = Get-SiteArmBase -Site $Site - - $SiteObj = New-CIPPAzRestRequest -Uri "$($ArmBase)?api-version=$ApiVersion" -Method GET - $Location = $SiteObj.location - $ServerFarmId = $SiteObj.properties.serverFarmId + # First attempt runs inline; a certificate that is not issued by the time it returns + # is followed up by hidden scheduled retries (see Invoke-CIPPCustomDomainCertificate). + $Message = Invoke-CIPPCustomDomainCertificate -Hostname $HostName + $AppService = Get-CIPPAppServiceSite + $SslState = ($AppService.Site.properties.hostNameSslStates | Where-Object { $_.name -eq $HostName } | Select-Object -First 1).sslState - # The binding must already exist — the managed cert is validated against it. - $Bindings = New-CIPPAzRestRequest -Uri "$($ArmBase)/hostNameBindings?api-version=$ApiVersion" -Method GET - $ExistingBinding = $Bindings.value | Where-Object { (($_.name -split '/')[-1]) -eq $HostName } | Select-Object -First 1 - if (-not $ExistingBinding) { - throw "No hostname binding exists for '$HostName'. Create the domain binding first." - } - - # Reuse a managed cert for this hostname if one is already issued, otherwise create it. - $CertName = "$($HostName -replace '[^a-zA-Z0-9-]', '-')-$($Site.SiteName)" - $CertUri = "https://management.azure.com/subscriptions/$($Site.Subscription)/resourceGroups/$($Site.RGName)/providers/Microsoft.Web/certificates/$CertName`?api-version=$ApiVersion" - - $Thumbprint = $null - try { - $ExistingCert = New-CIPPAzRestRequest -Uri $CertUri -Method GET - $Thumbprint = $ExistingCert.properties.thumbprint - } catch { - Write-Information "No existing certificate '$CertName', creating a new managed certificate." - } - - if (-not $Thumbprint) { - $CertBody = @{ - location = $Location - properties = @{ - serverFarmId = $ServerFarmId - canonicalName = $HostName - domainValidationMethod = 'cname-delegation' - } - } - # Managed-cert issuance validates the domain during the PUT. If the alias is proxied - # (e.g. Cloudflare orange-cloud) validation can fail — the operator should turn the - # proxy off until the cert is issued, then re-enable it. - $NewCert = New-CIPPAzRestRequest -Uri $CertUri -Method PUT -Body $CertBody -ContentType 'application/json' - $Thumbprint = $NewCert.properties.thumbprint - - # Occasionally the thumbprint isn't populated on the create response; poll briefly. - $Attempt = 0 - while (-not $Thumbprint -and $Attempt -lt 6) { - Start-Sleep -Seconds 5 - $Attempt++ - try { - $PolledCert = New-CIPPAzRestRequest -Uri $CertUri -Method GET - $Thumbprint = $PolledCert.properties.thumbprint - } catch { - Write-Information "Polling certificate '$CertName' (attempt $Attempt): $($_.Exception.Message)" - } - } - } - - if (-not $Thumbprint) { - throw "The managed certificate for '$HostName' was created but is still provisioning. Re-run this step in a minute to finish the SNI binding." - } - - # Enable the SNI SSL binding by merging sslState + thumbprint into the existing binding. - $BindingUri = "$($ArmBase)/hostNameBindings/$HostName`?api-version=$ApiVersion" - $BindingBody = @{ - properties = @{ - siteName = $Site.SiteName - hostNameType = 'Verified' - sslState = 'SniEnabled' - thumbprint = $Thumbprint - } + Write-LogMessage -API $APIName -headers $Headers -message $Message -sev Info + $Body = @{ + Results = $Message + Secured = $SslState -in @('SniEnabled', 'IpBasedEnabled') } - New-CIPPAzRestRequest -Uri $BindingUri -Method PUT -Body $BindingBody -ContentType 'application/json' | Out-Null - - Write-LogMessage -API $APIName -headers $Headers -message "Provisioned managed certificate and SNI binding for '$HostName'" -sev Info - $Body = @{ Results = "Managed certificate issued and SNI SSL enabled for '$HostName'. The domain is now secured." } } 'Remove' { $HostName = $Request.Body.Hostname ?? $Request.Query.Hostname - if (-not [string]::IsNullOrWhiteSpace($HostName)) { $HostName = ([string]$HostName).Trim().ToLower() } if ([string]::IsNullOrWhiteSpace($HostName)) { throw 'Hostname is required' } + $HostName = Get-CleanHostname $HostName if ($HostName -like '*.azurewebsites.net') { throw 'The default *.azurewebsites.net hostname cannot be removed.' } - $Site = Get-AppServiceSiteInfo - $ArmBase = Get-SiteArmBase -Site $Site - - $BindingUri = "$($ArmBase)/hostNameBindings/$HostName`?api-version=$ApiVersion" - New-CIPPAzRestRequest -Uri $BindingUri -Method DELETE | Out-Null - - # Best effort: drop the managed cert we created for this hostname so it doesn't linger. - $CertName = "$($HostName -replace '[^a-zA-Z0-9-]', '-')-$($Site.SiteName)" - $CertUri = "https://management.azure.com/subscriptions/$($Site.Subscription)/resourceGroups/$($Site.RGName)/providers/Microsoft.Web/certificates/$CertName`?api-version=$ApiVersion" - try { - New-CIPPAzRestRequest -Uri $CertUri -Method DELETE | Out-Null - } catch { - Write-Information "Could not remove certificate '$CertName' (may not exist): $($_.Exception.Message)" + $AppService = Get-CIPPAppServiceSite + $Api = $AppService.ApiVersion + $null = New-CIPPAzRestRequest -Uri "$($AppService.ArmBase)/hostNameBindings/$HostName`?api-version=$Api" -Method DELETE -ErrorAction Stop + + # Best effort: drop the managed certificate(s) for this hostname so they don't linger and + # keep holding the one-certificate-per-hostname slot on the plan. + foreach ($Cert in @($AppService.Certificates | Where-Object { $_.properties.canonicalName -eq $HostName })) { + try { + $null = New-CIPPAzRestRequest -Uri "https://management.azure.com$($Cert.id)?api-version=$Api" -Method DELETE -ErrorAction Stop + } catch { + Write-Information "Could not remove certificate '$($Cert.name)': $($_.Exception.Message)" + } } - Write-LogMessage -API $APIName -headers $Headers -message "Removed custom domain '$HostName' from $($Site.SiteName)" -sev Info + Write-LogMessage -API $APIName -headers $Headers -message "Removed custom domain '$HostName' from $($AppService.SiteName)" -sev Info $Body = @{ Results = "Custom domain '$HostName' removed from the App Service." } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBackendURLs.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBackendURLs.ps1 index d154b1c3f6e98..fbfead871ce0b 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBackendURLs.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBackendURLs.ps1 @@ -5,7 +5,7 @@ function Invoke-ExecBackendURLs { .ROLE CIPP.AppSettings.Read .DESCRIPTION - Returns Azure portal deep links for the CIPP deployment's own infrastructure (resource group, key vault, function app, static web app) plus its subscription, SKU, hosting mode and timezone. + Returns Azure portal deep links for the CIPP deployment's own infrastructure (resource group, key vault, the function app or web app, its App Service plan, static web app) plus its subscription, SKU and timezone. Whether the instance is CyberDrain-hosted or CIPP-NG comes from /api/me. #> [CmdletBinding()] param($Request, $TriggerMetadata) @@ -22,7 +22,18 @@ function Invoke-ExecBackendURLs { $RGName = $null } + # The plan's name is only known to ARM. Best effort: local dev and an identity without rights + # on the site leave the link empty. + $AppServicePlan = $null + try { + $PlanId = [string](Get-CIPPAppServiceSite).Site.properties.serverFarmId + if ($PlanId) { $AppServicePlan = "https://portal.azure.com/#@/resource$PlanId/overview" } + } catch { + Write-Information "Could not resolve the App Service plan: $($_.Exception.Message)" + } + $results = @{ + AppServicePlan = $AppServicePlan ResourceGroup = "https://portal.azure.com/#@/resource/subscriptions/$Subscription/resourceGroups/$RGName/overview" KeyVault = "https://portal.azure.com/#@/resource/subscriptions/$Subscription/resourceGroups/$RGName/providers/Microsoft.KeyVault/vaults/$($env:WEBSITE_SITE_NAME)/secrets" FunctionApp = "https://portal.azure.com/#@/resource/subscriptions/$Subscription/resourceGroups/$RGName/providers/Microsoft.Web/sites/$($env:WEBSITE_SITE_NAME)/appServices" @@ -34,7 +45,6 @@ function Invoke-ExecBackendURLs { RGName = $RGName FunctionName = $env:WEBSITE_SITE_NAME SWAName = $SWAName - Hosted = $env:CIPP_HOSTED -eq 'true' ?? $false OS = $IsLinux ? 'Linux' : 'Windows' SKU = $env:WEBSITE_SKU } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCippReplacemap.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCippReplacemap.ps1 index d22ee5b46585e..2b1d5ca9d7fc0 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCippReplacemap.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCippReplacemap.ps1 @@ -8,6 +8,9 @@ function Invoke-ExecCippReplacemap { [CmdletBinding()] param($Request, $TriggerMetadata) + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $Table = Get-CippTable -tablename 'CippReplacemap' $Action = $Request.Query.Action ?? $Request.Body.Action $TenantId = $Request.Query.tenantId ?? $Request.Body.tenantId @@ -201,7 +204,9 @@ function Invoke-ExecCippReplacemap { } Add-CIPPAzDataTableEntity @Table -Entity $VariableEntity -Force - $Body = @{ Results = "Variable '$VariableName' saved successfully" } + $Result = "Variable '$VariableName' saved successfully" + Write-LogMessage -headers $Headers -API $APIName -tenant $customerId -message $Result -Sev 'Info' + $Body = @{ Results = $Result } } 'Delete' { $VariableName = $Request.Body.RowKey @@ -209,7 +214,9 @@ function Invoke-ExecCippReplacemap { $VariableEntity = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq '$customerId' and RowKey eq '$VariableName'" if ($VariableEntity) { Remove-CIPPAzDataTableEntity @Table -Entity $VariableEntity -Force - $Body = @{ Results = "Variable '$VariableName' deleted successfully" } + $Result = "Variable '$VariableName' deleted successfully" + Write-LogMessage -headers $Headers -API $APIName -tenant $customerId -message $Result -Sev 'Info' + $Body = @{ Results = $Result } } else { $Body = @{ Results = "Variable '$VariableName' not found" } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCustomData.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCustomData.ps1 index 0a7b3a16623ac..2dd4c7371af5b 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCustomData.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCustomData.ps1 @@ -8,6 +8,9 @@ function Invoke-ExecCustomData { [CmdletBinding()] param($Request, $TriggerMetadata) + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $Action = $Request.Query.Action ?? $Request.Body.Action $CustomDataTable = Get-CippTable -TableName 'CustomData' $CustomDataMappingsTable = Get-CippTable -TableName 'CustomDataMappings' @@ -63,18 +66,22 @@ function Invoke-ExecCustomData { Add-CIPPAzDataTableEntity @CustomDataTable -Entity $Entity -Force $SchemaExtensions = Get-CIPPSchemaExtensions | Where-Object { $_.id -eq $SchemaExtension.id } + $Result = "Schema extension '$($SchemaExtension.id)' added successfully." + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Info' $Body = @{ Results = @{ state = 'success' - resultText = "Schema extension '$($SchemaExtension.id)' added successfully." + resultText = $Result } } } catch { + $Result = "Failed to add schema extension: $($_.Exception.Message)" + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Error' $Body = @{ Results = @( @{ state = 'error' - resultText = "Failed to add schema extension: $($_.Exception.Message)" + resultText = $Result } ) } @@ -109,18 +116,22 @@ function Invoke-ExecCustomData { # Delete the schema extension entity Remove-CIPPAzDataTableEntity @CustomDataTable -Entity $SchemaEntity + $Result = "Schema extension '$SchemaId' deleted successfully." + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Info' $Body = @{ Results = @{ state = 'success' - resultText = "Schema extension '$SchemaId' deleted successfully." + resultText = $Result } } } catch { + $Result = "Failed to delete schema extension: $($_.Exception.Message)" + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Error' $Body = @{ Results = @( @{ state = 'error' - resultText = "Failed to delete schema extension: $($_.Exception.Message)" + resultText = $Result } ) } @@ -173,18 +184,22 @@ function Invoke-ExecCustomData { Add-CIPPAzDataTableEntity @CustomDataTable -Entity $SchemaEntity -Force try { $null = Get-CIPPSchemaExtensions } catch {} + $Result = "Property '$($NewProperty.name)' added to schema extension '$SchemaId' successfully." + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Info' $Body = @{ Results = @{ state = 'success' - resultText = "Property '$($NewProperty.name)' added to schema extension '$SchemaId' successfully." + resultText = $Result } } } catch { + $Result = "Failed to add property to schema extension: $($_.Exception.Message)" + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Error' $Body = @{ Results = @( @{ state = 'error' - resultText = "Failed to add property to schema extension: $($_.Exception.Message)" + resultText = $Result } ) } @@ -223,18 +238,22 @@ function Invoke-ExecCustomData { Add-CIPPAzDataTableEntity @CustomDataTable -Entity $SchemaEntity -Force $null = Get-CIPPSchemaExtensions + $Result = "Schema extension '$SchemaId' status changed to '$NewStatus' successfully." + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Info' $Body = @{ Results = @{ state = 'success' - resultText = "Schema extension '$SchemaId' status changed to '$NewStatus' successfully." + resultText = $Result } } } catch { + $Result = "Failed to change schema extension status: $($_.Exception.Message)" + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Error' $Body = @{ Results = @( @{ state = 'error' - resultText = "Failed to change schema extension status: $($_.Exception.Message)" + resultText = $Result } ) } @@ -295,14 +314,6 @@ function Invoke-ExecCustomData { $Response = New-GraphPOSTRequest -Uri $Uri -Body $BodyContent -AsApp $true -NoAuthCheck $true -tenantid $env:TenantID - $Body = @{ - Results = @{ - state = 'success' - resultText = "Directory extension '$ExtensionName' added successfully." - extension = $Response - } - } - # store the extension in the custom data table $Entity = @{ PartitionKey = 'DirectoryExtension' @@ -310,12 +321,24 @@ function Invoke-ExecCustomData { JSON = [string](ConvertTo-Json $Response -Compress -Depth 5) } Add-CIPPAzDataTableEntity @CustomDataTable -Entity $Entity -Force + + $Result = "Directory extension '$ExtensionName' added successfully." + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Info' + $Body = @{ + Results = @{ + state = 'success' + resultText = $Result + extension = $Response + } + } } catch { + $Result = "Failed to add directory extension: $($_.Exception.Message)" + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Error' $Body = @{ Results = @( @{ state = 'error' - resultText = "Failed to add directory extension: $($_.Exception.Message)" + resultText = $Result } ) } @@ -344,18 +367,22 @@ function Invoke-ExecCustomData { Write-Warning "Failed to delete directory extension from custom data table: $($_.Exception.Message)" } + $Result = "Directory extension '$ExtensionName' deleted successfully." + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Info' $Body = @{ Results = @{ state = 'success' - resultText = "Directory extension '$ExtensionName' deleted successfully." + resultText = $Result } } } catch { + $Result = "Failed to delete directory extension: $($_.Exception.Message)" + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Error' $Body = @{ Results = @( @{ state = 'error' - resultText = "Failed to delete directory extension: $($_.Exception.Message)" + resultText = $Result } ) } @@ -428,18 +455,22 @@ function Invoke-ExecCustomData { Add-CIPPAzDataTableEntity @CustomDataMappingsTable -Entity $Entity -Force Register-CIPPExtensionScheduledTasks + $Result = 'Mapping saved successfully.' + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Info' $Body = @{ Results = @{ state = 'success' - resultText = 'Mapping saved successfully.' + resultText = $Result } } } catch { + $Result = "Failed to add mapping: $($_.Exception.Message)" + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Error' $Body = @{ Results = @( @{ state = 'error' - resultText = "Failed to add mapping: $($_.Exception.Message)" + resultText = $Result } ) } @@ -461,18 +492,22 @@ function Invoke-ExecCustomData { # Delete the mapping entity Remove-CIPPAzDataTableEntity @CustomDataMappingsTable -Entity $MappingEntity Register-CIPPExtensionScheduledTasks + $Result = 'Mapping deleted successfully.' + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Info' $Body = @{ Results = @{ state = 'success' - resultText = 'Mapping deleted successfully.' + resultText = $Result } } } catch { + $Result = "Failed to delete mapping: $($_.Exception.Message)" + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Error' $Body = @{ Results = @( @{ state = 'error' - resultText = "Failed to delete mapping: $($_.Exception.Message)" + resultText = $Result } ) } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCustomRole.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCustomRole.ps1 index 1594453d77e7d..9f7e017f44c01 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCustomRole.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCustomRole.ps1 @@ -83,13 +83,14 @@ function Invoke-ExecCustomRole { $PermissionRules = ConvertTo-CippPermissionRules -Permissions $Request.Body.Permissions } $Role = @{ - 'PartitionKey' = 'CustomRoles' - 'RowKey' = "$($Request.Body.RoleName.ToLower())" - 'Permissions' = "$($Request.Body.Permissions | ConvertTo-Json -Compress)" - 'PermissionRules' = "$($PermissionRules | ConvertTo-Json -Compress -Depth 5)" - 'AllowedTenants' = "$($Request.Body.AllowedTenants | ConvertTo-Json -Compress)" - 'BlockedTenants' = "$($Request.Body.BlockedTenants | ConvertTo-Json -Compress)" - 'BlockedEndpoints' = "$($Request.Body.BlockedEndpoints | ConvertTo-Json -Compress)" + 'PartitionKey' = 'CustomRoles' + 'RowKey' = "$($Request.Body.RoleName.ToLower())" + 'Permissions' = "$($Request.Body.Permissions | ConvertTo-Json -Compress)" + 'PermissionRules' = "$($PermissionRules | ConvertTo-Json -Compress -Depth 5)" + 'AllowedTenants' = "$($Request.Body.AllowedTenants | ConvertTo-Json -Compress)" + 'BlockedTenants' = "$($Request.Body.BlockedTenants | ConvertTo-Json -Compress)" + 'BlockedEndpoints' = "$($Request.Body.BlockedEndpoints | ConvertTo-Json -Compress)" + 'AllowedRolesTemplate' = "$($Request.Body.AllowedRolesTemplate | ConvertTo-Json -Compress)" } Add-CIPPAzDataTableEntity @Table -Entity $Role -Force | Out-Null $Results.Add("Custom role $($Request.Body.RoleName) saved") @@ -166,13 +167,14 @@ function Invoke-ExecCustomRole { } $NewRole = @{ - 'PartitionKey' = 'CustomRoles' - 'RowKey' = "$($Request.Body.NewRoleName.ToLower())" - 'Permissions' = $ExistingRole.Permissions - 'PermissionRules' = "$($ExistingRole.PermissionRules)" - 'AllowedTenants' = $ExistingRole.AllowedTenants - 'BlockedTenants' = $ExistingRole.BlockedTenants - 'BlockedEndpoints' = $ExistingRole.BlockedEndpoints + 'PartitionKey' = 'CustomRoles' + 'RowKey' = "$($Request.Body.NewRoleName.ToLower())" + 'Permissions' = $ExistingRole.Permissions + 'PermissionRules' = "$($ExistingRole.PermissionRules)" + 'AllowedTenants' = $ExistingRole.AllowedTenants + 'BlockedTenants' = $ExistingRole.BlockedTenants + 'BlockedEndpoints' = $ExistingRole.BlockedEndpoints + 'AllowedRolesTemplate' = $ExistingRole.AllowedRolesTemplate } Add-CIPPAzDataTableEntity @Table -Entity $NewRole -Force | Out-Null # Clone IP ranges if they exist @@ -290,6 +292,15 @@ function Invoke-ExecCustomRole { } else { $Role | Add-Member -NotePropertyName BlockedEndpoints -NotePropertyValue @() -Force } + if ($Role.AllowedRolesTemplate) { + try { + $Role.AllowedRolesTemplate = $Role.AllowedRolesTemplate | ConvertFrom-Json + } catch { + $Role.AllowedRolesTemplate = $null + } + } else { + $Role | Add-Member -NotePropertyName AllowedRolesTemplate -NotePropertyValue $null -Force + } $EntraRoleGroup = $EntraRoleGroups | Where-Object -Property RowKey -EQ $Role.RowKey if ($EntraRoleGroup) { $EntraGroup = $EntraRoleGroups | Where-Object -Property RowKey -EQ $Role.RowKey | Select-Object @{Name = 'label'; Expression = { $_.GroupName } }, @{Name = 'value'; Expression = { $_.GroupId } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecMaintenanceScripts.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecMaintenanceScripts.ps1 index 7438b6e88dc71..f99bcdb2b8b7e 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecMaintenanceScripts.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecMaintenanceScripts.ps1 @@ -60,6 +60,7 @@ Function Invoke-ExecMaintenanceScripts { } Add-CIPPAzDataTableEntity @Table -Entity $MaintenanceScriptRow -Force + Write-LogMessage -headers $Request.Headers -API $APIName -tenant 'Global' -message "Created one-time maintenance script link for $Filename" -Sev 'Info' $Body = @{ Link = "/api/PublicScripts?guid=$LinkGuid" } } else { $Body = @{ ScriptContent = $ScriptContent } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecNotificationConfig.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecNotificationConfig.ps1 index 46e3341eb2c6a..b5a724756827c 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecNotificationConfig.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecNotificationConfig.ps1 @@ -7,6 +7,9 @@ Function Invoke-ExecNotificationConfig { #> [CmdletBinding()] param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers $sev = ([pscustomobject]$Request.body.Severity).value -join (',') $config = @{ email = $Request.body.email @@ -25,6 +28,11 @@ Function Invoke-ExecNotificationConfig { sev = $sev } $Results = Set-cippNotificationConfig @Config + if ($Results -like 'Failed*') { + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Results -Sev 'Error' + } else { + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Results -Sev 'Info' + } $body = [pscustomobject]@{'Results' = $Results } return ([HttpResponseContext]@{ diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecOffloadFunctions.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecOffloadFunctions.ps1 index a2081374dffc9..c74df0f48c892 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecOffloadFunctions.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecOffloadFunctions.ps1 @@ -9,6 +9,9 @@ function Invoke-ExecOffloadFunctions { [CmdletBinding()] param($Request, $TriggerMetadata) + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $Table = Get-CippTable -tablename 'Config' if ($Request.Query.Action -eq 'ListCurrent') { @@ -16,6 +19,9 @@ function Invoke-ExecOffloadFunctions { $VersionTable = Get-CippTable -tablename 'Version' $Version = Get-CIPPAzDataTableEntity @VersionTable -Filter "RowKey ne 'Version'" $MainVersion = $Version | Where-Object { $_.RowKey -eq $env:WEBSITE_SITE_NAME } + # The main app's row is keyed by WEBSITE_SITE_NAME, which the container runtime does not + # set, so fall back to the running build rather than comparing against an empty string. + $MainVersionString = if ($MainVersion.Version) { $MainVersion.Version } elseif ($env:APP_VERSION) { $env:APP_VERSION } else { $null } $OffloadVersions = $Version | Where-Object { Test-CippOffloadFunctionApp -SiteName $_.RowKey } $Alerts = [System.Collections.Generic.List[string]]::new() @@ -29,9 +35,12 @@ function Invoke-ExecOffloadFunctions { foreach ($Offload in $OffloadVersions) { $FunctionName = $Offload.RowKey - if ([semver]$Offload.Version -ne [semver]$MainVersion.Version) { + if (-not $MainVersionString) { + $CanEnable = $false + $Alerts.Add("The version of $FunctionName ($($Offload.Version)) could not be checked because the current version is unknown.") + } elseif ([semver]$Offload.Version -ne [semver]$MainVersionString) { $CanEnable = $false - $Alerts.Add("The version of $FunctionName ($($Offload.Version)) does not match the current version of $($MainVersion.Version).") + $Alerts.Add("The version of $FunctionName ($($Offload.Version)) does not match the current version of $MainVersionString.") } } @@ -75,9 +84,11 @@ function Invoke-ExecOffloadFunctions { } else { $Results = 'Disabled Offload Functions' } + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Results -Sev 'Info' return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::OK Body = @{ results = $Results } }) } } + diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecPartnerMode.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecPartnerMode.ps1 index a7b6783ad57dc..02cb0e9d7b351 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecPartnerMode.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecPartnerMode.ps1 @@ -8,6 +8,8 @@ function Invoke-ExecPartnerMode { [CmdletBinding()] param($Request, $TriggerMetadata) + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers $Table = Get-CippTable -tablename 'tenantMode' if ($request.body.TenantMode) { @@ -39,12 +41,15 @@ function Invoke-ExecPartnerMode { Start-CIPPOrchestrator -InputObject $InputObject } + $Result = "Set Tenant mode to $($Request.body.TenantMode)" + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Info' + return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::OK Body = @{ results = @( @{ - resultText = "Set Tenant mode to $($Request.body.TenantMode)" + resultText = $Result state = 'success' } ) @@ -72,3 +77,4 @@ function Invoke-ExecPartnerMode { } } + diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecPermissionRepair.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecPermissionRepair.ps1 index 513e0bd5aca07..d6933a8a82fbc 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecPermissionRepair.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecPermissionRepair.ps1 @@ -12,11 +12,18 @@ function Invoke-ExecPermissionRepair { [CmdletBinding()] param($Request, $TriggerMetadata) + $APIName = $Request.Params.CIPPEndpoint ?? 'PermissionRepair' + $Headers = $Request.Headers + try { $User = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Request.Headers.'x-ms-client-principal')) | ConvertFrom-Json - $Result = Update-CippSamPermissions -UpdatedBy ($User.UserDetails ?? 'CIPP-API') + $UpdatedBy = $User.UserDetails ?? 'CIPP-API' + $Result = Update-CippSamPermissions -UpdatedBy $UpdatedBy + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "CIPP-SAM permissions reconciled by ${UpdatedBy}: applied table now contains the CIPP manifest permissions plus any additional permissions." -Sev 'Info' $Body = @{'Results' = $Result } } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "Failed to reconcile permissions: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage $Body = @{ 'Results' = "$($_.Exception.Message) - at line $($_.InvocationInfo.ScriptLineNumber)" } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecRefreshMyAccess.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecRefreshMyAccess.ps1 index e9105d3f72a83..50af017976148 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecRefreshMyAccess.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecRefreshMyAccess.ps1 @@ -59,6 +59,7 @@ function Invoke-ExecRefreshMyAccess { $SecondsSince = ((Get-Date).ToUniversalTime() - $CooldownMarker.Timestamp.UtcDateTime).TotalSeconds if ($SecondsSince -lt $CooldownSeconds) { $WaitSeconds = [math]::Ceiling($CooldownSeconds - $SecondsSince) + Write-LogMessage -API 'RefreshMyAccess' -headers $Request.Headers -message "$Upn hit the access-refresh cooldown; returned 429 asking them to retry in $WaitSeconds seconds." -sev Info return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::TooManyRequests Body = @{ Results = "Your access was refreshed less than $CooldownSeconds seconds ago. Try again in $WaitSeconds seconds." } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecRemoveTenant.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecRemoveTenant.ps1 index c0d710475bc79..fedd9526fd5f4 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecRemoveTenant.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecRemoveTenant.ps1 @@ -8,6 +8,9 @@ function Invoke-ExecRemoveTenant { [CmdletBinding()] param($Request, $TriggerMetadata) + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + if ($Request.Body.TenantID -notmatch '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') { $Body = @{Results = "Tenant ID $($Request.Body.TenantID) is not a valid GUID." } $StatusCode = [HttpStatusCode]::BadRequest @@ -17,10 +20,15 @@ function Invoke-ExecRemoveTenant { if ($Tenant) { try { Remove-CIPPAzDataTableEntity -Force @Table -Entity $Tenant - $Body = @{Results = "$($Tenant.displayName) ($($Tenant.customerId)) deleted from CIPP. Note: This does not remove the GDAP relationship, see the Tenant Offboarding wizard to perform that action." } + $Result = "$($Tenant.displayName) ($($Tenant.customerId)) deleted from CIPP. Note: This does not remove the GDAP relationship, see the Tenant Offboarding wizard to perform that action." + Write-LogMessage -headers $Headers -API $APIName -tenant $Tenant.customerId -message $Result -Sev 'Info' + $Body = @{Results = $Result } $StatusCode = [HttpStatusCode]::OK } catch { - $Body = @{Results = "Failed to delete $($Tenant.displayName) ($($Tenant.customerId)) from CIPP. Error: $($_.Exception.Message)" } + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to delete $($Tenant.displayName) ($($Tenant.customerId)) from CIPP. Error: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant $Tenant.customerId -message $Result -Sev 'Error' -LogData $ErrorMessage + $Body = @{Results = $Result } $StatusCode = [HttpStatusCode]::InternalServerError } } else { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecRunTenantGroupRule.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecRunTenantGroupRule.ps1 index 4ce8658e9fc2a..f159af6c8014b 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecRunTenantGroupRule.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecRunTenantGroupRule.ps1 @@ -31,7 +31,9 @@ function Invoke-ExecRunTenantGroupRule { $null = Start-TenantDynamicGroupOrchestrator -GroupId $GroupId - $Body = @{ Results = "Dynamic rules executed successfully for group '$($Group.Name)'. Processing will continue in the background. Check the logbook for details." } + $Result = "Dynamic rules executed successfully for group '$($Group.Name)'. Processing will continue in the background. Check the logbook for details." + Write-LogMessage -API 'TenantGroups' -tenant 'Global' -headers $Request.Headers -message $Result -Sev 'Info' + $Body = @{ Results = $Result } return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::OK @@ -39,7 +41,7 @@ function Invoke-ExecRunTenantGroupRule { }) } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message - Write-LogMessage -API 'TenantGroups' -message "Failed to execute tenant group rules: $ErrorMessage" -sev Error + Write-LogMessage -API 'TenantGroups' -tenant 'Global' -message "Failed to execute tenant group rules: $ErrorMessage" -sev Error $Body = @{ Results = "Failed to execute dynamic rules: $ErrorMessage" } return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::InternalServerError diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecSAMRoles.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecSAMRoles.ps1 index 9c5ad86db193a..f7f8af3500ac0 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecSAMRoles.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecSAMRoles.ps1 @@ -8,17 +8,29 @@ function Invoke-ExecSAMRoles { [CmdletBinding()] param($Request, $TriggerMetadata) + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $SAMRolesTable = Get-CIPPTable -tablename 'SAMRoles' switch ($Request.Query.Action) { 'Update' { - $Entity = [pscustomobject]@{ - PartitionKey = 'SAMRoles' - RowKey = 'SAMRoles' - Roles = [string](ConvertTo-Json -Depth 5 -Compress -InputObject $Request.Body.Roles) - Tenants = [string](ConvertTo-Json -Depth 5 -Compress -InputObject $Request.Body.Tenants) + try { + $Entity = [pscustomobject]@{ + PartitionKey = 'SAMRoles' + RowKey = 'SAMRoles' + Roles = [string](ConvertTo-Json -Depth 5 -Compress -InputObject $Request.Body.Roles) + Tenants = [string](ConvertTo-Json -Depth 5 -Compress -InputObject $Request.Body.Tenants) + } + $null = Add-CIPPAzDataTableEntity @SAMRolesTable -Entity $Entity -Force + $Result = 'Successfully updated SAM roles' + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Info' + $Body = [pscustomobject]@{'Results' = $Result } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to update SAM roles: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Error' -LogData $ErrorMessage + $Body = [pscustomobject]@{'Results' = $Result } } - $null = Add-CIPPAzDataTableEntity @SAMRolesTable -Entity $Entity -Force - $Body = [pscustomobject]@{'Results' = 'Successfully updated SAM roles' } } default { $SAMRoles = Get-CIPPAzDataTableEntity @SAMRolesTable diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecTenantGroup.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecTenantGroup.ps1 index b4387430ff53e..6938be3bb72a2 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecTenantGroup.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecTenantGroup.ps1 @@ -144,6 +144,7 @@ function Invoke-ExecTenantGroup { }) } + Write-LogMessage -API 'TenantGroups' -tenant 'Global' -headers $Request.Headers -message "Group '$groupName' saved successfully" -Sev 'Info' $Body = @{ Results = $Results } } 'Delete' { @@ -151,7 +152,9 @@ function Invoke-ExecTenantGroup { $GroupEntity = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'TenantGroup' and RowKey eq '$groupId'" if ($GroupEntity) { Remove-CIPPAzDataTableEntity @Table -Entity $GroupEntity -Force - $Body = @{ Results = "Group '$($GroupEntity.Name)' deleted successfully" } + $Result = "Group '$($GroupEntity.Name)' deleted successfully" + Write-LogMessage -API 'TenantGroups' -tenant 'Global' -headers $Request.Headers -message $Result -Sev 'Info' + $Body = @{ Results = $Result } } else { $Body = @{ Results = "Group '$groupId' not found" } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecUserBookmarks.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecUserBookmarks.ps1 index 1b6ca6abc3302..8d83ec8001e13 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecUserBookmarks.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecUserBookmarks.ps1 @@ -1,11 +1,15 @@ function Invoke-ExecUserBookmarks { <# .FUNCTIONALITY - Entrypoint + Entrypoint,AnyTenant .ROLE CIPP.Core.ReadWrite #> param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + try { $Bookmarks = $Request.Body.currentSettings.bookmarks if ($null -eq $Bookmarks) { @@ -24,10 +28,14 @@ function Invoke-ExecUserBookmarks { PartitionKey = 'UserBookmarks' } $StatusCode = [HttpStatusCode]::OK - $Results = [pscustomobject]@{'Results' = 'Successfully added user bookmarks' } + $Result = 'Successfully added user bookmarks' + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Info' + $Results = [pscustomobject]@{'Results' = $Result } } catch { $ErrorMsg = Get-NormalizedError -message $($_.Exception.Message) - $Results = "Function Error: $ErrorMsg" + $Result = "Function Error: $ErrorMsg" + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Error' + $Results = $Result $StatusCode = [HttpStatusCode]::BadRequest } return [HttpResponseContext]@{ diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecUserSettings.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecUserSettings.ps1 index 3bea3e902cda0..b665b8ea004b2 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecUserSettings.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecUserSettings.ps1 @@ -6,6 +6,10 @@ function Invoke-ExecUserSettings { CIPP.Core.ReadWrite #> param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + try { $object = $Request.Body.currentSettings | Select-Object * -ExcludeProperty CurrentTenant, pageSizes, sidebarShow, sidebarUnfoldable, _persist | ConvertTo-Json -Compress -Depth 10 $User = $Request.Body.user @@ -17,10 +21,14 @@ function Invoke-ExecUserSettings { PartitionKey = 'UserSettings' } $StatusCode = [HttpStatusCode]::OK - $Results = [pscustomobject]@{'Results' = 'Successfully added user settings' } + $Result = 'Successfully added user settings' + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Info' + $Results = [pscustomobject]@{'Results' = $Result } } catch { $ErrorMsg = Get-NormalizedError -message $($_.Exception.Message) - $Results = "Function Error: $ErrorMsg" + $Result = "Function Error: $ErrorMsg" + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Error' + $Results = $Result $StatusCode = [HttpStatusCode]::BadRequest } return [HttpResponseContext]@{ diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ListCustomRole.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ListCustomRole.ps1 index e231cdcd085db..7c1be797a8032 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ListCustomRole.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ListCustomRole.ps1 @@ -139,6 +139,21 @@ function Invoke-ListCustomRole { $Role | Add-Member -NotePropertyName EntraGroup -NotePropertyValue $EntraGroup.GroupName -Force $Role | Add-Member -NotePropertyName EntraGroupId -NotePropertyValue $EntraGroup.GroupId -Force } + + # Custom roles keep their IP allow-list in AccessIPRanges (same as the built-in roles + # above); surface it here so this read-only list carries it too. + $IPRangeEntity = $AccessIPRanges | Where-Object -Property RowKey -EQ $Role.RowKey + if ($IPRangeEntity) { + try { + $IPRanges = @($IPRangeEntity.IPRanges | ConvertFrom-Json) + } catch { + $IPRanges = @() + } + } else { + $IPRanges = @() + } + $Role | Add-Member -NotePropertyName IPRange -NotePropertyValue $IPRanges -Force + $RoleList.Add($Role) } $Body = @($RoleList) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ListExcludedLicenses.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ListExcludedLicenses.ps1 index 49c19af758381..e69bb30a84736 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ListExcludedLicenses.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ListExcludedLicenses.ps1 @@ -30,7 +30,7 @@ function Invoke-ListExcludedLicenses { $_ | Add-Member -NotePropertyName 'ExcludedEverywhere' -NotePropertyValue $true -Force } if ($null -eq $_.ShowInLicenseDropdown) { - $_ | Add-Member -NotePropertyName 'ShowInLicenseDropdown' -NotePropertyValue $false -Force + $_ | Add-Member -NotePropertyName 'ShowInLicenseDropdown' -NotePropertyValue $true -Force } $ExclusionType = if ($_.ExcludedEverywhere -eq $true) { 'Excluded Everywhere' } else { 'Excluded from Alerts Only' } $_ | Add-Member -NotePropertyName 'ExclusionType' -NotePropertyValue $ExclusionType -Force diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCombinedSetup.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCombinedSetup.ps1 index a6e6622499adc..2ae54831342f8 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCombinedSetup.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCombinedSetup.ps1 @@ -11,6 +11,38 @@ function Invoke-ExecCombinedSetup { #Make arraylist of Results $Results = [System.Collections.ArrayList]::new() try { + # Certificate-auth toggle for an existing install: enabling keeps the client secret as a rollback + # and switches SAM tokens to the certificate. Idempotent with a certificate-only First Setup. + if ($null -ne $Request.Body.certificateAuth) { + # Ensure credentials are loaded so the secret-usability check below is accurate on a cold + # runspace (otherwise a legitimate secret-based install could be wrongly refused a disable). + $null = Get-CIPPAuthentication + if ($Request.Body.certificateAuth -eq $true) { + try { + # Make sure the certificate exists and is registered on the app before switching to it. + $null = Update-CIPPSAMCertificate -ErrorAction Stop + $Cert = Get-CIPPSAMCertificate -SkipCache -ErrorAction Stop + if (-not $Cert) { throw 'No SAM certificate is available to authenticate with.' } + $null = Set-CIPPFeatureFlag -Id 'CertificateAuthentication' -Enabled $true -Force + $env:CertificateAuthMode = $true + $Results.add('Enabled certificate authentication. CIPP now authenticates with the SAM certificate instead of the client secret. The client secret is kept as a rollback - disable this option to switch back.') + } catch { + $Results.add("Could not enable certificate authentication: $($_.Exception.Message). The existing authentication method is unchanged.") + } + } else { + # Refuse to disable with no usable secret to fall back to - that would break auth. + $SecretPlaceholderPattern = '^(LongApplicationId|AppSecret|RefreshToken|tenantId)$' + $SecretUsable = $env:ApplicationSecret -and $env:ApplicationSecret -notmatch $SecretPlaceholderPattern + if (-not $SecretUsable) { + $Results.add('Certificate authentication cannot be disabled: this install has no client secret to fall back to.') + } else { + $null = Set-CIPPFeatureFlag -Id 'CertificateAuthentication' -Enabled $false -Force + $env:CertificateAuthMode = $null + $Results.add('Disabled certificate authentication. CIPP will use the client secret again.') + } + } + } + if ($request.body.selectedBaselines -and $request.body.baselineOption -eq 'downloadBaselines') { #do a single download of the selected baselines. foreach ($template in $request.body.selectedBaselines) { @@ -54,6 +86,11 @@ function Invoke-ExecCombinedSetup { $notificationConfig = $request.body | Select-Object email, webhook, onepertenant, logsToInclude, sendtoIntegration, sev | ConvertTo-Json | ConvertFrom-Json -AsHashtable $notificationResults = Set-CIPPNotificationConfig @notificationConfig $Results.add($notificationResults) + if ($notificationResults -like 'Failed*') { + Write-LogMessage -headers $Request.Headers -API ($Request.Params.CIPPEndpoint ?? 'CombinedSetup') -tenant 'Global' -message $notificationResults -Sev 'Error' + } else { + Write-LogMessage -headers $Request.Headers -API ($Request.Params.CIPPEndpoint ?? 'CombinedSetup') -tenant 'Global' -message $notificationResults -Sev 'Info' + } } if ($Request.Body.selectedOption -eq 'Manual') { $KV = Get-CippKeyVaultName diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 index e215ea1794b07..bce1a1c0c93a2 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 @@ -9,11 +9,16 @@ function Invoke-ExecCreateSAMApp { [CmdletBinding()] param($Request, $TriggerMetadata) + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers $KV = Get-CippKeyVaultName try { $Token = $Request.body if ($Token) { + # A certificate-only setup provisions no client secret. Determined up front so every app + # management policy call in this flow leaves the password-addition block in force. + $CertificateOnly = $Request.body.certificateOnly -eq $true $URL = $Request.headers.origin ?? $Request.headers.referer?.TrimEnd('/') $RedirectUri = "$URL/authredirect" $AuthCallbackUri = "$URL/.auth/callback" @@ -71,14 +76,31 @@ function Invoke-ExecCreateSAMApp { try { - $AppPolicyStatus = Update-AppManagementPolicy -Headers @{ authorization = "Bearer $($Token.access_token)" } -ApplicationId $appId.appId + $AppPolicyStatus = Update-AppManagementPolicy -Headers @{ authorization = "Bearer $($Token.access_token)" } -ApplicationId $appId.appId -CertificateOnly $CertificateOnly Write-Information $AppPolicyStatus.PolicyAction } catch { Write-Warning "Error updating app management policy $($_.Exception.Message)." Write-Information ($_.InvocationInfo.PositionMessage) } - $AppPassword = (Invoke-RestMethod "https://graph.microsoft.com/v1.0/applications/$($AppId.id)/addPassword" -Headers @{ authorization = "Bearer $($Token.access_token)" } -Method POST -Body '{"passwordCredential":{"displayName":"CIPPInstall"}}' -ContentType 'application/json').secretText + # A certificate-only setup provisions no client secret - the SAM certificate is the sole + # credential. Register it now (before the token step, which will authenticate with it) and + # turn on the feature flag so the reload below resolves certificate mode. + if ($CertificateOnly) { + try { + # Enable certificate mode BEFORE provisioning so the app management policy exemption + # leaves the password-addition block in force (no client secret is ever added). + $null = Set-CIPPFeatureFlag -Id 'CertificateAuthentication' -Enabled $true -Force + $env:CertificateAuthMode = $true + $CertResult = Update-CIPPSAMCertificate -ApplicationId $AppId.appId -Headers @{ authorization = "Bearer $($Token.access_token)" } -ErrorAction Stop + Write-Information "Registered SAM certificate for certificate-only setup. Thumbprint: $($CertResult.Thumbprint), storage mode: $($CertResult.StorageMode)" + } catch { + throw "Certificate-only setup was selected but the SAM certificate could not be registered on the application. Setup cannot continue without a credential. $($_.Exception.Message)" + } + $AppPassword = $null + } else { + $AppPassword = (Invoke-RestMethod "https://graph.microsoft.com/v1.0/applications/$($AppId.id)/addPassword" -Headers @{ authorization = "Bearer $($Token.access_token)" } -Method POST -Body '{"passwordCredential":{"displayName":"CIPPInstall"}}' -ContentType 'application/json').secretText + } if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { $DevSecretsTable = Get-CIPPTable -tablename 'DevSecrets' @@ -88,12 +110,17 @@ function Invoke-ExecCreateSAMApp { $Secret | Add-Member -MemberType NoteProperty -Name 'RowKey' -Value 'Secret' -Force $Secret | Add-Member -MemberType NoteProperty -Name 'tenantid' -Value $TenantId -Force $Secret | Add-Member -MemberType NoteProperty -Name 'applicationid' -Value $AppId.appId -Force - $Secret | Add-Member -MemberType NoteProperty -Name 'applicationsecret' -Value $AppPassword -Force + # Blank the stored secret in certificate-only mode so CIPP falls through to the certificate + $Secret | Add-Member -MemberType NoteProperty -Name 'applicationsecret' -Value ($AppPassword ?? '') -Force Add-CIPPAzDataTableEntity @DevSecretsTable -Entity $Secret -Force } else { Set-CippKeyVaultSecret -VaultName $kv -Name 'tenantid' -SecretValue (ConvertTo-SecureString -String $TenantId -AsPlainText -Force) Set-CippKeyVaultSecret -VaultName $kv -Name 'applicationid' -SecretValue (ConvertTo-SecureString -String $Appid.appId -AsPlainText -Force) - Set-CippKeyVaultSecret -VaultName $kv -Name 'applicationsecret' -SecretValue (ConvertTo-SecureString -String $AppPassword -AsPlainText -Force) + # Certificate-only setups create no secret; leave the placeholder in place so it reads + # back as unusable and CIPP authenticates with the certificate instead. + if ($AppPassword) { + Set-CippKeyVaultSecret -VaultName $kv -Name 'applicationsecret' -SecretValue (ConvertTo-SecureString -String $AppPassword -AsPlainText -Force) + } } # Populate this process straight from the values we just created. The wizard # moves to the next step immediately and every reader treats $env:ApplicationID @@ -134,7 +161,7 @@ function Invoke-ExecCreateSAMApp { } } if (-not $SecretsReadable) { - Write-LogMessage -message "Created the application registration but could not read the application id back from storage after $ReadAttempts attempts. This instance holds the new credentials, but other instances may still serve the previous values until the write propagates." -Sev 'Warning' + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "Created the application registration but could not read the application id back from storage after $ReadAttempts attempts. This instance holds the new credentials, but other instances may still serve the previous values until the write propagates." -Sev 'Warning' } $ConfigTable = Get-CippTable -tablename 'Config' @@ -172,7 +199,9 @@ function Invoke-ExecCreateSAMApp { Write-Warning "Failed to create SAM certificate during setup, the weekly token update will create it: $($_.Exception.Message)" } - $Results = @{'message' = "Successfully $state the application registration. The application ID is $($AppId.appid). You may continue to the next step."; severity = 'success' } + $CredentialNote = if ($CertificateOnly) { ' This is a certificate-only setup - no client secret was created, and CIPP will authenticate with the SAM certificate.' } else { '' } + $Results = @{'message' = "Successfully $state the application registration. The application ID is $($AppId.appid).$CredentialNote You may continue to the next step."; severity = 'success' } + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "Successfully $state CIPP-SAM application registration AppId=$($AppId.appId) (certificate-only=$CertificateOnly)" -Sev 'Info' } } catch { @@ -184,6 +213,7 @@ function Invoke-ExecCreateSAMApp { } else { $_.Exception.Message } + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "Failed to create or update CIPP-SAM application registration: $ErrorDetail" -Sev 'Error' $Results = [pscustomobject]@{'Results' = "Failed. $($_.InvocationInfo.ScriptLineNumber): $ErrorDetail"; severity = 'failed' } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecSamSecretStatus.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecSamSecretStatus.ps1 index 06cdc3c424844..982d802e5072a 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecSamSecretStatus.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecSamSecretStatus.ps1 @@ -18,6 +18,25 @@ function Invoke-ExecSamSecretStatus { try { $null = Get-CIPPAuthentication + # Certificate mode has no client secret to wait on - report ready once the SAM certificate + # exists, so the sign-in step isn't gated on a secret that will never be created. + if ($env:CertificateAuthMode) { + $Cert = Get-CIPPSAMCertificate -SkipCache -ErrorAction SilentlyContinue + if ($Cert) { + $Results = @{ ready = $true; reason = 'certificate' } + } else { + $Results = @{ + ready = $false + reason = 'certificatePending' + message = 'Preparing the SAM certificate. This unlocks automatically once the certificate is registered on the application.' + } + } + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = $Results + }) + } + # Same placeholder set the deployment template seeds and Get-CIPPAuthentication skips. $PlaceholderPattern = '^(LongApplicationId|AppSecret|RefreshToken|tenantId)$' $Configured = $env:ApplicationID -and $env:ApplicationID -notmatch $PlaceholderPattern -and diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecTokenExchange.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecTokenExchange.ps1 index dcf2154812270..0d986f51f3f49 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecTokenExchange.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecTokenExchange.ps1 @@ -38,46 +38,71 @@ function Invoke-ExecTokenExchange { Write-LogMessage -API $APIName -message "Making token request to $TokenUrl" -Sev 'Info' - # Make sure we get the latest authentication + # Make sure we get the latest authentication (also refreshes $env:CertificateAuthMode) $auth = Get-CIPPAuthentication - # Check if environment variable is already set and not the placeholder value + # Convert the token request to form data first, so the chosen credential - client secret + # or certificate assertion - can be layered on top. + $FormData = @{} + foreach ($key in $TokenRequest.PSObject.Properties.Name) { + $FormData[$key] = $TokenRequest.$key + } + + # Resolve the client secret, tolerating its absence. A secret-less certificate-only setup + # never has one, and certificate mode does not use it even when it exists. + $ClientSecret = $null if ($auth -and $env:ApplicationSecret -and $env:ApplicationSecret -ne 'AppSecret') { $ClientSecret = $env:ApplicationSecret - Write-LogMessage -API $APIName -message 'Using client secret from environment variable' -Sev 'Debug' } elseif ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { $DevSecretsTable = Get-CIPPTable -tablename 'DevSecrets' $Secret = Get-CIPPAzDataTableEntity @DevSecretsTable -Filter "PartitionKey eq 'Secret' and RowKey eq 'Secret'" $ClientSecret = $Secret.applicationsecret - Write-LogMessage -API $APIName -message 'Retrieved client secret from development secrets' -Sev 'Debug' } else { try { $ClientSecret = (Get-CippKeyVaultSecret -VaultName $kv -Name 'applicationsecret' -AsPlainText) - Write-LogMessage -API $APIName -message 'Retrieved client secret from key vault' -Sev 'Debug' } catch { - Write-LogMessage -API $APIName -message "Failed to retrieve client secret: $($_.Exception.Message)" -Sev 'Error' - throw "Failed to retrieve client secret: $($_.Exception.Message)" + Write-LogMessage -API $APIName -message "Could not retrieve client secret (expected for a certificate-only setup): $($_.Exception.Message)" -Sev 'Debug' } } + $SecretUsable = $ClientSecret -and $ClientSecret -ne 'AppSecret' - # Check if client secret is still the default placeholder value from ARM template - if (!$ClientSecret -or $ClientSecret -eq 'AppSecret') { - Write-LogMessage -API $APIName -message 'Client secret is not configured' -Sev 'Error' - throw 'Application secret has not been configured. Please complete the setup process first.' - } + # Use a signed certificate assertion instead of the client secret when certificate mode is + # on, or when there is no usable secret at all (a secret-less setup - the only way to auth). + $UseCertAssertion = [bool]$env:CertificateAuthMode -or -not $SecretUsable - # Convert token request to form data and add client secret - $FormData = @{} - foreach ($key in $TokenRequest.PSObject.Properties.Name) { - $FormData[$key] = $TokenRequest.$key - } - - # Add client_secret to the form data if not already present - if (!$FormData.ContainsKey('client_secret')) { + if ($UseCertAssertion) { + $AppId = $FormData['client_id'] + if (!$AppId) { throw 'Token request is missing client_id; cannot build a certificate assertion.' } + $SAMCert = Get-CIPPSAMCertificate -SkipCache + if (-not $SAMCert) { + throw 'Certificate authentication is required but no SAM certificate is available. Complete the application step first, then retry.' + } + # Assertion audience = the tenant the sign-in targets ($env:TenantID). The body's tenantId is + # actually the app id, so it is not a valid audience; fall back to the multi-tenant authority. + $GuidPattern = '^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$' + $AssertionTenant = if ($env:TenantID -match $GuidPattern) { $env:TenantID } else { 'organizations' } + $FormData.Remove('client_secret') + $FormData['client_assertion_type'] = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' + Write-LogMessage -API $APIName -message 'Using the SAM certificate assertion for the token exchange' -Sev 'Debug' + } elseif (!$FormData.ContainsKey('client_secret')) { $FormData['client_secret'] = $ClientSecret } - $Results = Invoke-RestMethod -Uri $TokenUrl -Method Post -Body $FormData -ContentType 'application/x-www-form-urlencoded' -ErrorAction Stop -SkipHttpErrorCheck + # AADSTS700027 fires transiently while a freshly registered certificate propagates - retry + # briefly, regenerating the assertion each attempt so it never expires mid-retry. + $MaxAttempts = if ($UseCertAssertion) { 3 } else { 1 } + for ($Attempt = 1; $Attempt -le $MaxAttempts; $Attempt++) { + if ($UseCertAssertion) { + $FormData['client_assertion'] = New-CIPPCertificateAssertion -TenantId $AssertionTenant -AppId $AppId -Certificate $SAMCert.Certificate + } + $Results = Invoke-RestMethod -Uri $TokenUrl -Method Post -Body $FormData -ContentType 'application/x-www-form-urlencoded' -ErrorAction Stop -SkipHttpErrorCheck + if ($UseCertAssertion -and $Attempt -lt $MaxAttempts -and $Results.error_description -match 'AADSTS700027') { + Write-LogMessage -API $APIName -message "Certificate not yet recognized by the token service (attempt $Attempt of $MaxAttempts). Retrying." -Sev 'Warning' + Start-Sleep -Seconds 10 + continue + } + break + } } catch { $ErrorMessage = $_.Exception $Results = @{ diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecUpdateRefreshToken.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecUpdateRefreshToken.ps1 index 321388cd22332..0e6fe7123457e 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecUpdateRefreshToken.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecUpdateRefreshToken.ps1 @@ -9,6 +9,8 @@ function Invoke-ExecUpdateRefreshToken { [CmdletBinding()] param($Request, $TriggerMetadata) + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers $KV = Get-CippKeyVaultName try { @@ -72,8 +74,10 @@ function Invoke-ExecUpdateRefreshToken { } else { $TenantName = $request.body.tenantId } + $Result = "Successfully updated the credentials for $($TenantName). You may continue to the next step, or add additional tenants if required." + Write-LogMessage -headers $Headers -API $APIName -tenant $Request.body.tenantId -message $Result -Sev 'Info' $Results = @{ - 'resultText' = "Successfully updated the credentials for $($TenantName). You may continue to the next step, or add additional tenants if required." + 'resultText' = $Result 'state' = 'success' } @@ -82,6 +86,9 @@ function Invoke-ExecUpdateRefreshToken { Body = $Results }) } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to update refresh token credentials. $($_.InvocationInfo.ScriptLineNumber): $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant $Request.body.tenantId -message $Result -Sev 'Error' -LogData $ErrorMessage $Results = [pscustomobject]@{ 'Results' = @{ resultText = "Failed. $($_.InvocationInfo.ScriptLineNumber): $($_.Exception.message)" diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-RemoveContact.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-RemoveContact.ps1 index 9407a92f8fc9c..89ea2f79e8fc2 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-RemoveContact.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-RemoveContact.ps1 @@ -21,7 +21,7 @@ Function Invoke-RemoveContact { Identity = $GUID } $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-MailContact' -cmdParams $Params -UseSystemMailbox $true - Write-LogMessage -Headers $Request.Headers -API $APIName -tenant $TenantFilter -message "Deleted contact $GUID" -sev Debug + Write-LogMessage -Headers $Request.Headers -API $APIName -tenant $TenantFilter -message "Deleted contact $GUID" -sev 'Info' $Result = "Deleted $Mail" $StatusCode = [HttpStatusCode]::OK } catch { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecMailboxMobileDevices.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecMailboxMobileDevices.ps1 index 559b47142f3ba..50c153b02f6dc 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecMailboxMobileDevices.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecMailboxMobileDevices.ps1 @@ -9,12 +9,19 @@ Function Invoke-ExecMailboxMobileDevices { param($Request, $TriggerMetadata) $APIName = $Request.Params.CIPPEndpoint - # Interact with query parameters or the body of the request. + # Interact with query parameters or the body of the request. This is a state-changing action, + # so the frontend dispatches it as a POST; keep the query fallback for backwards compatibility. + $UserId = $Request.Body.Userid ?? $Request.Query.Userid + $Guid = $Request.Body.guid ?? $Request.Query.guid + $DeviceId = $Request.Body.deviceid ?? $Request.Query.deviceid + $Quarantine = $Request.Body.Quarantine ?? $Request.Query.Quarantine + $Delete = $Request.Body.Delete ?? $Request.Query.Delete + $TenantFilter = $Request.Body.tenantFilter ?? $Request.Query.tenantFilter Try { - $MobileResults = Set-CIPPMobileDevice -UserId $request.query.Userid -Guid $request.query.guid -DeviceId $request.query.deviceid -Quarantine $request.query.Quarantine -tenantFilter $request.query.tenantfilter -APIName $APINAME -Delete $Request.query.Delete -Headers $Request.Headers + $MobileResults = Set-CIPPMobileDevice -UserId $UserId -Guid $Guid -DeviceId $DeviceId -Quarantine $Quarantine -tenantFilter $TenantFilter -APIName $APINAME -Delete $Delete -Headers $Request.Headers $Results = [pscustomobject]@{'Results' = $MobileResults } } catch { - $Results = [pscustomobject]@{'Results' = "Failed $($request.query.Userid): $($_.Exception.Message)" } + $Results = [pscustomobject]@{'Results' = "Failed $($UserId): $($_.Exception.Message)" } } return ([HttpResponseContext]@{ diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecScheduleForwardingVacation.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecScheduleForwardingVacation.ps1 index f6475705c88d2..a7252770b63bf 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecScheduleForwardingVacation.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecScheduleForwardingVacation.ps1 @@ -84,6 +84,7 @@ function Invoke-ExecScheduleForwardingVacation { }) -hidden $false $Result = "Successfully scheduled forwarding vacation mode for $UserDisplay." + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev 'Info' $StatusCode = [HttpStatusCode]::OK } catch { $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecScheduleMailboxVacation.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecScheduleMailboxVacation.ps1 index 04eac0b8393ea..8a8a9c4c3e7f2 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecScheduleMailboxVacation.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecScheduleMailboxVacation.ps1 @@ -124,6 +124,7 @@ function Invoke-ExecScheduleMailboxVacation { Add-CIPPScheduledTask -Task $RemoveTaskBody -hidden $false $Result = "Successfully scheduled mailbox vacation mode for $DelegateDisplay -> $OwnerDisplay." + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev 'Info' $StatusCode = [HttpStatusCode]::OK } catch { $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecScheduleOOOVacation.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecScheduleOOOVacation.ps1 index 79fcbea7d9208..693ed5b50adbe 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecScheduleOOOVacation.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecScheduleOOOVacation.ps1 @@ -86,6 +86,7 @@ function Invoke-ExecScheduleOOOVacation { }) -hidden $false $Result = "Successfully scheduled OOO vacation mode for $UserDisplay." + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev 'Info' $StatusCode = [HttpStatusCode]::OK } catch { $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListMailboxes.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListMailboxes.ps1 index 500bdf5ff006c..56202680ad0ee 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListMailboxes.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListMailboxes.ps1 @@ -5,7 +5,7 @@ function Invoke-ListMailboxes { .ROLE Exchange.Mailbox.Read .DESCRIPTION - Lists Exchange Online mailboxes for a tenant. Supports UseReportDB=true query parameter to retrieve cached data from the reporting database for significantly better performance, especially when querying AllTenants. + Lists Exchange Online mailboxes for a tenant. Supports UseReportDB=true query parameter to retrieve cached data from the reporting database for significantly better performance, especially when querying AllTenants. When manualPagination is also set, one page is returned per request as { Results, Metadata } with a continuation token in Metadata.nextLink. #> [CmdletBinding()] param($Request, $TriggerMetadata) @@ -13,10 +13,30 @@ function Invoke-ListMailboxes { $TenantFilter = $Request.Query.tenantFilter # Serve from the reporting database cache instead of live Graph. Much faster, especially for AllTenants. $UseReportDB = $Request.Query.UseReportDB -eq $true + # Return one page per request as { Results, Metadata } with a continuation token in Metadata.nextLink; cached reads only. + $ManualPagination = $Request.Query.manualPagination -and [System.Convert]::ToBoolean($Request.Query.manualPagination) try { # If UseReportDB is specified, retrieve from report database if ($UseReportDB) { try { + if ($ManualPagination) { + # Rows per page, clamped between 250 and 10000. Defaults to 5000. + $PageSize = 5000 + if ($Request.Query.PageSize -as [int]) { + $PageSize = [Math]::Min([Math]::Max([int]$Request.Query.PageSize, 250), 10000) + } + # Continuation token from the previous page's Metadata.nextLink; opaque to callers. + $Page = Get-CIPPMailboxesReport -TenantFilter $TenantFilter -PageSize $PageSize -ContinuationToken $Request.Query.nextLink -ErrorAction Stop + $Metadata = @{} + if ($Page.NextToken) { $Metadata.nextLink = $Page.NextToken } + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = [PSCustomObject]@{ + Results = @($Page.Items) + Metadata = $Metadata + } + }) + } $GraphRequest = Get-CIPPMailboxesReport -TenantFilter $TenantFilter -ErrorAction Stop $StatusCode = [HttpStatusCode]::OK } catch { @@ -33,7 +53,14 @@ function Invoke-ListMailboxes { # Original live EXO logic $ZeroArchiveGuid = '00000000-0000-0000-0000-000000000000' - $Select = 'id,ExchangeGuid,ArchiveGuid,UserPrincipalName,DisplayName,PrimarySMTPAddress,RecipientType,RecipientTypeDetails,EmailAddresses,WhenSoftDeleted,IsInactiveMailbox,ForwardingSmtpAddress,DeliverToMailboxAndForward,ForwardingAddress,HiddenFromAddressListsEnabled,ExternalDirectoryObjectId,IsDirSynced,MessageCopyForSendOnBehalfEnabled,MessageCopyForSentAsEnabled,PersistedCapabilities,LitigationHoldEnabled,LitigationHoldDate,LitigationHoldDuration,ComplianceTagHoldApplied,RetentionHoldEnabled,InPlaceHolds,RetentionPolicy,AutoExpandingArchiveEnabled' + # Picker mode: address autocompletes only need the address + name, so skip the heavy field + # set, the per-mailbox computed properties, and the extra Get-OrganizationConfig call. + $Minimal = $Request.Query.Minimal -eq $true + if ($Minimal) { + $Select = 'id,UserPrincipalName,DisplayName,PrimarySMTPAddress' + } else { + $Select = 'id,ExchangeGuid,ArchiveGuid,UserPrincipalName,DisplayName,PrimarySMTPAddress,RecipientType,RecipientTypeDetails,EmailAddresses,WhenSoftDeleted,IsInactiveMailbox,ForwardingSmtpAddress,DeliverToMailboxAndForward,ForwardingAddress,HiddenFromAddressListsEnabled,ExternalDirectoryObjectId,IsDirSynced,MessageCopyForSendOnBehalfEnabled,MessageCopyForSentAsEnabled,PersistedCapabilities,LitigationHoldEnabled,LitigationHoldDate,LitigationHoldDuration,ComplianceTagHoldApplied,RetentionHoldEnabled,InPlaceHolds,RetentionPolicy,AutoExpandingArchiveEnabled' + } $ExoRequest = @{ tenantid = $TenantFilter cmdlet = 'Get-Mailbox' @@ -73,6 +100,18 @@ function Invoke-ListMailboxes { } } + if ($Minimal) { + $GraphRequest = @(New-ExoRequest @ExoRequest) | Select-Object Id, + @{ Name = 'UPN'; Expression = { $_.'UserPrincipalName' } }, + @{ Name = 'displayName'; Expression = { $_.'DisplayName' } }, + @{ Name = 'primarySmtpAddress'; Expression = { $_.'PrimarySMTPAddress' } } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @($GraphRequest) + }) + } + $OrgAutoExpandingArchiveEnabled = (New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-OrganizationConfig' -Select 'AutoExpandingArchiveEnabled').AutoExpandingArchiveEnabled $GraphRequest = foreach ($Mailbox in @(New-ExoRequest @ExoRequest)) { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Reports/Invoke-ListMailFlowReports.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Reports/Invoke-ListMailFlowReports.ps1 new file mode 100644 index 0000000000000..e1769d38d4424 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Reports/Invoke-ListMailFlowReports.ps1 @@ -0,0 +1,66 @@ +function Invoke-ListMailFlowReports { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Exchange.Mailbox.Read + .DESCRIPTION + Returns Exchange Online mail flow reports: disposition counts by day (Get-MailFlowStatusReport) + and top sender/recipient summaries (Get-MailTrafficSummaryReport). Both support up to 90 days. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + try { + $TenantFilter = $Request.Query.tenantFilter + $ReportType = $Request.Query.reportType ?? 'MailFlowStatus' + $Days = [Math]::Min([Math]::Max([int]($Request.Query.days ?? 14), 1), 90) + $StartDate = (Get-Date).AddDays(-$Days).ToUniversalTime().ToString('s') + $EndDate = (Get-Date).ToUniversalTime().ToString('s') + + $Report = switch ($ReportType) { + 'MailFlowStatus' { + $CmdParams = @{ StartDate = $StartDate; EndDate = $EndDate } + New-ExoRequest -TenantId $TenantFilter -Cmdlet 'Get-MailFlowStatusReport' -CmdParams $CmdParams | + Select-Object @{ Name = 'Date'; Expression = { ([DateTime]$_.Date).ToString('yyyy-MM-dd') } }, Direction, EventType, @{ Name = 'Count'; Expression = { $_.MessageCount } } + } + 'TrafficSummary' { + $Category = $Request.Query.category ?? 'TopMailSender' + $CmdParams = @{ Category = $Category; StartDate = $StartDate; EndDate = $EndDate } + # C1/C2/C3 are generic columns whose meaning depends on the category; for the Top* categories + # C1 is the address/name and C2 the message count. + New-ExoRequest -TenantId $TenantFilter -Cmdlet 'Get-MailTrafficSummaryReport' -CmdParams $CmdParams | + Select-Object @{ Name = 'Name'; Expression = { $_.C1 } }, @{ Name = 'Count'; Expression = { $_.C2 } }, @{ Name = 'Extra'; Expression = { $_.C3 } } + } + default { + throw "Unknown report type '$ReportType'. Supported: MailFlowStatus, TrafficSummary." + } + } + + $StatusCode = [HttpStatusCode]::OK + $Body = @{ + Results = @($Report) + Metadata = @{ + ReportType = $ReportType + StartDate = $StartDate + EndDate = $EndDate + } + } + } catch { + $ErrorMessage = Get-NormalizedError -message $_.Exception.Message + Write-LogMessage -headers $Headers -API $APIName -tenant $($TenantFilter) -message "Failed to retrieve mail flow report. Error: $ErrorMessage" -Sev 'Error' + $StatusCode = [HttpStatusCode]::InternalServerError + $Body = @{ + Results = @() + Metadata = @{ Error = "Failed to retrieve mail flow report: $ErrorMessage" } + } + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = $Body + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-AddSpamFilterTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-AddSpamFilterTemplate.ps1 index d371af5d956fa..8069797a7b5c2 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-AddSpamFilterTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-AddSpamFilterTemplate.ps1 @@ -32,12 +32,12 @@ Function Invoke-AddSpamFilterTemplate { PartitionKey = 'SpamfilterTemplate' } $Result = "Successfully created Spam Filter Template: $($Request.Body.name) with GUID $GUID" - Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Debug' + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Info' $StatusCode = [HttpStatusCode]::OK } catch { $ErrorMessage = Get-CippException -Exception $_ $Result = "Failed to create Spam Filter Template: $($ErrorMessage.NormalizedError)" - Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Error' -LogData $ErrorMessage + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Error' -LogData $ErrorMessage $StatusCode = [HttpStatusCode]::InternalServerError } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListUserReportedMessage.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListUserReportedMessage.ps1 new file mode 100644 index 0000000000000..8db86ac0c7c00 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListUserReportedMessage.ps1 @@ -0,0 +1,85 @@ +function Invoke-ListUserReportedMessage { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Exchange.SpamFilter.Read + .DESCRIPTION + Retrieves the raw EML content of a user reported message by its Internet Message ID. Tries the quarantine store first (Export-QuarantineMessage), then falls back to reading the message from the recipient's or reporter's mailbox via Graph. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter + $InternetMessageId = $Request.Query.InternetMessageId + $Mailboxes = @($Request.Query.RecipientEmail, $Request.Query.ReporterEmail) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique + + try { + if ([string]::IsNullOrWhiteSpace($InternetMessageId)) { throw 'This submission has no Internet Message ID, so the message content cannot be retrieved.' } + + $EmlBase64 = $null + $Source = $null + $Errors = [System.Collections.Generic.List[string]]::new() + + # A reported message that was (or later got) quarantined can always be exported from quarantine + try { + $Quarantined = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-QuarantineMessage' -cmdParams @{ MessageId = $InternetMessageId } | Select-Object -First 1 + if ($Quarantined.Identity) { + $Export = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Export-QuarantineMessage' -cmdParams @{ Identity = $Quarantined.Identity } + if (-not [string]::IsNullOrEmpty($Export.Eml)) { + $EmlBase64 = $Export.Eml + $Source = 'Quarantine' + } + } + } catch { + $Errors.Add("Quarantine lookup: $(Get-NormalizedError -Message $_.Exception.Message)") + } + + # Otherwise the copy in the mailbox (Deleted Items included) is the only source left + if (-not $EmlBase64) { + $SafeMessageId = ConvertTo-CIPPODataFilterValue -Value $InternetMessageId -Type String + $Filter = [System.Uri]::EscapeDataString("internetMessageId eq '$SafeMessageId'") + foreach ($Mailbox in $Mailboxes) { + try { + $SafeMailbox = [System.Uri]::EscapeDataString($Mailbox) + $Message = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/users/$SafeMailbox/messages?`$filter=$Filter&`$select=id&`$top=5" -tenantid $TenantFilter | Select-Object -First 1 + if ($Message.id) { + $Mime = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/users/$SafeMailbox/messages/$($Message.id)/`$value" -tenantid $TenantFilter -ReturnRawResponse + if ($Mime.StatusCode -eq 200 -and -not [string]::IsNullOrEmpty($Mime.Content)) { + $EmlBase64 = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes([string]$Mime.Content)) + $Source = 'Mailbox' + break + } + } + } catch { + $Errors.Add("Mailbox $($Mailbox): $(Get-NormalizedError -Message $_.Exception.Message)") + } + } + } + + if (-not $EmlBase64) { + $Detail = if ($Errors.Count -gt 0) { " ($($Errors -join ' | '))" } else { '' } + throw "The reported message could not be retrieved: it is not in quarantine and could not be read from the mailbox. Mailbox retrieval requires the Mail.Read Graph permission on the CIPP-SAM application.$Detail" + } + + $EmlContent = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($EmlBase64)) + $Header = ($EmlContent -split "\r?\n\r?\n", 2)[0] + $Body = @{ + 'InternetMessageId' = $InternetMessageId + 'Message' = $EmlContent + 'EmlBase64' = $EmlBase64 + 'Header' = $Header + 'Source' = $Source + } + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + $StatusCode = [HttpStatusCode]::Forbidden + $Body = $ErrorMessage + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = $Body + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListUserReportedMessages.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListUserReportedMessages.ps1 new file mode 100644 index 0000000000000..448cabeaebe87 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListUserReportedMessages.ps1 @@ -0,0 +1,64 @@ +function Invoke-ListUserReportedMessages { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Exchange.SpamFilter.Read + .DESCRIPTION + Lists user reported email threat submissions (Defender Submissions with source 'user') for a tenant. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter + + try { + if ($TenantFilter -eq 'AllTenants') { + $GraphRequest = @() + $Metadata = [PSCustomObject]@{ + QueueMessage = 'User reported messages are loaded per tenant. Select a tenant to view its reported messages.' + } + } else { + $Submissions = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/security/threatSubmission/emailThreats' -tenantid $TenantFilter -AsApp $true + $Results = [System.Collections.Generic.List[object]]::new() + foreach ($Submission in $Submissions) { + # Admin submissions share the same Graph collection; this page only covers user reports + if ($Submission.source -ne 'user') { continue } + $Results.Add([PSCustomObject]@{ + ReportedDateTime = $Submission.createdDateTime + ReceivedDateTime = $Submission.receivedDateTime + Subject = $Submission.subject ?? $Submission.emailSubject + Sender = $Submission.sender + SenderIP = $Submission.senderIP + RecipientEmail = $Submission.recipientEmailAddress + ReportedBy = $Submission.createdBy.user.displayName + ReporterEmail = $Submission.createdBy.user.email + Category = $Submission.category + OriginalCategory = $Submission.originalCategory + Status = $Submission.status + ResultCategory = $Submission.result.category + ResultDetail = $Submission.result.detail + AdminReviewResult = $Submission.adminReview.reviewResult + InternetMessageId = $Submission.internetMessageId + Id = $Submission.id + Tenant = $TenantFilter + }) + } + $GraphRequest = $Results | Sort-Object -Property ReportedDateTime -Descending + } + $Body = [PSCustomObject]@{ + Results = @($GraphRequest) + Metadata = $Metadata + } + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + $StatusCode = [HttpStatusCode]::Forbidden + $Body = $ErrorMessage + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = $Body + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Tools/Invoke-ExecHistoricalSearch.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Tools/Invoke-ExecHistoricalSearch.ps1 new file mode 100644 index 0000000000000..5bf9b6c789f6f --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Tools/Invoke-ExecHistoricalSearch.ps1 @@ -0,0 +1,99 @@ +function Invoke-ExecHistoricalSearch { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Exchange.Mailbox.ReadWrite + .DESCRIPTION + Starts or cancels an Exchange Online historical search. Historical searches cover up to 90 days, + deliver results as CSV (max 100,000 rows) and are limited to 250 submissions per day per tenant. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + try { + $TenantFilter = $Request.Body.tenantFilter + $Action = $Request.Body.Action ?? 'Start' + + if ($Action -eq 'Stop') { + $JobId = $Request.Body.jobId + if ([string]::IsNullOrEmpty($JobId)) { + throw 'jobId is required to cancel a historical search.' + } + $null = New-ExoRequest -TenantId $TenantFilter -Cmdlet 'Stop-HistoricalSearch' -CmdParams @{ JobId = $JobId } + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Cancelled historical search $JobId" -Sev 'Info' + $Result = "Cancelled historical search $JobId. Cancelled searches still count toward the daily quota." + } else { + $CmdParams = @{ + ReportTitle = $Request.Body.reportTitle + ReportType = $Request.Body.reportType.value ?? $Request.Body.reportType + } + if ([string]::IsNullOrEmpty($CmdParams.ReportTitle)) { + throw 'A report title is required.' + } + if ([string]::IsNullOrEmpty($CmdParams.ReportType)) { + throw 'A report type is required.' + } + + foreach ($DateField in @('startDate', 'endDate')) { + $Value = $Request.Body.$DateField + if ([string]::IsNullOrEmpty($Value)) { + throw 'A start and end date are required.' + } + $Parsed = $Value -match '^\d+$' ? [DateTimeOffset]::FromUnixTimeSeconds([int64]$Value).UtcDateTime : ([DateTime]$Value).ToUniversalTime() + $CmdParams[($DateField.Substring(0, 1).ToUpper() + $DateField.Substring(1))] = $Parsed.ToString('s') + } + + $Senders = @($Request.Body.senderAddress).value ?? @($Request.Body.senderAddress) | Where-Object { -not [string]::IsNullOrEmpty($_) } + if ($Senders) { + $CmdParams.SenderAddress = @($Senders) + } + $Recipients = @($Request.Body.recipientAddress).value ?? @($Request.Body.recipientAddress) | Where-Object { -not [string]::IsNullOrEmpty($_) } + if ($Recipients) { + $CmdParams.RecipientAddress = @($Recipients) + } + if (![string]::IsNullOrEmpty($Request.Body.messageId)) { + $CmdParams.MessageID = @($Request.Body.messageId -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + } + if (!$CmdParams.SenderAddress -and !$CmdParams.RecipientAddress -and !$CmdParams.MessageID) { + throw 'At least one sender address, recipient address or message ID filter is required.' + } + + $Direction = $Request.Body.direction.value ?? $Request.Body.direction + if (![string]::IsNullOrEmpty($Direction) -and $Direction -ne 'All') { + $CmdParams.Direction = $Direction + } + $DeliveryStatus = $Request.Body.deliveryStatus.value ?? $Request.Body.deliveryStatus + if (![string]::IsNullOrEmpty($DeliveryStatus)) { + $CmdParams.DeliveryStatus = $DeliveryStatus + } + if (![string]::IsNullOrEmpty($Request.Body.originalClientIP)) { + $CmdParams.OriginalClientIP = $Request.Body.originalClientIP + } + $NotifyAddresses = @($Request.Body.notifyAddress).value ?? @($Request.Body.notifyAddress) | Where-Object { -not [string]::IsNullOrEmpty($_) } + if ($NotifyAddresses) { + $CmdParams.NotifyAddress = @($NotifyAddresses) + } + + $Job = New-ExoRequest -TenantId $TenantFilter -Cmdlet 'Start-HistoricalSearch' -CmdParams $CmdParams + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Started historical search '$($CmdParams.ReportTitle)' ($($CmdParams.ReportType))" -Sev 'Info' + $Result = "Started historical search '$($CmdParams.ReportTitle)'. Job ID: $($Job.JobId)" + } + + $StatusCode = [HttpStatusCode]::OK + $Body = @{ Results = @($Result) } + } catch { + $ErrorMessage = Get-NormalizedError -message $_.Exception.Message + Write-LogMessage -headers $Headers -API $APIName -tenant $($TenantFilter) -message "Historical search action failed. Error: $ErrorMessage" -Sev 'Error' + $StatusCode = [HttpStatusCode]::InternalServerError + $Body = @{ Results = @("Historical search action failed: $ErrorMessage") } + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = $Body + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Tools/Invoke-ExecMailboxRestore.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Tools/Invoke-ExecMailboxRestore.ps1 index eebb3e554d11f..52aa78859ec24 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Tools/Invoke-ExecMailboxRestore.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Tools/Invoke-ExecMailboxRestore.ps1 @@ -6,6 +6,8 @@ function Invoke-ExecMailboxRestore { Exchange.Mailbox.ReadWrite #> Param($Request, $TriggerMetadata) + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers try { $Action = $Request.Query.Action ?? $Request.Body.Action $Identity = $Request.Query.Identity ?? $Request.Body.Identity @@ -106,6 +108,7 @@ function Invoke-ExecMailboxRestore { } $GraphRequest = New-ExoRequest @ExoRequest + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $SuccessMessage -Sev 'Info' $Body = @{ RestoreRequest = $GraphRequest @@ -113,11 +116,13 @@ function Invoke-ExecMailboxRestore { } $StatusCode = [HttpStatusCode]::OK } catch { - $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to $($Action ?? 'create') mailbox restore request: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev 'Error' -LogData $ErrorMessage $StatusCode = [HttpStatusCode]::OK $Body = @{ RestoreRequest = $null - Results = @($ErrorMessage) + Results = @($ErrorMessage.NormalizedError) colour = 'danger' } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Tools/Invoke-ListHistoricalSearches.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Tools/Invoke-ListHistoricalSearches.ps1 new file mode 100644 index 0000000000000..c0b12a60a1741 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Tools/Invoke-ListHistoricalSearches.ps1 @@ -0,0 +1,49 @@ +function Invoke-ListHistoricalSearches { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Exchange.Mailbox.Read + .DESCRIPTION + Lists Exchange Online historical searches (async message trace/report jobs) submitted in the last 10 days. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + try { + $TenantFilter = $Request.Query.tenantFilter + $CmdParams = @{} + if (![string]::IsNullOrEmpty($Request.Query.jobId)) { + $CmdParams.JobId = $Request.Query.jobId + } + + # FileUrl is the legacy admin.protection.outlook.com download endpoint. It is not GDAP-aware + # (401s for delegated partners by every path, including the modern EAC), so the CSV is only + # retrievable by a customer-native admin login, or delivered to a customer NotifyAddress. + $Searches = New-ExoRequest -TenantId $TenantFilter -Cmdlet 'Get-HistoricalSearch' -CmdParams $CmdParams | + Sort-Object -Property SubmitDate -Descending | + Select-Object JobId, ReportTitle, ReportType, Status, JobProgress, Rows, FileRows, ErrorDescription, FileUrl, + @{ Name = 'SubmitDate'; Expression = { $_.SubmitDate ? ([DateTime]$_.SubmitDate).ToString('u') : $null } }, + @{ Name = 'CompletionDate'; Expression = { $_.CompletionDate ? ([DateTime]$_.CompletionDate).ToString('u') : $null } }, + @{ Name = 'StartDate'; Expression = { $_.StartDate ? ([DateTime]$_.StartDate).ToString('u') : $null } }, + @{ Name = 'EndDate'; Expression = { $_.EndDate ? ([DateTime]$_.EndDate).ToString('u') : $null } }, + @{ Name = 'SenderAddress'; Expression = { @($_.SenderAddress) -join ', ' } }, + @{ Name = 'RecipientAddress'; Expression = { @($_.RecipientAddress) -join ', ' } } + + $StatusCode = [HttpStatusCode]::OK + $Body = @($Searches) + } catch { + $ErrorMessage = Get-NormalizedError -message $_.Exception.Message + Write-LogMessage -headers $Headers -API $APIName -tenant $($TenantFilter) -message "Failed to list historical searches. Error: $ErrorMessage" -Sev 'Error' + $StatusCode = [HttpStatusCode]::InternalServerError + $Body = @{ Results = @("Failed to list historical searches: $ErrorMessage") } + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = $Body + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Tools/Invoke-ListMessageTrace.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Tools/Invoke-ListMessageTrace.ps1 index 17976e179d6de..b8fde5f6afce9 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Tools/Invoke-ListMessageTrace.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Tools/Invoke-ListMessageTrace.ps1 @@ -5,82 +5,203 @@ function Invoke-ListMessageTrace { .ROLE Exchange.Mailbox.Read .DESCRIPTION - Traces email message delivery in Exchange Online, searchable by message ID, sender, recipient, and date range. + Traces email delivery in Exchange Online via the Graph message trace API + (/beta/admin/exchange/tracing/messageTraces), searchable by sender, recipient, subject, + status, IP, message ID and date range. Graph works over GDAP/app-only where the legacy + reporting endpoints do not. Requires the "Transport Data Platform" service principal + (8bd644d1-64a1-4d4b-ae52-2e0cbf64e373) in the tenant; it is provisioned on demand. Until + that SP activates (which can take hours), or where the Graph permission is not yet + consented, the request falls back to Get-MessageTraceV2 so results are returned immediately. #> [CmdletBinding()] param($Request, $TriggerMetadata) $APIName = $Request.Params.CIPPEndpoint $Headers = $Request.Headers + $TransportAppId = '8bd644d1-64a1-4d4b-ae52-2e0cbf64e373' + $GraphBase = 'https://graph.microsoft.com/beta/admin/exchange/tracing/messageTraces' + $State = @{ Fallback = $false } - try { - $TenantFilter = $Request.Body.tenantFilter + # Escape a value for an OData string literal (single quotes are doubled). + function ConvertTo-ODataLiteral { param([string]$Value) return ($Value -replace "'", "''") } - if ($Request.Body.MessageId) { - $SearchParams = @{ 'MessageId' = $Request.Body.messageId } - } else { - $SearchParams = @{} - if ($Request.Body.days) { - $Days = $Request.Body.days - $SearchParams.StartDate = (Get-Date).AddDays(-$Days).ToUniversalTime().ToString('s') - $SearchParams.EndDate = (Get-Date).ToUniversalTime().ToString('s') - } else { - if ($Request.Body.startDate) { - if ($Request.Body.startDate -match '^\d+$') { - $SearchParams.StartDate = [DateTimeOffset]::FromUnixTimeSeconds([int64]$Request.Body.startDate).UtcDateTime.ToString('s') - } else { - $SearchParams.StartDate = [DateTime]::ParseExact($Request.Body.startDate, 'yyyy-MM-ddTHH:mm:ssZ', $null).ToUniversalTime().ToString('s') + # Runs the Graph scriptblock; on a missing service principal or a consent/permission error it + # provisions the SP (best effort) and runs the Get-MessageTraceV2 scriptblock instead, so the + # first call in a tenant still returns data while Graph activates. + $RunWithFallback = { + param($GraphBlock, $V2Block) + try { + return (& $GraphBlock) + } catch { + $Message = $_.Exception.Message + $SpMissing = $Message -match $TransportAppId -or $Message -match 'service principal' + $Consent = $Message -match 'Authorization_RequestDenied' -or $Message -match 'insufficient' -or $Message -match 'consent' -or $Message -match 'Forbidden' -or $Message -match 'AADSTS' + if ($SpMissing -or $Consent) { + # Provision the SP only when it is genuinely absent. Once created it can still take + # hours to activate, during which Graph keeps reporting it missing - checking first + # avoids re-issuing (and log-spamming) a create that would fail as 'already in use'. + if ($SpMissing) { + $SpPresent = $false + try { + $SpPresent = [bool](New-GraphGetRequest -Uri "https://graph.microsoft.com/beta/servicePrincipals(appId='$TransportAppId')" -tenantid $TenantFilter -NoAuthCheck $true).id + } catch { + $SpPresent = $false } - } - if ($Request.Body.endDate) { - if ($Request.Body.endDate -match '^\d+$') { - $SearchParams.EndDate = [DateTimeOffset]::FromUnixTimeSeconds([int64]$Request.Body.endDate).UtcDateTime.ToString('s') - } else { - $SearchParams.EndDate = [DateTime]::ParseExact($Request.Body.endDate, 'yyyy-MM-ddTHH:mm:ssZ', $null).ToUniversalTime().ToString('s') + if (-not $SpPresent) { + try { + $null = New-GraphPostRequest -Uri 'https://graph.microsoft.com/beta/servicePrincipals' -tenantid $TenantFilter -type POST -body (@{ appId = $TransportAppId } | ConvertTo-Json -Compress) -NoAuthCheck $true + } catch { + # Lost a race with a concurrent provision - the V2 fallback covers this request. + } } } + $State.Fallback = $true + return (& $V2Block) } + throw + } + } - if ($Request.Body.status) { - $SearchParams.Add('Status', $Request.Body.status.value) + try { + $TenantFilter = $Request.Body.tenantFilter + $Recipient = $Request.Body.recipient.value ?? $Request.Body.recipient + + if ($Request.Body.traceDetail) { + $DetailUri = "$GraphBase/$($Request.Body.ID)/getDetailsByRecipient(recipientAddress='$(ConvertTo-ODataLiteral $Recipient)')" + $GraphDetail = { + New-GraphGetRequest -uri $DetailUri -tenantid $TenantFilter -AsApp $true | + Select-Object @{ Name = 'Date'; Expression = { $_.dateTime ? ([DateTime]$_.dateTime).ToString('u') : $null } }, + @{ Name = 'Event'; Expression = { $_.event } }, + @{ Name = 'Action'; Expression = { $_.action } }, + @{ Name = 'Detail'; Expression = { $_.description } } } - if (![string]::IsNullOrEmpty($Request.Body.fromIP)) { - $SearchParams.Add('FromIP', $Request.Body.fromIP) + $V2Detail = { + New-ExoRequest -TenantId $TenantFilter -Cmdlet 'Get-MessageTraceDetailV2' -CmdParams @{ MessageTraceId = $Request.Body.ID; RecipientAddress = $Recipient } | + Select-Object @{ Name = 'Date'; Expression = { $_.Date.ToString('u') } }, Event, Action, Detail + } + $Detail = @(& $RunWithFallback $GraphDetail $V2Detail) + $Body = @{ Results = @($Detail); Metadata = @{ TraceDetail = $true; Source = $State.Fallback ? 'Get-MessageTraceV2' : 'Graph' } } + return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::OK; Body = $Body }) + } + + # Parse the shared search inputs. + if ($Request.Body.days) { + # Single UtcNow capture keeps the window exactly N days, not N days plus call latency. + $End = [DateTime]::UtcNow + $Start = $End.AddDays(-[double]$Request.Body.days) + } elseif ($Request.Body.startDate -or $Request.Body.endDate) { + $Start = $Request.Body.startDate ? ($Request.Body.startDate -match '^\d+$' ? [DateTimeOffset]::FromUnixTimeSeconds([int64]$Request.Body.startDate).UtcDateTime : [DateTime]::Parse($Request.Body.startDate, [cultureinfo]::InvariantCulture, 'AdjustToUniversal')) : $null + $End = $Request.Body.endDate ? ($Request.Body.endDate -match '^\d+$' ? [DateTimeOffset]::FromUnixTimeSeconds([int64]$Request.Body.endDate).UtcDateTime : [DateTime]::Parse($Request.Body.endDate, [cultureinfo]::InvariantCulture, 'AdjustToUniversal')) : $null + } + if ($Start -and $End) { + if (($End - $Start).TotalDays -gt 10) { + throw 'Message trace queries are limited to a 10 day window. Narrow the date range, or use a historical search for longer periods.' } - if (![string]::IsNullOrEmpty($Request.Body.toIP)) { - $SearchParams.Add('ToIP', $Request.Body.toIP) + if (([DateTime]::UtcNow - $Start).TotalDays -gt 90) { + throw 'Message trace data is only available for the last 90 days.' } } - if ($Request.Body.recipient) { - $SearchParams.Add('RecipientAddress', $($Request.Body.recipient.value ?? $Request.Body.recipient)) + $MessageId = $Request.Body.messageId ?? $Request.Body.MessageId + $MessageTraceId = $Request.Body.messageTraceId + # No explicit window plus a message id: sweep backwards in 10-day pages (Graph's window + # cap) instead of relying on Graph's silent ~48h default, which misses older messages. + $Sweep = (-not $Start -and -not $End) -and (![string]::IsNullOrEmpty($MessageId) -or ![string]::IsNullOrEmpty($MessageTraceId)) + $Senders = @(@($Request.Body.sender).value ?? @($Request.Body.sender) | Where-Object { -not [string]::IsNullOrEmpty($_) }) + $Recipients = @(@($Request.Body.recipient).value ?? @($Request.Body.recipient) | Where-Object { -not [string]::IsNullOrEmpty($_) }) + $Statuses = @(@($Request.Body.status).value ?? @($Request.Body.status) | Where-Object { -not [string]::IsNullOrEmpty($_) }) + $ToIP = $Request.Body.toIP + $FromIP = $Request.Body.fromIP + $Subject = $Request.Body.subject + $SubjectType = ($Request.Body.subjectFilterType.value ?? $Request.Body.subjectFilterType ?? 'StartsWith') + + # Graph search: build the $filter clause. + $Filters = [System.Collections.Generic.List[string]]::new() + if ($Start -and $End) { + $Filters.Add("receivedDateTime ge $($Start.ToString('yyyy-MM-ddTHH:mm:ssZ')) and receivedDateTime le $($End.ToString('yyyy-MM-ddTHH:mm:ssZ'))") } - if ($Request.Body.sender) { - $SearchParams.Add('SenderAddress', $($Request.Body.sender.value ?? $Request.Body.sender)) + if (![string]::IsNullOrEmpty($MessageId)) { $Filters.Add("messageId eq '$(ConvertTo-ODataLiteral $MessageId)'") } + if (![string]::IsNullOrEmpty($MessageTraceId)) { $Filters.Add("id eq '$(ConvertTo-ODataLiteral $MessageTraceId)'") } + if ($Senders) { $Filters.Add('(' + (($Senders | ForEach-Object { "senderAddress eq '$(ConvertTo-ODataLiteral $_)'" }) -join ' or ') + ')') } + if ($Recipients) { $Filters.Add('(' + (($Recipients | ForEach-Object { "recipientAddress eq '$(ConvertTo-ODataLiteral $_)'" }) -join ' or ') + ')') } + if ($Statuses) { + # Graph status enum is camelCase (delivered, filteredAsSpam, ...); the UI sends PascalCase. + $Filters.Add('(' + (($Statuses | ForEach-Object { "status eq '$($_.Substring(0, 1).ToLower() + $_.Substring(1))'" }) -join ' or ') + ')') } + if (![string]::IsNullOrEmpty($ToIP)) { $Filters.Add("toIP eq '$(ConvertTo-ODataLiteral $ToIP)'") } + # Note: Graph cannot filter on fromIP (returned but not queryable); the V2 fallback can. + if (![string]::IsNullOrEmpty($Subject)) { + $Func = switch ($SubjectType) { 'Contains' { 'contains' } 'EndsWith' { 'endswith' } default { 'startswith' } } + $Filters.Add("$Func(subject, '$(ConvertTo-ODataLiteral $Subject)')") + } + $Uri = $GraphBase + '?$top=5000' + if ($Filters.Count -gt 0) { $Uri += "&`$filter=$([uri]::EscapeDataString($Filters -join ' and '))" } - $Trace = if ($Request.Body.traceDetail) { - $CmdParams = @{ - MessageTraceId = $Request.Body.ID - RecipientAddress = $Request.Body.recipient + $GraphSearch = { + New-GraphGetRequest -uri $Uri -tenantid $TenantFilter -AsApp $true | + Select-Object @{ Name = 'MessageTraceId'; Expression = { $_.id } }, + @{ Name = 'MessageId'; Expression = { $_.messageId } }, + @{ Name = 'Status'; Expression = { $_.status ? ($_.status.Substring(0, 1).ToUpper() + $_.status.Substring(1)) : $null } }, + @{ Name = 'Subject'; Expression = { $_.subject } }, + @{ Name = 'RecipientAddress'; Expression = { $_.recipientAddress } }, + @{ Name = 'SenderAddress'; Expression = { $_.senderAddress } }, + @{ Name = 'Received'; Expression = { $_.receivedDateTime ? ([DateTime]$_.receivedDateTime).ToString('u') : $null } }, + @{ Name = 'Size'; Expression = { $_.size } }, + @{ Name = 'FromIP'; Expression = { $_.fromIP } }, + @{ Name = 'ToIP'; Expression = { $_.toIP } } + } + $V2Search = { + $CmdParams = @{ ResultSize = 5000 } + if ($Start -and $End) { $CmdParams.StartDate = $Start.ToString('s'); $CmdParams.EndDate = $End.ToString('s') } + if (![string]::IsNullOrEmpty($MessageId)) { $CmdParams.MessageId = @($MessageId) } + if (![string]::IsNullOrEmpty($MessageTraceId)) { $CmdParams.MessageTraceId = $MessageTraceId } + if ($Senders) { $CmdParams.SenderAddress = @($Senders) } + if ($Recipients) { $CmdParams.RecipientAddress = @($Recipients) } + if ($Statuses) { $CmdParams.Status = @($Statuses) } + if (![string]::IsNullOrEmpty($ToIP)) { $CmdParams.ToIP = $ToIP } + if (![string]::IsNullOrEmpty($FromIP)) { $CmdParams.FromIP = $FromIP } + if (![string]::IsNullOrEmpty($Subject)) { $CmdParams.Subject = $Subject; $CmdParams.SubjectFilterType = $SubjectType } + New-ExoRequest -TenantId $TenantFilter -Cmdlet 'Get-MessageTraceV2' -CmdParams $CmdParams | + Select-Object MessageTraceId, MessageId, Status, Subject, RecipientAddress, SenderAddress, + @{ Name = 'Received'; Expression = { $_.Received.ToString('u') } }, Size, FromIP, ToIP + } + + if ($Sweep) { + # 9 pages of 10 days covers the 90 day lookback limit. + $Trace = @() + for ($Window = 0; $Window -lt 9; $Window++) { + $End = [DateTime]::UtcNow.AddDays(-10 * $Window) + $Start = $End.AddDays(-10) + $WindowFilters = [System.Collections.Generic.List[string]]::new($Filters) + $WindowFilters.Insert(0, "receivedDateTime ge $($Start.ToString('yyyy-MM-ddTHH:mm:ssZ')) and receivedDateTime le $($End.ToString('yyyy-MM-ddTHH:mm:ssZ'))") + $Uri = $GraphBase + '?$top=5000' + if ($WindowFilters.Count -gt 0) { $Uri += "&`$filter=$([uri]::EscapeDataString($WindowFilters -join ' and '))" } + $Trace = @(& $RunWithFallback $GraphSearch $V2Search) + if (@($Trace).Count -gt 0) { break } } - New-ExoRequest -TenantId $TenantFilter -Cmdlet 'Get-MessageTraceDetailV2' -CmdParams $CmdParams | Select-Object @{ Name = 'Date'; Expression = { $_.Date.ToString('u') } }, Event, Action, Detail } else { - Write-Information ($SearchParams | ConvertTo-Json) - - New-ExoRequest -TenantId $TenantFilter -Cmdlet 'Get-MessageTraceV2' -CmdParams $SearchParams | Select-Object MessageTraceId, Status, Subject, RecipientAddress, SenderAddress, @{ Name = 'Received'; Expression = { $_.Received.ToString('u') } }, FromIP, ToIP - Write-LogMessage -headers $Headers -API $APIName -tenant $($TenantFilter) -message 'Executed message trace' -Sev 'Info' + $Trace = @(& $RunWithFallback $GraphSearch $V2Search) + } + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message 'Executed message trace' -Sev 'Info' + $Metadata = @{ Returned = @($Trace).Count; Source = $State.Fallback ? 'Get-MessageTraceV2' : 'Graph' } + if ($State.Fallback) { + $Metadata.Note = 'Served via Get-MessageTraceV2 while the Graph message trace service principal activates for this tenant (this can take a few hours on first use).' } + $Body = @{ Results = @($Trace); Metadata = $Metadata } + $StatusCode = [HttpStatusCode]::OK } catch { - Write-LogMessage -headers $Headers -API $APIName -tenant $($TenantFilter) -message "Failed executing Message Trace. Error: $($_.Exception.Message)" -Sev 'Error' - $Trace = @{Status = "Failed to retrieve message trace $($_.Exception.Message)" } + $ErrorMessage = Get-NormalizedError -message $_.Exception.Message + Write-LogMessage -headers $Headers -API $APIName -tenant $($TenantFilter) -message "Failed executing Message Trace. Error: $ErrorMessage" -Sev 'Error' + $Body = @{ + Results = @() + Metadata = @{ Error = "Failed to retrieve message trace: $ErrorMessage" } + } $StatusCode = [HttpStatusCode]::InternalServerError } return ([HttpResponseContext]@{ - StatusCode = ($StatusCode ?? [HttpStatusCode]::OK) - Body = @($Trace) + StatusCode = $StatusCode + Body = $Body }) - } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Transport/Invoke-AddConnectionFilterTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Transport/Invoke-AddConnectionFilterTemplate.ps1 index 7b7709391e2e4..729e7e9f6da4c 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Transport/Invoke-AddConnectionFilterTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Transport/Invoke-AddConnectionFilterTemplate.ps1 @@ -35,12 +35,12 @@ function Invoke-AddConnectionFilterTemplate { PartitionKey = 'ConnectionfilterTemplate' } $Result = "Successfully created Connection Filter Template: $($Request.Body.name) with GUID $GUID" - Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Debug' + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Info' $StatusCode = [HttpStatusCode]::OK } catch { $ErrorMessage = Get-CippException -Exception $_ $Result = "Failed to create Connection Filter Template: $($ErrorMessage.NormalizedError)" - Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Error' -LogData $ErrorMessage + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Error' -LogData $ErrorMessage $StatusCode = [HttpStatusCode]::InternalServerError } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Transport/Invoke-AddExConnectorTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Transport/Invoke-AddExConnectorTemplate.ps1 index 1b5b965a5f84d..67c3d5e582b5d 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Transport/Invoke-AddExConnectorTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Transport/Invoke-AddExConnectorTemplate.ps1 @@ -16,6 +16,10 @@ Function Invoke-AddExConnectorTemplate { try { $GUID = (New-Guid).GUID + # Posted from the row action; without a name the template lists blank. + if ([string]::IsNullOrWhiteSpace($Request.Body.name)) { + throw 'Connector template name is required but was not provided' + } $Select = if ($Request.Body.cippconnectortype -eq 'outbound') { @( 'name', 'AllAcceptedDomains', 'CloudServicesMailEnabled', 'Comment', 'Confirm', 'ConnectorSource', 'ConnectorType', 'Enabled', 'IsTransportRuleScoped', 'RecipientDomains', 'RouteAllMessagesViaOnPremises', 'SmartHosts', 'TestMode', 'TlsDomain', 'TlsSettings', 'UseMXRecord' @@ -40,12 +44,12 @@ Function Invoke-AddExConnectorTemplate { PartitionKey = 'ExConnectorTemplate' } $Result = "Successfully created Connector Template: $($Request.Body.name) with GUID $GUID" - Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Debug' + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Info' $StatusCode = [HttpStatusCode]::OK } catch { $ErrorMessage = Get-CippException -Exception $_ $Result = "Failed to create Connector Template: $($ErrorMessage.NormalizedError)" - Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Error' + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Error' $StatusCode = [HttpStatusCode]::InternalServerError } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Transport/Invoke-AddTransportTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Transport/Invoke-AddTransportTemplate.ps1 index bbf830c0a691f..df00724a6385d 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Transport/Invoke-AddTransportTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Transport/Invoke-AddTransportTemplate.ps1 @@ -15,6 +15,10 @@ Function Invoke-AddTransportTemplate { try { $GUID = (New-Guid).GUID + # Posted from the row action; without a name the template lists blank and deploys with no parameters. + if (-not $Request.Body.PowerShellCommand -and [string]::IsNullOrWhiteSpace($Request.Body.Name)) { + throw 'Transport rule template name is required but was not provided' + } $JSON = if ($request.body.PowerShellCommand) { Write-Host 'PowerShellCommand' $request.body.PowerShellCommand | ConvertFrom-Json @@ -33,12 +37,12 @@ Function Invoke-AddTransportTemplate { RowKey = "$GUID" PartitionKey = 'TransportTemplate' } - Write-LogMessage -Headers $Headers -API $APINAME -message "Created Transport Rule Template $($Request.body.name) with GUID $GUID" -Sev Debug + Write-LogMessage -Headers $Headers -API $APINAME -tenant 'Global' -message "Created Transport Rule Template $($Request.body.name) with GUID $GUID" -Sev 'Info' $body = [pscustomobject]@{'Results' = "Created Transport Rule Template $($Request.body.name) with GUID $GUID" } $StatusCode = [HttpStatusCode]::OK } catch { $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -Headers $Headers -API $APINAME -message "Failed to create Transport Rule Template: $($ErrorMessage.NormalizedError)" -Sev Error -LogData $ErrorMessage + Write-LogMessage -Headers $Headers -API $APINAME -tenant 'Global' -message "Failed to create Transport Rule Template: $($ErrorMessage.NormalizedError)" -Sev Error -LogData $ErrorMessage $body = [pscustomobject]@{'Results' = "Failed to create Transport Rule Template: $($ErrorMessage.NormalizedError)" } $StatusCode = [HttpStatusCode]::Forbidden } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Transport/Invoke-ListExConnectorTemplates.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Transport/Invoke-ListExConnectorTemplates.ps1 index c94ad02a045a7..b992745bebb5a 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Transport/Invoke-ListExConnectorTemplates.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Transport/Invoke-ListExConnectorTemplates.ps1 @@ -26,8 +26,10 @@ function Invoke-ListExConnectorTemplates { $GUID = $_.RowKey $Direction = $_.direction $data = $_.JSON | ConvertFrom-Json - $data | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $GUID -Force - $data | Add-Member -NotePropertyName 'cippconnectortype' -NotePropertyValue $Direction -Force + $data | Add-Member -NotePropertyMembers ([ordered]@{ + GUID = $GUID + cippconnectortype = $Direction + }) -Force $data } | Sort-Object -Property displayName } else { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Transport/Invoke-RemoveExConnector.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Transport/Invoke-RemoveExConnector.ps1 index 61bb16ae5c3c9..3c04f8c45c4f0 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Transport/Invoke-RemoveExConnector.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Transport/Invoke-RemoveExConnector.ps1 @@ -20,7 +20,7 @@ Function Invoke-RemoveExConnector { $null = New-ExoRequest -tenantid $TenantFilter -cmdlet "Remove-$($Type)Connector" -cmdParams $params -useSystemMailbox $true $Result = "Deleted Connector: $($Guid)" - Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Deleted connector $($Guid)" -sev Debug + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Deleted connector $($Guid)" -sev 'Info' $StatusCode = [HttpStatusCode]::OK } catch { $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddAppTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddAppTemplate.ps1 index 6dcf3031d2c26..896a34b4516f2 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddAppTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddAppTemplate.ps1 @@ -23,6 +23,17 @@ function Invoke-AddAppTemplate { $AppsList = [System.Collections.Generic.List[hashtable]]::new() foreach ($App in @($RawApps)) { $ConfigValue = if ($App.config -is [string]) { $App.config } else { $App.config | ConvertTo-Json -Depth 15 -Compress } + + # An IntuneBody with an id was read off Graph. Only Office/Edge can rebuild from one; + # CIPP cannot re-upload a customer's .intunewin, so the template could never deploy. + $AppType = [string]$App.appType + $ParsedConfig = $null + try { $ParsedConfig = $ConfigValue | ConvertFrom-Json -Depth 100 -ErrorAction Stop } catch { $ParsedConfig = $null } + if ($ParsedConfig.IntuneBody.id -and $AppType -notin @('officeApp', 'edgeApp')) { + $AppName = if ($App.appName) { [string]$App.appName } else { [string]$ParsedConfig.ApplicationName } + throw "'$AppName' is an existing Intune application with uploaded installer content. CIPP application templates are rebuilt from a package or script at deployment, so only Store, Chocolatey, Office, Edge, MSP and Custom (script) applications can be templated." + } + $AppsList.Add(@{ appType = [string]$App.appType appName = [string]$App.appName diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddEdgeApp.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddEdgeApp.ps1 new file mode 100644 index 0000000000000..da7116e13f553 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddEdgeApp.ps1 @@ -0,0 +1,50 @@ +function Invoke-AddEdgeApp { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + Endpoint.Application.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + $Tenants = ($Request.Body.selectedTenants | Where-Object { $AllowedTenants -contains $_.customerId -or $AllowedTenants -contains 'AllTenants' }).defaultDomainName + $Headers = $Request.Headers + $APIName = $Request.Params.CIPPEndpoint + if ('AllTenants' -in $Tenants) { $Tenants = (Get-Tenants).defaultDomainName } + $AssignTo = $Request.Body.AssignTo -eq 'customGroup' ? $Request.Body.CustomGroup : $Request.Body.AssignTo + $ExcludeGroup = $Request.Body.excludeGroup + + $Results = foreach ($Tenant in $Tenants) { + try { + $ExistingEdge = New-GraphGetRequest -Uri 'https://graph.microsoft.com/beta/deviceAppManagement/mobileApps' -tenantid $Tenant | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.windowsMicrosoftEdgeApp' } + if (!$ExistingEdge) { + $ObjBody = Get-CIPPEdgeAppBody -Config $Request.Body + if (-not $ObjBody) { + throw 'No Edge configuration could be built from the supplied settings.' + } + Write-Host ($ObjBody | ConvertTo-Json -Compress) + $EdgeAppID = New-GraphPostRequest -Uri 'https://graph.microsoft.com/beta/deviceAppManagement/mobileApps' -tenantid $Tenant -Body (ConvertTo-Json -InputObject $ObjBody -Depth 10) -Type POST + } else { + "Edge deployment already exists for $($Tenant)" + continue + } + Write-LogMessage -headers $Headers -API $APIName -tenant $($Tenant) -message "Added Edge app to $($Tenant)" -Sev 'Info' + if ($AssignTo -and $AssignTo -ne 'On') { + Set-CIPPAssignedApplication -ApplicationId $EdgeAppID.id -TenantFilter $Tenant -Intent 'Required' -GroupName $AssignTo -ExcludeGroup $ExcludeGroup -APIName $APIName -Headers $Headers + Write-LogMessage -headers $Headers -API $APIName -tenant $($Tenant) -message "Assigned Edge to $AssignTo" -Sev 'Info' + } + "Successfully added Edge App for $($Tenant)" + } catch { + $ErrorMessage = Get-CippException -Exception $_ + "Failed to add Edge App for $($Tenant): $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant $($Tenant) -message "Failed to add Edge App. Error: $($ErrorMessage.NormalizedError)" -Sev 'Error' -Logdata $ErrorMessage + continue + } + } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @{'Results' = $Results } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecDeployAppTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecDeployAppTemplate.ps1 index f8d6a9a07c8f5..75aa395bc5146 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecDeployAppTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecDeployAppTemplate.ps1 @@ -57,15 +57,17 @@ function Invoke-ExecDeployAppTemplate { $AppType = "$($App.appType ?? $App.AppType)" $RequestBody = $Config | ConvertTo-Json -Depth 100 | ConvertFrom-Json -Depth 100 - $RequestBody | Add-Member -NotePropertyName 'selectedTenants' -NotePropertyValue $SelectedTenants -Force - $RequestBody | Add-Member -NotePropertyName 'tenantFilter' -NotePropertyValue 'allTenants' -Force - + $RequestProps = [ordered]@{ + selectedTenants = $SelectedTenants + tenantFilter = 'allTenants' + } if ($OverrideAssignTo) { - $RequestBody | Add-Member -NotePropertyName 'AssignTo' -NotePropertyValue $OverrideAssignTo -Force + $RequestProps['AssignTo'] = $OverrideAssignTo if ($OverrideAssignTo -eq 'customGroup' -and $OverrideCustomGroup) { - $RequestBody | Add-Member -NotePropertyName 'CustomGroup' -NotePropertyValue $OverrideCustomGroup -Force + $RequestProps['CustomGroup'] = $OverrideCustomGroup } } + $RequestBody | Add-Member -NotePropertyMembers $RequestProps -Force $MockRequest = [PSCustomObject]@{ Body = $RequestBody @@ -80,16 +82,28 @@ function Invoke-ExecDeployAppTemplate { 'officeApp' { Invoke-AddOfficeApp -Request $MockRequest -TriggerMetadata $null } 'win32ScriptApp' { Invoke-AddWin32ScriptApp -Request $MockRequest -TriggerMetadata $null } 'mspApp' { Invoke-AddMSPApp -Request $MockRequest -TriggerMetadata $null } + 'edgeApp' { Invoke-AddEdgeApp -Request $MockRequest -TriggerMetadata $null } default { throw "Unknown app type: $AppType" } } - if ($HandlerResult.Body.Results) { + $DeployedResult = if ($HandlerResult.Body.Results) { $HandlerResult.Body.Results } elseif ($HandlerResult.Body) { $HandlerResult.Body } else { "Queued '$($App.appName)'" } + + # Handlers signal rejection by status code, not by throwing. + $HandlerStatus = [int]$HandlerResult.StatusCode + if ($HandlerStatus -ge 200 -and $HandlerStatus -lt 300) { + Write-LogMessage -headers $Headers -API $APIName -message "Deployed app '$($App.appName)' ($AppType) from template $TemplateId" -Sev 'Info' + $DeployedResult + } else { + $FailureText = "Failed to deploy app '$($App.appName)' ($AppType) from template $($TemplateId): $($DeployedResult -join '; ')" + Write-LogMessage -headers $Headers -API $APIName -message $FailureText -Sev 'Error' + $FailureText + } } catch { $ErrorMessage = Get-CippException -Exception $_ "Failed '$($App.appName)': $($ErrorMessage.NormalizedError)" diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ListApps.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ListApps.ps1 index 3e0d0795b3b06..258e9f032fb02 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ListApps.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ListApps.ps1 @@ -74,8 +74,10 @@ function Invoke-ListApps { } } - $App | Add-Member -NotePropertyName 'AppAssignment' -NotePropertyValue ($AppAssignment -join ', ') -Force - $App | Add-Member -NotePropertyName 'AppExclude' -NotePropertyValue ($AppExclude -join ', ') -Force + $App | Add-Member -NotePropertyMembers ([ordered]@{ + AppAssignment = ($AppAssignment -join ', ') + AppExclude = ($AppExclude -join ', ') + }) -Force $App } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddAssignmentFilterTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddAssignmentFilterTemplate.ps1 index 1c1acccfdad2d..d43f3555a4e28 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddAssignmentFilterTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddAssignmentFilterTemplate.ps1 @@ -47,11 +47,11 @@ function Invoke-AddAssignmentFilterTemplate { RowKey = "$GUID" PartitionKey = 'AssignmentFilterTemplate' } - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Created Assignment Filter template named $displayName with GUID $GUID" -Sev 'Debug' + Write-LogMessage -headers $Request.Headers -API $APINAME -tenant 'Global' -message "Created Assignment Filter template named $displayName with GUID $GUID" -Sev 'Info' $body = [pscustomobject]@{'Results' = 'Successfully added template' } } catch { - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Assignment Filter Template Creation failed: $($_.Exception.Message)" -Sev 'Error' + Write-LogMessage -headers $Request.Headers -API $APINAME -tenant 'Global' -message "Assignment Filter Template Creation failed: $($_.Exception.Message)" -Sev 'Error' $body = [pscustomobject]@{'Results' = "Assignment Filter Template Creation failed: $($_.Exception.Message)" } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntuneReusableSettingTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntuneReusableSettingTemplate.ps1 index c5d96910299a3..c72b161450f3b 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntuneReusableSettingTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntuneReusableSettingTemplate.ps1 @@ -75,12 +75,12 @@ function Invoke-AddIntuneReusableSettingTemplate { RawJSON = "$sanitizedJson" # ensure string serialization for table storage } - Write-LogMessage -headers $Headers -API $APINAME -message "Created Intune reusable setting template named $displayName with GUID $GUID" -Sev 'Debug' + Write-LogMessage -headers $Headers -API $APINAME -tenant 'Global' -message "Created Intune reusable setting template named $displayName with GUID $GUID" -Sev 'Info' $body = [pscustomobject]@{ Results = 'Successfully added reusable setting template' } $StatusCode = [System.Net.HttpStatusCode]::OK } catch { $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -headers $Headers -API $APINAME -message "Reusable Settings Template creation failed: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + Write-LogMessage -headers $Headers -API $APINAME -tenant 'Global' -message "Reusable Settings Template creation failed: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage $body = [pscustomobject]@{ Results = "Reusable Settings Template creation failed: $($ErrorMessage.NormalizedError)" } $StatusCode = [System.Net.HttpStatusCode]::InternalServerError } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntuneTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntuneTemplate.ps1 index 10380b5e2ad53..d0f3cb10012bf 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntuneTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntuneTemplate.ps1 @@ -35,7 +35,7 @@ function Invoke-AddIntuneTemplate { PartitionKey = 'IntuneTemplate' GUID = "$GUID" } - Write-LogMessage -headers $Headers -API $APIName -message "Created intune policy template named $($Request.Body.displayName) with GUID $GUID" -Sev 'Debug' + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "Created intune policy template named $($Request.Body.displayName) with GUID $GUID" -Sev 'Info' $Result = 'Successfully added template' $StatusCode = [HttpStatusCode]::OK @@ -73,7 +73,7 @@ function Invoke-AddIntuneTemplate { RowKey = "$GUID" PartitionKey = 'IntuneTemplate' } - Write-LogMessage -headers $Headers -API $APIName -message "Created intune policy template $($Request.Body.displayName) with GUID $GUID using an original policy from a tenant" -Sev 'Debug' + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "Created intune policy template $($Request.Body.displayName) with GUID $GUID using an original policy from a tenant" -Sev 'Info' $Result = 'Successfully added template' $StatusCode = [HttpStatusCode]::OK @@ -82,7 +82,7 @@ function Invoke-AddIntuneTemplate { $StatusCode = [HttpStatusCode]::InternalServerError $ErrorMessage = Get-CippException -Exception $_ $Result = "Intune Template Deployment failed: $($ErrorMessage.NormalizedMessage)" - Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Error' -LogData $ErrorMessage + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Error' -LogData $ErrorMessage } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-EditIntuneScript.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-EditIntuneScript.ps1 index 1674a0f7cd6d7..7c56fa60fdd1d 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-EditIntuneScript.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-EditIntuneScript.ps1 @@ -107,6 +107,8 @@ function Invoke-EditIntuneScript { try { $patchResult = New-GraphPOSTRequest @parms -type 'PATCH' + $Result = "Updated Intune $scriptType script $($Request.Body.ScriptId)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $Request.Body.TenantFilter -message $Result -Sev 'Info' $body = [pscustomobject]@{'Results' = $patchResult } return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::OK @@ -114,6 +116,8 @@ function Invoke-EditIntuneScript { }) } catch { $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to update Intune $scriptType script $($Request.Body.ScriptId): $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $Request.Body.TenantFilter -message $Result -Sev 'Error' -LogData $ErrorMessage return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::BadRequest Body = "Failed to update script: $($ErrorMessage.NormalizedError)" diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecCompareIntunePolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecCompareIntunePolicy.ps1 index 22dd50c3f0525..b735e80f3fde0 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecCompareIntunePolicy.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecCompareIntunePolicy.ps1 @@ -56,6 +56,9 @@ function Invoke-ExecCompareIntunePolicy { [string]$TemplateGuid, [string]$TenantFilter, [string]$Label, + # The standards template the caller is looking at, used only to name a template + # that no longer exists by the label the standard still carries for it. + [string]$StandardsTemplateId, # Set when the caller only needs the template's identity and type in order to find # the tenant's own copy. Skips the reusable settings sync, which writes to the # tenant and is the other source's job to run. @@ -63,10 +66,18 @@ function Invoke-ExecCompareIntunePolicy { ) $Table = Get-CippTable -tablename 'templates' - $TemplateEntity = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'IntuneTemplate' and RowKey eq '$TemplateGuid'" + # The picker surfaces the GUID column while the engine keys on RowKey. CIPP writes both + # to the same value, but a template re-synced by an older release can carry a JSON GUID + # that differs from its RowKey - accept either rather than calling a present template missing. + $SafeGuid = ConvertTo-CIPPODataFilterValue -Value $TemplateGuid -Type String + $TemplateEntity = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'IntuneTemplate' and (RowKey eq '$SafeGuid' or GUID eq '$SafeGuid')" | Select-Object -First 1 if (-not $TemplateEntity) { - throw "$Label : Template with GUID '$TemplateGuid' not found" + # A bare id sends people searching the template table for a row that is gone. Name + # it by the label the standards template still holds, and say where to fix it. + $StandardEntry = Get-StandardEntry -StandardsTemplateId $StandardsTemplateId -TemplateGuid $TemplateGuid + $KnownAs = if ($StandardEntry.TemplateList.label) { "'$($StandardEntry.TemplateList.label)' " } else { '' } + throw "$Label : Intune template $KnownAs($TemplateGuid) no longer exists in the template library. Remove it from the standards or drift template, or select the template again." } $JSONData = $TemplateEntity.JSON | ConvertFrom-Json -Depth 100 @@ -122,30 +133,41 @@ function Invoke-ExecCompareIntunePolicy { # A standard can be configured to replace a similarly named policy on deployment rather than # create a new one. That setting lives on the standards template, keyed by the Intune # template GUID, and is stored as a string by the settings form. - function Get-StandardFuzzyDistance { + # The IntuneTemplate entry of a standards template for one Intune template GUID - the + # settings (fuzzy distance, assignment target, verifyAssignments) the standard runs with. + function Get-StandardEntry { param( [string]$StandardsTemplateId, [string]$TemplateGuid ) - if (-not $StandardsTemplateId) { return 0 } + if (-not $StandardsTemplateId -or -not $TemplateGuid) { return $null } try { $Table = Get-CippTable -tablename 'templates' - $Entity = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'StandardsTemplateV2' and RowKey eq '$StandardsTemplateId'" - if (-not $Entity) { return 0 } + $SafeId = ConvertTo-CIPPODataFilterValue -Value $StandardsTemplateId -Type String + $Entity = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'StandardsTemplateV2' and RowKey eq '$SafeId'" + if (-not $Entity) { return $null } $Standards = ($Entity.JSON | ConvertFrom-Json -Depth 100).standards.IntuneTemplate - $Match = @($Standards) | Where-Object { $_.TemplateList.value -eq $TemplateGuid } | Select-Object -First 1 - - if ([string]::IsNullOrWhiteSpace($Match.levenshteinDistance)) { return 0 } - return [int]$Match.levenshteinDistance + return @($Standards) | Where-Object { $_.TemplateList.value -eq $TemplateGuid } | Select-Object -First 1 } catch { - Write-Warning "Could not read the fuzzy match distance from standards template '$StandardsTemplateId': $($_.Exception.Message)" - return 0 + Write-Warning "Could not read standards template '$StandardsTemplateId': $($_.Exception.Message)" + return $null } } + function Get-StandardFuzzyDistance { + param( + [string]$StandardsTemplateId, + [string]$TemplateGuid + ) + + $Match = Get-StandardEntry -StandardsTemplateId $StandardsTemplateId -TemplateGuid $TemplateGuid + if ([string]::IsNullOrWhiteSpace($Match.levenshteinDistance)) { return 0 } + return [int]$Match.levenshteinDistance + } + # Resolve a source descriptor to its policy object and metadata function Resolve-PolicySource { param( @@ -161,7 +183,7 @@ function Invoke-ExecCompareIntunePolicy { # tenantFilter is optional here - supplying it resolves the template the way the # standard would for that tenant instead of comparing the stored template verbatim. - $Template = Get-ComparisonTemplate -TemplateGuid $Source.templateGuid -TenantFilter $Source.tenantFilter -Label $Label + $Template = Get-ComparisonTemplate -TemplateGuid $Source.templateGuid -TenantFilter $Source.tenantFilter -Label $Label -StandardsTemplateId $Source.standardsTemplateId $LabelSuffix = if ($Source.tenantFilter) { "Template, resolved for $($Source.tenantFilter)" } else { 'Template' } return @{ @@ -182,7 +204,7 @@ function Invoke-ExecCompareIntunePolicy { # Resolved for the tenant, because the name a policy is deployed under can depend on # tenant variables in the payload. The reusable settings sync is skipped - it writes # to the tenant, and the baseline source already runs it. - $Template = Get-ComparisonTemplate -TemplateGuid $Source.templateGuid -TenantFilter $Source.tenantFilter -SkipReusableSync -Label $Label + $Template = Get-ComparisonTemplate -TemplateGuid $Source.templateGuid -TenantFilter $Source.tenantFilter -SkipReusableSync -Label $Label -StandardsTemplateId $Source.standardsTemplateId if (-not $Template.TemplateType) { throw "$Label : Template '$($Template.DisplayName)' has no policy type and none could be inferred. Re-import the template to fix this." @@ -243,13 +265,37 @@ function Invoke-ExecCompareIntunePolicy { $PolicyObj = $Policy.cippconfiguration | ConvertFrom-Json -Depth 100 + # The settings diff ignores assignments. When the standard verifies them, run the + # same comparison the standard runs, so this dialog cannot say "matches" while the + # drift report for the same policy says the assignments differ. + $AssignmentComparison = $null + $StandardEntry = Get-StandardEntry -StandardsTemplateId $Source.standardsTemplateId -TemplateGuid $Source.templateGuid + if ($StandardEntry.verifyAssignments -eq $true) { + try { + $ExistingAssignments = Get-CIPPIntunePolicyAssignments -PolicyId $Policy.id -TemplateType $Template.TemplateType -TenantFilter $Source.tenantFilter -ExistingPolicy $Policy + $AssignmentDetail = Compare-CIPPIntuneAssignments -ExistingAssignments $ExistingAssignments -ExpectedAssignTo $StandardEntry.AssignTo -ExpectedCustomGroup $StandardEntry.customGroup -ExpectedExcludeGroup $StandardEntry.excludeGroup -ExpectedAssignmentFilter $StandardEntry.assignmentFilter -ExpectedAssignmentFilterType ($StandardEntry.assignmentFilterType ?? 'include') -PolicyType $Template.TemplateType -TenantFilter $Source.tenantFilter + $AssignmentComparison = @{ + matched = if ($AssignmentDetail.Unknown) { $null } else { [bool]$AssignmentDetail.Matched } + unknown = [bool]$AssignmentDetail.Unknown + reasons = @($AssignmentDetail.Reasons) + } + } catch { + $AssignmentComparison = @{ + matched = $null + unknown = $true + reasons = @("Assignments could not be read: $($_.Exception.Message)") + } + } + } + return @{ - Object = $PolicyObj - TemplateType = $Template.TemplateType - Label = "$MatchedName ($($Source.tenantFilter))" - RawData = $PolicyObj - MatchType = $MatchType - MatchedName = $MatchedName + Object = $PolicyObj + TemplateType = $Template.TemplateType + Label = "$MatchedName ($($Source.tenantFilter))" + RawData = $PolicyObj + MatchType = $MatchType + MatchedName = $MatchedName + AssignmentComparison = $AssignmentComparison } } elseif ($Source.type -eq 'tenantPolicy') { @@ -389,6 +435,9 @@ function Invoke-ExecCompareIntunePolicy { # the result should be read - the caller says so rather than implying a name match. matchType = $ResolvedB.MatchType ?? $ResolvedA.MatchType matchedName = $ResolvedB.MatchedName ?? $ResolvedA.MatchedName + # Present only when the standard verifies assignments; null otherwise so the dialog + # says nothing about a dimension that was not compared. + assignmentComparison = $ResolvedB.AssignmentComparison ?? $ResolvedA.AssignmentComparison } Write-LogMessage -headers $Headers -API $APIName -message "Compared Intune policies: $($ResolvedA.Label) vs $($ResolvedB.Label) - $($ComparisonResults.Count) differences found" -Sev 'Info' diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListAppProtectionPolicies.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListAppProtectionPolicies.ps1 index 5e8cfd8286caa..b19899d6291f4 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListAppProtectionPolicies.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListAppProtectionPolicies.ps1 @@ -126,11 +126,13 @@ function Invoke-ListAppProtectionPolicies { } } - $Policy | Add-Member -NotePropertyName 'PolicyTypeName' -NotePropertyValue $policyType -Force - # $Policy | Add-Member -NotePropertyName 'URLName' -NotePropertyValue 'managedAppPolicies' -Force - $Policy | Add-Member -NotePropertyName 'PolicySource' -NotePropertyValue 'AppProtection' -Force - $Policy | Add-Member -NotePropertyName 'PolicyAssignment' -NotePropertyValue ($PolicyAssignment -join ', ') -Force - $Policy | Add-Member -NotePropertyName 'PolicyExclude' -NotePropertyValue ($PolicyExclude -join ', ') -Force + # URLName is intentionally not set here (already carried from the per-type bulk fetch). + $Policy | Add-Member -NotePropertyMembers ([ordered]@{ + PolicyTypeName = $policyType + PolicySource = 'AppProtection' + PolicyAssignment = ($PolicyAssignment -join ', ') + PolicyExclude = ($PolicyExclude -join ', ') + }) -Force $GraphRequest.Add($Policy) } } @@ -167,16 +169,18 @@ function Invoke-ListAppProtectionPolicies { } } - $Config | Add-Member -NotePropertyName 'PolicyTypeName' -NotePropertyValue $policyType -Force - $Config | Add-Member -NotePropertyName 'URLName' -NotePropertyValue 'mobileAppConfigurations' -Force - $Config | Add-Member -NotePropertyName 'PolicySource' -NotePropertyValue 'AppConfiguration' -Force - $Config | Add-Member -NotePropertyName 'PolicyAssignment' -NotePropertyValue ($PolicyAssignment -join ', ') -Force - $Config | Add-Member -NotePropertyName 'PolicyExclude' -NotePropertyValue ($PolicyExclude -join ', ') -Force - + $ConfigProps = [ordered]@{ + PolicyTypeName = $policyType + URLName = 'mobileAppConfigurations' + PolicySource = 'AppConfiguration' + PolicyAssignment = ($PolicyAssignment -join ', ') + PolicyExclude = ($PolicyExclude -join ', ') + } # Ensure isAssigned property exists for consistency if (-not $Config.PSObject.Properties['isAssigned']) { - $Config | Add-Member -NotePropertyName 'isAssigned' -NotePropertyValue $false -Force + $ConfigProps['isAssigned'] = $false } + $Config | Add-Member -NotePropertyMembers $ConfigProps -Force $GraphRequest.Add($Config) } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListAppleEnrollmentProfiles.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListAppleEnrollmentProfiles.ps1 index 66f111b067af1..e910537a93287 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListAppleEnrollmentProfiles.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListAppleEnrollmentProfiles.ps1 @@ -18,16 +18,18 @@ function Invoke-ListAppleEnrollmentProfiles { $DepOnboardingSettings = @(New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/depOnboardingSettings' -tenantid $TenantFilter) $Tokens = foreach ($DepSetting in $DepOnboardingSettings) { $Token = $DepSetting | Select-Object * - $Token | Add-Member -NotePropertyName 'daysUntilExpiration' -NotePropertyValue $( - if ($Token.tokenExpirationDateTime) { - [math]::Floor(([datetime]$Token.tokenExpirationDateTime - [datetime]::UtcNow).TotalDays) - } else { - $null - } - ) -Force - $Token | Add-Member -NotePropertyName 'isExpired' -NotePropertyValue $( - if ($Token.tokenExpirationDateTime) { ([datetime]$Token.tokenExpirationDateTime) -lt [datetime]::UtcNow } else { $false } - ) -Force + $Token | Add-Member -NotePropertyMembers ([ordered]@{ + daysUntilExpiration = $( + if ($Token.tokenExpirationDateTime) { + [math]::Floor(([datetime]$Token.tokenExpirationDateTime - [datetime]::UtcNow).TotalDays) + } else { + $null + } + ) + isExpired = $( + if ($Token.tokenExpirationDateTime) { ([datetime]$Token.tokenExpirationDateTime) -lt [datetime]::UtcNow } else { $false } + ) + }) -Force $Token } @@ -47,13 +49,15 @@ function Invoke-ListAppleEnrollmentProfiles { } $ProfileObject = $EnrollmentProfile | Select-Object * - $ProfileObject | Add-Member -NotePropertyName 'platform' -NotePropertyValue $Platform -Force - $ProfileObject | Add-Member -NotePropertyName 'profileType' -NotePropertyValue 'apple' -Force - $ProfileObject | Add-Member -NotePropertyName 'tokenId' -NotePropertyValue $DepSetting.id -Force - $ProfileObject | Add-Member -NotePropertyName 'tokenName' -NotePropertyValue $DepSetting.tokenName -Force - $ProfileObject | Add-Member -NotePropertyName 'appleIdentifier' -NotePropertyValue $DepSetting.appleIdentifier -Force - $ProfileObject | Add-Member -NotePropertyName 'tokenExpirationDateTime' -NotePropertyValue $DepSetting.tokenExpirationDateTime -Force - $ProfileObject | Add-Member -NotePropertyName 'tokenType' -NotePropertyValue $DepSetting.tokenType -Force + $ProfileObject | Add-Member -NotePropertyMembers ([ordered]@{ + platform = $Platform + profileType = 'apple' + tokenId = $DepSetting.id + tokenName = $DepSetting.tokenName + appleIdentifier = $DepSetting.appleIdentifier + tokenExpirationDateTime = $DepSetting.tokenExpirationDateTime + tokenType = $DepSetting.tokenType + }) -Force $ProfileObject } } catch { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListCompliancePolicies.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListCompliancePolicies.ps1 index 528cddb548eae..bb69466248b14 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListCompliancePolicies.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListCompliancePolicies.ps1 @@ -91,9 +91,11 @@ function Invoke-ListCompliancePolicies { } } - $Policy | Add-Member -NotePropertyName 'PolicyTypeName' -NotePropertyValue $policyType -Force - $Policy | Add-Member -NotePropertyName 'PolicyAssignment' -NotePropertyValue ($PolicyAssignment -join ', ') -Force - $Policy | Add-Member -NotePropertyName 'PolicyExclude' -NotePropertyValue ($PolicyExclude -join ', ') -Force + $Policy | Add-Member -NotePropertyMembers ([ordered]@{ + PolicyTypeName = $policyType + PolicyAssignment = ($PolicyAssignment -join ', ') + PolicyExclude = ($PolicyExclude -join ', ') + }) -Force $GraphRequest.Add($Policy) } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneScript.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneScript.ps1 index 7c98ea1e65879..3933ac1d599f4 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneScript.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneScript.ps1 @@ -114,8 +114,10 @@ function Invoke-ListIntuneScript { } } - $script | Add-Member -NotePropertyName 'ScriptAssignment' -NotePropertyValue ($ScriptAssignment -join ', ') -Force - $script | Add-Member -NotePropertyName 'ScriptExclude' -NotePropertyValue ($ScriptExclude -join ', ') -Force + $script | Add-Member -NotePropertyMembers ([ordered]@{ + ScriptAssignment = ($ScriptAssignment -join ', ') + ScriptExclude = ($ScriptExclude -join ', ') + }) -Force } $scripts | Add-Member -MemberType NoteProperty -Name scriptType -Value $scriptId diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneTemplates.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneTemplates.ps1 index f7cc6f7a511dc..d6d1ace41a9b0 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneTemplates.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneTemplates.ps1 @@ -38,14 +38,16 @@ function Invoke-ListIntuneTemplates { $JSONData = $Row.JSON | ConvertFrom-Json -Depth 100 -ErrorAction SilentlyContinue $data = $JSONData.RAWJson | ConvertFrom-Json -Depth 100 -ErrorAction SilentlyContinue if ($null -eq $data) { throw 'RAWJson is empty or not valid JSON' } - $data | Add-Member -NotePropertyName 'displayName' -NotePropertyValue $JSONData.Displayname -Force - $data | Add-Member -NotePropertyName 'description' -NotePropertyValue $JSONData.Description -Force - $data | Add-Member -NotePropertyName 'Type' -NotePropertyValue $JSONData.Type -Force - $data | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $Row.RowKey -Force - $data | Add-Member -NotePropertyName 'package' -NotePropertyValue $Row.Package -Force - $data | Add-Member -NotePropertyName 'isSynced' -NotePropertyValue (![string]::IsNullOrEmpty($Row.SHA)) -Force - $data | Add-Member -NotePropertyName 'source' -NotePropertyValue $Row.Source -Force - $data | Add-Member -NotePropertyName 'reusableSettings' -NotePropertyValue $JSONData.ReusableSettings -Force + $data | Add-Member -NotePropertyMembers ([ordered]@{ + displayName = $JSONData.Displayname + description = $JSONData.Description + Type = $JSONData.Type + GUID = $Row.RowKey + package = $Row.Package + isSynced = (![string]::IsNullOrEmpty($Row.SHA)) + source = $Row.Source + reusableSettings = $JSONData.ReusableSettings + }) -Force $data } catch { # A row that fails to parse used to be dropped from this list entirely, so a corrupt @@ -137,14 +139,16 @@ function Invoke-ListIntuneTemplates { try { $JSONData = $_.JSON | ConvertFrom-Json -Depth 100 -ErrorAction SilentlyContinue $data = $JSONData.RAWJson | ConvertFrom-Json -Depth 100 -ErrorAction SilentlyContinue - $data | Add-Member -NotePropertyName 'displayName' -NotePropertyValue $JSONData.Displayname -Force - $data | Add-Member -NotePropertyName 'description' -NotePropertyValue $JSONData.Description -Force - $data | Add-Member -NotePropertyName 'Type' -NotePropertyValue $JSONData.Type -Force - $data | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $_.RowKey -Force - $data | Add-Member -NotePropertyName 'package' -NotePropertyValue $_.Package -Force - $data | Add-Member -NotePropertyName 'source' -NotePropertyValue $_.Source -Force - $data | Add-Member -NotePropertyName 'isSynced' -NotePropertyValue (![string]::IsNullOrEmpty($_.SHA)) -Force - $data | Add-Member -NotePropertyName 'reusableSettings' -NotePropertyValue $JSONData.ReusableSettings -Force + $data | Add-Member -NotePropertyMembers ([ordered]@{ + displayName = $JSONData.Displayname + description = $JSONData.Description + Type = $JSONData.Type + GUID = $_.RowKey + package = $_.Package + source = $_.Source + isSynced = (![string]::IsNullOrEmpty($_.SHA)) + reusableSettings = $JSONData.ReusableSettings + }) -Force $data } catch { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-AddGroupTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-AddGroupTemplate.ps1 index d5c985745043f..5654cfaa16cb5 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-AddGroupTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-AddGroupTemplate.ps1 @@ -64,11 +64,11 @@ function Invoke-AddGroupTemplate { RowKey = "$GUID" PartitionKey = 'GroupTemplate' } - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Created Group template named $displayName with GUID $GUID" -Sev 'Debug' + Write-LogMessage -headers $Request.Headers -API $APINAME -tenant 'Global' -message "Created Group template named $displayName with GUID $GUID" -Sev 'Info' $body = [pscustomobject]@{'Results' = 'Successfully added template' } } catch { - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Group Template Creation failed: $($_.Exception.Message)" -Sev 'Error' + Write-LogMessage -headers $Request.Headers -API $APINAME -tenant 'Global' -message "Group Template Creation failed: $($_.Exception.Message)" -Sev 'Error' $body = [pscustomobject]@{'Results' = "Group Template Creation failed: $($_.Exception.Message)" } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-EditGroup.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-EditGroup.ps1 index e7042c65e82b2..eaf9675db2db6 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-EditGroup.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-EditGroup.ps1 @@ -509,10 +509,12 @@ function Invoke-EditGroup { $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/groups/$($GroupID)" -type PATCH -tenantid $TenantId -body (@{'visibility' = $VisibilityValue } | ConvertTo-Json) $Results.Add("Set group visibility to $VisibilityValue for $($GroupName).") + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantId -message "Set group visibility to $VisibilityValue for $($GroupName)." -Sev 'Info' } catch { $ErrorMessage = Get-CippException -Exception $_ Write-Warning "Error in visibility: $($ErrorMessage.NormalizedError) - $($_.InvocationInfo.ScriptLineNumber)" $Results.Add("Failed to set group visibility for $($GroupName): $($ErrorMessage.NormalizedError)") + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantId -message "Failed to set group visibility for $($GroupName). Error:$($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-ListGroups.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-ListGroups.ps1 index 2a9c3d5f99e4e..e396293a497d1 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-ListGroups.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-ListGroups.ps1 @@ -5,7 +5,7 @@ function Invoke-ListGroups { .ROLE Identity.Group.Read .DESCRIPTION - Lists Entra ID groups for a tenant, including group members and owners. Supports UseReportDB=true query parameter to retrieve cached data from the reporting database for significantly better performance, especially when querying AllTenants. + Lists Entra ID groups for a tenant, including group members and owners. Supports UseReportDB=true query parameter to retrieve cached data from the reporting database for significantly better performance, especially when querying AllTenants. When manualPagination is also set on a cached read, one page is returned per request as { Results, Metadata } with a continuation token in Metadata.nextLink. #> [CmdletBinding()] param($Request, $TriggerMetadata) @@ -24,6 +24,8 @@ function Invoke-ListGroups { $ExpandOwners = $Request.Query.expandOwners -eq $true # Serve from the reporting database cache instead of live Graph. Much faster, especially for AllTenants. $UseReportDB = $Request.Query.UseReportDB -eq $true + # Return one page per request as { Results, Metadata } with a continuation token in Metadata.nextLink; cached reads only. + $ManualPagination = $Request.Query.manualPagination -and [System.Convert]::ToBoolean($Request.Query.manualPagination) # members/owners read groups//..., so without a groupID the URL collapses to # groups//members and the failure surfaces as an opaque parameter binding error. @@ -37,6 +39,25 @@ function Invoke-ListGroups { # Cache path: list view only — skip when fetching a specific group's details if ((-not $GroupID) -and (-not $Members) -and (-not $Owners) -and ($TenantFilter -eq 'AllTenants' -or $UseReportDB)) { try { + if ($ManualPagination) { + # Rows per page, clamped between 100 and 5000. Defaults to 750: group rows + # carry full member arrays and run far heavier than other report types. + $PageSize = 750 + if ($Request.Query.PageSize -as [int]) { + $PageSize = [Math]::Min([Math]::Max([int]$Request.Query.PageSize, 100), 5000) + } + # Continuation token from the previous page's Metadata.nextLink; opaque to callers. + # Stream the cached blobs as raw JSON so member arrays are never re-parsed here. + $Page = Get-CIPPGroupsReport -TenantFilter $TenantFilter -PageSize $PageSize -ContinuationToken $Request.Query.nextLink -AsRawJson -ErrorAction Stop + $Metadata = @{} + if ($Page.NextToken) { $Metadata.nextLink = $Page.NextToken } + $MetadataJson = ConvertTo-Json -InputObject $Metadata -Depth 5 -Compress + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + ContentType = 'application/json' + Body = '{"Results":' + $Page.CippPagedJson + ',"Metadata":' + $MetadataJson + '}' + }) + } $GraphRequest = Get-CIPPGroupsReport -TenantFilter $TenantFilter -ErrorAction Stop $StatusCode = [HttpStatusCode]::OK } catch { @@ -148,26 +169,41 @@ function Invoke-ListGroups { # add a bulk sub-request above, so this branch always means "every group in the # tenant". The URL previously interpolated $GroupID and $Members, both necessarily # empty here, which produced 'groups//' and only worked because Graph tolerated it. - $GraphRequest = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/groups?`$top=999&select=$SelectString" -tenantid $TenantFilter | Select-Object *, @{ Name = 'primDomain'; Expression = { $_.mail -split '@' | Select-Object -Last 1 } }, - @{Name = 'membersCsv'; Expression = { $_.members.userPrincipalName -join ',' } }, - @{Name = 'ownersCsv'; Expression = { $_.owners.userPrincipalName -join ',' } }, - @{Name = 'teamsEnabled'; Expression = { if ($_.resourceProvisioningOptions -like '*Team*') { $true }else { $false } } }, - @{Name = 'groupType'; Expression = { + # membersCsv/ownersCsv are computed from the expanded navigation property, so emit + # them only when that expand was requested - otherwise they ride along always-empty + # and surface as permanently blank columns in the UI and exports. + $GroupProperties = [System.Collections.Generic.List[object]]::new() + $GroupProperties.Add('*') + $GroupProperties.Add(@{ Name = 'primDomain'; Expression = { $_.mail -split '@' | Select-Object -Last 1 } }) + if ($ExpandMembers) { + $GroupProperties.Add(@{Name = 'membersCsv'; Expression = { $_.members.userPrincipalName -join ',' } }) + } + if ($ExpandOwners -and -not $ExpandMembers) { + # hasOwner only - ownersCsv is deliberately not emitted here. Populating it would + # make subTableShowsCachedColumn() (frontend) swap the interactive "View owners" + # button (Add/Remove Owner actions) for a read-only CSV column tenant-wide, since + # that check only looks at whether the field is populated, not which caller asked + # for it. + $GroupProperties.Add(@{Name = 'hasOwner'; Expression = { $_.owners.Count -gt 0 } }) + } + $GroupProperties.Add(@{Name = 'teamsEnabled'; Expression = { if ($_.resourceProvisioningOptions -like '*Team*') { $true }else { $false } } }) + $GroupProperties.Add(@{Name = 'groupType'; Expression = { if ($_.groupTypes -contains 'Unified') { 'Microsoft 365' } elseif ($_.mailEnabled -and $_.securityEnabled) { 'Mail-Enabled Security' } elseif (-not $_.mailEnabled -and $_.securityEnabled) { 'Security' } elseif (([string]::isNullOrEmpty($_.groupTypes)) -and ($_.mailEnabled) -and (-not $_.securityEnabled)) { 'Distribution List' } } - }, - @{Name = 'calculatedGroupType'; Expression = { + }) + $GroupProperties.Add(@{Name = 'calculatedGroupType'; Expression = { if ($_.groupTypes -contains 'Unified') { 'm365' } elseif ($_.mailEnabled -and $_.securityEnabled) { 'security' } elseif (-not $_.mailEnabled -and $_.securityEnabled) { 'generic' } elseif (([string]::isNullOrEmpty($_.groupTypes)) -and ($_.mailEnabled) -and (-not $_.securityEnabled)) { 'distributionList' } } - }, - @{Name = 'dynamicGroupBool'; Expression = { if ($_.groupTypes -contains 'DynamicMembership') { $true } else { $false } } }, - @{Name = 'SID'; Expression = { Convert-AzureAdObjectIdToSid -ObjectID $_.id } } + }) + $GroupProperties.Add(@{Name = 'dynamicGroupBool'; Expression = { if ($_.groupTypes -contains 'DynamicMembership') { $true } else { $false } } }) + $GroupProperties.Add(@{Name = 'SID'; Expression = { Convert-AzureAdObjectIdToSid -ObjectID $_.id } }) + $GraphRequest = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/groups?`$top=999&select=$SelectString" -tenantid $TenantFilter | Select-Object -Property $GroupProperties $GraphRequest = @($GraphRequest | Sort-Object displayName) } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Roles/Invoke-AddPIMRoleSettingsTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Roles/Invoke-AddPIMRoleSettingsTemplate.ps1 new file mode 100644 index 0000000000000..0d44e886b48b3 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Roles/Invoke-AddPIMRoleSettingsTemplate.ps1 @@ -0,0 +1,140 @@ +function Invoke-AddPIMRoleSettingsTemplate { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + Identity.Role.ReadWrite + .SYNOPSIS + Create or update a PIM role settings template. + .DESCRIPTION + Saves a Privileged Identity Management role settings template. The settings are validated against CIPP's secure floor (activation must expire within 24 hours and require MFA or an authentication context plus a justification; eligibilities and active assignments must expire within a year; active assignments must require a justification). A template below the floor is rejected with the list of problems rather than silently adjusted. Pass GUID to update an existing template. Pass captureRoleId with a tenantFilter to build the settings from that role's current PIM policy in the tenant instead of supplying them: values below the secure floor are raised to the closest value the floor allows and every raise is reported in the results. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + try { + # Existing template GUID when editing. + $GUID = $Request.Body.GUID + $TemplateName = "$($Request.Body.templateName)".Trim() + $Description = "$($Request.Body.description)".Trim() + # PrivilegedRoles | AllRoles | Custom + $RoleScope = $Request.Body.roleScope.value ?? $Request.Body.roleScope + # Roles (label/value pairs of role template ids) when roleScope is Custom. + $Roles = @($Request.Body.roles | ForEach-Object { + if ($null -eq $_) { return } + $Value = $_.value ?? $_ + $Label = $_.label ?? $_.value ?? $_ + if ([string]::IsNullOrWhiteSpace("$Value")) { return } + @{ label = "$Label"; value = "$Value" } + }) + + if ([string]::IsNullOrWhiteSpace($TemplateName)) { throw 'templateName is required' } + + # Capture mode: build the settings from a role's current PIM policy in a tenant. + $CaptureRoleId = $Request.Body.captureRoleId.value ?? $Request.Body.captureRoleId + $Adjustments = @() + if (-not [string]::IsNullOrWhiteSpace("$CaptureRoleId")) { + $CaptureTenant = $Request.Body.tenantFilter.value ?? $Request.Body.tenantFilter + if ([string]::IsNullOrWhiteSpace("$CaptureTenant") -or "$CaptureTenant" -eq 'AllTenants') { throw 'A single tenantFilter is required when capturing settings from a role.' } + $Policy = @(Get-CIPPPIMRolePolicies -TenantFilter $CaptureTenant) | Where-Object { $_.RoleDefinitionId -eq $CaptureRoleId } | Select-Object -First 1 + if (-not $Policy) { throw "No PIM role management policy was found for role $CaptureRoleId in $CaptureTenant. Privileged Identity Management may not be onboarded there yet." } + $Captured = ConvertFrom-CIPPPIMPolicyRules -Rules $Policy.Rules + # A tenant's live policy may sit below the floor (Entra's defaults do); a template must + # never store that, so offending values are raised and each raise is reported. + $Repair = Repair-CIPPPIMRoleSettingsFloor -Settings $Captured + $SettingsInput = $Repair.Settings + $Adjustments = @($Repair.Adjustments) + + $CaptureRoleName = "$($Request.Body.captureRoleName)".Trim() + if ([string]::IsNullOrWhiteSpace($CaptureRoleName)) { $CaptureRoleName = "$CaptureRoleId" } + if ([string]::IsNullOrWhiteSpace($RoleScope)) { + $RoleScope = 'Custom' + $Roles = @(@{ label = $CaptureRoleName; value = "$CaptureRoleId" }) + } + if ([string]::IsNullOrWhiteSpace($Description)) { $Description = "Captured from the $CaptureRoleName role in $CaptureTenant." } + } else { + $SettingsInput = $Request.Body.settings + } + + if ([string]::IsNullOrWhiteSpace($RoleScope)) { $RoleScope = 'PrivilegedRoles' } + if ($RoleScope -notin @('PrivilegedRoles', 'AllRoles', 'Custom')) { throw "roleScope '$RoleScope' is not valid. Use PrivilegedRoles, AllRoles or Custom." } + if ($RoleScope -eq 'Custom' -and $Roles.Count -eq 0) { throw 'Select at least one role when roleScope is Custom.' } + + # Role activation, eligibility, assignment, approval and notification settings. + $Settings = ConvertTo-CIPPPIMRoleSettings -InputObject $SettingsInput + $Floor = Test-CIPPPIMRoleSettingsFloor -Settings $Settings + if (-not $Floor.Valid) { + $Message = "PIM role settings template '$TemplateName' was not saved because it is below the secure floor: $($Floor.Errors -join ' ')" + Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev 'Error' + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = @($Message) + @($Floor.Errors | ForEach-Object { @{ resultText = $_; state = 'error' } }) } + } + } + + $UserDetails = try { + ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Headers.'x-ms-client-principal')) | ConvertFrom-Json).userDetails + } catch { 'Unknown' } + $Now = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + + $Table = Get-CippTable -tablename 'templates' + $Existing = $null + if (-not [string]::IsNullOrWhiteSpace($GUID)) { + $SafeGUID = ConvertTo-CIPPODataFilterValue -Value $GUID -Type String + $Existing = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'PIMRoleSettingsTemplate' and RowKey eq '$SafeGUID'" + if (-not $Existing) { throw "PIM role settings template $GUID was not found" } + } else { + $GUID = (New-Guid).Guid + } + + $ExistingData = if ($Existing) { $Existing.JSON | ConvertFrom-Json -Depth 100 } else { $null } + $TemplateObject = [ordered]@{ + templateName = $TemplateName + description = $Description + roleScope = $RoleScope + roles = @($Roles) + settings = $Settings + createdBy = $ExistingData.createdBy ?? $UserDetails + createdDate = $ExistingData.createdDate ?? $Now + updatedBy = $UserDetails + updatedDate = $Now + GUID = $GUID + } + + $JSON = ConvertTo-Json -InputObject $TemplateObject -Depth 20 -Compress + $Table.Force = $true + Add-CIPPAzDataTableEntity @Table -Entity @{ + JSON = "$JSON" + RowKey = "$GUID" + PartitionKey = 'PIMRoleSettingsTemplate' + GUID = "$GUID" + } + + $Verb = if ($Existing) { 'Updated' } else { 'Created' } + $Result = "$Verb PIM role settings template '$TemplateName' with GUID $GUID" + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Info' + foreach ($Adjustment in $Adjustments) { + # Captured value was below the secure floor; the raise must be visible in the logbook. + Write-LogMessage -headers $Headers -API $APIName -message "PIM role settings template '$TemplateName': raised to the secure floor - $Adjustment" -Sev 'Warning' + } + foreach ($Warning in $Floor.Warnings) { + # Above the recommended value but inside the hard cap: allowed, and visible in the logbook. + Write-LogMessage -headers $Headers -API $APIName -message "PIM role settings template '$TemplateName': $Warning" -Sev 'Warning' + } + $Results = @($Result) + @($Adjustments | ForEach-Object { @{ resultText = "Raised to the secure floor: $_"; state = 'warning' } }) + @($Floor.Warnings | ForEach-Object { @{ resultText = $_; state = 'warning' } }) + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Results = @("Failed to save PIM role settings template: $($ErrorMessage.NormalizedError)") + Write-LogMessage -headers $Headers -API $APIName -message $Results[0] -Sev 'Error' -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + } + + return [HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ Results = @($Results) } + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Roles/Invoke-ExecPIMRoleAssignment.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Roles/Invoke-ExecPIMRoleAssignment.ps1 new file mode 100644 index 0000000000000..a8caa6f58322e --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Roles/Invoke-ExecPIMRoleAssignment.ps1 @@ -0,0 +1,112 @@ +function Invoke-ExecPIMRoleAssignment { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Identity.Role.ReadWrite + .SYNOPSIS + Change a directory role assignment through PIM in the secure direction only. + .DESCRIPTION + Converts a permanent assignment to eligible, grants a time-bound active assignment, extends or renews a time-bound assignment or eligibility, or removes an assignment. Every request must carry an expiration (a duration or an end date); permanent / no-expiration assignments are refused, as are changes to group-inherited rows, the CIPP-SAM application and the last active Global Administrator. Requires Entra ID P2. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Body.tenantFilter.value ?? $Request.Body.tenantFilter + # ConvertToEligible | GrantActive | Extend | Renew | Remove + $Action = $Request.Body.Action.value ?? $Request.Body.Action + # Object id of the user, group or service principal. + $PrincipalId = $Request.Body.PrincipalId.value ?? $Request.Body.PrincipalId + # Role template id (roleDefinitionId as PIM reports it). + $RoleDefinitionId = $Request.Body.RoleDefinitionId.value ?? $Request.Body.RoleDefinitionId + # '/' for the whole directory or '/administrativeUnits/{id}'. + $DirectoryScopeId = $Request.Body.DirectoryScopeId.value ?? $Request.Body.DirectoryScopeId + # The row's current assignment type: Permanent | Active | ActivatedFromEligible | Eligible + $AssignmentType = $Request.Body.AssignmentType.value ?? $Request.Body.AssignmentType + # ISO 8601 lifetime such as PT4H or P1Y. Use either Duration or EndDateTime, not both. + $Duration = $Request.Body.Duration.value ?? $Request.Body.Duration + # The dialog's "Custom end date" option carries no lifetime of its own; EndDateTime does. + if ("$Duration" -eq 'custom') { $Duration = $null } + # Absolute end (unix seconds or ISO 8601). Use either Duration or EndDateTime, not both. + $EndDateTimeRaw = $Request.Body.EndDateTime.value ?? $Request.Body.EndDateTime + # IANA time zone of the browser (e.g. Australia/Perth); only used to word the end time in the result. + $TimeZone = [string]($Request.Body.TimeZone.value ?? $Request.Body.TimeZone) + # Reason recorded on the PIM request and in the CIPP logbook. + $Justification = $Request.Body.Justification + + $Fail = { + param([string]$Message, [HttpStatusCode]$Status = [HttpStatusCode]::BadRequest) + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Message -Sev 'Error' + return [HttpResponseContext]@{ + StatusCode = $Status + Body = @{ Results = @(@{ resultText = $Message; state = 'error' }) } + } + } + + if ([string]::IsNullOrWhiteSpace($TenantFilter) -or [string]::IsNullOrWhiteSpace($Action) -or [string]::IsNullOrWhiteSpace($PrincipalId) -or [string]::IsNullOrWhiteSpace($RoleDefinitionId)) { + return (& $Fail 'tenantFilter, Action, PrincipalId and RoleDefinitionId are required.') + } + if ($Action -notin @('ConvertToEligible', 'GrantActive', 'Extend', 'Renew', 'Remove')) { + return (& $Fail "Action '$Action' is not supported. Use ConvertToEligible, GrantActive, Extend, Renew or Remove.") + } + if ([string]::IsNullOrWhiteSpace($Justification)) { + return (& $Fail 'A justification is required.') + } + if ("$Duration" -match '^\s*(noExpiration|permanent|never|none|unlimited)\s*$' -or "$EndDateTimeRaw" -match '^\s*(noExpiration|permanent|never|none|unlimited)\s*$') { + return (& $Fail 'Permanent (no-expiration) assignments cannot be created through CIPP. Supply a duration or an end date.') + } + + $EndDateTime = $null + if (-not [string]::IsNullOrWhiteSpace("$EndDateTimeRaw")) { + try { + $EndDateTime = if ("$EndDateTimeRaw" -match '^\d{9,11}$') { + ([System.DateTimeOffset]::FromUnixTimeSeconds([int64]$EndDateTimeRaw)).UtcDateTime + } else { + ([datetime]$EndDateTimeRaw).ToUniversalTime() + } + } catch { + return (& $Fail "EndDateTime '$EndDateTimeRaw' is not a valid date.") + } + } + + if ($Action -ne 'Remove' -and $Action -ne 'ConvertToEligible' -and [string]::IsNullOrWhiteSpace($Duration) -and $null -eq $EndDateTime) { + return (& $Fail "$Action requires a Duration or an EndDateTime; CIPP never creates permanent assignments.") + } + if (-not [string]::IsNullOrWhiteSpace($Duration) -and $null -ne $EndDateTime) { + return (& $Fail 'Supply either Duration or EndDateTime, not both.') + } + + $Params = @{ + TenantFilter = $TenantFilter + Action = $Action + PrincipalId = $PrincipalId + RoleDefinitionId = $RoleDefinitionId + DirectoryScopeId = if ([string]::IsNullOrWhiteSpace($DirectoryScopeId)) { '/' } else { $DirectoryScopeId } + Justification = $Justification + Headers = $Headers + APIName = $APIName + } + if ($AssignmentType -in @('Permanent', 'Active', 'ActivatedFromEligible', 'Eligible')) { $Params.AssignmentType = $AssignmentType } + if (-not [string]::IsNullOrWhiteSpace($Duration)) { $Params.Duration = $Duration } + if ($null -ne $EndDateTime) { $Params.EndDateTime = $EndDateTime } + if (-not [string]::IsNullOrWhiteSpace($TimeZone)) { $Params.TimeZone = $TimeZone } + + try { + $Result = Invoke-CIPPPIMAssignmentAction @Params + $StatusCode = [HttpStatusCode]::OK + $Results = @(@{ resultText = $Result.resultText; state = 'success' }) + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Message = "PIM $Action failed for $PrincipalId on $RoleDefinitionId`: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Message -Sev 'Error' -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + $Results = @(@{ resultText = $Message; state = 'error' }) + } + + return [HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ Results = @($Results) } + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Roles/Invoke-ListPIMRoleSettingsTemplates.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Roles/Invoke-ListPIMRoleSettingsTemplates.ps1 new file mode 100644 index 0000000000000..c8d2bade4282d --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Roles/Invoke-ListPIMRoleSettingsTemplates.ps1 @@ -0,0 +1,51 @@ +function Invoke-ListPIMRoleSettingsTemplates { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + Identity.Role.Read + .SYNOPSIS + List PIM role settings templates. + .DESCRIPTION + Lists saved Privileged Identity Management role settings templates. A template names a set of roles and the activation, eligibility, assignment, approval and notification rules to enforce on them; the PIMRoleSettings standard deploys a template to tenants. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + # Return only the template with this GUID. + $GUID = $Request.Query.GUID ?? $Request.Query.id + + $Table = Get-CippTable -tablename 'templates' + $Filter = "PartitionKey eq 'PIMRoleSettingsTemplate'" + if (-not [string]::IsNullOrWhiteSpace($GUID)) { + $SafeGUID = ConvertTo-CIPPODataFilterValue -Value $GUID -Type String + $Filter = "$Filter and RowKey eq '$SafeGUID'" + } + + $Templates = @(Get-CIPPAzDataTableEntity @Table -Filter $Filter | ForEach-Object { + $Row = $_ + try { + $Data = $Row.JSON | ConvertFrom-Json -Depth 100 -ErrorAction Stop + # Grade the stored settings so the list shows a template that has drifted below + # the floor (e.g. edited in the table) before it is deployed. + $Floor = Test-CIPPPIMRoleSettingsFloor -Settings (ConvertTo-CIPPPIMRoleSettings -InputObject $Data.settings) + $Data | Add-Member -NotePropertyMembers ([ordered]@{ + GUID = $Row.GUID + RowKey = $Row.RowKey + meetsSecureFloor = $Floor.Valid + floorIssues = @($Floor.Errors) + roleCount = (@($Data.roles).Count) + }) -Force + $Data + } catch { + Write-LogMessage -headers $Headers -API $APIName -message "Failed to read PIM role settings template $($Row.RowKey): $($_.Exception.Message)" -sev 'Warning' + } + } | Sort-Object -Property templateName) + + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @($Templates) + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Roles/Invoke-ListPIMRoles.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Roles/Invoke-ListPIMRoles.ps1 new file mode 100644 index 0000000000000..42303dfda204a --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Roles/Invoke-ListPIMRoles.ps1 @@ -0,0 +1,115 @@ +function Invoke-ListPIMRoles { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Identity.Role.Read + .SYNOPSIS + List Entra directory roles grouped with their PIM assignment breakdown. + .DESCRIPTION + Returns one row per role (per tenant when AllTenants is selected) with the role's definition details, how many principals hold it permanently, eligibly or with a time-bound active assignment, the role's PIM policy summary, a slim Members list and the full assignment rows for drill-in. Roles nobody holds are included for a single tenant so the result is also the role catalogue. Powers the Roles & PIM page; ListRoles keeps its original per-definition shape and ListRoleAssignments stays one flat row per assignment. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $TenantFilter = $Request.Query.tenantFilter + # Restrict to one role template id, e.g. from an alert link. + $RoleTemplateId = $Request.Query.roleTemplateId + # Restrict to the roles one principal (object id) holds. + $PrincipalId = $Request.Query.principalId + + try { + if ($TenantFilter -eq 'AllTenants') { + $Counts = @( + Get-CIPPDbItem -TenantFilter 'allTenants' -Type 'RoleAssignmentScheduleInstances' -CountsOnly + Get-CIPPDbItem -TenantFilter 'allTenants' -Type 'Roles' -CountsOnly + ) | Where-Object { $_ } + $RefreshedAt = @{} + foreach ($Count in $Counts) { + $Existing = $RefreshedAt[$Count.PartitionKey] + if (-not $Existing -or $Count.Timestamp -gt $Existing) { $RefreshedAt[$Count.PartitionKey] = $Count.Timestamp } + } + $TenantList = Get-Tenants -IncludeErrors + $Tenants = @($RefreshedAt.Keys | Where-Object { $TenantList.defaultDomainName -contains $_ }) + + $Rows = [System.Collections.Generic.List[object]]::new() + foreach ($Tenant in $Tenants) { + try { + foreach ($Row in Get-CIPPPIMRoleAssignments -TenantFilter $Tenant -FromCache -IncludePolicy) { + $Row | Add-Member -NotePropertyName 'LastRefreshed' -NotePropertyValue $RefreshedAt[$Tenant] -Force + $Rows.Add($Row) + } + } catch { + Write-LogMessage -API $APIName -tenant $Tenant -message "Failed to read cached role assignments: $($_.Exception.Message)" -sev Warning + } + } + $Rows = @($Rows) + } else { + # A single-principal read drops the catalogue rows: the caller wants the roles the + # principal holds, not every role it does not. + $Params = @{ TenantFilter = $TenantFilter; IncludePolicy = $true; IncludeUnassignedRoles = [string]::IsNullOrWhiteSpace($PrincipalId) } + if (-not [string]::IsNullOrWhiteSpace($PrincipalId)) { $Params.PrincipalId = $PrincipalId } + if (-not [string]::IsNullOrWhiteSpace($RoleTemplateId)) { $Params.RoleDefinitionId = $RoleTemplateId } + $Rows = @(Get-CIPPPIMRoleAssignments @Params) + } + + if (-not [string]::IsNullOrWhiteSpace($PrincipalId)) { $Rows = @($Rows | Where-Object { $_.PrincipalId -eq $PrincipalId }) } + if (-not [string]::IsNullOrWhiteSpace($RoleTemplateId)) { $Rows = @($Rows | Where-Object { $_.RoleDefinitionId -eq $RoleTemplateId }) } + + $Grouped = @( + foreach ($Group in ($Rows | Group-Object -Property Tenant, RoleDefinitionId)) { + $GroupRows = @($Group.Group) + $Meta = $GroupRows[0] + # Catalogue rows (AssignmentType 'Unassigned') carry the role but no principal. + $Assignments = @($GroupRows | Where-Object { $_.PrincipalId }) + $PermanentCount = @($Assignments | Where-Object { $_.AssignmentType -eq 'Permanent' }).Count + $EligibleCount = @($Assignments | Where-Object { $_.AssignmentType -eq 'Eligible' }).Count + $ActiveCount = @($Assignments | Where-Object { $_.AssignmentType -in @('Active', 'ActivatedFromEligible') }).Count + [PSCustomObject]@{ + Tenant = $Meta.Tenant + RoleDefinitionId = $Meta.RoleDefinitionId + RoleDisplayName = $Meta.RoleDisplayName + RoleDescription = $Meta.RoleDescription + RoleIsBuiltIn = $Meta.RoleIsBuiltIn + IsPrivilegedRole = $Meta.IsPrivilegedRole + PIMCapable = $Meta.PIMCapable + PolicySummary = $Meta.PolicySummary + PolicyBelowFloor = $Meta.PolicyBelowFloor + MemberCount = $Assignments.Count + PermanentCount = $PermanentCount + EligibleCount = $EligibleCount + ActiveCount = $ActiveCount + IsAssigned = ($Assignments.Count -gt 0) + HasPermanentMembers = ($PermanentCount -gt 0) + # camelCase like the original ListRoles members, so the Members cell formatter + # and its CSV/PDF export read them. + Members = @($Assignments | ForEach-Object { + [PSCustomObject]@{ + displayName = $_.PrincipalDisplayName + userPrincipalName = $_.PrincipalUserPrincipalName + principalType = $_.PrincipalType + assignmentType = $_.AssignmentType + endDateTime = $_.EndDateTime + } + }) + Assignments = @($Assignments) + LastRefreshed = $Meta.LastRefreshed + } + } + ) + $Results = @($Grouped | Sort-Object -Property Tenant, RoleDisplayName) + + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -tenant $TenantFilter -message "Failed to list roles with PIM data: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + $Results = "Failed to list roles with PIM data for $TenantFilter. $($ErrorMessage.NormalizedError)" + $StatusCode = [HttpStatusCode]::BadRequest + } + + return [HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @($Results) + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Roles/Invoke-ListRoleAssignments.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Roles/Invoke-ListRoleAssignments.ps1 new file mode 100644 index 0000000000000..4220c47580e6f --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Roles/Invoke-ListRoleAssignments.ps1 @@ -0,0 +1,76 @@ +function Invoke-ListRoleAssignments { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Identity.Role.Read + .SYNOPSIS + List Entra directory role assignments with their PIM assignment type. + .DESCRIPTION + Returns one row per principal, role and scope showing whether the assignment is Permanent (active with no end date), Active (time-bound), ActivatedFromEligible or Eligible, whether it is held directly or through a role-assignable group, the scope (directory or administrative unit), the principal type, the role's description and built-in flag, and the role's PIM policy summary. For a single tenant roles that nobody holds are included as Unassigned rows so the result is also the role catalogue. A single tenant is read live from Graph (Entra ID P2 tenants via PIM, others via unified RBAC where every assignment is permanent); AllTenants is served from the reporting cache. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $TenantFilter = $Request.Query.tenantFilter + # Restrict to one principal (object id), e.g. for the user view page. + $PrincipalId = $Request.Query.principalId + # Restrict to one role template id. + $RoleTemplateId = $Request.Query.roleTemplateId + # Only return permanent (active, no end date) assignments. + $PermanentOnly = [bool]($Request.Query.permanentOnly -eq $true -or "$($Request.Query.permanentOnly)" -eq 'true') + # Single tenant: also list roles that nobody holds (AssignmentType 'Unassigned'), so the result is the full role catalogue. Default true; set to false for assignments only. + $IncludeUnassigned = -not ("$($Request.Query.includeUnassigned)" -eq 'false') + + try { + if ($TenantFilter -eq 'AllTenants') { + $Counts = @( + Get-CIPPDbItem -TenantFilter 'allTenants' -Type 'RoleAssignmentScheduleInstances' -CountsOnly + Get-CIPPDbItem -TenantFilter 'allTenants' -Type 'Roles' -CountsOnly + ) | Where-Object { $_ } + $RefreshedAt = @{} + foreach ($Count in $Counts) { + $Existing = $RefreshedAt[$Count.PartitionKey] + if (-not $Existing -or $Count.Timestamp -gt $Existing) { $RefreshedAt[$Count.PartitionKey] = $Count.Timestamp } + } + $TenantList = Get-Tenants -IncludeErrors + $Tenants = @($RefreshedAt.Keys | Where-Object { $TenantList.defaultDomainName -contains $_ }) + + $Results = [System.Collections.Generic.List[object]]::new() + foreach ($Tenant in $Tenants) { + try { + $Rows = Get-CIPPPIMRoleAssignments -TenantFilter $Tenant -FromCache -IncludePolicy + foreach ($Row in $Rows) { + $Row | Add-Member -NotePropertyName 'LastRefreshed' -NotePropertyValue $RefreshedAt[$Tenant] -Force + $Results.Add($Row) + } + } catch { + Write-LogMessage -API $APIName -tenant $Tenant -message "Failed to read cached role assignments: $($_.Exception.Message)" -sev Warning + } + } + $Results = @($Results) + } else { + $Params = @{ TenantFilter = $TenantFilter; IncludePolicy = $true; IncludeUnassignedRoles = ($IncludeUnassigned -and -not $PermanentOnly) } + if (-not [string]::IsNullOrWhiteSpace($PrincipalId)) { $Params.PrincipalId = $PrincipalId } + if (-not [string]::IsNullOrWhiteSpace($RoleTemplateId)) { $Params.RoleDefinitionId = $RoleTemplateId } + $Results = @(Get-CIPPPIMRoleAssignments @Params) + } + + if (-not [string]::IsNullOrWhiteSpace($PrincipalId)) { $Results = @($Results | Where-Object { $_.PrincipalId -eq $PrincipalId }) } + if (-not [string]::IsNullOrWhiteSpace($RoleTemplateId)) { $Results = @($Results | Where-Object { $_.RoleDefinitionId -eq $RoleTemplateId }) } + if ($PermanentOnly) { $Results = @($Results | Where-Object { $_.AssignmentType -eq 'Permanent' }) } + + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -tenant $TenantFilter -message "Failed to list role assignments: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + $Results = "Failed to list role assignments for $TenantFilter. $($ErrorMessage.NormalizedError)" + $StatusCode = [HttpStatusCode]::BadRequest + } + + return [HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @($Results) + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Roles/Invoke-RemovePIMRoleSettingsTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Roles/Invoke-RemovePIMRoleSettingsTemplate.ps1 new file mode 100644 index 0000000000000..dcb5985269453 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Roles/Invoke-RemovePIMRoleSettingsTemplate.ps1 @@ -0,0 +1,48 @@ +function Invoke-RemovePIMRoleSettingsTemplate { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + Identity.Role.ReadWrite + .SYNOPSIS + Delete a PIM role settings template. + .DESCRIPTION + Deletes a saved Privileged Identity Management role settings template by GUID. Tenants already configured from the template keep their settings. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + try { + # GUID of the template to delete. + $ID = $Request.Query.ID ?? $Request.Body.ID ?? $Request.Body.GUID + if ([string]::IsNullOrWhiteSpace($ID)) { throw 'ID is required' } + + $Table = Get-CippTable -tablename 'templates' + $SafeID = ConvertTo-CIPPODataFilterValue -Value $ID -Type String + $Template = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'PIMRoleSettingsTemplate' and RowKey eq '$SafeID'" + + if ($Template) { + Remove-CIPPAzDataTableEntity @Table -Entity $Template + $Result = "Successfully deleted PIM role settings template with ID: $ID" + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Info' + $StatusCode = [HttpStatusCode]::OK + } else { + $Result = "PIM role settings template with ID $ID not found" + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Warning' + $StatusCode = [HttpStatusCode]::NotFound + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to delete PIM role settings template: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Error' -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::InternalServerError + } + + return [HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ Results = "$Result" } + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddJITRoleTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddJITRoleTemplate.ps1 new file mode 100644 index 0000000000000..1d5a2567ae39f --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddJITRoleTemplate.ps1 @@ -0,0 +1,81 @@ +function Invoke-AddJITRoleTemplate { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Identity.Role.ReadWrite + .DESCRIPTION + Creates a JIT Role Template - a named allow-list of directory roles that can be assigned to a + CIPP custom role to restrict which roles that role's members may grant via JIT Admin. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + try { + $TemplateName = $Request.Body.templateName + + if ([string]::IsNullOrWhiteSpace($TemplateName)) { + throw 'templateName is required' + } + if (-not $Request.Body.roles -or @($Request.Body.roles).Count -eq 0) { + throw 'At least one role is required' + } + + Write-LogMessage -headers $Headers -API $APIName -message "Creating JIT Role template '$TemplateName'" -Sev 'Info' + + # Get user info for audit + $UserDetails = ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Headers.'x-ms-client-principal')) | ConvertFrom-Json).userDetails + + # Check if template name already exists + $Table = Get-CippTable -tablename 'templates' + $ExistingTemplates = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'JITRoleTemplate'" + $ExistingNames = $ExistingTemplates | ForEach-Object { + try { + $data = $_.JSON | ConvertFrom-Json -Depth 100 -ErrorAction Stop + if ($data.templateName -eq $TemplateName) { + $data + } + } catch {} + } + + if ($ExistingNames) { + throw "A JIT Role Template with name '$TemplateName' already exists" + } + + $TemplateObject = @{ + templateName = $TemplateName + roles = $Request.Body.roles + createdBy = $UserDetails + createdDate = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + } + + $GUID = (New-Guid).GUID + $JSON = ConvertTo-Json -InputObject $TemplateObject -Depth 100 -Compress + + $Table.Force = $true + Add-CIPPAzDataTableEntity @Table -Entity @{ + JSON = "$JSON" + RowKey = "$GUID" + PartitionKey = 'JITRoleTemplate' + GUID = "$GUID" + } + + $Result = "Created JIT Role Template '$($TemplateName)' with GUID $GUID" + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Info' + $StatusCode = [HttpStatusCode]::OK + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to create JIT Role Template: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Error' -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::InternalServerError + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{'Results' = "$Result" } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUser.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUser.ps1 index 0bea284e0079d..5f36856d9bce8 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUser.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUser.ps1 @@ -39,6 +39,7 @@ function Invoke-AddUser { Parameters = [pscustomobject]@{ UserObj = $UserObj } ScheduledTime = $UserObj.Scheduled.date Reference = $UserObj.reference ?? $null + PsaTicketId = $UserObj.PsaTicketId ?? $null PostExecution = @{ Webhook = [bool]$Request.Body.PostExecution.Webhook Email = [bool]$Request.Body.PostExecution.Email diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUserBulk.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUserBulk.ps1 index 5e0cdf6c0f263..67ddccdaddea4 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUserBulk.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUserBulk.ps1 @@ -128,6 +128,7 @@ function Invoke-AddUserBulk { $LicenseSkus = $AssignedLicenses.value ?? $AssignedLicenses | Where-Object { $_ -match $GuidPattern } Set-CIPPUserLicense -UserId $BulkResult.id -AddLicenses $LicenseSkus -TenantFilter $TenantFilter -APIName $APIName -Headers $Headers } + Write-LogMessage -headers $Request.Headers -API $APIName -tenant $TenantFilter -message $Message.resultText -Sev 'Info' $Results.Add(@{ resultText = $Message.resultText state = 'success' diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUserDefaults.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUserDefaults.ps1 index 675322f302019..c9b58c10e6788 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUserDefaults.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUserDefaults.ps1 @@ -51,6 +51,7 @@ function Invoke-AddUserDefaults { $Autopassword = $Request.Body.Autopassword $Password = $Request.Body.password $MustChangePass = $Request.Body.MustChangePass + $PerUserMfa = [System.Convert]::ToBoolean($Request.Body.perUserMfa) $UsageLocation = if ($Request.Body.usageLocation -is [string]) { $Request.Body.usageLocation @@ -119,6 +120,7 @@ function Invoke-AddUserDefaults { Autopassword = $Autopassword password = $Password MustChangePass = $MustChangePass + perUserMfa = $PerUserMfa usageLocation = $UsageLocation licenses = $Licenses removeLicenses = $RemoveLicenses diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditJITRoleTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditJITRoleTemplate.ps1 new file mode 100644 index 0000000000000..b216337efc998 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditJITRoleTemplate.ps1 @@ -0,0 +1,94 @@ +function Invoke-EditJITRoleTemplate { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Identity.Role.ReadWrite + .DESCRIPTION + Updates an existing JIT Role Template. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + try { + $GUID = $Request.Body.GUID + $TemplateName = $Request.Body.templateName + + if ([string]::IsNullOrWhiteSpace($GUID)) { + throw 'GUID is required' + } + if ([string]::IsNullOrWhiteSpace($TemplateName)) { + throw 'templateName is required' + } + if (-not $Request.Body.roles -or @($Request.Body.roles).Count -eq 0) { + throw 'At least one role is required' + } + + Write-LogMessage -headers $Headers -API $APIName -message "Editing JIT Role template '$GUID'" -Sev 'Info' + + $UserDetails = ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Headers.'x-ms-client-principal')) | ConvertFrom-Json).userDetails + + $Table = Get-CippTable -tablename 'templates' + $SafeGUID = ConvertTo-CIPPODataFilterValue -Value $GUID -Type Guid + $Filter = "PartitionKey eq 'JITRoleTemplate' and RowKey eq '$SafeGUID'" + $ExistingTemplate = Get-CIPPAzDataTableEntity @Table -Filter $Filter + + if (!$ExistingTemplate) { + throw "JIT Role Template with GUID '$GUID' not found" + } + + $ExistingData = $ExistingTemplate.JSON | ConvertFrom-Json -Depth 100 + + # Check if template name is unique (excluding current template) + $AllTemplates = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'JITRoleTemplate'" + $DuplicateName = $AllTemplates | Where-Object { $_.RowKey -ne $GUID } | ForEach-Object { + try { + $data = $_.JSON | ConvertFrom-Json -Depth 100 -ErrorAction Stop + if ($data.templateName -eq $TemplateName) { + $data + } + } catch {} + } + + if ($DuplicateName) { + throw "A JIT Role Template with name '$TemplateName' already exists" + } + + $TemplateObject = @{ + templateName = $TemplateName + roles = $Request.Body.roles + createdBy = $ExistingData.createdBy + createdDate = $ExistingData.createdDate + modifiedBy = $UserDetails + modifiedDate = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + } + + $JSON = ConvertTo-Json -InputObject $TemplateObject -Depth 100 -Compress + + $Table.Force = $true + Add-CIPPAzDataTableEntity @Table -Entity @{ + JSON = "$JSON" + RowKey = "$GUID" + PartitionKey = 'JITRoleTemplate' + GUID = "$GUID" + } + + $Result = "Updated JIT Role Template '$($TemplateName)' (GUID: $GUID)" + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Info' + $StatusCode = [HttpStatusCode]::OK + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to update JIT Role Template: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Error' -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::InternalServerError + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{'Results' = "$Result" } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecJITAdmin.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecJITAdmin.ps1 index 9233c506da6a9..f63405c35d442 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecJITAdmin.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecJITAdmin.ps1 @@ -53,6 +53,27 @@ function Invoke-ExecJITAdmin { # Continue execution if we can't check the setting } + # Enforce the caller's allowed JIT roles (from the JIT Role Template on their custom role). + # Get-CIPPJITAdminAllowedRoles is authoritative and fails closed for restricted callers, so we + # trust its result rather than swallowing errors here. + $RequestedRoles = @($Request.Body.AdminRoles.value | Where-Object { $_ }) + if ($RequestedRoles.Count -gt 0) { + $AllowedRoles = Get-CIPPJITAdminAllowedRoles -Headers $Headers + if ($AllowedRoles.Restricted) { + $ForbiddenRoles = @($RequestedRoles | Where-Object { $AllowedRoles.AllowedRoleIds -notcontains $_ }) + if ($ForbiddenRoles.Count -gt 0) { + $ForbiddenLabels = @($Request.Body.AdminRoles | Where-Object { $ForbiddenRoles -contains $_.value } | ForEach-Object { $_.label ?? $_.value }) + if ($ForbiddenLabels.Count -eq 0) { $ForbiddenLabels = $ForbiddenRoles } + $ErrorMessage = "You are not permitted to assign the following role(s): $($ForbiddenLabels -join ', ')" + Write-LogMessage -headers $Headers -API $APIName -message $ErrorMessage -Sev 'Error' + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{'Results' = @($ErrorMessage) } + }) + } + } + } + if ($Request.Body.userAction -eq 'create') { $Domain = $Request.Body.Domain.value ? $Request.Body.Domain.value : $Request.Body.Domain $Username = "$($Request.Body.Username)@$($Domain)" @@ -186,6 +207,7 @@ function Invoke-ExecJITAdmin { $PasswordLink = New-PwPushLink -Payload $TempPass $Password = $PasswordLink ? $PasswordLink : $TempPass + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Created Temporary Access Pass for $Username (lifetime: $PasswordExpiration minutes)" -Sev 'Info' $Results.Add(@{ resultText = "Temporary Access Pass: $Password" copyField = $Password @@ -247,6 +269,7 @@ function Invoke-ExecJITAdmin { if ($Request.Body.userAction -ne 'create') { Set-CIPPUserJITAdminProperties -TenantFilter $TenantFilter -UserId $Request.Body.existingUser.value -Expiration $Expiration -StartDate $Start -Reason $Request.Body.Reason -CreatedBy (([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Headers.'x-ms-client-principal')) | ConvertFrom-Json).userDetails) } + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Scheduled JIT Admin enable task for $Username" -Sev 'Info' $Results.Add("Scheduling JIT Admin enable task for $Username") } else { try { @@ -286,6 +309,7 @@ function Invoke-ExecJITAdmin { ScheduledTime = $Request.Body.EndDate } $null = Add-CIPPScheduledTask -Task $DisableTaskBody -hidden $false + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Scheduled JIT Admin $($Request.Body.ExpireAction.value) task for $Username" -Sev 'Info' $Results.Add("Scheduling JIT Admin $($Request.Body.ExpireAction.value) task for $Username") return ([HttpResponseContext]@{ diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecOffboardUser.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecOffboardUser.ps1 index 068c95ec1c3b9..b58b806278e9c 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecOffboardUser.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecOffboardUser.ps1 @@ -4,10 +4,68 @@ function Invoke-ExecOffboardUser { Entrypoint .ROLE Identity.User.ReadWrite + .DESCRIPTION + Runs the offboarding wizard: one scheduled offboarding job per user, immediately or at the + scheduled time, reporting live progress under one job id. Action=Rerun queues an existing + offboarding task again; Action=RerunStep queues one step of it, reported to the same progress row. #> [CmdletBinding()] param($Request, $TriggerMetadata) + $Action = $Request.Query.Action ?? $Request.Body.Action + if ($Action -in @('Rerun', 'RerunStep')) { + try { + # RowKey of the offboarding task to run again + $TaskId = [string]$Request.Body.TaskId + if (-not $TaskId) { throw 'TaskId is required' } + $TenantFilter = [string]($Request.Body.tenantFilter.value ?? $Request.Body.tenantFilter) + $Table = Get-CIPPTable -TableName 'ScheduledTasks' + $SafeTaskId = $TaskId -replace "'", "''" + $Task = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'ScheduledTask' and RowKey eq '$SafeTaskId'" + # Access to tenantFilter was checked on the way in; the task must belong to that tenant. + if (-not $Task -or $Task.Command -ne 'Invoke-CIPPOffboardingJob' -or [string]$Task.Tenant -ne $TenantFilter) { + throw 'No offboarding task with that id exists in this tenant' + } + + if ($Action -eq 'Rerun') { + $Result = Add-CIPPScheduledTask -RunNow -RowKey $TaskId -Headers $Request.Headers + } else { + # Zero-based index of the step, as listed in the progress row, to run again + $StepIndex = $Request.Body.StepIndex -as [int] + if ($null -eq $StepIndex) { throw 'StepIndex is required' } + # Title of that step, used to name the re-run task + $StepTitle = [string]$Request.Body.StepTitle + $Parameters = $Task.Parameters | ConvertFrom-Json + # A step re-run is its own scheduled task (so it has results and logs of its own) that + # reports to the original job's progress row. + $taskObject = [PSCustomObject]@{ + TenantFilter = $TenantFilter + Name = "Offboarding: $($Parameters.Username) - re-run $(if ($StepTitle) { $StepTitle } else { "step $StepIndex" })" + Command = @{ value = 'Invoke-CIPPOffboardingJob' } + Parameters = [pscustomobject]@{ + Username = $Parameters.Username + APIName = 'Scheduled Offboarding' + options = $Parameters.options + RunScheduled = $true + DeploymentId = $Parameters.DeploymentId + StepIndexes = @($StepIndex) + } + Reference = $Task.Reference + } + $Result = Add-CIPPScheduledTask -Task $taskObject -hidden $false -RunNow -Headers $Request.Headers + } + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @{ Results = $Result } + }) + } catch { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = "Failed to queue the re-run: $($_.Exception.Message)" } + }) + } + } + $Validation = Test-CIPPOffboardingRequest -Body $Request.Body if (-not $Validation.IsValid) { return ([HttpResponseContext]@{ @@ -20,6 +78,16 @@ function Invoke-ExecOffboardUser { $TenantFilter = $Validation.TenantFilter $OffboardingOptions = $Request.Body | Select-Object * -ExcludeProperty user, tenantFilter, Scheduled + # One live-progress job per wizard run with a queued row per user: the wizard polls it when + # running now, and the task page shows it for any job. Progress is a nice-to-have, so failing to + # create the rows must not stop the offboarding itself. + $DeploymentId = $null + try { + $DeploymentId = New-CIPPAsyncDeployment -Names $AllUsers -Source 'Offboarding' -TenantFilter $TenantFilter + } catch { + Write-LogMessage -headers $Request.Headers -API $Request.Params.CIPPEndpoint -tenant $TenantFilter -message "Could not create the offboarding progress rows: $($_.Exception.Message)" -sev Warn + } + $StatusCode = [HttpStatusCode]::OK $Results = foreach ($username in $AllUsers) { try { @@ -35,6 +103,7 @@ function Invoke-ExecOffboardUser { APIName = 'Scheduled Offboarding' options = $OffboardingOptions RunScheduled = $true + DeploymentId = $DeploymentId } PostExecution = @{ Webhook = [bool]$Request.Body.PostExecution.webhook @@ -42,6 +111,7 @@ function Invoke-ExecOffboardUser { PSA = [bool]$Request.Body.PostExecution.psa } Reference = $Request.Body.reference + PsaTicketId = $Request.Body.PsaTicketId } $Params = @{ Task = $taskObject @@ -60,6 +130,10 @@ function Invoke-ExecOffboardUser { } } $body = [pscustomobject]@{'Results' = @($Results) } + if ($DeploymentId -and -not $Request.Body.Scheduled.enabled) { + # Only a run-now job is worth polling straight away; a scheduled one is watched from its task page. + $body | Add-Member -NotePropertyName DeploymentId -NotePropertyValue $DeploymentId + } return ([HttpResponseContext]@{ StatusCode = $StatusCode Body = $Body diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecSendPush.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecSendPush.ps1 index 8df1305cb4734..5fc330a57ad3d 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecSendPush.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecSendPush.ps1 @@ -5,7 +5,7 @@ function Invoke-ExecSendPush { .ROLE Identity.User.Read .DESCRIPTION - Sends a test MFA push notification to a user's authenticator app and reports whether it was approved. Used to confirm a user's MFA registration works. This causes a real prompt on the user's device. + Sends a test MFA push notification to a user's authenticator app and reports whether it was approved, or - when an OTP code is supplied - verifies that typed code without sending a push. Used to confirm a user's MFA registration works. The push path causes a real prompt on the user's device. #> [CmdletBinding()] param($Request, $TriggerMetadata) @@ -13,117 +13,113 @@ function Invoke-ExecSendPush { $APIName = $Request.Params.CIPPEndpoint $TenantFilter = $Request.body.TenantFilter $UserEmail = $Request.body.UserEmail - $MFAAppID = '981f26a1-7f43-403b-a875-f8b09b8cd720' + # When an OTP code is supplied we verify that code instead of sending a push notification. + $OTP = $Request.body.OTP + $VerifyOtp = -not [string]::IsNullOrWhiteSpace($OTP) - # Function to keep trying to get the access token while we wait for MS to actually set the temp password - function Get-ClientAccess { - param( - $uri, - $body, - $count = 1 - ) - try { - $ClientToken = Invoke-RestMethod -Method post -Uri $uri -Body $body -ea stop - } catch { - if ($count -lt 20) { - - $count++ - Start-Sleep 1 - $ClientToken = Get-ClientAccess -uri $uri -body $body -count $count - } else { - throw "Could not get Client Token: $_" - } - } - return $ClientToken - } - - - # Get all service principals - $SPResult = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/servicePrincipals?`$top=999&`$select=id,appId" -tenantid $TenantFilter -AsApp $true - - # Check if we have one for the MFA App - $SPID = ($SPResult | Where-Object { $_.appId -eq $MFAAppID }).id - - # Create a service principal if needed - if (!$SPID) { - - $SPBody = [pscustomobject]@{ - appId = $MFAAppID - } | ConvertTo-Json -Depth 5 - $SPID = (New-GraphPostRequest -uri 'https://graph.microsoft.com/v1.0/servicePrincipals' -tenantid $TenantFilter -type POST -body $SPBody -AsApp $true).id - } + # Defaults so every path returns a well-formed state, even when an early step fails. + $State = 'error' + $Body = 'An unknown error occurred while processing the MFA request.' + $obj = $null + $ResultValue = $null + # Mint a connector token (this provisions a temporary secret on the MFA client service principal). try { - $PolicyUpdate = Update-AppManagementPolicy -TenantFilter $TenantFilter -ApplicationId $MFAAppID - Write-Information $PolicyUpdate.PolicyAction + $Connector = New-CIPPMFAConnectorToken -TenantFilter $TenantFilter -Headers $Request.Headers } catch { - Write-Information "Failed to update app management policy: $($_.Exception.Message)" + $Body = $_.Exception.Message + Write-LogMessage -headers $Request.Headers -API $APINAME -message "Failed MFA request for $UserEmail - $Body" -Sev 'Error' + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = [pscustomobject]@{'Results' = @{ resultText = $Body; state = 'error' } } + }) } - $PassReqBody = @{ - 'passwordCredential' = @{ - 'displayName' = 'MFA Temporary Password' - 'endDateTime' = $((Get-Date).AddMinutes(5)) - 'startDateTime' = $((Get-Date).AddMinutes(-5)) - } - } | ConvertTo-Json -Depth 5 + $ClientHeaders = @{ 'Authorization' = "Bearer $($Connector.AccessToken)" } - $TempPass = (New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/servicePrincipals/$SPID/addPassword" -tenantid $TenantFilter -type POST -body $PassReqBody -AsApp $true).secretText + # Policy: a typed code is only accepted when the user has no Microsoft Authenticator registered. When the + # Authenticator is present the stronger interactive push is required, so a TOTP code is refused. + $HasAuthenticator = $false + if ($VerifyOtp) { + $UserMethods = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$UserEmail/authentication/methods" -tenantid $TenantFilter + $HasAuthenticator = @($UserMethods).'@odata.type' -contains '#microsoft.graph.microsoftAuthenticatorAuthenticationMethod' + } - # Give it a chance to apply - #Start-Sleep 5 + if ($VerifyOtp -and $HasAuthenticator) { + $Body = 'This user has Microsoft Authenticator registered, so a push notification is required instead of a typed code.' + $State = 'error' + } elseif ($VerifyOtp) { + # OTP verification is a two-call handshake against the modernized StrongAuthenticationService host + # (the adnotifications host only supports push): Begin with SyncCall=false so no push is sent, then + # End with the typed code in AdditionalAuthData, keyed to the returned SessionId. + $StrongAuthUri = 'https://strongauthenticationservice.auth.microsoft.com/StrongAuthenticationService.svc/Connector' + $ContextId = (New-Guid).Guid + $BeginXML = @" + +1.0 +$UserEmail +en-usOverrideVoiceOtpfalse$ContextId +falsetrueradiusUNKNOWN: +"@ + $BeginResp = Invoke-RestMethod -Uri "$StrongAuthUri/BeginTwoWayAuthentication" -Method POST -Headers $ClientHeaders -Body $BeginXML -ContentType 'application/xml' + $SessionId = $BeginResp.BeginTwoWayAuthenticationResponse.SessionId - # Generate the XML for the push request - $XML = @" + if ($SessionId) { + $EndXML = @" + +1.0 +$SessionId +$OTP + +"@ + $obj = Invoke-RestMethod -Uri "$StrongAuthUri/EndTwoWayAuthentication" -Method POST -Headers $ClientHeaders -Body $EndXML -ContentType 'application/xml' + $ResultValue = $obj.EndTwoWayAuthenticationResponse.Result.Value + + if ($obj.EndTwoWayAuthenticationResponse.AuthenticationResult -eq $true -and $ResultValue -eq 'Success') { + $Body = 'The MFA code was verified successfully.' + $State = 'success' + } elseif ($ResultValue -eq 'OathCodeIncorrect') { + $Body = 'The MFA code was incorrect. Please check the code and try again.' + $State = 'error' + } else { + $Body = "MFA code verification failed: $ResultValue" + $State = 'error' + } + } else { + $Body = 'Could not start an MFA verification session. Does the user have an authenticator (OTP) method registered?' + $State = 'error' + } + } else { + # Push notification: SyncCall=true blocks until the user approves or denies on their device. + # AuthenticationMethodId forces the Authenticator push so it prompts even when the user's default + # method is something else (e.g. an OATH code); otherwise the connector targets the default and + # returns immediately without a prompt. + $ContextId = (New-Guid).Guid + $XML = @" 1.0 $UserEmail -en-usOverrideVoiceOtpfalse69ff05bf-eb61-47f7-a70e-e7d77b6d47d0 +en-usPhoneAppNotificationOverrideVoiceOtpfalse$ContextId truetrueradiusUNKNOWN: "@ - - # Request to get client token - $body = @{ - 'resource' = 'https://adnotifications.windowsazure.com/StrongAuthenticationService.svc/Connector' - 'client_id' = $MFAAppID - 'client_secret' = $TempPass - 'grant_type' = 'client_credentials' - 'scope' = 'openid' - } - - # Attempt to get a token using the temp password - $ClientUri = "https://login.microsoftonline.com/$TenantFilter/oauth2/token" - try { - $ClientToken = Get-ClientAccess -Uri $ClientUri -Body $body - } catch { - $Body = 'Failed to create temporary token for MFA Application. Error: ' + $_.Exception.Message - } - - # If we got a token send a push - if ($ClientToken) { - - $ClientHeaders = @{ 'Authorization' = "Bearer $($ClientToken.access_token)" } - $obj = Invoke-RestMethod -Uri 'https://adnotifications.windowsazure.com/StrongAuthenticationService.svc/Connector//BeginTwoWayAuthentication' -Method POST -Headers $ClientHeaders -Body $XML -ContentType 'application/xml' + $ResultValue = $obj.BeginTwoWayAuthenticationResponse.result.value - if ($obj.BeginTwoWayAuthenticationResponse.result) { - $Body = "Received an MFA confirmation: $($obj.BeginTwoWayAuthenticationResponse.result.value | Out-String)" + if ($obj.BeginTwoWayAuthenticationResponse.AuthenticationResult -eq $true) { + $Body = "Received an MFA confirmation: $($ResultValue | Out-String)" $State = 'success' - } - if ($obj.BeginTwoWayAuthenticationResponse.AuthenticationResult -ne $true) { - $Body = "Authentication Failed! Does the user have Push/Phone call MFA configured? ErrorCode: $($obj.BeginTwoWayAuthenticationResponse.result.value | Out-String)" + } else { + $Body = "Authentication Failed! Does the user have Push/Phone call MFA configured? ErrorCode: $($ResultValue | Out-String)" $State = 'error' } - } $Results = [pscustomobject]@{'Results' = @{ resultText = $Body; state = $State } } - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Sent push request to $UserEmail - Result: $($obj.BeginTwoWayAuthenticationResponse.result.value | Out-String)" -Sev 'Info' + $LogAction = if ($VerifyOtp) { 'Verified MFA code' } else { 'Sent push request' } + Write-LogMessage -headers $Request.Headers -API $APINAME -message "$LogAction for $UserEmail - Result: $($ResultValue | Out-String)" -Sev 'Info' return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::OK Body = $Results }) - - } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListGuestUsers.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListGuestUsers.ps1 index 0255de67a6cdf..981052311f28f 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListGuestUsers.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListGuestUsers.ps1 @@ -7,7 +7,7 @@ function Invoke-ListGuestUsers { .SYNOPSIS List guest users with lifecycle status .DESCRIPTION - Lists all guest accounts in a tenant with a computed lifecycle status (Active, Pending Acceptance, Stale, Never Signed In or Disabled) based on the invitation state and sign-in activity. Supports UseReportDB=true to serve cached data from the reporting database; AllTenants always uses the cache. + Lists all guest accounts in a tenant with a computed lifecycle status (Active, Pending Acceptance, Stale, Never Signed In or Disabled) based on the invitation state and sign-in activity. Supports UseReportDB=true to serve cached data from the reporting database; AllTenants always uses the cache. When manualPagination is set on a cached read, one page is returned per request as { Results, Metadata } with a continuation token in Metadata.nextLink. #> [CmdletBinding()] param($Request, $TriggerMetadata) @@ -21,13 +21,28 @@ function Invoke-ListGuestUsers { $StaleDays = $Request.Query.staleDays ? [int]$Request.Query.staleDays : 90 # Serve from the reporting database cache instead of live Graph. AllTenants always uses the cache. $UseReportDB = $Request.Query.UseReportDB -eq $true + # Return one page per request as { Results, Metadata } with a continuation token in Metadata.nextLink; cached reads only. + $ManualPagination = $Request.Query.manualPagination -and [System.Convert]::ToBoolean($Request.Query.manualPagination) + $NextToken = $null try { if ($TenantFilter -eq 'AllTenants' -or $UseReportDB) { # Cached rows carry a per-row signInLogsCapable stamp written by the cache job, # so sign-in availability is judged per row below. $SignInLogsCapable = $null - $GuestUsers = Get-CIPPGuestUsersReport -TenantFilter $TenantFilter + if ($ManualPagination) { + # Rows per page, clamped between 250 and 10000. Defaults to 5000. + $PageSize = 5000 + if ($Request.Query.PageSize -as [int]) { + $PageSize = [Math]::Min([Math]::Max([int]$Request.Query.PageSize, 250), 10000) + } + # Continuation token from the previous page's Metadata.nextLink; opaque to callers. + $Page = Get-CIPPGuestUsersReport -TenantFilter $TenantFilter -PageSize $PageSize -ContinuationToken $Request.Query.nextLink + $GuestUsers = $Page.Items + $NextToken = $Page.NextToken + } else { + $GuestUsers = Get-CIPPGuestUsersReport -TenantFilter $TenantFilter + } } else { # signInActivity can only be requested on tenants with an Entra ID P1 license - Graph # rejects the whole query on unlicensed tenants, so fall back to listing without @@ -113,6 +128,19 @@ function Invoke-ListGuestUsers { $GraphRequest = @{ Error = $ErrorMessage.NormalizedError } } + # Paged cached reads return { Results, Metadata }; everything else keeps the legacy bare array. + if ($ManualPagination -and ($TenantFilter -eq 'AllTenants' -or $UseReportDB) -and $StatusCode -eq [System.Net.HttpStatusCode]::OK) { + $Metadata = @{} + if ($NextToken) { $Metadata.nextLink = $NextToken } + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = [PSCustomObject]@{ + Results = @($GraphRequest) + Metadata = $Metadata + } + }) + } + return ([HttpResponseContext]@{ StatusCode = $StatusCode Body = @($GraphRequest) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITAdmin.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITAdmin.ps1 index 3c5a66238d963..06830b6c183c9 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITAdmin.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITAdmin.ps1 @@ -15,6 +15,18 @@ $Schema = Get-CIPPSchemaExtensions | Where-Object { $_.id -match '_cippUser' } | Select-Object -First 1 $TenantFilter = $Request.Query.TenantFilter + # Resolve which directory roles the caller may see. When restricted, a JIT admin is only shown if + # every directory role it currently holds is within the caller's allow-list (strict subset). JIT + # admins with no resolvable roles (e.g. scheduled-but-not-yet-active, or stale cache) are shown. + $AllowedRoles = Get-CIPPJITAdminAllowedRoles -Headers $Request.Headers + $FilterJITAdmin = { + param($RoleTemplateIds) + if (-not $AllowedRoles.Restricted) { return $true } + $Ids = @($RoleTemplateIds | Where-Object { $_ }) + if ($Ids.Count -eq 0) { return $true } + return @($Ids | Where-Object { $AllowedRoles.AllowedRoleIds -notcontains $_ }).Count -eq 0 + } + if ($TenantFilter -ne 'AllTenants') { # Single tenant logic $BulkRequests = [System.Collections.Generic.List[object]]::new() @@ -29,27 +41,37 @@ $BulkRequests.Clear() foreach ($User in $Users) { + # memberOf (groups + roles) for display $BulkRequests.Add(@{ id = $User.id method = 'GET' url = "users/$($User.id)/memberOf?`$select=id,displayName" }) + # directory roles with roleTemplateId, used for allow-list filtering + $BulkRequests.Add(@{ + id = "role_$($User.id)" + method = 'GET' + url = "users/$($User.id)/memberOf/microsoft.graph.directoryRole?`$select=id,displayName,roleTemplateId" + }) } $RoleResults = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($BulkRequests) # Write-Information ($RoleResults | ConvertTo-Json -Depth 10 ) $Results = $Users | ForEach-Object { $MemberOf = ($RoleResults | Where-Object -Property id -EQ $_.id).body.value | Select-Object displayName, id - [PSCustomObject]@{ - id = $_.id - displayName = $_.displayName - userPrincipalName = $_.userPrincipalName - accountEnabled = $_.accountEnabled - jitAdminEnabled = $_.($Schema.id).jitAdminEnabled - jitAdminExpiration = $_.($Schema.id).jitAdminExpiration - jitAdminStartDate = $_.($Schema.id).jitAdminStartDate - jitAdminReason = $_.($Schema.id).jitAdminReason - jitAdminCreatedBy = $_.($Schema.id).jitAdminCreatedBy - memberOf = $MemberOf + $DirectoryRoles = ($RoleResults | Where-Object -Property id -EQ "role_$($_.id)").body.value | Select-Object displayName, id, roleTemplateId + if ((& $FilterJITAdmin ($DirectoryRoles.roleTemplateId))) { + [PSCustomObject]@{ + id = $_.id + displayName = $_.displayName + userPrincipalName = $_.userPrincipalName + accountEnabled = $_.accountEnabled + jitAdminEnabled = $_.($Schema.id).jitAdminEnabled + jitAdminExpiration = $_.($Schema.id).jitAdminExpiration + jitAdminStartDate = $_.($Schema.id).jitAdminStartDate + jitAdminReason = $_.($Schema.id).jitAdminReason + jitAdminCreatedBy = $_.($Schema.id).jitAdminCreatedBy + memberOf = $MemberOf + } } } @@ -102,6 +124,9 @@ Write-Information "Found $($Rows.Count) rows in the cache" foreach ($row in ($Rows | Select-CippAllowedTenantData -TenantProperty 'Tenant')) { $UserObject = $row.JITAdminUser | ConvertFrom-Json + if (-not (& $FilterJITAdmin ($UserObject.roleTemplateIds))) { + continue + } $Results.Add( [PSCustomObject]@{ Tenant = $row.Tenant diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITAdminTemplates.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITAdminTemplates.ps1 index 2f3dabfc8b674..22791252d721a 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITAdminTemplates.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITAdminTemplates.ps1 @@ -32,8 +32,10 @@ function Invoke-ListJITAdminTemplates { try { $row = $_ $data = $row.JSON | ConvertFrom-Json -Depth 100 -ErrorAction Stop - $data | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $row.GUID -Force - $data | Add-Member -NotePropertyName 'RowKey' -NotePropertyValue $row.RowKey -Force + $data | Add-Member -NotePropertyMembers ([ordered]@{ + GUID = $row.GUID + RowKey = $row.RowKey + }) -Force $data } catch { Write-LogMessage -headers $Headers -API $APIName -message "Failed to process JIT Admin template: $($row.RowKey) - $($_.Exception.Message)" -sev 'Warning' diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITAllowedRoles.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITAllowedRoles.ps1 new file mode 100644 index 0000000000000..1a281ef002eb7 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITAllowedRoles.ps1 @@ -0,0 +1,24 @@ +function Invoke-ListJITAllowedRoles { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + Identity.Role.Read + .DESCRIPTION + Returns the directory roles the calling user is permitted to assign via JIT Admin, based on the + JIT Role Template(s) attached to their CIPP custom role(s). When the caller is unrestricted the + full role catalog is available (Restricted = false). + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $Allowed = Get-CIPPJITAdminAllowedRoles -Headers $Request.Headers + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @{ + Restricted = $Allowed.Restricted + AllowedRoleIds = @($Allowed.AllowedRoleIds) + } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITRoleTemplates.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITRoleTemplates.ps1 new file mode 100644 index 0000000000000..8905296544e51 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITRoleTemplates.ps1 @@ -0,0 +1,47 @@ +function Invoke-ListJITRoleTemplates { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + Identity.Role.Read + .DESCRIPTION + Lists JIT Role Templates - named allow-lists of directory roles used to restrict which roles a + CIPP custom role may assign via JIT Admin. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + $Table = Get-CippTable -tablename 'templates' + $Filter = "PartitionKey eq 'JITRoleTemplate'" + + $Templates = (Get-CIPPAzDataTableEntity @Table -Filter $Filter) | ForEach-Object { + try { + $row = $_ + $data = $row.JSON | ConvertFrom-Json -Depth 100 -ErrorAction Stop + $data | Add-Member -NotePropertyMembers ([ordered]@{ + GUID = $row.GUID + RowKey = $row.RowKey + }) -Force + $data + } catch { + Write-LogMessage -headers $Headers -API $APIName -message "Failed to process JIT Role template: $($row.RowKey) - $($_.Exception.Message)" -sev 'Warning' + } + } + + $Templates = $Templates | Sort-Object -Property templateName + + # If a specific GUID is requested, filter to that template + if ($Request.query.GUID) { + $Templates = $Templates | Where-Object -Property GUID -EQ $Request.query.GUID + } + + $Templates = ConvertTo-Json -InputObject @($Templates) -Depth 100 + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = $Templates + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListNewUserDefaults.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListNewUserDefaults.ps1 index cdcd4c66fb645..68a384011c33a 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListNewUserDefaults.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListNewUserDefaults.ps1 @@ -31,8 +31,10 @@ function Invoke-ListNewUserDefaults { try { $row = $_ $data = $row.JSON | ConvertFrom-Json -Depth 100 -ErrorAction Stop - $data | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $row.GUID -Force - $data | Add-Member -NotePropertyName 'RowKey' -NotePropertyValue $row.RowKey -Force + $data | Add-Member -NotePropertyMembers ([ordered]@{ + GUID = $row.GUID + RowKey = $row.RowKey + }) -Force $data } catch { Write-Warning "Failed to process User Default template: $($row.RowKey) - $($_.Exception.Message)" diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListOffboardingProgress.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListOffboardingProgress.ps1 new file mode 100644 index 0000000000000..e96afac7ff28b --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListOffboardingProgress.ps1 @@ -0,0 +1,33 @@ +function Invoke-ListOffboardingProgress { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + Identity.User.Read + .SYNOPSIS + Get the live progress of an offboarding job + .DESCRIPTION + Returns the progress rows of an offboarding job started from the wizard: one row per user with + its overall status and the status and message of every step. Same rows as ListAsyncDeployment. + This is a read-only GET, so it carries a read role; an offboarding operator (who holds the + broader user write role) can still follow and re-run their jobs. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + # The DeploymentId handed back by ExecOffboardUser, also stored on each offboarding task + $DeploymentId = $Request.Query.DeploymentId + if (-not $DeploymentId) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = 'DeploymentId is required' } + }) + } + + # Rows carry the tenant they belong to; a tenant-restricted caller only gets rows in scope. + $Rows = @(Get-CIPPAsyncDeployment -JobId $DeploymentId | Select-CippAllowedTenantData -TenantProperty @('TenantFilter', 'Name')) + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = ConvertTo-Json -Depth 10 -InputObject $Rows + }) +} \ No newline at end of file diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserCounts.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserCounts.ps1 index 8e840b76a2b7c..b897bf97e809e 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserCounts.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserCounts.ps1 @@ -98,11 +98,32 @@ Function Invoke-ListUserCounts { } } + # PIM view of the Global Administrator role: how many of the admins are standing (permanent) + # versus eligible. Only meaningful on Entra ID P2 tenants; elsewhere every GA is permanent. + $PermanentGAs = $GAs + $EligibleGAs = 0 + $PIMCapable = $false + if ($TenantFilter -ne 'AllTenants') { + try { + $GARows = @(Get-CIPPPIMRoleAssignments -TenantFilter $TenantFilter -RoleDefinitionId '62e90394-69f5-4237-9190-012177145e10') + $PIMCapable = [bool](($GARows | Select-Object -First 1).PIMCapable) + if ($PIMCapable) { + $PermanentGAs = @($GARows | Where-Object { $_.AssignmentType -eq 'Permanent' }).Count + $EligibleGAs = @($GARows | Where-Object { $_.AssignmentType -eq 'Eligible' }).Count + } + } catch { + Write-Information "Could not read PIM assignments for the Global Administrator role: $($_.Exception.Message)" + } + } + $Counts = @{ - Users = $Users - LicUsers = $LicUsers - Gas = $GAs - Guests = $Guests + Users = $Users + LicUsers = $LicUsers + Gas = $GAs + PermanentGas = $PermanentGAs + EligibleGas = $EligibleGAs + PIMCapable = $PIMCapable + Guests = $Guests } return ([HttpResponseContext]@{ diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserMailboxDetails.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserMailboxDetails.ps1 index c28b9d95cc1d4..583d8263442a8 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserMailboxDetails.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserMailboxDetails.ps1 @@ -17,6 +17,22 @@ function Invoke-ListUserMailboxDetails { Write-Host "UserID: $UserID" Write-Host "UserMail: $UserMail" + # Archive size and item count are the slow part of the mailbox detail (Get-MailboxStatistics + # -Archive). Set-CIPPDBCacheMailboxes already caches both per mailbox, keyed by the Entra object + # id, so when the reporting DB has a row for this user we read them from there and drop that + # cmdlet from the bulk request; only fall back to the live call when there is no cached row. + $CachedMailbox = $null + try { + $ReportingTable = Get-CIPPTable -TableName 'CippReportingDB' + $CachedEntity = Get-CIPPAzDataTableEntity @ReportingTable -Filter "PartitionKey eq '$TenantFilter' and RowKey eq 'Mailboxes-$UserID'" + if ($CachedEntity.Data) { + $CachedMailbox = $CachedEntity.Data | ConvertFrom-Json + } + } catch { + $CachedMailbox = $null + } + $UseCachedArchiveStats = $null -ne $CachedMailbox + try { $Requests = @( @{ @@ -24,36 +40,39 @@ function Invoke-ListUserMailboxDetails { CmdletName = 'Get-Mailbox' Parameters = @{ Identity = $UserID } } - }, + } @{ CmdletInput = @{ CmdletName = 'Get-MailboxPermission' Parameters = @{ Identity = $UserID } } - }, + } @{ CmdletInput = @{ CmdletName = 'Get-CASMailbox' Parameters = @{ Identity = $UserID } } - }, + } @{ CmdletInput = @{ CmdletName = 'Get-OrganizationConfig' } - }, - @{ - CmdletInput = @{ - CmdletName = 'Get-MailboxStatistics' - Parameters = @{ Identity = $UserID; Archive = $true } + } + # Only fetch archive statistics live when the reporting DB has no cached copy. + if (-not $UseCachedArchiveStats) { + @{ + CmdletInput = @{ + CmdletName = 'Get-MailboxStatistics' + Parameters = @{ Identity = $UserID; Archive = $true } + } } - }, + } @{ CmdletInput = @{ CmdletName = 'Get-BlockedSenderAddress' Parameters = @{ SenderAddress = $UserMail } } - }, + } @{ CmdletInput = @{ CmdletName = 'Get-RecipientPermission' @@ -185,7 +204,6 @@ function Invoke-ListUserMailboxDetails { $ProhibitSendQuotaString = $MailboxDetailedRequest.ProhibitSendQuota -split ' ' $ProhibitSendReceiveQuotaString = $MailboxDetailedRequest.ProhibitSendReceiveQuota -split ' ' $TotalItemSizeString = $StatsRequest.TotalItemSize -split ' ' - $TotalArchiveItemSizeString = (Get-ExoOnlineStringBytes -SizeString $ArchiveSizeRequest.TotalItemSize) / 1GB $ProhibitSendQuota = try { [math]::Round([float]($ProhibitSendQuotaString[0]), 2) } catch { 0 } $ProhibitSendReceiveQuota = try { [math]::Round([float]($ProhibitSendReceiveQuotaString[0]), 2) } catch { 0 } @@ -194,8 +212,15 @@ function Invoke-ListUserMailboxDetails { $TotalItemSize = try { [math]::Round([float]($TotalItemSizeString[0]) / $ItemSizeType, 2) } catch { 0 } if ($ArchiveEnabled -eq $true) { - $TotalArchiveItemSize = try { [math]::Round([float]($TotalArchiveItemSizeString[0]), 2) } catch { 0 } - $TotalArchiveItemCount = try { [math]::Round($ArchiveSizeRequest.ItemCount, 2) } catch { 0 } + if ($UseCachedArchiveStats) { + # The reporting DB stores ArchiveSize as a byte count and ArchiveItemCount as an integer. + $TotalArchiveItemSize = try { [math]::Round([float]$CachedMailbox.ArchiveSize / 1GB, 2) } catch { 0 } + $TotalArchiveItemCount = try { [math]::Round([float]$CachedMailbox.ArchiveItemCount, 2) } catch { 0 } + } else { + $TotalArchiveItemSizeString = (Get-ExoOnlineStringBytes -SizeString $ArchiveSizeRequest.TotalItemSize) / 1GB + $TotalArchiveItemSize = try { [math]::Round([float]($TotalArchiveItemSizeString[0]), 2) } catch { 0 } + $TotalArchiveItemCount = try { [math]::Round($ArchiveSizeRequest.ItemCount, 2) } catch { 0 } + } } # Parse InPlaceHolds to determine hold types if available diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUsers.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUsers.ps1 index a43c639147c95..aecc61f06115c 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUsers.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUsers.ps1 @@ -75,12 +75,14 @@ Function Invoke-ListUsers { } } $UserData | ForEach-Object { - $_ | Add-Member -MemberType NoteProperty -Name 'onPremisesSyncEnabled' -Value ([bool]($_.onPremisesSyncEnabled)) -Force - $_ | Add-Member -MemberType NoteProperty -Name 'username' -Value ($_.userPrincipalName -split '@' | Select-Object -First 1) -Force - $_ | Add-Member -MemberType NoteProperty -Name 'Aliases' -Value ($_.ProxyAddresses -join ', ') -Force $SkuID = $_.AssignedLicenses.skuid - $_ | Add-Member -MemberType NoteProperty -Name 'LicJoined' -Value ((@($SkuID | ForEach-Object { ($ConversionTable | Where-Object guid -EQ ([string]$_) | Select-Object -First 1 -ExpandProperty Product_Display_Name) }) -join ', ')) -Force - $_ | Add-Member -MemberType NoteProperty -Name 'primDomain' -Value @{value = ($_.userPrincipalName -split '@' | Select-Object -Last 1); label = ($_.userPrincipalName -split '@' | Select-Object -Last 1); } -Force + $_ | Add-Member -NotePropertyMembers ([ordered]@{ + onPremisesSyncEnabled = [bool]($_.onPremisesSyncEnabled) + username = ($_.userPrincipalName -split '@' | Select-Object -First 1) + Aliases = ($_.ProxyAddresses -join ', ') + LicJoined = ((@($SkuID | ForEach-Object { ($ConversionTable | Where-Object guid -EQ ([string]$_) | Select-Object -First 1 -ExpandProperty Product_Display_Name) }) -join ', ')) + primDomain = @{value = ($_.userPrincipalName -split '@' | Select-Object -Last 1); label = ($_.userPrincipalName -split '@' | Select-Object -Last 1); } + }) -Force $_ } } elseif ($null -ne (Get-CippRequestContext).AllowedTenants) { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-RemoveJITRoleTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-RemoveJITRoleTemplate.ps1 new file mode 100644 index 0000000000000..89df493bd0c06 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-RemoveJITRoleTemplate.ps1 @@ -0,0 +1,50 @@ +function Invoke-RemoveJITRoleTemplate { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Identity.Role.ReadWrite + .DESCRIPTION + Deletes a JIT Role Template. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + try { + $ID = $Request.Query.ID ?? $Request.Body.ID + + if ([string]::IsNullOrWhiteSpace($ID)) { + throw 'ID is required' + } + + $Table = Get-CippTable -tablename 'templates' + $SafeID = ConvertTo-CIPPODataFilterValue -Value $ID -Type Guid + $Filter = "PartitionKey eq 'JITRoleTemplate' and RowKey eq '$SafeID'" + $Template = Get-CIPPAzDataTableEntity @Table -Filter $Filter + + if ($Template) { + Remove-AzDataTableEntity @Table -Entity $Template + $Result = "Successfully deleted JIT Role Template with ID: $ID" + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Info' + $StatusCode = [HttpStatusCode]::OK + } else { + $Result = "JIT Role Template with ID $ID not found" + Write-LogMessage -headers $Headers -API $APIName -message $Result -sev 'Warning' + $StatusCode = [HttpStatusCode]::NotFound + } + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to delete JIT Role Template: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Error' -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::InternalServerError + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{'Results' = "$Result" } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Reports/Invoke-ListGroupUsage.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Reports/Invoke-ListGroupUsage.ps1 new file mode 100644 index 0000000000000..7d8c54af49fb9 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Reports/Invoke-ListGroupUsage.ps1 @@ -0,0 +1,29 @@ +function Invoke-ListGroupUsage { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Identity.Group.Read + .DESCRIPTION + Compiles where each Entra group is used (Conditional Access, Intune assignments, group-based + licensing, Teams, nested groups, Entra roles, enterprise applications, Exchange transport + rules) from the CIPP reporting database cache. Always served from cache — no live Graph calls. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter + + try { + $GraphRequest = Get-CIPPGroupUsageReport -TenantFilter $TenantFilter -ErrorAction Stop + $StatusCode = [HttpStatusCode]::OK + } catch { + $StatusCode = [HttpStatusCode]::InternalServerError + $GraphRequest = $_.Exception.Message + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @($GraphRequest) + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Reports/Invoke-ListInactiveAccounts.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Reports/Invoke-ListInactiveAccounts.ps1 index cb08149fc8efe..1609511ecb6bd 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Reports/Invoke-ListInactiveAccounts.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Reports/Invoke-ListInactiveAccounts.ps1 @@ -98,8 +98,9 @@ function Get-InactiveUsersFromDB { } $InactiveUsers = foreach ($User in $Users) { - # Skip disabled users by default - if ($User.accountEnabled -eq $false) { continue } + # Disabled (blocked) users are kept: a dormant, already-blocked account is a cleanup + # candidate, and the accountEnabled field below lets the report show which inactive + # accounts are already blocked and need no further action. # Skip guest users if ($User.userType -eq 'Guest') { continue } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Reports/Invoke-ListMFAUsers.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Reports/Invoke-ListMFAUsers.ps1 index cf7f46962c0bd..4bf91153098d0 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Reports/Invoke-ListMFAUsers.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Reports/Invoke-ListMFAUsers.ps1 @@ -5,7 +5,7 @@ function Invoke-ListMFAUsers { .ROLE Identity.User.Read .DESCRIPTION - Lists users and their MFA registration status for a tenant. Supports UseReportDB=true query parameter to retrieve cached data from the reporting database for significantly better performance, especially when querying AllTenants. + Lists users and their MFA registration status for a tenant. Supports UseReportDB=true query parameter to retrieve cached data from the reporting database for significantly better performance, especially when querying AllTenants. When manualPagination is also set, one page is returned per request as { Results, Metadata } with a continuation token in Metadata.nextLink. #> [CmdletBinding()] param($Request, $TriggerMetadata) @@ -13,10 +13,30 @@ function Invoke-ListMFAUsers { $TenantFilter = $Request.Query.tenantFilter # Serve from the reporting database cache instead of live Graph. Much faster, especially for AllTenants. $UseReportDB = $Request.Query.UseReportDB -eq $true + # Return one page per request as { Results, Metadata } with a continuation token in Metadata.nextLink; cached reads only. + $ManualPagination = $Request.Query.manualPagination -and [System.Convert]::ToBoolean($Request.Query.manualPagination) try { # If UseReportDB is specified, retrieve from report database if ($UseReportDB) { try { + if ($ManualPagination) { + # Rows per page, clamped between 250 and 10000. Defaults to 5000. + $PageSize = 5000 + if ($Request.Query.PageSize -as [int]) { + $PageSize = [Math]::Min([Math]::Max([int]$Request.Query.PageSize, 250), 10000) + } + # Continuation token from the previous page's Metadata.nextLink; opaque to callers. + $Page = Get-CIPPMFAStateReport -TenantFilter $TenantFilter -PageSize $PageSize -ContinuationToken $Request.Query.nextLink -ErrorAction Stop + $Metadata = @{} + if ($Page.NextToken) { $Metadata.nextLink = $Page.NextToken } + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = [PSCustomObject]@{ + Results = @($Page.Items) + Metadata = $Metadata + } + }) + } $GraphRequest = Get-CIPPMFAStateReport -TenantFilter $TenantFilter -ErrorAction Stop $StatusCode = [HttpStatusCode]::OK } catch { @@ -46,8 +66,10 @@ function Invoke-ListMFAUsers { UPN = 'Loading data for all tenants. Please check back in a few minutes' } $Batch = $TenantList | ForEach-Object { - $_ | Add-Member -NotePropertyName FunctionName -NotePropertyValue 'ListMFAUsersQueue' - $_ | Add-Member -NotePropertyName QueueId -NotePropertyValue $Queue.RowKey + $_ | Add-Member -NotePropertyMembers ([ordered]@{ + FunctionName = 'ListMFAUsersQueue' + QueueId = $Queue.RowKey + }) $_ } if (($Batch | Measure-Object).Count -gt 0) { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-AddDlpCompliancePolicyTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-AddDlpCompliancePolicyTemplate.ps1 index c45e6e38fdd9d..8c8bf8bd25ebe 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-AddDlpCompliancePolicyTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-AddDlpCompliancePolicyTemplate.ps1 @@ -87,12 +87,12 @@ Function Invoke-AddDlpCompliancePolicyTemplate { PartitionKey = 'DlpCompliancePolicyTemplate' } $Result = "Successfully created DLP Compliance Policy Template: $($Ordered['name']) with GUID $GUID" - Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Debug' + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Info' $StatusCode = [HttpStatusCode]::OK } catch { $ErrorMessage = Get-CippException -Exception $_ $Result = "Failed to create DLP Compliance Policy Template: $($ErrorMessage.NormalizedError)" - Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Error' -LogData $ErrorMessage + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Error' -LogData $ErrorMessage $StatusCode = [HttpStatusCode]::InternalServerError } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-AddRetentionCompliancePolicyTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-AddRetentionCompliancePolicyTemplate.ps1 index 9c3830cebc1ee..43b3baa48ad70 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-AddRetentionCompliancePolicyTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-AddRetentionCompliancePolicyTemplate.ps1 @@ -124,12 +124,12 @@ Function Invoke-AddRetentionCompliancePolicyTemplate { PartitionKey = 'RetentionCompliancePolicyTemplate' } $Result = "Successfully created Retention Compliance Policy Template: $($Ordered['name']) with GUID $GUID" - Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Debug' + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Info' $StatusCode = [HttpStatusCode]::OK } catch { $ErrorMessage = Get-CippException -Exception $_ $Result = "Failed to create Retention Compliance Policy Template: $($ErrorMessage.NormalizedError)" - Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Error' -LogData $ErrorMessage + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Error' -LogData $ErrorMessage $StatusCode = [HttpStatusCode]::InternalServerError } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-ListRetentionCompliancePolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-ListRetentionCompliancePolicy.ps1 index c8aa16e242752..d47cbcc8eca6c 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-ListRetentionCompliancePolicy.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-ListRetentionCompliancePolicy.ps1 @@ -11,12 +11,59 @@ Function Invoke-ListRetentionCompliancePolicy { param($Request, $TriggerMetadata) $TenantFilter = $Request.Query.tenantFilter + # Get-RetentionCompliancePolicy only populates the per-location properties (ExchangeLocation, ...) when + # -DistributionDetail is set. Without it the flat 'Workload' string it returns is a fixed superset + # ('Exchange, SharePoint, OneDriveForBusiness, Skype, ModernGroup, DynamicScope') that does not reflect + # the policy's real scope, so we derive the scope from the populated location fields instead. + $LocationLabels = [ordered]@{ + ExchangeLocation = 'Exchange' + SharePointLocation = 'SharePoint' + OneDriveLocation = 'OneDrive' + ModernGroupLocation = 'Microsoft 365 Groups' + TeamsChatLocation = 'Teams Chats' + TeamsChannelLocation = 'Teams Channels' + SkypeLocation = 'Skype' + PublicFolderLocation = 'Public Folders' + AdaptiveScopeLocation = 'Adaptive Scope' + } + try { - $Policies = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-RetentionCompliancePolicy' -Compliance -AsApp | Select-Object * -ExcludeProperty *odata*, *data.type* + # Teams-scoped retention policies are not returned by the default call - they require -TeamsPolicyOnly. + # Fetch both sets (with distribution detail for the real location data) and merge, de-duped by Guid. + $Policies = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-RetentionCompliancePolicy' -cmdParams @{ DistributionDetail = $true } -Compliance -AsApp | Select-Object * -ExcludeProperty *odata*, *data.type* + $TeamsPolicies = try { + New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-RetentionCompliancePolicy' -cmdParams @{ DistributionDetail = $true; TeamsPolicyOnly = $true } -Compliance -AsApp | Select-Object * -ExcludeProperty *odata*, *data.type* + } catch { @() } + + $SeenGuids = [System.Collections.Generic.HashSet[string]]::new() + $AllPolicies = @(@($Policies) + @($TeamsPolicies) | Where-Object { $_ -and $SeenGuids.Add([string]$_.Guid) }) + $Rules = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-RetentionComplianceRule' -Compliance -AsApp | Select-Object * -ExcludeProperty *odata*, *data.type* - $GraphRequest = $Policies | Select-Object *, - @{l = 'AssociatedRules'; e = { $name = $_.Name; @($Rules | Where-Object { $_.Policy -eq $name }) } }, - @{l = 'RuleCount'; e = { $name = $_.Name; (@($Rules | Where-Object { $_.Policy -eq $name })).Count } } + + $GraphRequest = foreach ($Policy in $AllPolicies) { + # Get-RetentionComplianceRule reports its parent policy via the policy Guid, not the policy Name. + $PolicyRules = @($Rules | Where-Object { $_.Policy -eq $Policy.Guid }) + $PrimaryRule = $PolicyRules | Select-Object -First 1 + + # Real scope is the set of location fields that actually carry a value. + $Locations = foreach ($Field in $LocationLabels.Keys) { + if (@($Policy.$Field).Where({ $_ }).Count -gt 0) { $LocationLabels[$Field] } + } + + $RetentionDuration = if ($PrimaryRule) { + if ([string]::IsNullOrEmpty([string]$PrimaryRule.RetentionDuration) -or $PrimaryRule.RetentionDuration -eq 'Unlimited') { 'Unlimited' } else { $PrimaryRule.RetentionDuration } + } else { $null } + + # Note: Get-RetentionCompliancePolicy -DistributionDetail returns its own (empty) 'Locations' + # property, so the derived scope summary is exposed as 'ScopedLocations' to avoid the collision + # (Select-Object silently drops a computed property whose name already exists on the object). + $Policy | Select-Object *, + @{l = 'AssociatedRules'; e = { $PolicyRules } }, + @{l = 'RuleCount'; e = { $PolicyRules.Count } }, + @{l = 'ScopedLocations'; e = { @($Locations) -join ', ' } }, + @{l = 'RetentionAction'; e = { $PrimaryRule.RetentionComplianceAction } }, + @{l = 'RetentionDuration'; e = { $RetentionDuration } } + } $StatusCode = [HttpStatusCode]::OK } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-AddSensitivityLabelTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-AddSensitivityLabelTemplate.ps1 index b11d2771ea8fe..fa126d8aa740b 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-AddSensitivityLabelTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-AddSensitivityLabelTemplate.ps1 @@ -54,12 +54,12 @@ Function Invoke-AddSensitivityLabelTemplate { PartitionKey = 'SensitivityLabelTemplate' } $Result = "Successfully created Sensitivity Label Template: $DisplayName with GUID $GUID" - Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Debug' + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Info' $StatusCode = [HttpStatusCode]::OK } catch { $ErrorMessage = Get-CippException -Exception $_ $Result = "Failed to create Sensitivity Label Template: $($ErrorMessage.NormalizedError)" - Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Error' -LogData $ErrorMessage + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Error' -LogData $ErrorMessage $StatusCode = [HttpStatusCode]::InternalServerError } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Incidents/Invoke-ExecMdoAlertsList.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Incidents/Invoke-ExecMdoAlertsList.ps1 index a6ece00125709..636336f3f8893 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Incidents/Invoke-ExecMdoAlertsList.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Incidents/Invoke-ExecMdoAlertsList.ps1 @@ -5,7 +5,7 @@ function Invoke-ExecMDOAlertsList { .ROLE Security.Alert.Read .DESCRIPTION - Lists Microsoft Defender for Office 365 alerts for a tenant, filtered to that service source. tenantFilter=AllTenants reads the cached alert table rather than querying each tenant live. + Lists Microsoft Defender for Office 365 and Defender for Endpoint alerts for a tenant, filtered to those service sources. tenantFilter=AllTenants reads the cached alert table rather than querying each tenant live. #> [CmdletBinding()] param($Request, $TriggerMetadata) @@ -15,7 +15,7 @@ function Invoke-ExecMDOAlertsList { try { $GraphRequest = if ($TenantFilter -ne 'AllTenants') { # Single tenant functionality - New-GraphGetRequest -uri "https://graph.microsoft.com/beta/security/alerts_v2?`$filter=serviceSource eq 'microsoftDefenderForOffice365'" -tenantid $TenantFilter + New-GraphGetRequest -uri "https://graph.microsoft.com/beta/security/alerts_v2?`$filter=serviceSource eq 'microsoftDefenderForOffice365' or serviceSource eq 'microsoftDefenderForEndpoint'" -tenantid $TenantFilter } else { # AllTenants functionality $Table = Get-CIPPTable -TableName cachealertsandincidents diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Safe-Links-Policy/Invoke-AddSafeLinksPolicyTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Safe-Links-Policy/Invoke-AddSafeLinksPolicyTemplate.ps1 index 4d3c4b5cc2602..79c42cce7cec4 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Safe-Links-Policy/Invoke-AddSafeLinksPolicyTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Safe-Links-Policy/Invoke-AddSafeLinksPolicyTemplate.ps1 @@ -17,8 +17,9 @@ Function Invoke-AddSafeLinksPolicyTemplate { try { $GUID = (New-Guid).GUID - # Validate required fields - if ([string]::IsNullOrEmpty($Request.body.Name)) { + # Validate required fields. The "create template from policy" row action posts the policy + # row, which carries Name/PolicyName but no TemplateName. + if ([string]::IsNullOrEmpty($Request.body.Name) -and [string]::IsNullOrEmpty($Request.body.TemplateName)) { throw "Template name is required but was not provided" } @@ -29,8 +30,9 @@ Function Invoke-AddSafeLinksPolicyTemplate { # Create a new ordered hashtable to store selected properties $policyObject = [ordered]@{} - # Set name and comments - prioritize template-specific fields - $policyObject["TemplateName"] = $Request.body.TemplateName + # Set name and comments - prioritize template-specific fields, falling back to the policy + # name so a template made from a policy is not listed with a blank name. + $policyObject["TemplateName"] = if (-not [string]::IsNullOrEmpty($Request.body.TemplateName)) { $Request.body.TemplateName } else { $Request.body.PolicyName } $policyObject["TemplateDescription"] = $Request.body.TemplateDescription # For templates, if no specific policy description is provided, use template description as default diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Safe-Links-Policy/Invoke-ExecNewSafeLinksPolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Safe-Links-Policy/Invoke-ExecNewSafeLinksPolicy.ps1 index bd731bd8e810f..5830edb409bcc 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Safe-Links-Policy/Invoke-ExecNewSafeLinksPolicy.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Safe-Links-Policy/Invoke-ExecNewSafeLinksPolicy.ps1 @@ -17,6 +17,16 @@ function Invoke-ExecNewSafeLinksPolicy { # Interact with query parameters or the body of the request. $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + # Exchange cmdlets need one tenant. With All Tenants selected the request used to fall through + # the authorisation check, skip the cmdlets and still report success, so the policy was never + # created anywhere while the logbook said it was. + if ([string]::IsNullOrWhiteSpace($TenantFilter) -or $TenantFilter -eq 'AllTenants') { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = 'Select a single tenant before creating a Safe Links policy. Safe Links policies cannot be created with All Tenants selected.' } + }) + } + # Extract policy settings from body $PolicyName = $Request.Body.PolicyName $EnableSafeLinksForEmail = $Request.Body.EnableSafeLinksForEmail diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecBulkRemoveSharingLinks.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecBulkRemoveSharingLinks.ps1 index a4608323d50ab..ccdf6a0440293 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecBulkRemoveSharingLinks.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecBulkRemoveSharingLinks.ps1 @@ -54,27 +54,33 @@ function Invoke-ExecBulkRemoveSharingLinks { $Revoked = [System.Collections.Generic.List[string]]::new() $Failed = [System.Collections.Generic.List[string]]::new() - foreach ($Link in $Targets) { - try { - $null = New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/drives/$($Link.driveId)/items/$($Link.itemId)/permissions/$($Link.permissionId)" -tenantid $TenantFilter -type DELETE -asapp $true - $Revoked.Add("$($Link.fileName) ($($Link.classification))") + + # Batch the DELETEs through Graph $batch (New-GraphBulkRequest, ~20 per request) instead of + # one sequential call per link - a heavily-shared site can have hundreds/thousands. + $BulkRequests = @(for ($i = 0; $i -lt $Targets.Count; $i++) { + $Link = $Targets[$i] + @{ + id = "$i" + method = 'DELETE' + url = "drives/$($Link.driveId)/items/$($Link.itemId)/permissions/$($Link.permissionId)" + } + }) + $Responses = @(New-GraphBulkRequest -tenantid $TenantFilter -Requests $BulkRequests -asapp $true -Version 'v1.0') + + foreach ($Response in $Responses) { + $Link = $Targets[[int]$Response.id] + $Status = [int]$Response.status + # 2xx = revoked; 404 = the link was already gone (treat as revoked). Either way, clean + # the reporting cache row so it does not linger. + if (($Status -ge 200 -and $Status -lt 300) -or $Status -eq 404) { + $Revoked.Add($(if ($Status -eq 404) { "$($Link.fileName) (already removed)" } else { "$($Link.fileName) ($($Link.classification))" })) if ($Link.id) { - try { - Remove-CIPPDbItem -TenantFilter $TenantFilter -Type 'SharePointSharingLinks' -ItemId $Link.id - } catch { + try { Remove-CIPPDbItem -TenantFilter $TenantFilter -Type 'SharePointSharingLinks' -ItemId $Link.id } catch { Write-Information "Revoked link but could not update reporting cache row $($Link.id): $($_.Exception.Message)" } } - } catch { - # A 404 means the link was already gone; treat as revoked and clean the cache row. - if ($_.Exception.Message -match 'itemNotFound|404') { - $Revoked.Add("$($Link.fileName) (already removed)") - if ($Link.id) { - try { Remove-CIPPDbItem -TenantFilter $TenantFilter -Type 'SharePointSharingLinks' -ItemId $Link.id } catch {} - } - } else { - $Failed.Add("$($Link.fileName): $($_.Exception.Message)") - } + } else { + $Failed.Add("$($Link.fileName): $($Response.body.error.message ?? "status $Status")") } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecEmptySiteRecycleBin.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecEmptySiteRecycleBin.ps1 new file mode 100644 index 0000000000000..55d9a6140b6b2 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecEmptySiteRecycleBin.ps1 @@ -0,0 +1,134 @@ +function Invoke-ExecEmptySiteRecycleBin { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.SiteRecycleBin.ReadWrite + .DESCRIPTION + Permanently empty a site recycle bin (first stage, second stage, or both). + Item ids are used only server-side; the response never includes file names. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Body.tenantFilter ?? $Request.Body.TenantFilter + $SiteUrl = $Request.Body.SiteUrl + $Stage = [string]($Request.Body.Stage ?? 'Both') + + try { + if ([string]::IsNullOrWhiteSpace($TenantFilter)) { throw 'tenantFilter is required.' } + if ([string]::IsNullOrWhiteSpace($SiteUrl)) { throw 'SiteUrl is required.' } + if ($Stage -notin @('First', 'Second', 'Both')) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = "Invalid Stage '$Stage'. Valid values: First, Second, Both." } + }) + } + + $RestContext = Resolve-CIPPSharePointRestContext -TenantFilter $TenantFilter -SiteUrl $SiteUrl + $Scope = $RestContext.Scope + $JsonAccept = $RestContext.Headers + $BaseUri = $RestContext.BaseUri + $DeletedCount = 0 + $Errors = [System.Collections.Generic.List[string]]::new() + + function Invoke-CIPPRecycleDeleteAll { + param([string]$Uri) + $null = New-GraphPostRequest -uri $Uri -tenantid $TenantFilter -scope $Scope -type POST -body '{}' -contentType 'application/json;odata=nometadata' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true + } + + function Invoke-CIPPRecycleDeleteByIdsBatch { + param( + [ValidateSet('First', 'Second')] + [string]$TargetStage + ) + $StateFilter = if ($TargetStage -eq 'Second') { 2 } else { 1 } + $BatchDeleted = 0 + $NextUri = "$BaseUri/site/RecycleBin?`$select=Id,ItemState&`$top=100&`$orderby=DeletedDate desc" + $Guard = 0 + while ($NextUri -and $Guard -lt 200) { + $Guard++ + $Page = New-GraphGetRequest -uri $NextUri -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true -noPagination $true -SkipValueExtraction + $Items = @($Page.value) + $NextLink = $Page.'@odata.nextLink' + $Ids = @( + foreach ($Item in $Items) { + $State = 0 + try { $State = [int]$Item.ItemState } catch { $State = 0 } + if ($State -eq $StateFilter -and $Item.Id) { [string]$Item.Id } + } + ) + if ($Ids.Count -eq 0) { + if ([string]::IsNullOrWhiteSpace($NextLink)) { break } + $NextUri = $NextLink + continue + } + for ($i = 0; $i -lt $Ids.Count; $i += 25) { + $Chunk = @($Ids[$i..([Math]::Min($i + 24, $Ids.Count - 1))]) + $DeleteBody = ConvertTo-Json -Compress -Depth 5 -InputObject @{ ids = @($Chunk) } + $null = New-GraphPostRequest -uri "$BaseUri/site/RecycleBin/DeleteByIds" -tenantid $TenantFilter -scope $Scope -type POST -body $DeleteBody -contentType 'application/json;odata=nometadata' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true + $BatchDeleted += $Chunk.Count + } + # After deletes, restart from the first page so we do not skip items when the list shifts. + $NextUri = "$BaseUri/site/RecycleBin?`$select=Id,ItemState&`$top=100&`$orderby=DeletedDate desc" + } + return $BatchDeleted + } + + if ($Stage -in @('First', 'Both')) { + try { + Invoke-CIPPRecycleDeleteAll -Uri "$BaseUri/web/RecycleBin/deleteAll()" + $DeletedCount += 1 # deleteAll does not return a count; mark attempt + } catch { + try { + $DeletedCount += Invoke-CIPPRecycleDeleteByIdsBatch -TargetStage First + } catch { + $Errors.Add("First stage: $($_.Exception.Message)") + } + } + } + + if ($Stage -in @('Second', 'Both')) { + try { + Invoke-CIPPRecycleDeleteAll -Uri "$BaseUri/site/RecycleBin/deleteAllSecondStageItems" + $DeletedCount += 1 + } catch { + try { + Invoke-CIPPRecycleDeleteAll -Uri "$BaseUri/site/RecycleBin/deleteAll()" + $DeletedCount += 1 + } catch { + try { + $DeletedCount += Invoke-CIPPRecycleDeleteByIdsBatch -TargetStage Second + } catch { + $Errors.Add("Second stage: $($_.Exception.Message)") + } + } + } + } + + if ($Errors.Count -gt 0 -and $DeletedCount -eq 0) { + throw ($Errors -join '; ') + } + + $Results = "Emptied recycle bin ($Stage) for $SiteUrl." + if ($Errors.Count -gt 0) { + $Results += " Partial warnings: $($Errors -join '; ')" + } + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info + $StatusCode = [HttpStatusCode]::OK + $Body = @{ Results = $Results; deletedAttempts = $DeletedCount } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Results = "Failed to empty recycle bin on $($SiteUrl): $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + $Body = @{ Results = $Results } + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = $Body + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecReactivateSite.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecReactivateSite.ps1 new file mode 100644 index 0000000000000..270e63b8409e2 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecReactivateSite.ps1 @@ -0,0 +1,81 @@ +function Invoke-ExecReactivateSite { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.ReadWrite + .SYNOPSIS + Reactivate an archived SharePoint or OneDrive site. + .DESCRIPTION + Reactivates (unarchives) a Microsoft 365 Archive site through the Graph beta + site: unarchive endpoint (POST /beta/sites/{site-id}/unarchive). Primarily used to + reactivate archived OneDrive accounts before granting permissions to them. + Reactivation is asynchronous (can take up to 24 hours) and, for fully-archived + accounts, may incur Microsoft 365 Archive charges and require Unlicensed OneDrive + billing to be enabled on the tenant. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + # Tenant the archived site belongs to. + $TenantFilter = $Request.Body.tenantFilter + # Full web URL of the archived site / OneDrive (the row's webUrl). + $SiteUrl = $Request.Body.SiteUrl + # Site-collection GUID (the row's siteId / sharepointIds.siteId). Used to build the Graph + # composite site id without touching the locked, archived site. + $SiteId = $Request.Body.SiteId + # Web GUID (the row's webId / sharepointIds.webId). + $WebId = $Request.Body.WebId + + try { + if ([string]::IsNullOrWhiteSpace($TenantFilter)) { throw 'tenantFilter is required.' } + if ([string]::IsNullOrWhiteSpace($SiteUrl)) { throw 'SiteUrl is required.' } + + $SiteHost = ([System.Uri]$SiteUrl).Host + if ([string]::IsNullOrWhiteSpace($SiteHost)) { throw "SiteUrl '$SiteUrl' is not a valid URL." } + + # Prefer building the Graph composite id ({host},{siteCollectionId},{webId}) from the + # ids the site listing already carries: an archived site is locked, so avoid any lookup + # against it. Fall back to resolving the id by path only when those ids are absent. + if (-not [string]::IsNullOrWhiteSpace($SiteId) -and -not [string]::IsNullOrWhiteSpace($WebId)) { + $GraphSiteId = '{0},{1},{2}' -f $SiteHost, $SiteId, $WebId + } else { + $RelativePath = ([System.Uri]$SiteUrl).AbsolutePath.TrimStart('/') + $Resolved = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/sites/$($SiteHost):/$($RelativePath)?`$select=id" -tenantid $TenantFilter -AsApp $true + $GraphSiteId = $Resolved.id + } + + if ([string]::IsNullOrWhiteSpace($GraphSiteId)) { + throw "Could not determine the site id for $SiteUrl." + } + + # site: unarchive is beta-only. App-only auth is used deliberately: the SAM app holds the + # Sites.FullControl.All application role, which this endpoint accepts, so no per-admin + # SharePoint-admin role is required. A 202 with an empty body (Invoke-CIPPRestMethod + # returns $null) is the success signal - no exception means reactivation was accepted. + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/sites/$GraphSiteId/unarchive" -tenantid $TenantFilter -AsApp $true -type POST -body '' + + $Results = "Reactivation started for $SiteUrl. It can take up to 24 hours to complete. If the account was fully archived, this may incur Microsoft 365 Archive charges and requires Unlicensed OneDrive billing to be enabled on the tenant." + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Results = "Failed to reactivate $($SiteUrl): $($ErrorMessage.NormalizedError)" + # A 423 (Locked) / "blocked" response is a known limitation of the beta unarchive endpoint + # for archived sites, and a billing failure means Unlicensed OneDrive billing is off. In + # both cases the reliable fallback is the SharePoint admin center. + if ($ErrorMessage.NormalizedError -match '423|[Ll]ocked|blocked|billing') { + $Results += ' Reactivation may need Unlicensed OneDrive billing enabled on the tenant, or the site cannot be reactivated via the API right now - reactivate it from the SharePoint admin center.' + } + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ 'Results' = $Results } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRestoreRecycleBinItems.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRestoreRecycleBinItems.ps1 index cfad4b5705659..cc8eb40a4efb5 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRestoreRecycleBinItems.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRestoreRecycleBinItems.ps1 @@ -22,10 +22,10 @@ function Invoke-ExecRestoreRecycleBinItems { if (-not $SiteUrl) { throw 'SiteUrl is required.' } if ($Ids.Count -eq 0) { throw 'No recycle bin items were selected.' } - $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter - $Scope = "$($SharePointInfo.SharePointUrl)/.default" - $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } - $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + $RestContext = Resolve-CIPPSharePointRestContext -TenantFilter $TenantFilter -SiteUrl $SiteUrl + $Scope = $RestContext.Scope + $JsonAccept = $RestContext.Headers + $BaseUri = $RestContext.BaseUri $RestoreBody = ConvertTo-Json -Compress -Depth 5 -InputObject @{ ids = @($Ids) } try { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetSharePointMember.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetSharePointMember.ps1 index fdffad7a36c74..3a65a68860458 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetSharePointMember.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetSharePointMember.ps1 @@ -75,10 +75,10 @@ function Invoke-ExecSetSharePointMember { $SiteUrl = $Request.Body.URL if (-not $SiteUrl) { throw 'No site URL was provided for this site.' } - $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter - $Scope = "$($SharePointInfo.SharePointUrl)/.default" - $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } - $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + $RestContext = Resolve-CIPPSharePointRestContext -TenantFilter $TenantFilter -SiteUrl $SiteUrl + $Scope = $RestContext.Scope + $JsonAccept = $RestContext.Headers + $BaseUri = $RestContext.BaseUri $RoleGroup = $AssociatedGroups[[string]$Role] $RoleLabel = ([string]$Role).ToLower().TrimEnd('s') $Article = if ($RoleLabel -match '^[aeiou]') { 'an' } else { 'a' } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSharePointTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSharePointTemplate.ps1 index fbb70900fda51..3a8c38165e330 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSharePointTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSharePointTemplate.ps1 @@ -124,8 +124,10 @@ function Invoke-ExecSharePointTemplate { $Body = $Templates | ForEach-Object { $TemplateData = $_.JSON | ConvertFrom-Json $OutputObject = $TemplateData | Select-Object -Property * - $OutputObject | Add-Member -NotePropertyName 'TemplateId' -NotePropertyValue $_.RowKey -Force - $OutputObject | Add-Member -NotePropertyName 'Timestamp' -NotePropertyValue $_.Timestamp.DateTime.ToString('yyyy-MM-ddTHH:mm:ssZ') -Force + $OutputObject | Add-Member -NotePropertyMembers ([ordered]@{ + TemplateId = $_.RowKey + Timestamp = $_.Timestamp.DateTime.ToString('yyyy-MM-ddTHH:mm:ssZ') + }) -Force return $OutputObject } } @@ -198,8 +200,10 @@ function Invoke-ExecSharePointTemplate { $Body = $Templates | ForEach-Object { $TemplateData = $_.JSON | ConvertFrom-Json $OutputObject = $TemplateData | Select-Object -Property * - $OutputObject | Add-Member -NotePropertyName 'TemplateId' -NotePropertyValue $_.RowKey -Force - $OutputObject | Add-Member -NotePropertyName 'Timestamp' -NotePropertyValue $_.Timestamp.DateTime.ToString('yyyy-MM-ddTHH:mm:ssZ') -Force + $OutputObject | Add-Member -NotePropertyMembers ([ordered]@{ + TemplateId = $_.RowKey + Timestamp = $_.Timestamp.DateTime.ToString('yyyy-MM-ddTHH:mm:ssZ') + }) -Force return $OutputObject } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSiteBrowserLibraryCopy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSiteBrowserLibraryCopy.ps1 new file mode 100644 index 0000000000000..3d388181849e4 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSiteBrowserLibraryCopy.ps1 @@ -0,0 +1,80 @@ +function Invoke-ExecSiteBrowserLibraryCopy { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.ReadWrite + .DESCRIPTION + Starts or preflights a SharePoint document library content copy (CreateCopyJobs + MoveButKeepSource). + Actions: PreflightLibraryCopy, StartLibraryCopy. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Body.tenantFilter ?? $Request.Body.TenantFilter + $Action = $Request.Body.Action ?? $Request.Query.Action + $StatusCode = [HttpStatusCode]::OK + + try { + if ([string]::IsNullOrWhiteSpace($TenantFilter)) { throw 'tenantFilter is required.' } + if ([string]::IsNullOrWhiteSpace($Action)) { throw 'Action is required.' } + if ($Action -notin @('PreflightLibraryCopy', 'StartLibraryCopy')) { + throw "Unknown Action '$Action'. Supported: PreflightLibraryCopy, StartLibraryCopy." + } + + $User = try { + [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Headers.'x-ms-client-principal')) | ConvertFrom-Json + } catch { $null } + $StartedBy = $User.userDetails ?? $Headers.'x-ms-client-principal-name' ?? 'CIPP-API' + + $ConflictRaw = $Request.Body.NameConflictBehavior ?? $Request.Body.nameConflictBehavior ?? 'Replace' + $NameConflictBehavior = switch ([string]$ConflictRaw) { + 'Fail' { 0 } + 'Replace' { 1 } + default { [int]$ConflictRaw } + } + + $Params = @{ + Mode = $Action + TenantFilter = $TenantFilter + SourceSiteId = [string]($Request.Body.SourceSiteId ?? $Request.Body.sourceSiteId) + SourceSiteUrl = [string]($Request.Body.SourceSiteUrl ?? $Request.Body.sourceSiteUrl) + SourceListId = [string]($Request.Body.SourceListId ?? $Request.Body.sourceListId) + SourceSiteName = [string]($Request.Body.SourceSiteName ?? $Request.Body.sourceSiteName) + SourceLibraryName = [string]($Request.Body.SourceLibraryName ?? $Request.Body.sourceLibraryName) + DestSiteId = [string]($Request.Body.DestSiteId ?? $Request.Body.destSiteId) + DestSiteUrl = [string]($Request.Body.DestSiteUrl ?? $Request.Body.destSiteUrl) + DestListId = [string]($Request.Body.DestListId ?? $Request.Body.destListId) + DestSiteName = [string]($Request.Body.DestSiteName ?? $Request.Body.destSiteName) + DestLibraryName = [string]($Request.Body.DestLibraryName ?? $Request.Body.destLibraryName) + NameConflictBehavior = $NameConflictBehavior + StartedBy = $StartedBy + Headers = $Headers + APIName = $APIName + } + + if ([string]::IsNullOrWhiteSpace($Params.SourceListId)) { throw 'SourceListId is required.' } + if ([string]::IsNullOrWhiteSpace($Params.DestListId)) { throw 'DestListId is required.' } + if ([string]::IsNullOrWhiteSpace($Params.SourceSiteId) -and [string]::IsNullOrWhiteSpace($Params.SourceSiteUrl)) { + throw 'SourceSiteId or SourceSiteUrl is required.' + } + if ([string]::IsNullOrWhiteSpace($Params.DestSiteId) -and [string]::IsNullOrWhiteSpace($Params.DestSiteUrl)) { + throw 'DestSiteId or DestSiteUrl is required.' + } + + $Result = Start-CIPPSharePointLibraryCopy @Params + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message "Library copy action $Action completed." -sev Info + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to run Action '$Action'. Error: $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ Results = $Result } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSiteBrowserPermissions.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSiteBrowserPermissions.ps1 index b6834377d59a4..e609e06f67356 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSiteBrowserPermissions.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSiteBrowserPermissions.ps1 @@ -234,10 +234,10 @@ function Invoke-ExecSiteBrowserPermissions { $GroupId = $Request.Body.GroupId if ([string]::IsNullOrWhiteSpace($GroupId)) { throw 'GroupId is required.' } - $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter - $Scope = "$($SharePointInfo.SharePointUrl)/.default" - $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } - $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + $RestContext = Resolve-CIPPSharePointRestContext -TenantFilter $TenantFilter -SiteUrl $SiteUrl + $Scope = $RestContext.Scope + $JsonAccept = $RestContext.Headers + $BaseUri = $RestContext.BaseUri $Principals = ConvertTo-BrowserPermissionPrincipals ` -PrincipalId $Request.Body.PrincipalId ` diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharePointTemplates.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharePointTemplates.ps1 index ba1088f590efb..1e7b3f9619d8b 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharePointTemplates.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharePointTemplates.ps1 @@ -40,8 +40,10 @@ function Invoke-ListSharePointTemplates { # Surface scalar counts so the list can show/sort them without inspecting the nested arrays. $SiteTemplates = @($TemplateData.siteTemplates | Where-Object { $_ }) $LibraryCount = ($SiteTemplates | ForEach-Object { @($_.libraries | Where-Object { $_ }).Count } | Measure-Object -Sum).Sum - $TemplateObject | Add-Member -NotePropertyName 'SiteTemplateCount' -NotePropertyValue ([int]$SiteTemplates.Count) -Force - $TemplateObject | Add-Member -NotePropertyName 'LibraryCount' -NotePropertyValue ([int]$LibraryCount) -Force + $TemplateObject | Add-Member -NotePropertyMembers ([ordered]@{ + SiteTemplateCount = ([int]$SiteTemplates.Count) + LibraryCount = ([int]$LibraryCount) + }) -Force return $TemplateObject } catch { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharepointSettings.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharepointSettings.ps1 index e7db82d04897d..6f0538c5b4013 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharepointSettings.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharepointSettings.ps1 @@ -9,16 +9,13 @@ Function Invoke-ListSharepointSettings { #> [CmdletBinding()] param($Request, $TriggerMetadata) - # XXX - Seems to be an unused endpoint? -Bobby - - # Interact with query parameters or the body of the request. $Tenant = $Request.Query.tenantFilter - $Request = New-GraphGetRequest -tenantid $Tenant -Uri 'https://graph.microsoft.com/beta/admin/sharepoint/settings' + $SharePointSettings = New-GraphGetRequest -tenantid $Tenant -Uri 'https://graph.microsoft.com/beta/admin/sharepoint/settings' -AsApp $true return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::OK - Body = @($Request) + Body = @($SharePointSettings) }) } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteActivity.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteActivity.ps1 index a57482965dce8..a32ad761e491c 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteActivity.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteActivity.ps1 @@ -69,8 +69,10 @@ function Invoke-ListSiteActivity { if ($RowSiteId -ne $LookupSiteId) { continue } } - $Row | Add-Member -NotePropertyName 'Tenant' -NotePropertyValue $Tenant -Force - $Row | Add-Member -NotePropertyName 'CacheTimestamp' -NotePropertyValue $CacheTimestamp -Force + $Row | Add-Member -NotePropertyMembers ([ordered]@{ + Tenant = $Tenant + CacheTimestamp = $CacheTimestamp + }) -Force [void]$AllResults.Add($Row) } } catch { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteBrowser.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteBrowser.ps1 index 29538fe0213dc..725eb4f53c378 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteBrowser.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteBrowser.ps1 @@ -27,22 +27,6 @@ function Invoke-ListSiteBrowser { }) } - function ConvertTo-StorageUsedBytes { - param($Raw) - if ($null -eq $Raw -or $Raw -eq '') { return $null } - $Clean = ([string]$Raw).Replace(',', '').Trim() - if ($Clean -eq '') { return $null } - try { return [int64][double]$Clean } catch { return $null } - } - - function ConvertTo-NullableInt64 { - param($Raw) - if ($null -eq $Raw -or $Raw -eq '') { return $null } - $Clean = ([string]$Raw).Replace(',', '').Trim() - if ($Clean -eq '') { return $null } - try { return [int64][double]$Clean } catch { return $null } - } - function ConvertTo-SiteTypeLabel { param( [string]$Template, @@ -178,8 +162,6 @@ function Invoke-ListSiteBrowser { } $RootWebTemplate = [string]$Row.TemplateName - $StorageRaw = if ($null -ne $Row.'StorageUsed.') { $Row.'StorageUsed.' } else { $Row.StorageUsed } - $FilesRaw = if ($null -ne $Row.'NumOfFiles.') { $Row.'NumOfFiles.' } else { $Row.NumOfFiles } $Results.Add([PSCustomObject]@{ type = 'site' @@ -191,10 +173,10 @@ function Invoke-ListSiteBrowser { description = $GraphSite.description webUrl = $(if ($RowUrl) { $RowUrl } else { $GraphSite.webUrl }) createdDateTime = $(if ($Row.TimeCreated) { $Row.TimeCreated } else { $GraphSite.createdDateTime }) - storageUsedInBytes = ConvertTo-StorageUsedBytes -Raw $StorageRaw + storageUsedInBytes = $Row.StorageUsed siteType = ConvertTo-SiteTypeLabel -Template $RootWebTemplate -ItemType 'site' rootWebTemplate = $RootWebTemplate - fileCount = ConvertTo-NullableInt64 -Raw $FilesRaw + fileCount = $Row.NumOfFiles }) } @@ -221,7 +203,7 @@ function Invoke-ListSiteBrowser { if ([string]::IsNullOrWhiteSpace($SiteId)) { $SiteId = $SiteMeta.id } - $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + $BaseUri = (Resolve-CIPPSharePointRestContext -TenantFilter $TenantFilter -SiteUrl $SiteUrl -SharePointInfo $SharePointInfo).BaseUri $SiteInfo = [PSCustomObject]@{ id = $SiteId webUrl = $SiteUrl @@ -234,27 +216,44 @@ function Invoke-ListSiteBrowser { foreach ($List in @($Lists | Where-Object { $_.list.hidden -ne $true -and $_.list.template -in @('documentLibrary', 'webPageLibrary') })) { $StorageUsed = $null $FileCount = $null + $FileStreamSize = $null + $MetadataSize = $null + $VersionEstimate = $null try { $Metrics = New-GraphGetRequest -uri "$BaseUri/web/lists(guid'$($List.id)')/RootFolder?`$select=StorageMetrics&`$expand=StorageMetrics" -tenantid $TenantFilter -scope $SpoScope -extraHeaders $JsonAccept -UseCertificate -AsApp $true - $StorageUsed = ConvertTo-StorageUsedBytes -Raw $Metrics.StorageMetrics.TotalSize - $FileCount = ConvertTo-NullableInt64 -Raw $Metrics.StorageMetrics.TotalFileCount + $TotalSize = ConvertTo-SPOAdminListInt64 -Raw $Metrics.StorageMetrics.TotalSize + $FileStreamSize = ConvertTo-SPOAdminListInt64 -Raw $Metrics.StorageMetrics.TotalFileStreamSize + $MetadataSize = ConvertTo-SPOAdminListInt64 -Raw $Metrics.StorageMetrics.MetadataSize + $FileCount = ConvertTo-SPOAdminListInt64 -Raw $Metrics.StorageMetrics.TotalFileCount + $StorageUsed = $TotalSize + if ($null -ne $TotalSize) { + $Tip = if ($null -ne $FileStreamSize) { $FileStreamSize } else { [int64]0 } + $Meta = if ($null -ne $MetadataSize) { $MetadataSize } else { [int64]0 } + $VersionEstimate = [Math]::Max([int64]0, $TotalSize - $Tip - $Meta) + } } catch { $StorageUsed = $null $FileCount = $null + $FileStreamSize = $null + $MetadataSize = $null + $VersionEstimate = $null } $Results.Add([PSCustomObject]@{ - type = 'library' - id = $List.id - siteId = $SiteId - displayName = $List.displayName - name = $List.name - template = $List.list.template - siteType = ConvertTo-SiteTypeLabel -ItemType 'library' -LibraryTemplate $List.list.template - webUrl = $List.webUrl - createdDateTime = $List.createdDateTime - storageUsedInBytes = $StorageUsed - fileCount = $FileCount + type = 'library' + id = $List.id + siteId = $SiteId + displayName = $List.displayName + name = $List.name + template = $List.list.template + siteType = ConvertTo-SiteTypeLabel -ItemType 'library' -LibraryTemplate $List.list.template + webUrl = $List.webUrl + createdDateTime = $List.createdDateTime + storageUsedInBytes = $StorageUsed + fileStreamSizeInBytes = $FileStreamSize + metadataSizeInBytes = $MetadataSize + versionEstimateBytes = $VersionEstimate + fileCount = $FileCount }) } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteBrowserLibraryCopy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteBrowserLibraryCopy.ps1 new file mode 100644 index 0000000000000..068387a360c31 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteBrowserLibraryCopy.ps1 @@ -0,0 +1,37 @@ +function Invoke-ListSiteBrowserLibraryCopy { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.Read + .DESCRIPTION + Returns sanitized aggregate status for a SharePoint library copy operation (OperationId). + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Query.TenantFilter ?? $Request.Body.tenantFilter ?? $Request.Body.TenantFilter + $OperationId = $Request.Query.OperationId ?? $Request.Query.operationId ?? $Request.Body.OperationId ?? $Request.Body.operationId + $StatusCode = [HttpStatusCode]::OK + + try { + if ([string]::IsNullOrWhiteSpace($TenantFilter)) { throw 'tenantFilter is required.' } + if ([string]::IsNullOrWhiteSpace($OperationId)) { throw 'OperationId is required.' } + + $Result = Update-CIPPSharePointLibraryCopyStatus -TenantFilter $TenantFilter -OperationId $OperationId + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter ` + -message "Library copy status $OperationId -> $($Result.Status)" -sev Debug + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to retrieve library copy status: $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ Results = $Result } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteBrowserPermissions.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteBrowserPermissions.ps1 index 94eef3575ac98..7b1631acc33ec 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteBrowserPermissions.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteBrowserPermissions.ps1 @@ -153,10 +153,10 @@ function Invoke-ListSiteBrowserPermissions { $IsLibrary = -not [string]::IsNullOrWhiteSpace($ListId) try { - $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter - $SpoScope = "$($SharePointInfo.SharePointUrl)/.default" - $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } - $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + $RestContext = Resolve-CIPPSharePointRestContext -TenantFilter $TenantFilter -SiteUrl $SiteUrl + $SpoScope = $RestContext.Scope + $JsonAccept = $RestContext.Headers + $BaseUri = $RestContext.BaseUri # --- Target / inheritance --- $TargetTitle = $null @@ -164,8 +164,19 @@ function Invoke-ListSiteBrowserPermissions { if ($IsLibrary) { try { $ListInfo = New-GraphGetRequest -uri "$BaseUri/web/lists(guid'$ListId')?`$select=HasUniqueRoleAssignments,Title,Id" -tenantid $TenantFilter -scope $SpoScope -extraHeaders $JsonAccept -UseCertificate -AsApp $true - $HasUniqueRoleAssignments = [bool]$ListInfo.HasUniqueRoleAssignments $TargetTitle = $ListInfo.Title + # [bool]$null is $false — that hides "Fix inheritance" when the property is not + # projected. Probe the scalar endpoint before treating the library as inheriting. + $HasUniqueRaw = $ListInfo.HasUniqueRoleAssignments + if ($null -eq $HasUniqueRaw) { + try { + $Probe = New-GraphGetRequest -uri "$BaseUri/web/lists(guid'$ListId')/HasUniqueRoleAssignments" -tenantid $TenantFilter -scope $SpoScope -extraHeaders $JsonAccept -UseCertificate -AsApp $true + $HasUniqueRaw = if ($null -ne $Probe.PSObject.Properties['value']) { $Probe.value } else { $Probe } + } catch { + $HasUniqueRaw = $null + } + } + $HasUniqueRoleAssignments = $HasUniqueRaw -eq $true -or "$HasUniqueRaw" -eq 'true' } catch { $Errors.Add([PSCustomObject]@{ section = 'target'; message = $_.Exception.Message }) } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteMembers.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteMembers.ps1 index d1f2627b12d16..893e9faa6ee2d 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteMembers.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteMembers.ps1 @@ -34,10 +34,10 @@ Function Invoke-ListSiteMembers { $Members = [System.Collections.Generic.List[object]]::new() try { - $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter - $Scope = "$($SharePointInfo.SharePointUrl)/.default" - $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } - $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + $RestContext = Resolve-CIPPSharePointRestContext -TenantFilter $TenantFilter -SiteUrl $SiteUrl + $Scope = $RestContext.Scope + $JsonAccept = $RestContext.Headers + $BaseUri = $RestContext.BaseUri $RoleGroups = [ordered]@{ 'Owners' = 'associatedownergroup' diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSitePermissions.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSitePermissions.ps1 index 5710dc9d498df..d4b1981e15feb 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSitePermissions.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSitePermissions.ps1 @@ -29,10 +29,10 @@ function Invoke-ListSitePermissions { try { if ([string]::IsNullOrWhiteSpace($SiteUrl)) { throw 'SiteUrl is required.' } - $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter - $Scope = "$($SharePointInfo.SharePointUrl)/.default" - $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } - $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + $RestContext = Resolve-CIPPSharePointRestContext -TenantFilter $TenantFilter -SiteUrl $SiteUrl + $Scope = $RestContext.Scope + $JsonAccept = $RestContext.Headers + $BaseUri = $RestContext.BaseUri # Scope: a document library, or the site root web when no list was supplied. $IsLibrary = -not [string]::IsNullOrWhiteSpace($ListId) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteRecycleBin.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteRecycleBin.ps1 index b0530cb6d2ad8..8993c803fe9f6 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteRecycleBin.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteRecycleBin.ps1 @@ -20,10 +20,10 @@ function Invoke-ListSiteRecycleBin { try { if (-not $SiteUrl) { throw 'SiteUrl is required.' } - $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter - $Scope = "$($SharePointInfo.SharePointUrl)/.default" - $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } - $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + $RestContext = Resolve-CIPPSharePointRestContext -TenantFilter $TenantFilter -SiteUrl $SiteUrl + $Scope = $RestContext.Scope + $JsonAccept = $RestContext.Headers + $BaseUri = $RestContext.BaseUri $Items = New-GraphGetRequest -uri "$BaseUri/site/RecycleBin?`$top=500&`$orderby=DeletedDate desc" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteRecycleBinSummary.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteRecycleBinSummary.ps1 new file mode 100644 index 0000000000000..e461cb3f51e91 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteRecycleBinSummary.ps1 @@ -0,0 +1,97 @@ +function Invoke-ListSiteRecycleBinSummary { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.SiteRecycleBin.Read + .DESCRIPTION + Aggregate recycle bin sizes for a site (counts + bytes by stage). Never returns + item titles, leaf names, or paths — storage-report privacy ceiling. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $TenantFilter = $Request.Query.TenantFilter ?? $Request.Query.tenantFilter ?? $Request.Body.TenantFilter ?? $Request.Body.tenantFilter + $SiteUrl = $Request.Query.SiteUrl ?? $Request.Body.SiteUrl + $MaxItems = [int]($Request.Query.MaxItems ?? $Request.Body.MaxItems ?? 5000) + if ($MaxItems -lt 1) { $MaxItems = 5000 } + if ($MaxItems -gt 20000) { $MaxItems = 20000 } + + try { + if ([string]::IsNullOrWhiteSpace($TenantFilter)) { throw 'tenantFilter is required.' } + if ([string]::IsNullOrWhiteSpace($SiteUrl)) { throw 'SiteUrl is required.' } + + $RestContext = Resolve-CIPPSharePointRestContext -TenantFilter $TenantFilter -SiteUrl $SiteUrl + $Scope = $RestContext.Scope + $JsonAccept = $RestContext.Headers + $BaseUri = $RestContext.BaseUri + + $FirstCount = [int64]0 + $FirstBytes = [int64]0 + $SecondCount = [int64]0 + $SecondBytes = [int64]0 + $Seen = 0 + $Capped = $false + $NextUri = "$BaseUri/site/RecycleBin?`$select=Id,Size,ItemState&`$top=500&`$orderby=DeletedDate desc" + + while ($NextUri) { + $Page = New-GraphGetRequest -uri $NextUri -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true -noPagination $true -SkipValueExtraction + $Items = @() + $NextLink = $null + if ($null -ne $Page.value) { + $Items = @($Page.value) + $NextLink = $Page.'@odata.nextLink' + } elseif ($Page -is [System.Array]) { + $Items = @($Page) + } elseif ($Page.PSObject.Properties.Name -contains 'Id') { + $Items = @($Page) + } + + foreach ($Item in $Items) { + if ($Seen -ge $MaxItems) { + $Capped = $true + break + } + $Seen++ + $Size = 0 + try { $Size = [int64][double]$Item.Size } catch { $Size = 0 } + $State = 0 + try { $State = [int]$Item.ItemState } catch { $State = 0 } + if ($State -eq 2) { + $SecondCount++ + $SecondBytes += $Size + } else { + $FirstCount++ + $FirstBytes += $Size + } + } + + if ($Capped -or [string]::IsNullOrWhiteSpace($NextLink)) { break } + $NextUri = $NextLink + } + + $Body = [PSCustomObject]@{ + siteUrl = $SiteUrl.TrimEnd('/') + itemCount = $FirstCount + $SecondCount + totalBytes = $FirstBytes + $SecondBytes + firstStageCount = $FirstCount + firstStageBytes = $FirstBytes + secondStageCount = $SecondCount + secondStageBytes = $SecondBytes + capped = $Capped + scannedItems = $Seen + } + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Body = "Failed to summarize recycle bin for $($SiteUrl): $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Request.Headers -API $APIName -tenant $TenantFilter -message $Body -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ Results = $Body } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteRoleDefinitions.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteRoleDefinitions.ps1 index f82dec1dddf60..bae31ce644515 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteRoleDefinitions.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteRoleDefinitions.ps1 @@ -22,10 +22,10 @@ function Invoke-ListSiteRoleDefinitions { try { if ([string]::IsNullOrWhiteSpace($SiteUrl)) { throw 'SiteUrl is required.' } - $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter - $Scope = "$($SharePointInfo.SharePointUrl)/.default" - $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } - $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + $RestContext = Resolve-CIPPSharePointRestContext -TenantFilter $TenantFilter -SiteUrl $SiteUrl + $Scope = $RestContext.Scope + $JsonAccept = $RestContext.Headers + $BaseUri = $RestContext.BaseUri $RoleDefinitions = @(New-GraphGetRequest -uri "$BaseUri/web/roledefinitions?`$select=Id,Name,Description,RoleTypeKind,Hidden,Order" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteStorageComposition.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteStorageComposition.ps1 new file mode 100644 index 0000000000000..4ffe18289266f --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteStorageComposition.ps1 @@ -0,0 +1,93 @@ +function Invoke-ListSiteStorageComposition { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.Read + .DESCRIPTION + Site-level storage composition at library ceiling: tip / previous-version estimate / + recycle estimate from root web StorageMetrics + site StorageUsed. No file names. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $TenantFilter = $Request.Query.TenantFilter ?? $Request.Query.tenantFilter ?? $Request.Body.TenantFilter ?? $Request.Body.tenantFilter + $SiteUrl = $Request.Query.SiteUrl ?? $Request.Body.SiteUrl + + function ConvertTo-StorageBytes { + param($Raw) + if ($null -eq $Raw -or $Raw -eq '') { return $null } + $Clean = ([string]$Raw).Replace(',', '').Trim() + if ($Clean -eq '') { return $null } + try { return [int64][double]$Clean } catch { return $null } + } + + try { + if ([string]::IsNullOrWhiteSpace($TenantFilter)) { throw 'tenantFilter is required.' } + if ([string]::IsNullOrWhiteSpace($SiteUrl)) { throw 'SiteUrl is required.' } + + $RestContext = Resolve-CIPPSharePointRestContext -TenantFilter $TenantFilter -SiteUrl $SiteUrl + $SpoScope = $RestContext.Scope + $JsonAccept = $RestContext.Headers + $BaseUri = $RestContext.BaseUri + + $RootMetrics = New-GraphGetRequest -uri "$BaseUri/web/RootFolder?`$select=StorageMetrics&`$expand=StorageMetrics" -tenantid $TenantFilter -scope $SpoScope -extraHeaders $JsonAccept -UseCertificate -AsApp $true + $TotalSize = ConvertTo-StorageBytes -Raw $RootMetrics.StorageMetrics.TotalSize + $FileStreamSize = ConvertTo-StorageBytes -Raw $RootMetrics.StorageMetrics.TotalFileStreamSize + $MetadataSize = ConvertTo-StorageBytes -Raw $RootMetrics.StorageMetrics.MetadataSize + $FileCount = ConvertTo-StorageBytes -Raw $RootMetrics.StorageMetrics.TotalFileCount + + $Tip = if ($null -ne $FileStreamSize) { $FileStreamSize } else { [int64]0 } + $Meta = if ($null -ne $MetadataSize) { $MetadataSize } else { [int64]0 } + $Total = if ($null -ne $TotalSize) { $TotalSize } else { [int64]0 } + $VersionEstimate = [Math]::Max([int64]0, $Total - $Tip - $Meta) + + $StorageUsed = $null + try { + $Usage = New-GraphGetRequest -uri "$BaseUri/site/Usage" -tenantid $TenantFilter -scope $SpoScope -extraHeaders $JsonAccept -UseCertificate -AsApp $true + $StorageUsed = ConvertTo-StorageBytes -Raw $Usage.StorageUsageBytes + if ($null -eq $StorageUsed) { + $StorageUsed = ConvertTo-StorageBytes -Raw $Usage.StorageUsed + } + } catch { + $StorageUsed = $null + } + if ($null -eq $StorageUsed) { + try { + $Web = New-GraphGetRequest -uri "$BaseUri/site?`$select=Usage" -tenantid $TenantFilter -scope $SpoScope -extraHeaders $JsonAccept -UseCertificate -AsApp $true + $StorageUsed = ConvertTo-StorageBytes -Raw $Web.Usage.StorageUsedInBytes + } catch { + $StorageUsed = $null + } + } + + $RecycleEstimate = $null + if ($null -ne $StorageUsed -and $null -ne $TotalSize) { + $RecycleEstimate = [Math]::Max([int64]0, $StorageUsed - $TotalSize) + } + + $Body = [PSCustomObject]@{ + siteUrl = $SiteUrl.TrimEnd('/') + storageUsedInBytes = $StorageUsed + tipBytes = $Tip + metadataSizeInBytes = $Meta + totalSizeInBytes = $Total + versionEstimateBytes = $VersionEstimate + recycleEstimateBytes = $RecycleEstimate + fileCount = $FileCount + estimatesLabeled = $true + } + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Body = "Failed to get storage composition for $($SiteUrl): $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Request.Headers -API $APIName -tenant $TenantFilter -message $Body -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ Results = $Body } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSites.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSites.ps1 index 7c70a742e9b2e..19c874be7152a 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSites.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSites.ps1 @@ -5,7 +5,7 @@ function Invoke-ListSites { .ROLE Sharepoint.Site.Read .DESCRIPTION - Lists SharePoint sites or OneDrive usage for a tenant. Requires a Type parameter (SharePointSiteUsage or OneDriveUsageAccount). Supports UseReportDB=true query parameter to retrieve cached data from the reporting database for significantly better performance, especially when querying AllTenants. + Lists SharePoint sites or OneDrive usage for a tenant. Requires a Type parameter (SharePointSiteUsage or OneDriveUsageAccount). SharePoint live data uses SPO admin RLD plus Graph enrichment; OneDrive live data uses Graph usage reports. Supports UseReportDB=true query parameter to retrieve cached data from the reporting database for significantly better performance, especially when querying AllTenants. #> [CmdletBinding()] param($Request, $TriggerMetadata) @@ -55,86 +55,69 @@ function Invoke-ListSites { } } - $Tenant = Get-Tenants -TenantFilter $TenantFilter - $TenantId = $Tenant.customerId - - if ($Type -eq 'SharePointSiteUsage') { - $Filter = 'isPersonalSite eq false' - } else { - $Filter = 'isPersonalSite eq true' - } - try { - $BulkRequests = @( - @{ - id = 'listAllSites' - method = 'GET' - url = "sites/getAllSites?`$filter=$($Filter)&`$select=id,createdDateTime,description,name,displayName,isPersonalSite,lastModifiedDateTime,webUrl,siteCollection,sharepointIds&`$top=999" - } - @{ - id = 'usage' - method = 'GET' - url = "reports/get$($type)Detail(period='D7')?`$format=application/json&`$top=999" + if ($Type -eq 'SharePointSiteUsage') { + $Built = Get-CIPPSharePointSiteUsageRows -TenantFilter $TenantFilter -LogApi 'ListSites' + $UsageBySiteId = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($UsageRow in @($Built.UsageRows)) { + if (-not [string]::IsNullOrWhiteSpace($UsageRow.siteId)) { + $UsageBySiteId[[string]$UsageRow.siteId.Trim('{}')] = $UsageRow + } } - ) - - $Result = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($BulkRequests) -asapp $true - $Sites = ($Result | Where-Object { $_.id -eq 'listAllSites' }).body.value - $UsageResponse = $Result | Where-Object { $_.id -eq 'usage' } - if ($UsageResponse.status -and $UsageResponse.status -ne 200) { - throw ($UsageResponse.body.error.message ?? "Usage report request failed with status $($UsageResponse.status)") - } - $UsageBody = $UsageResponse.body - if ($UsageBody -is [string]) { - $UsageJson = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($UsageBody)) - $Usage = ($UsageJson | ConvertFrom-Json).value - } else { - $Usage = @($UsageBody.value) - } - $GraphRequest = foreach ($Site in $Sites) { - $SiteUsage = $Usage | Where-Object { $_.siteId -eq $Site.sharepointIds.siteId } - [PSCustomObject]@{ - siteId = $Site.sharepointIds.siteId - webId = $Site.sharepointIds.webId - createdDateTime = $Site.createdDateTime - displayName = $Site.displayName - webUrl = $Site.webUrl - ownerDisplayName = $SiteUsage.ownerDisplayName - ownerPrincipalName = $SiteUsage.ownerPrincipalName - lastActivityDate = $SiteUsage.lastActivityDate - fileCount = $SiteUsage.fileCount - # Null, not 0, when the usage report has no row for this site: '0' reads as an - # authoritative "this site is empty" and is indistinguishable from a real empty - # site, which is exactly the confusion an absent usage report should not create. - storageUsedInGigabytes = if ($null -ne $SiteUsage.storageUsedInBytes) { [math]::round([double]$SiteUsage.storageUsedInBytes / 1GB, 2) } else { $null } - storageAllocatedInGigabytes = if ($null -ne $SiteUsage.storageAllocatedInBytes) { [math]::round([double]$SiteUsage.storageAllocatedInBytes / 1GB, 2) } else { $null } - storageUsedInBytes = $SiteUsage.storageUsedInBytes - storageAllocatedInBytes = $SiteUsage.storageAllocatedInBytes - rootWebTemplate = $SiteUsage.rootWebTemplate - reportRefreshDate = $SiteUsage.reportRefreshDate - AutoMapUrl = '' + $GraphRequest = foreach ($Site in @($Built.SiteListing)) { + $SiteUsage = $null + [void]$UsageBySiteId.TryGetValue([string]$Site.sharepointIds.siteId.Trim('{}'), [ref]$SiteUsage) + ConvertTo-CIPPSharePointSiteUsagePayload -Site $Site -SiteUsage $SiteUsage } - } - - $int = 0 - if ($Type -eq 'SharePointSiteUsage') { - $Requests = foreach ($Site in $GraphRequest) { + } else { + $BulkRequests = @( + @{ + id = 'listAllSites' + method = 'GET' + url = "sites/getAllSites?`$filter=isPersonalSite eq true&`$select=id,createdDateTime,description,name,displayName,isPersonalSite,lastModifiedDateTime,webUrl,siteCollection,sharepointIds&`$top=999" + } @{ - id = $int++ + id = 'usage' method = 'GET' - url = "sites/$($Site.siteId)/lists?`$select=id,name,list,parentReference" + url = "reports/get$($type)Detail(period='D7')?`$format=application/json&`$top=999" } + ) + + $Result = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($BulkRequests) -asapp $true + $Sites = ($Result | Where-Object { $_.id -eq 'listAllSites' }).body.value + $UsageResponse = $Result | Where-Object { $_.id -eq 'usage' } + if ($UsageResponse.status -and $UsageResponse.status -ne 200) { + throw ($UsageResponse.body.error.message ?? "Usage report request failed with status $($UsageResponse.status)") } - try { - $Requests = (New-GraphBulkRequest -tenantid $TenantFilter -scope 'https://graph.microsoft.com/.default' -Requests @($Requests) -asapp $true).body.value | Where-Object { $_.list.template -eq 'DocumentLibrary' } - } catch { - Write-LogMessage -Headers $Headers -Message "Error getting auto map urls: $($_.Exception.Message)" -Sev 'Error' -tenant $TenantFilter -API 'ListSites' -LogData (Get-CippException -Exception $_) + $UsageBody = $UsageResponse.body + if ($UsageBody -is [string]) { + $UsageJson = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($UsageBody)) + $Usage = ($UsageJson | ConvertFrom-Json).value + } else { + $Usage = @($UsageBody.value) } - $GraphRequest = foreach ($Site in $GraphRequest) { - $ListId = ($Requests | Where-Object { $_.parentReference.siteId -like "*$($Site.siteId)*" }).id - $site.AutoMapUrl = "tenantId=$($TenantId)&webId={$($Site.webId)}&siteid={$($Site.siteId)}&webUrl=$($Site.webUrl)&listId={$($ListId)}" - $site + + $GraphRequest = foreach ($Site in $Sites) { + $SiteUsage = $Usage | Where-Object { $_.siteId -eq $Site.sharepointIds.siteId } + [PSCustomObject]@{ + siteId = $Site.sharepointIds.siteId + webId = $Site.sharepointIds.webId + createdDateTime = $Site.createdDateTime + displayName = $Site.displayName + webUrl = $Site.webUrl + ownerDisplayName = $SiteUsage.ownerDisplayName + ownerPrincipalName = $SiteUsage.ownerPrincipalName + lastActivityDate = $SiteUsage.lastActivityDate + fileCount = $SiteUsage.fileCount + storageUsedInGigabytes = if ($null -ne $SiteUsage.storageUsedInBytes) { [math]::round([double]$SiteUsage.storageUsedInBytes / 1GB, 2) } else { $null } + storageAllocatedInGigabytes = if ($null -ne $SiteUsage.storageAllocatedInBytes) { [math]::round([double]$SiteUsage.storageAllocatedInBytes / 1GB, 2) } else { $null } + storageUsedInBytes = $SiteUsage.storageUsedInBytes + storageAllocatedInBytes = $SiteUsage.storageAllocatedInBytes + rootWebTemplate = $SiteUsage.rootWebTemplate + reportRefreshDate = $SiteUsage.reportRefreshDate + AutoMapUrl = '' + } } } $StatusCode = [HttpStatusCode]::OK diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListStorageCleanupScan.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListStorageCleanupScan.ps1 new file mode 100644 index 0000000000000..b58dd4bebd1a7 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListStorageCleanupScan.ps1 @@ -0,0 +1,131 @@ +function Invoke-ListStorageCleanupScan { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.Read + .DESCRIPTION + Reads the hold-only StorageCleanupScan CIPPDB cache and rebuilds the scans map expected by + the storage report cleanup opportunity helpers. No live enumeration — refresh via + ExecCIPPDBCache Name=StorageCleanupScan. Report-private; not used by other List APIs. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter ?? $Request.Query.TenantFilter ?? $Request.Body.TenantFilter + + if ([string]::IsNullOrWhiteSpace($TenantFilter)) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = 'tenantFilter is required.' } + }) + } + + try { + $CacheRows = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'StorageCleanupScan') + } catch { + $CacheRows = @() + } + + $CleanupSynced = $false + $LastDataRefresh = $null + try { + $CountRow = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'StorageCleanupScan' -CountsOnly | Select-Object -First 1 + if ($CountRow) { $CleanupSynced = $true } + if ($CountRow.Timestamp) { $LastDataRefresh = $CountRow.Timestamp } + } catch {} + + $SiteRows = @($CacheRows | Where-Object { $_.rowType -eq 'Site' }) + $LibraryRows = @($CacheRows | Where-Object { $_.rowType -eq 'Library' }) + + $LibrariesBySiteUrl = @{} + foreach ($Lib in $LibraryRows) { + $Key = [string]$Lib.siteUrl + if ([string]::IsNullOrWhiteSpace($Key)) { continue } + $Key = $Key.TrimEnd('/') + if (-not $LibrariesBySiteUrl.ContainsKey($Key)) { + $LibrariesBySiteUrl[$Key] = [System.Collections.Generic.List[object]]::new() + } + $LibrariesBySiteUrl[$Key].Add([PSCustomObject]@{ + id = $Lib.libraryId + name = $Lib.libraryName + displayName = $Lib.libraryDisplayName + storageUsedInBytes = $Lib.storageUsedInBytes + versionEstimateBytes = $Lib.versionEstimateBytes + }) + } + + $Scans = @{} + $SitesScanned = 0 + $SitesSkipped = 0 + $LibrariesScanned = 0 + $SitesWithRecycle = 0 + + foreach ($Site in $SiteRows) { + $SitesScanned++ + if ($Site.collectionStatus -eq 'Skipped') { $SitesSkipped++ } + + $SiteUrl = [string]$Site.siteUrl + if ([string]::IsNullOrWhiteSpace($SiteUrl)) { continue } + $NormalizedUrl = $SiteUrl.TrimEnd('/') + + $Libraries = @() + if ($LibrariesBySiteUrl.ContainsKey($NormalizedUrl)) { + $Libraries = @($LibrariesBySiteUrl[$NormalizedUrl]) + } + $LibrariesScanned += $Libraries.Count + + $Recycle = $null + if ($null -ne $Site.recycleTotalBytes -or $null -ne $Site.recycleItemCount) { + $SitesWithRecycle++ + $Recycle = [PSCustomObject]@{ + siteUrl = $NormalizedUrl + totalBytes = $Site.recycleTotalBytes + itemCount = $Site.recycleItemCount + firstStageBytes = $Site.recycleFirstStageBytes + firstStageCount = $Site.recycleFirstStageCount + secondStageBytes = $Site.recycleSecondStageBytes + secondStageCount = $Site.recycleSecondStageCount + capped = $Site.recycleCapped + scannedItems = $Site.recycleScannedItems + } + } + + $ScanEntry = @{ + libraries = $Libraries + recycle = $Recycle + } + $Scans[$SiteUrl] = $ScanEntry + if ($SiteUrl -ne $NormalizedUrl) { + $Scans[$NormalizedUrl] = $ScanEntry + } + } + + # Libraries whose site row is missing (should not happen after a full store) still surface. + foreach ($Key in $LibrariesBySiteUrl.Keys) { + if ($Scans.ContainsKey($Key)) { continue } + $Scans[$Key] = @{ + libraries = @($LibrariesBySiteUrl[$Key]) + recycle = $null + } + $SitesScanned++ + $LibrariesScanned += $LibrariesBySiteUrl[$Key].Count + } + + $Body = [PSCustomObject]@{ + summary = [PSCustomObject]@{ + cleanupSynced = $CleanupSynced + lastDataRefresh = $LastDataRefresh + sitesScanned = $SitesScanned + sitesSkipped = $SitesSkipped + librariesScanned = $LibrariesScanned + sitesWithRecycle = $SitesWithRecycle + } + scans = $Scans + } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = $Body + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddAlert.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddAlert.ps1 index 8afe3d021f1c0..fac3cedb5a371 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddAlert.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddAlert.ps1 @@ -38,15 +38,18 @@ function Invoke-AddAlert { $Actions = $Request.Body.actions | ConvertTo-Json -Compress -Depth 10 | Out-String $RowKey = $Request.Body.RowKey ? $Request.Body.RowKey : (New-Guid).ToString() $CompleteObject = @{ - Tenants = [string]$TenantsJson - excludedTenants = [string]$excludedTenantsJson - Conditions = [string]$Conditions - Actions = [string]$Actions - type = $Request.Body.logbook.value - RowKey = $RowKey - PartitionKey = 'Webhookv2' - AlertComment = [string]$Request.Body.AlertComment - CustomSubject = [string]$Request.Body.CustomSubject + Tenants = [string]$TenantsJson + excludedTenants = [string]$excludedTenantsJson + Conditions = [string]$Conditions + Actions = [string]$Actions + type = $Request.Body.logbook.value + RowKey = $RowKey + PartitionKey = 'Webhookv2' + AlertComment = [string]$Request.Body.AlertComment + CustomSubject = [string]$Request.Body.CustomSubject + # The audit form posts the raw form values, so an autocomplete selection arrives as a + # {label, value} object - unwrap it to the bare Halo priority id before storing. + PsaTicketPriority = [string]($Request.Body.PsaTicketPriority.value ?? $Request.Body.PsaTicketPriority) } $WebhookTable = Get-CippTable -TableName 'WebhookRules' if ($Request.Body.RowKey) { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddScriptedAlert.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddScriptedAlert.ps1 index 7efff1a444d2d..d04f7298eeb45 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddScriptedAlert.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddScriptedAlert.ps1 @@ -4,60 +4,38 @@ function Invoke-AddScriptedAlert { Entrypoint .ROLE CIPP.Alert.ReadWrite + .DESCRIPTION + Creates or updates a scripted CIPP alert, stored as a hidden scheduled task. + + A selection of two or more tenants or groups is stored verbatim and expanded on every run, + so tenant group membership is always current. #> [CmdletBinding()] param($Request, $TriggerMetadata) - $tenantsJsonForStorage = $null - + # The tenants, tenant groups or *All Tenants the alert applies to. At least one is required. if ($Request.Body.tenantFilter -is [array] -and @($Request.Body.tenantFilter).Count -eq 1) { $Request.Body | Add-Member -MemberType NoteProperty -Name 'tenantFilter' -Value $Request.Body.tenantFilter[0] -Force } if ($Request.Body.tenantFilter -is [array] -and @($Request.Body.tenantFilter).Count -gt 1) { - try { - $originalSelection = @($Request.Body.tenantFilter) - $tenantsJsonForStorage = $originalSelection | ConvertTo-Json -Compress -Depth 10 - - $hasAllTenants = @($originalSelection | Where-Object { $_.value -eq 'AllTenants' }).Count -gt 0 - - if (-not $hasAllTenants) { - $ExpandedSelection = Expand-CIPPTenantGroups -TenantFilter $originalSelection - $targetDomains = @($ExpandedSelection | ForEach-Object { $_.value }) - - $AllTenantsList = Get-Tenants -IncludeErrors - $computedExcluded = @($AllTenantsList.defaultDomainName | Where-Object { $_ -notin $targetDomains }) - - $existingEntries = @() - if ($Request.Body.PSObject.Properties['excludedTenants'] -and $Request.Body.excludedTenants) { - $existingEntries = @($Request.Body.excludedTenants) - } - # Keep user-picked groups as typed objects so Add-CIPPScheduledTask stores them - # for runtime expansion instead of flattening them into the domain list - $excludedGroupEntries = @($existingEntries | Where-Object { $_.type -eq 'Group' }) - $existingExcluded = @($existingEntries | Where-Object { $_.type -ne 'Group' } | ForEach-Object { $_.value ?? $_ }) - $mergedExcluded = @($existingExcluded + $computedExcluded) | Where-Object { $_ } | Select-Object -Unique + $Request.Body | Add-Member -MemberType NoteProperty -Name 'Tenants' -Value @($Request.Body.tenantFilter) -Force - $excludedValue = @($mergedExcluded | ForEach-Object { - [PSCustomObject]@{ value = $_; label = $_ } - }) + $excludedGroupEntries - $Request.Body | Add-Member -MemberType NoteProperty -Name 'excludedTenants' -Value $excludedValue -Force - } - - if (-not $Request.Body.PSObject.Properties['RowKey'] -or -not $Request.Body.RowKey) { - $Request.Body | Add-Member -MemberType NoteProperty -Name 'RowKey' -Value ((New-Guid).Guid) -Force - } - - $tenantFilterValue = [PSCustomObject]@{ + # Tenant stays 'AllTenants' - the execution gates gate on that literal; Tenants holds the real scope. + $Request.Body | Add-Member -MemberType NoteProperty -Name 'tenantFilter' -Value ([PSCustomObject]@{ value = 'AllTenants' label = '*All Tenants' type = 'Tenant' - } - $Request.Body | Add-Member -MemberType NoteProperty -Name 'tenantFilter' -Value $tenantFilterValue -Force - } catch { - Write-Warning "Failed to process multi-tenant alert selection: $($_.Exception.Message)" - $tenantsJsonForStorage = $null - } + }) -Force + } + + # Tenants or tenant groups to skip even when they fall within the selection above. Optional. + if ($Request.Body.excludedTenants) { + # Add-CIPPScheduledTask drops entries with no value, so wrap bare domain strings. + $NormalizedExclusions = @(@($Request.Body.excludedTenants) | Where-Object { $_ } | ForEach-Object { + if ($_.value) { $_ } else { [PSCustomObject]@{ value = [string]$_; label = [string]$_; type = 'Tenant' } } + }) + $Request.Body | Add-Member -MemberType NoteProperty -Name 'excludedTenants' -Value $NormalizedExclusions -Force } $ForwardRequest = @{ @@ -65,20 +43,6 @@ function Invoke-AddScriptedAlert { Body = $Request.Body Headers = $Request.Headers } - $Response = Invoke-AddScheduledItem -Request $ForwardRequest -TriggerMetadata $TriggerMetadata - - if ($tenantsJsonForStorage) { - try { - $Table = Get-CIPPTable -TableName 'ScheduledTasks' - $null = Update-AzDataTableEntity -Force @Table -Entity @{ - PartitionKey = 'ScheduledTask' - RowKey = [string]$Request.Body.RowKey - Tenants = [string]$tenantsJsonForStorage - } - } catch { - Write-Warning "Failed to persist multi-tenant selection for alert: $($_.Exception.Message)" - } - } - return $Response + return Invoke-AddScheduledItem -Request $ForwardRequest -TriggerMetadata $TriggerMetadata } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ExecScheduleAuditExclusionVacation.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ExecScheduleAuditExclusionVacation.ps1 index 702a61d5800e7..741d78c1a992c 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ExecScheduleAuditExclusionVacation.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ExecScheduleAuditExclusionVacation.ps1 @@ -67,6 +67,7 @@ function Invoke-ExecScheduleAuditExclusionVacation { }) -hidden $false $Result = "Successfully scheduled location alert exclusion vacation mode for $UserDisplay." + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev 'Info' $StatusCode = [HttpStatusCode]::OK } catch { $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAlertsQueue.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAlertsQueue.ps1 index 81f7e6013b88f..c00ca3409328a 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAlertsQueue.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAlertsQueue.ps1 @@ -38,14 +38,15 @@ function Invoke-ListAlertsQueue { CustomSubject = $Task.CustomSubject Enabled = $Task.Disabled -ne $true RawAlert = @{ - Conditions = @($Conditions) - Actions = @($($Task.Actions | ConvertFrom-Json -Depth 10 -ErrorAction SilentlyContinue)) - Tenants = @($Tenants) - type = $Task.type - RowKey = $Task.RowKey - PartitionKey = $Task.PartitionKey - AlertComment = $Task.AlertComment - CustomSubject = $Task.CustomSubject + Conditions = @($Conditions) + Actions = @($($Task.Actions | ConvertFrom-Json -Depth 10 -ErrorAction SilentlyContinue)) + Tenants = @($Tenants) + type = $Task.type + RowKey = $Task.RowKey + PartitionKey = $Task.PartitionKey + AlertComment = $Task.AlertComment + CustomSubject = $Task.CustomSubject + PsaTicketPriority = $Task.PsaTicketPriority } } @@ -69,8 +70,14 @@ function Invoke-ListAlertsQueue { } catch { Write-Warning "Failed to expand tenant group for webhook access check: $($_.Exception.Message)" } + } elseif ($Tenant.value -eq 'AllTenants') { + # AllTenants alerts cover the caller's own tenants, so restricted readers may see them + $HasAccess = $true } else { - if ($AllowedTenants -contains $Tenant.customerId) { + # Selector objects store customerId under addedFields; fall back to resolving + # the stored defaultDomainName for entries saved without it + $CustomerId = $Tenant.addedFields.customerId ?? $Tenant.customerId ?? ($TenantList | Where-Object -Property defaultDomainName -EQ $Tenant.value).customerId + if ($AllowedTenants -contains $CustomerId) { $HasAccess = $true } } @@ -112,7 +119,11 @@ function Invoke-ListAlertsQueue { type = $_.type ?? 'Tenant' } }) - $ExcludedTenants = @() + # A legacy row's excludedTenants is a snapshot of every unselected tenant, ignored at + # run time and hidden here. A versioned row's is the operator's own picks. + if (-not $Task.TenantSelectionVersion) { + $ExcludedTenants = @() + } } catch { Write-Warning "Failed to parse Tenants for alert task $($Task.RowKey): $($_.Exception.Message)" $TenantsForDisplay = @([PSCustomObject]@{ @@ -209,9 +220,13 @@ function Invoke-ListAlertsQueue { break } } + } elseif ($TenantItem.value -eq 'AllTenants') { + # AllTenants alerts cover the caller's own tenants, so restricted readers may see them + $HasAccess = $true } else { $TenantInfo = $TenantList | Where-Object -Property defaultDomainName -EQ $TenantItem.value - if ($TenantInfo -and $AllowedTenants -contains $TenantInfo.customerId) { + $CustomerId = $TenantItem.addedFields.customerId ?? $TenantInfo.customerId + if ($AllowedTenants -contains $CustomerId) { $HasAccess = $true } } @@ -220,6 +235,9 @@ function Invoke-ListAlertsQueue { } catch { Write-Warning "Failed to parse Tenants for access check on task $($Task.RowKey): $($_.Exception.Message)" } + } elseif ($Task.Tenant -eq 'AllTenants') { + # AllTenants alerts cover the caller's own tenants, so restricted readers may see them + $HasAccess = $true } else { # Regular single-tenant access check $Tenant = $TenantList | Where-Object -Property defaultDomainName -EQ $Task.Tenant diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-PublicWebhooks.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-PublicWebhooks.ps1 index aa8be40e3db2e..0a14ff6ab5e34 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-PublicWebhooks.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-PublicWebhooks.ps1 @@ -12,17 +12,25 @@ function Invoke-PublicWebhooks { $CIPPURL = [string]$url Write-Host $url + # Graph (and Partner Center) validate a new subscription by POSTing a token and comparing the raw + # response body to it byte for byte. The response has to be the bare token as text/plain - the + # default application/json content type re-quotes the string, and Graph then rejects the + # subscription with "did not return the expected validation token". + $ContentType = 'application/json' if ($Request.Query.ValidationToken) { Write-Host 'Validation token received - query ValidationToken' - $body = $Request.Query.ValidationToken + $body = [string]$Request.Query.ValidationToken + $ContentType = 'text/plain' $StatusCode = [HttpStatusCode]::OK } elseif ($Request.Body.validationCode) { Write-Host 'Validation token received - body validationCode' - $body = $Request.Body.validationCode + $body = [string]$Request.Body.validationCode + $ContentType = 'text/plain' $StatusCode = [HttpStatusCode]::OK } elseif ($Request.Query.validationCode) { Write-Host 'Validation token received - query validationCode' - $body = $Request.Query.validationCode + $body = [string]$Request.Query.validationCode + $ContentType = 'text/plain' $StatusCode = [HttpStatusCode]::OK } elseif ($Request.Query.CIPPID) { $CIPPID = ConvertTo-CIPPODataFilterValue -Value $Request.Query.CIPPID -Type Guid @@ -53,6 +61,7 @@ function Invoke-PublicWebhooks { FunctionName = 'PublicWebhookProcess' } Add-CIPPAzDataTableEntity @WebhookIncoming -Entity $Entity + Write-LogMessage -headers $Headers -API ($Request.Params.CIPPEndpoint ?? 'PublicWebhooks') -tenant 'Global' -message "Graph subscription webhook received and queued (CIPPID=$($Request.Query.CIPPID))" -Sev 'Info' } elseif ($Request.Query.Type -eq 'PartnerCenter') { [pscustomobject]$ReceivedItem = $Request.Body @@ -66,6 +75,7 @@ function Invoke-PublicWebhooks { FunctionName = 'PublicWebhookProcess' } Add-CIPPAzDataTableEntity @WebhookIncoming -Entity $Entity + Write-LogMessage -headers $Headers -API ($Request.Params.CIPPEndpoint ?? 'PublicWebhooks') -tenant 'Global' -message "Partner Center webhook received and queued (CIPPID=$($Request.Query.CIPPID))" -Sev 'Info' } else { $Body = 'This webhook is not authorized.' $StatusCode = [HttpStatusCode]::Forbidden @@ -80,7 +90,8 @@ function Invoke-PublicWebhooks { } return ([HttpResponseContext]@{ - StatusCode = $StatusCode - Body = $Body + StatusCode = $StatusCode + Body = $Body + ContentType = $ContentType }) } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-RemoveWebhookAlert.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-RemoveWebhookAlert.ps1 index 764a0d1524379..c18be0402cf6e 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-RemoveWebhookAlert.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-RemoveWebhookAlert.ps1 @@ -19,6 +19,7 @@ Function Invoke-RemoveWebhookAlert { $Entity = $WebhookRow | Where-Object -Property RowKey -EQ $Request.query.ID Remove-CIPPAzDataTableEntity -Force @WebhookTable -Entity $Entity | Out-Null $Results = "Removed Alert Rule for $($Request.query.TenantFilter)" + Write-LogMessage -headers $Request.Headers -API $APIName -tenant $Request.query.TenantFilter -message $Results -Sev 'Info' } else { if ($Request.query.TenantFilter -eq 'AllTenants') { $Tenants = Get-Tenants -IncludeAll -IncludeErrors | Select-Object -ExpandProperty defaultDomainName @@ -31,7 +32,7 @@ Function Invoke-RemoveWebhookAlert { } Remove-CIPPAzDataTableEntity -Force @Table -Entity $CompleteObject -ErrorAction SilentlyContinue | Out-Null } catch { - Write-LogMessage -headers $Request.Headers -API $APIName -message "Failed to remove webhook for AllTenants. $($_.Exception.Message)" -Sev 'Error' + Write-LogMessage -headers $Request.Headers -API $APIName -tenant 'Global' -message "Failed to remove webhook for AllTenants. $($_.Exception.Message)" -Sev 'Error' } } else { $Tenants = $Request.query.TenantFilter @@ -41,12 +42,14 @@ Function Invoke-RemoveWebhookAlert { Remove-CIPPGraphSubscription -TenantFilter $Tenant -Type 'AuditLog' $Entity = $WebhookRow | Where-Object -Property RowKey -EQ $Request.query.ID Remove-CIPPAzDataTableEntity -Force @WebhookTable -Entity $Entity | Out-Null - "Removed Alert Rule for $($Request.query.TenantFilter)" + $Message = "Removed Alert Rule for $($Request.query.TenantFilter)" + Write-LogMessage -headers $Request.Headers -API $APIName -tenant $Tenant -message $Message -Sev 'Info' + $Message } } $body = [pscustomobject]@{'Results' = $Results } } catch { - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Failed to remove webhook alert. $($_.Exception.Message)" -Sev 'Error' + Write-LogMessage -headers $Request.Headers -API $APINAME -tenant 'Global' -message "Failed to remove webhook alert. $($_.Exception.Message)" -Sev 'Error' $body = [pscustomobject]@{'Results' = "Failed to remove webhook alert: $($_.Exception.Message)" } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecAddMultiTenantApp.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecAddMultiTenantApp.ps1 index e53ce5c3924fd..11a88caf4c7c8 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecAddMultiTenantApp.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecAddMultiTenantApp.ps1 @@ -6,6 +6,8 @@ function Invoke-ExecAddMultiTenantApp { Tenant.Application.ReadWrite #> param($Request, $TriggerMetadata) + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers if ($Request.Body.configMode -eq 'manual') { $DelegateResources = $request.body.permissions | Where-Object -Property origin -EQ 'Delegated' | ForEach-Object { @{ id = $_.id; type = 'Scope' } } $DelegateResourceAccess = @{ ResourceAppId = '00000003-0000-0000-c000-000000000000'; resourceAccess = $DelegateResources } @@ -45,15 +47,18 @@ function Invoke-ExecAddMultiTenantApp { } $null = Start-CIPPOrchestrator -InputObject $InputObject $Results = 'Deploying {0} to {1}, see the logbook for details' -f $Request.Body.AppId, ($Request.Body.tenantFilter.label -join ', ') + Write-LogMessage -headers $Headers -API $APIName -message $Results -Sev 'Info' } catch { - $ErrorMsg = Get-NormalizedError -message $($_.Exception.Message) - $Results = "Function Error: $ErrorMsg" + $ErrorMessage = Get-CippException -Exception $_ + $Results = "Function Error: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -message $Results -Sev 'Error' -LogData $ErrorMessage } $StatusCode = [HttpStatusCode]::OK } catch { - $ErrorMsg = Get-NormalizedError -message $($_.Exception.Message) - $Results = "Function Error: $ErrorMsg" + $ErrorMessage = Get-CippException -Exception $_ + $Results = "Function Error: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -message $Results -Sev 'Error' -LogData $ErrorMessage $StatusCode = [HttpStatusCode]::BadRequest } } elseif ($Request.Body.configMode -eq 'template') { @@ -83,8 +88,11 @@ function Invoke-ExecAddMultiTenantApp { } $null = Start-CIPPOrchestrator -InputObject $InputObject $Results = 'Deploying {0} to {1}, see the logbook for details' -f $Request.Body.selectedTemplate.label, ($Request.Body.tenantFilter.label -join ', ') + Write-LogMessage -headers $Headers -API $APIName -message $Results -Sev 'Info' } catch { - $Results = "Error queuing application - $($_.Exception.Message)" + $ErrorMessage = Get-CippException -Exception $_ + $Results = "Error queuing application - $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -message $Results -Sev 'Error' -LogData $ErrorMessage } $StatusCode = [HttpStatusCode]::OK } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecAppApprovalTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecAppApprovalTemplate.ps1 index 3440b114303ee..003f81cb6836f 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecAppApprovalTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecAppApprovalTemplate.ps1 @@ -126,11 +126,12 @@ function Invoke-ExecAppApprovalTemplate { # Create output object preserving original structure $outputObject = $templateData | Select-Object -Property * - # Add the TemplateId (RowKey) to the output - $outputObject | Add-Member -NotePropertyName 'TemplateId' -NotePropertyValue $_.RowKey -Force - - # Add timestamp from the table entity - $outputObject | Add-Member -NotePropertyName 'Timestamp' -NotePropertyValue $_.Timestamp.DateTime.ToString('yyyy-MM-ddTHH:mm:ssZ') -Force + $outputObject | Add-Member -NotePropertyMembers ([ordered]@{ + # Add the TemplateId (RowKey) to the output + TemplateId = $_.RowKey + # Add timestamp from the table entity + Timestamp = $_.Timestamp.DateTime.ToString('yyyy-MM-ddTHH:mm:ssZ') + }) -Force return $outputObject } @@ -148,11 +149,12 @@ function Invoke-ExecAppApprovalTemplate { # Create output object preserving original structure $outputObject = $templateData | Select-Object -Property * - # Add the TemplateId (RowKey) to the output - $outputObject | Add-Member -NotePropertyName 'TemplateId' -NotePropertyValue $_.RowKey -Force - - # Add timestamp from the table entity - $outputObject | Add-Member -NotePropertyName 'Timestamp' -NotePropertyValue $_.Timestamp.DateTime.ToString('yyyy-MM-ddTHH:mm:ssZ') -Force + $outputObject | Add-Member -NotePropertyMembers ([ordered]@{ + # Add the TemplateId (RowKey) to the output + TemplateId = $_.RowKey + # Add timestamp from the table entity + Timestamp = $_.Timestamp.DateTime.ToString('yyyy-MM-ddTHH:mm:ssZ') + }) -Force return $outputObject } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecApplication.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecApplication.ps1 index 6436c66157592..8cabce7bf585e 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecApplication.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecApplication.ps1 @@ -7,8 +7,10 @@ function Invoke-ExecApplication { #> [CmdletBinding()] param($Request, $TriggerMetadata) + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers $ValidTypes = @('applications', 'servicePrincipals') - $ValidActions = @('Update', 'Upsert', 'Delete', 'RemoveKey', 'RemovePassword') + $ValidActions = @('Update', 'Upsert', 'Delete', 'RemoveKey', 'RemovePassword', 'Hide', 'Show') $Id = $Request.Query.Id ?? $Request.Body.Id $Type = $Request.Query.Type ?? $Request.Body.Type @@ -95,6 +97,7 @@ function Invoke-ExecApplication { state = 'success' details = @($Response) } + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Results.resultText -Sev 'Info' } else { # For password credentials, use bulk removePassword requests $BulkRequests = foreach ($KeyId in $KeyIds) { @@ -123,14 +126,49 @@ function Invoke-ExecApplication { state = if ($FailureCount -eq 0) { 'success' } else { 'error' } details = @($BulkResults) } + if ($FailureCount -eq 0) { + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Results.resultText -Sev 'Info' + } else { + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Results.resultText -Sev 'Error' + } + } + } elseif ($Action -eq 'Hide' -or $Action -eq 'Show') { + # MyApps portal visibility is stored as the 'HideApp' string in the service + # principal 'tags' collection. tags is a replace-collection on PATCH, so read the + # current tags and add/remove only HideApp to avoid clobbering the other tags + # (e.g. WindowsAzureActiveDirectoryIntegratedApp, which marks the app as SSO-integrated). + $Hidden = $Action -eq 'Hide' + $CurrentObject = New-GraphGetRequest -Uri $Uri -tenantid $TenantFilter -AsApp $true + # Rebuild the collection in a single @() subexpression (no in-place array growth): + # keep every tag except HideApp, then append HideApp only when hiding. + $Tags = @( + $CurrentObject.tags | Where-Object { $_ -ne 'HideApp' } + if ($Hidden) { 'HideApp' } + ) + # Encode each tag individually and wrap in brackets so tags is always a JSON array: + # a whole-collection ConvertTo-Json collapses a single-element array to a scalar (Graph + # rejects it) and emits nothing for an empty array. This handles 0, 1 and many tags. + $TagsJson = '[' + (($Tags | ForEach-Object { ConvertTo-Json -InputObject $_ -Compress }) -join ',') + ']' + $PatchBody = '{{"tags":{0}}}' -f $TagsJson + $null = New-GraphPOSTRequest -Uri $Uri -Type 'PATCH' -Body $PatchBody -tenantid $TenantFilter -AsApp $true + + $Results = @{ + resultText = if ($Hidden) { + "Hid '$($CurrentObject.displayName)' from the MyApps portal" + } else { + "Made '$($CurrentObject.displayName)' visible in the MyApps portal" + } + state = 'success' } + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Results.resultText -Sev 'Info' } else { - # Handle regular actions + # Handle regular actions (Update, Upsert, Delete) $null = New-GraphPOSTRequest @PostParams -tenantid $TenantFilter -AsApp $true $Results = @{ resultText = "Successfully executed $Action on $Type with Id: $Id" state = 'success' } + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Results.resultText -Sev 'Info' } return ([HttpResponseContext]@{ @@ -138,10 +176,12 @@ function Invoke-ExecApplication { Body = @{ Results = $Results } }) } catch { + $ErrorMessage = Get-CippException -Exception $_ $Results = @{ - resultText = "Failed to execute $Action on $Type with Id: $Id. Error: $($_.Exception.Message)" + resultText = "Failed to execute $Action on $Type with Id: $Id. Error: $($ErrorMessage.NormalizedError)" state = 'error' } + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Results.resultText -Sev 'Error' -LogData $ErrorMessage return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::InternalServerError diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-AddTenant.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-AddTenant.ps1 index 3d83a04a2bd6d..87086a1ad8d93 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-AddTenant.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-AddTenant.ps1 @@ -7,6 +7,7 @@ function Invoke-AddTenant { #> [CmdletBinding()] param($Request, $TriggerMetadata) + $APIName = $Request.Params.CIPPEndpoint $Headers = $Request.Headers @@ -78,10 +79,12 @@ function Invoke-AddTenant { } if (!$CanCreateCustomers) { + $Result = 'You do not have permission to create customers. You must be a Tier 1 or Tier 2 CSP.' + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Error' $Body = @{ - $Results = @(@{ + Results = @(@{ state = 'error' - resultText = 'You do not have permission to create customers. You must be a Tier 1 or Tier 2 CSP.' + resultText = $Result }) } } else { @@ -147,6 +150,8 @@ function Invoke-AddTenant { #### + $Result = "Tenant created successfully for $TenantName.onmicrosoft.com (username: $($Response.userCredentials.userName)@$TenantName.onmicrosoft.com)" + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Info' $Body = @{ Results = @(@{ state = 'success' @@ -155,10 +160,13 @@ function Invoke-AddTenant { }) } } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to create tenant: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Error' -LogData $ErrorMessage $Body = @{ Results = @(@{ state = 'error' - resultText = "Failed to create tenant: $($_.Exception.Message)" + resultText = $Result }) } $StatusCode = [HttpStatusCode]::BadRequest diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-EditTenant.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-EditTenant.ps1 index 1f034f561df2f..f1e53408b8749 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-EditTenant.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-EditTenant.ps1 @@ -38,6 +38,7 @@ function Invoke-EditTenant { Write-Host 'Removing alias' Remove-CIPPAzDataTableEntity @PropertiesTable -Entity $AliasEntity $null = Get-Tenants -TenantFilter $customerId -TriggerRefresh + Write-LogMessage -headers $Headers -API $APIName -tenant $Tenant.defaultDomainName -TenantId $Tenant.customerId -message "Removed tenant alias for $($Tenant.defaultDomainName)" -Sev 'Info' } } else { $aliasEntity = @{ @@ -50,11 +51,16 @@ function Invoke-EditTenant { $Tenant | Add-Member -NotePropertyName 'originalDisplayName' -NotePropertyValue $tenant.displayName -Force $Tenant.displayName = $tenantAlias $null = Add-CIPPAzDataTableEntity @TenantTable -Entity $Tenant -Force + Write-LogMessage -headers $Headers -API $APIName -tenant $Tenant.defaultDomainName -TenantId $Tenant.customerId -message "Set tenant alias to '$tenantAlias'" -Sev 'Info' } # Update tenant groups $GroupTable = Get-CippTable -TableName 'TenantGroups' - $StaticGroups = Get-CIPPAzDataTableEntity @GroupTable -Filter "PartitionKey eq 'TenantGroup' and GroupType ne 'dynamic'" + # Table-service comparisons skip entities missing the property, so a server-side + # "GroupType ne 'dynamic'" drops static groups created before GroupType existed and + # they can never be added or removed here - treat a missing GroupType as static instead + $AllGroups = Get-CIPPAzDataTableEntity @GroupTable -Filter "PartitionKey eq 'TenantGroup'" + $StaticGroups = $AllGroups | Where-Object { $_.GroupType -ne 'dynamic' } $StaticGroupIds = $StaticGroups.RowKey $CurrentGroupMemberships = Get-CIPPAzDataTableEntity @GroupMembersTable -Filter "customerId eq '$customerId'" foreach ($Group in $tenantGroups) { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-ExecAddSPN.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-ExecAddSPN.ps1 index 966ecb0072e90..e1fd10c4b39b5 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-ExecAddSPN.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-ExecAddSPN.ps1 @@ -16,6 +16,7 @@ Function Invoke-ExecAddSPN { try { $null = New-GraphPostRequest -uri 'https://graph.microsoft.com/v1.0/servicePrincipals' -tenantid $env:TenantID -type POST -Body "{ `"appId`": `"2832473f-ec63-45fb-976f-5d45a7d4bb91`" }" -NoAuthCheck $true $Result = "Successfully completed request. Add your GDAP migration permissions to your SAM application here: https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/CallAnAPI/appId/$($env:ApplicationID)/isMSAApp/ " + Write-LogMessage -headers $Headers -API $APIName -tenant $env:TenantID -message 'Successfully added GDAP migration service principal (SPN)' -Sev 'Info' $StatusCode = [HttpStatusCode]::OK } catch { $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-ExecOnboardTenant.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-ExecOnboardTenant.ps1 index 78199c7cc1e65..e0758ab8eb28d 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-ExecOnboardTenant.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-ExecOnboardTenant.ps1 @@ -31,6 +31,14 @@ function Invoke-ExecOnboardTenant { $TenMinutesAgo = (Get-Date).AddMinutes(-10).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') $TenantOnboarding = Get-CIPPAzDataTableEntity @OnboardTable -Filter "RowKey eq '$SafeId' and Timestamp ge datetime'$TenMinutesAgo'" if (!$TenantOnboarding -or [bool]$Request.Body.Retry) { + # Recover exclude flag from any prior row before overwrite (poll restarts omit it from the body) + $ExistingOnboarding = Get-CIPPAzDataTableEntity @OnboardTable -Filter "RowKey eq '$SafeId'" + if ($Request.Body.PSObject.Properties.Name -contains 'standardsExcludeAllTenants') { + $StandardsExcludeAllTenants = [bool]$Request.Body.standardsExcludeAllTenants + } else { + $StandardsExcludeAllTenants = [bool]$ExistingOnboarding.StandardsExcludeAllTenants + } + $OnboardingSteps = [PSCustomObject]@{ 'Step1' = @{ 'Status' = 'pending' @@ -59,14 +67,15 @@ function Invoke-ExecOnboardTenant { } } $TenantOnboarding = [PSCustomObject]@{ - PartitionKey = 'Onboarding' - RowKey = [string]$SafeId - CustomerId = '' - Status = 'queued' - OnboardingSteps = [string](ConvertTo-Json -InputObject $OnboardingSteps -Compress) - Relationship = '' - Logs = '' - Exception = '' + PartitionKey = 'Onboarding' + RowKey = [string]$SafeId + CustomerId = '' + Status = 'queued' + OnboardingSteps = [string](ConvertTo-Json -InputObject $OnboardingSteps -Compress) + Relationship = '' + Logs = '' + Exception = '' + StandardsExcludeAllTenants = $StandardsExcludeAllTenants } Add-CIPPAzDataTableEntity @OnboardTable -Entity $TenantOnboarding -Force -ErrorAction Stop @@ -77,7 +86,7 @@ function Invoke-ExecOnboardTenant { AddMissingGroups = $Request.Body.addMissingGroups IgnoreMissingRoles = $Request.Body.ignoreMissingRoles AutoMapRoles = $Request.Body.autoMapRoles - StandardsExcludeAllTenants = $Request.Body.standardsExcludeAllTenants + StandardsExcludeAllTenants = $StandardsExcludeAllTenants } $InputObject = @{ diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-ExecSendOrgMessage.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-ExecSendOrgMessage.ps1 index 65336dcd7486d..f02e274815bcf 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-ExecSendOrgMessage.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-ExecSendOrgMessage.ps1 @@ -7,12 +7,15 @@ Function Invoke-ExecSendOrgMessage { #> [CmdletBinding()] param($Request, $TriggerMetadata) + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers # Interact with query parameters or the body of the request. $TenantFilter = $Request.Query.TenantFilter $Device = $request.query.ID + $MessageType = $request.Query.type try { - $type = switch ($request.Query.type) { + $type = switch ($MessageType) { 'taskbar' { '844ec9d0-dd31-459c-a1e7-21fb1b39d5da' $placementDetails = @(@{ @@ -99,11 +102,15 @@ Function Invoke-ExecSendOrgMessage { Write-Host $tmpbody $GraphRequest = New-GraphPOSTRequest -noauthcheck $true -type 'POST' -uri 'https://graph.microsoft.com/beta/deviceManagement/organizationalMessageDetails' -tenantid $tenantfilter -body $tmpbody + $Result = "Successfully sent organizational message of type '$MessageType'" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev 'Info' $StatusCode = [HttpStatusCode]::OK } catch { - $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to send organizational message of type '$MessageType': $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev 'Error' -LogData $ErrorMessage $StatusCode = [HttpStatusCode]::Forbidden - $GraphRequest = $ErrorMessage + $GraphRequest = $ErrorMessage.NormalizedError } return [HttpResponseContext]@{ StatusCode = $StatusCode diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-RemoveTenantCapabilitiesCache.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-RemoveTenantCapabilitiesCache.ps1 index 776386a3101f1..1880fff66a9c6 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-RemoveTenantCapabilitiesCache.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-RemoveTenantCapabilitiesCache.ps1 @@ -12,8 +12,8 @@ function Invoke-RemoveTenantCapabilitiesCache { $Headers = $Request.Headers - # Get the tenant identifier from query parameters - $DefaultDomainName = $Request.Query.defaultDomainName + # Get the tenant identifier from the request body (POST) or query (legacy GET). + $DefaultDomainName = $Request.Body.defaultDomainName ?? $Request.Query.defaultDomainName if (-not $DefaultDomainName) { $body = [pscustomobject]@{'Results' = 'Missing required parameter: defaultDomainName' } $StatusCode = [HttpStatusCode]::BadRequest diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-AddCATemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-AddCATemplate.ps1 index 4bbd5ad79df3d..0daa40288a518 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-AddCATemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-AddCATemplate.ps1 @@ -27,13 +27,13 @@ Function Invoke-AddCATemplate { GUID = "$GUID" } $Result = "Created CA Template $($Name) with GUID $GUID" - Write-LogMessage -headers $Headers -API $APIName -message "Created CA Template $($Name) with GUID $GUID" -Sev 'Debug' + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "Created CA Template $($Name) with GUID $GUID" -Sev 'Info' $StatusCode = [HttpStatusCode]::OK } catch { $ErrorMessage = Get-CippException -Exception $_ $Result = "Failed to create CA Template: $($ErrorMessage.NormalizedError)" - Write-LogMessage -headers $Headers -API $APIName -message "Failed to create CA Template: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "Failed to create CA Template: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage $StatusCode = [HttpStatusCode]::InternalServerError } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ExecCAExclusion.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ExecCAExclusion.ps1 index 834ba2df0ff51..0543359359db6 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ExecCAExclusion.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ExecCAExclusion.ps1 @@ -185,12 +185,15 @@ function Invoke-ExecCAExclusion { Reference = $Request.Body.reference } Add-CIPPScheduledTask -Task $TravelRemoveTask -hidden $false - $Results += "Successfully scheduled temporary travel policy '$TravelPolicyName' restricting sign-ins to $($TravelCountries -join ', '). The policy and named location will be removed at the end date." + $TravelResult = "Successfully scheduled temporary travel policy '$TravelPolicyName' restricting sign-ins to $($TravelCountries -join ', '). The policy and named location will be removed at the end date." + Write-LogMessage -headers $Headers -API 'Invoke-ExecCAExclusion' -message $TravelResult -Sev 'Info' -tenant $TenantFilter + $Results += $TravelResult } if ($DuplicateGroupWarning) { $Results += $DuplicateGroupWarning } + Write-LogMessage -headers $Headers -API 'Invoke-ExecCAExclusion' -message "Successfully added vacation mode schedule for $Username on policy '$PolicyName'." -Sev 'Info' -tenant $TenantFilter $body = @{ Results = $Results } } else { $Parameters = @{ diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ListCAtemplates.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ListCAtemplates.ps1 index 7c08fb3741ed4..f15d37c9ef3c2 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ListCAtemplates.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ListCAtemplates.ps1 @@ -48,10 +48,12 @@ function Invoke-ListCAtemplates { templates = @($packageTemplates | ForEach-Object { try { $data = $_.JSON | ConvertFrom-Json -Depth 100 -ErrorAction SilentlyContinue - $data | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $_.GUID -Force - $data | Add-Member -NotePropertyName 'package' -NotePropertyValue $_.Package -Force - $data | Add-Member -NotePropertyName 'source' -NotePropertyValue $_.Source -Force - $data | Add-Member -NotePropertyName 'isSynced' -NotePropertyValue (![string]::IsNullOrEmpty($_.SHA)) -Force + $data | Add-Member -NotePropertyMembers ([ordered]@{ + GUID = $_.GUID + package = $_.Package + source = $_.Source + isSynced = (![string]::IsNullOrEmpty($_.SHA)) + }) -Force $data } catch { } @@ -71,10 +73,12 @@ function Invoke-ListCAtemplates { try { $row = $_ $data = $row.JSON | ConvertFrom-Json -Depth 100 -ErrorAction Stop - $data | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $row.GUID -Force - $data | Add-Member -NotePropertyName 'source' -NotePropertyValue $row.Source -Force - $data | Add-Member -NotePropertyName 'isSynced' -NotePropertyValue (![string]::IsNullOrEmpty($row.SHA)) -Force - $data | Add-Member -NotePropertyName 'package' -NotePropertyValue $row.Package -Force + $data | Add-Member -NotePropertyMembers ([ordered]@{ + GUID = $row.GUID + source = $row.Source + isSynced = (![string]::IsNullOrEmpty($row.SHA)) + package = $row.Package + }) -Force $data } catch { Write-Warning "Failed to process CA template: $($row.RowKey) - $($_.Exception.Message)" diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ListConditionalAccessPolicies.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ListConditionalAccessPolicies.ps1 index 09ff7e2b6b290..2a30b47017737 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ListConditionalAccessPolicies.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ListConditionalAccessPolicies.ps1 @@ -5,7 +5,7 @@ function Invoke-ListConditionalAccessPolicies { .ROLE Tenant.ConditionalAccess.Read .DESCRIPTION - Lists Conditional Access policies for a tenant with resolved display names for users, groups, applications, and locations. + Lists Conditional Access policies for a tenant with resolved display names for users, groups, applications, and locations. When manualPagination is set on an AllTenants read, one page is returned per request with a continuation token in Metadata.nextLink. #> [CmdletBinding()] param($Request, $TriggerMetadata) @@ -200,8 +200,22 @@ function Invoke-ListConditionalAccessPolicies { # AllTenants functionality $Table = Get-CIPPTable -TableName cacheCAPolicies $PartitionKey = 'CAPolicy' - $Filter = "PartitionKey eq '$PartitionKey'" - $Rows = Get-CIPPAzDataTableEntity @Table -filter $Filter | Where-Object -Property Timestamp -GT (Get-Date).AddMinutes(-60) + # Return one page per request with a continuation token in Metadata.nextLink; AllTenants reads only. + $ManualPagination = $Request.Query.manualPagination -and [System.Convert]::ToBoolean($Request.Query.manualPagination) + if ($ManualPagination) { + # Rows per page, clamped between 250 and 10000. Defaults to 5000. + $PageSize = 5000 + if ($Request.Query.PageSize -as [int]) { + $PageSize = [Math]::Min([Math]::Max([int]$Request.Query.PageSize, 250), 10000) + } + $FreshClause = "Timestamp ge datetime'{0}'" -f (Get-Date).AddMinutes(-60).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffK') + # Continuation token from the previous page's Metadata.nextLink; opaque to callers. + $Page = Get-CIPPPagedTableRows -Table $Table -PartitionKeys @($PartitionKey) -PageSize $PageSize -ContinuationToken $Request.Query.nextLink -ExtraFilterClauses @($FreshClause) + $Rows = @($Page.Rows) + } else { + $Filter = "PartitionKey eq '$PartitionKey'" + $Rows = Get-CIPPAzDataTableEntity @Table -filter $Filter | Where-Object -Property Timestamp -GT (Get-Date).AddMinutes(-60) + } $QueueReference = '{0}-{1}' -f $TenantFilter, $PartitionKey $RunningQueue = Get-CIPPQueueData -Reference $QueueReference | Where-Object { $_.Status -notmatch 'Completed' -and $_.Status -notmatch 'Failed' } # If a queue is running, we will not start a new one @@ -210,7 +224,7 @@ function Invoke-ListConditionalAccessPolicies { QueueMessage = 'Still loading data for all tenants. Please check back in a few more minutes' QueueId = $RunningQueue.RowKey } - } elseif (!$Rows -and !$RunningQueue) { + } elseif (!$Rows -and !$RunningQueue -and !$Request.Query.nextLink) { # If no rows are found and no queue is running, we will start a new one $TenantList = Get-Tenants -IncludeErrors $Queue = New-CippQueueEntry -Name 'Conditional Access Policies - All Tenants' -Link '/tenant/conditional/list-policies?customerId=AllTenants' -Reference $QueueReference -TotalTasks ($TenantList | Measure-Object).Count @@ -235,11 +249,26 @@ function Invoke-ListConditionalAccessPolicies { $Metadata = [PSCustomObject]@{ QueueId = $RunningQueue.RowKey ?? $null } - $Policies = $Rows | Select-CippAllowedTenantData -TenantProperty 'Tenant' - # Output all policies from all tenants the caller is allowed to see - foreach ($policy in $Policies) { - ($policy.Policy | ConvertFrom-Json) + if ($ManualPagination -and $Page.NextToken) { + $Metadata | Add-Member -NotePropertyName 'nextLink' -NotePropertyValue $Page.NextToken + } + # Each cached Policy blob is already the final shape; stitch the allowed rows + # into Results verbatim instead of parsing and letting Craft re-serialize. + $AllowedRows = @($Rows | Select-CippAllowedTenantData -TenantProperty 'Tenant') + $JsonParts = [System.Collections.Generic.List[string]]::new($AllowedRows.Count) + foreach ($Row in $AllowedRows) { + $Blob = [string]$Row.Policy + if ([string]::IsNullOrWhiteSpace($Blob)) { continue } + $Blob = $Blob.Trim() + if ($Blob[0] -eq '{' -or $Blob[0] -eq '[') { $JsonParts.Add($Blob) } } + $ResultsJson = '[' + ($JsonParts -join ',') + ']' + $MetadataJson = ConvertTo-Json -InputObject $Metadata -Depth 5 -Compress + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + ContentType = 'application/json' + Body = '{"Results":' + $ResultsJson + ',"Metadata":' + $MetadataJson + '}' + }) } } $StatusCode = [HttpStatusCode]::OK diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecAddGDAPRole.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecAddGDAPRole.ps1 index f02dff9d052be..ab6cae75ea7d2 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecAddGDAPRole.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecAddGDAPRole.ps1 @@ -7,6 +7,8 @@ function Invoke-ExecAddGDAPRole { #> [CmdletBinding()] param($Request, $TriggerMetadata) + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers $Action = $Request.Body.Action ?? $Request.Query.Action ?? 'AddRoleSimple' $GroupBlockList = @('All Users', 'AdminAgents', 'HelpdeskAgents', 'SalesAgents') @@ -16,155 +18,111 @@ function Invoke-ExecAddGDAPRole { $Results = @($Groups) } 'AddRoleAdvanced' { - $Mappings = $Request.Body.mappings - $Table = Get-CIPPTable -TableName 'GDAPRoles' - $ExistingGroups = New-GraphGetRequest -NoAuthCheck $True -uri 'https://graph.microsoft.com/beta/groups?$filter=securityEnabled eq true&$select=id,displayName&$top=999' -tenantid $env:TenantID -AsApp $true - $Results = [System.Collections.Generic.List[object]]::new() - $ErrorsFound = $false - $Entities = foreach ($Mapping in $Mappings) { - $GroupId = $Mapping.GroupId - if ($ExistingGroups.id -contains $GroupId) { - $ExistingGroup = $ExistingGroups | Where-Object -Property id -EQ $GroupId - if ($ExistingGroup.displayName -in $GroupBlockList) { - $Results.Add(@{ - state = 'error' - resultText = "Group $($ExistingGroup.displayName) is a reserved group and cannot be mapped to a GDAP role" - }) - $ErrorsFound = $true - } else { - @{ - PartitionKey = 'Roles' - RowKey = $GroupId - RoleName = $Mapping.RoleName - GroupName = $ExistingGroup.displayName - GroupId = $GroupId - roleDefinitionId = $Mapping.roleDefinitionId + try { + $Mappings = $Request.Body.mappings + $Table = Get-CIPPTable -TableName 'GDAPRoles' + $ExistingGroups = New-GraphGetRequest -NoAuthCheck $True -uri 'https://graph.microsoft.com/beta/groups?$filter=securityEnabled eq true&$select=id,displayName&$top=999' -tenantid $env:TenantID -AsApp $true + $Results = [System.Collections.Generic.List[object]]::new() + $ErrorsFound = $false + $Entities = foreach ($Mapping in $Mappings) { + $GroupId = $Mapping.GroupId + if ($ExistingGroups.id -contains $GroupId) { + $ExistingGroup = $ExistingGroups | Where-Object -Property id -EQ $GroupId + if ($ExistingGroup.displayName -in $GroupBlockList) { + $Results.Add(@{ + state = 'error' + resultText = "Group $($ExistingGroup.displayName) is a reserved group and cannot be mapped to a GDAP role" + }) + $ErrorsFound = $true + } else { + @{ + PartitionKey = 'Roles' + RowKey = $GroupId + RoleName = $Mapping.RoleName + GroupName = $ExistingGroup.displayName + GroupId = $GroupId + roleDefinitionId = $Mapping.roleDefinitionId + } + $Results.Add(@{ + state = 'success' + resultText = "Mapped $($ExistingGroup.displayName) to $($Mapping.RoleName)" + }) } - $Results.Add(@{ - state = 'success' - resultText = "Mapped $($ExistingGroup.displayName) to $($Mapping.RoleName)" - }) } } - } - if (($Entities | Measure-Object).Count -gt 0) { - Write-Warning "Adding $($Entities.Count) entities to table" - Write-Information ($Entities | ConvertTo-Json -Depth 10 -Compress) - Add-CIPPAzDataTableEntity @Table -Entity $Entities -Force - } elseif ($ErrorsFound -eq $false) { - $Results.Add(@{ - state = 'success' - resultText = 'All role mappings already exist' + if (($Entities | Measure-Object).Count -gt 0) { + Write-Warning "Adding $($Entities.Count) entities to table" + Write-Information ($Entities | ConvertTo-Json -Depth 10 -Compress) + Add-CIPPAzDataTableEntity @Table -Entity $Entities -Force + $Result = "Added $($Entities.Count) GDAP role mapping(s)" + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Info' + } elseif ($ErrorsFound -eq $false) { + $Results.Add(@{ + state = 'success' + resultText = 'All role mappings already exist' + }) + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message 'All GDAP role mappings already exist' -Sev 'Info' + } else { + $Result = 'Failed to add GDAP role mappings: reserved groups cannot be mapped' + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Error' + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to add GDAP role mappings: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Error' -LogData $ErrorMessage + $Results = @(@{ + state = 'error' + resultText = $Result }) } } 'AddRoleSimple' { - $CippDefaults = @( - @{ label = 'Application Administrator'; value = '9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3' }, - @{ label = 'User Administrator'; value = 'fe930be7-5e62-47db-91af-98c3a49a38b1' }, - @{ label = 'Intune Administrator'; value = '3a2c62db-5318-420d-8d74-23affee5d9d5' }, - @{ label = 'Exchange Administrator'; value = '29232cdf-9323-42fd-ade2-1d097af3e4de' }, - @{ label = 'Security Administrator'; value = '194ae4cb-b126-40b2-bd5b-6091b380977d' }, - @{ label = 'Cloud App Security Administrator'; value = '892c5842-a9a6-463a-8041-72aa08ca3cf6' }, - @{ label = 'Cloud Device Administrator'; value = '7698a772-787b-4ac8-901f-60d6b08affd2' }, - @{ label = 'Teams Administrator'; value = '69091246-20e8-4a56-aa4d-066075b2a7a8' }, - @{ label = 'SharePoint Administrator'; value = 'f28a1f50-f6e7-4571-818b-6a12f2af6b6c' }, - @{ label = 'Authentication Policy Administrator'; value = '0526716b-113d-4c15-b2c8-68e3c22b9f80' }, - @{ label = 'Privileged Role Administrator'; value = 'e8611ab8-c189-46e8-94e1-60213ab1f814' }, - @{ label = 'Privileged Authentication Administrator'; value = '7be44c8a-adaf-4e2a-84d6-ab2649e08a13' }, - @{ label = 'Billing Administrator'; value = 'b0f54661-2d74-4c50-afa3-1ec803f12efe' }, - @{ label = 'Global Reader'; value = 'f2ef992c-3afb-46b9-b7cf-a126ee74c451' }, - @{ label = 'Domain Name Administrator'; value = '8329153b-31d0-4727-b945-745eb3bc5f31' } - ) - - $Groups = $Request.Body.gdapRoles ?? $CippDefaults + try { + $CippDefaults = @( + @{ label = 'Application Administrator'; value = '9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3' }, + @{ label = 'User Administrator'; value = 'fe930be7-5e62-47db-91af-98c3a49a38b1' }, + @{ label = 'Intune Administrator'; value = '3a2c62db-5318-420d-8d74-23affee5d9d5' }, + @{ label = 'Exchange Administrator'; value = '29232cdf-9323-42fd-ade2-1d097af3e4de' }, + @{ label = 'Security Administrator'; value = '194ae4cb-b126-40b2-bd5b-6091b380977d' }, + @{ label = 'Cloud App Security Administrator'; value = '892c5842-a9a6-463a-8041-72aa08ca3cf6' }, + @{ label = 'Cloud Device Administrator'; value = '7698a772-787b-4ac8-901f-60d6b08affd2' }, + @{ label = 'Teams Administrator'; value = '69091246-20e8-4a56-aa4d-066075b2a7a8' }, + @{ label = 'SharePoint Administrator'; value = 'f28a1f50-f6e7-4571-818b-6a12f2af6b6c' }, + @{ label = 'Authentication Policy Administrator'; value = '0526716b-113d-4c15-b2c8-68e3c22b9f80' }, + @{ label = 'Privileged Role Administrator'; value = 'e8611ab8-c189-46e8-94e1-60213ab1f814' }, + @{ label = 'Privileged Authentication Administrator'; value = '7be44c8a-adaf-4e2a-84d6-ab2649e08a13' }, + @{ label = 'Billing Administrator'; value = 'b0f54661-2d74-4c50-afa3-1ec803f12efe' }, + @{ label = 'Global Reader'; value = 'f2ef992c-3afb-46b9-b7cf-a126ee74c451' }, + @{ label = 'Domain Name Administrator'; value = '8329153b-31d0-4727-b945-745eb3bc5f31' } + ) - $CustomSuffix = $Request.Body.customSuffix - $Table = Get-CIPPTable -TableName 'GDAPRoles' + $Groups = $Request.Body.gdapRoles ?? $CippDefaults + $CustomSuffix = $Request.Body.customSuffix - $Results = [System.Collections.Generic.List[string]]::new() - $Requests = [System.Collections.Generic.List[object]]::new() - $ExistingGroups = New-GraphGetRequest -NoAuthCheck $True -uri 'https://graph.microsoft.com/beta/groups' -tenantid $env:TenantID -AsApp $true + $Mapping = New-CIPPGDAPRoleMapping -Roles $Groups -CustomSuffix $CustomSuffix + $Results = $Mapping.Results + $RoleMappings = $Mapping.RoleMappings - $ExistingRoleMappings = foreach ($Group in $Groups) { - $RoleName = $Group.label ?? $Group.Name - $Value = $Group.value ?? $Group.ObjectId - - if ($CustomSuffix) { - $GroupName = "M365 GDAP $($RoleName) - $CustomSuffix" - $MailNickname = "M365GDAP$(($RoleName).replace(' ',''))$($CustomSuffix.replace(' ',''))" - } else { - $GroupName = "M365 GDAP $($RoleName)" - $MailNickname = "M365GDAP$(($RoleName).replace(' ',''))" - } - - if ($GroupName -in $ExistingGroups.displayName) { - @{ - PartitionKey = 'Roles' - RowKey = ($ExistingGroups | Where-Object -Property displayName -EQ $GroupName | Select-Object -First 1).id - RoleName = $RoleName - GroupName = $GroupName - GroupId = ($ExistingGroups | Where-Object -Property displayName -EQ $GroupName | Select-Object -First 1).id - roleDefinitionId = $Value - } - $Results.Add("$GroupName already exists") + $Created = @($Results | Where-Object { $_ -like 'Created *' }) + $Failed = @($Results | Where-Object { $_ -like 'Could not create GDAP group:*' }) + if ($Failed.Count -gt 0) { + $Result = "GDAP role mapping completed with errors. Created: $($Created.Count), Failed: $($Failed.Count)" + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Error' } else { - $Requests.Add(@{ - id = $Value - url = '/groups' - method = 'POST' - headers = @{ - 'Content-Type' = 'application/json' - } - body = @{ - displayName = $GroupName - description = "This group is used to manage M365 partner tenants at the $($RoleName) level." - securityEnabled = $true - mailEnabled = $false - mailNickname = $MailNickname - } - }) + $Result = "GDAP role mapping completed. Groups created/reused: $($RoleMappings.Count)" + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Info' } - } - if ($ExistingRoleMappings) { - Add-CIPPAzDataTableEntity @Table -Entity $ExistingRoleMappings -Force - } - if ($Requests) { - $ReturnedData = New-GraphBulkRequest -Requests $Requests -tenantid $env:TenantID -NoAuthCheck $True -asapp $true - $NewRoleMappings = foreach ($Return in $ReturnedData) { - if ($Return.body.error) { - $Results.Add("Could not create GDAP group: $($Return.body.error.message)") - } else { - $GroupName = $Return.body.displayName - @{ - PartitionKey = 'Roles' - RowKey = $Return.body.id - RoleName = $Return.body.displayName -replace '^M365 GDAP ', '' -replace " - $CustomSuffix$", '' - GroupName = $Return.body.displayName - GroupId = $Return.body.id - roleDefinitionId = $Return.id - } - $Results.Add("Created $($GroupName)") - } + if ($Request.Body.templateId) { + # Add-CIPPGDAPRoleTemplate already writes customer-visible logs for the template path + Add-CIPPGDAPRoleTemplate -Headers $Request.Headers -TemplateId $Request.Body.templateId -RoleMappings ($RoleMappings | Select-Object -Property RoleName, GroupName, GroupId, roleDefinitionId) + $Results.Add("Added role mappings to template $($Request.Body.templateId)") } - Write-Information ($NewRoleMappings | ConvertTo-Json -Depth 10 -Compress) - if ($NewRoleMappings) { - Add-CIPPAzDataTableEntity @Table -Entity $NewRoleMappings -Force - } - } - - $RoleMappings = [System.Collections.Generic.List[object]]::new() - if ($ExistingRoleMappings) { - $RoleMappings.AddRange(@($ExistingRoleMappings)) - } - if ($NewRoleMappings) { - $RoleMappings.AddRange(@($NewRoleMappings)) - } - - if ($Request.Body.templateId) { - Add-CIPPGDAPRoleTemplate -TemplateId $Request.Body.templateId -RoleMappings ($RoleMappings | Select-Object -Property RoleName, GroupName, GroupId, roleDefinitionId) - $Results.Add("Added role mappings to template $($Request.Body.templateId)") + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to add GDAP roles: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Error' -LogData $ErrorMessage + $Results = @($Result) } } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPAccessAssignment.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPAccessAssignment.ps1 index 41155b7e5d81b..4b64b0235f5e8 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPAccessAssignment.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPAccessAssignment.ps1 @@ -167,11 +167,13 @@ function Invoke-ExecGDAPAccessAssignment { $Results = foreach ($Result in $BulkResults) { $Message = $Messages | Where-Object id -EQ $Result.id if ($Result.status -in @('201', '202', '204')) { + Write-LogMessage -headers $Request.Headers -API $APIName -tenant 'Global' -message $Message.message -Sev 'Info' @{ resultText = $Message.message state = 'success' } } else { + Write-LogMessage -headers $Request.Headers -API $APIName -tenant 'Global' -message "Error: $($Message.message): $($Result.body.error.message)" -Sev 'Error' @{ resultText = "Error: $($Message.message): $($Result.body.error.message)" state = 'error' @@ -184,6 +186,7 @@ function Invoke-ExecGDAPAccessAssignment { resultText = 'This relationship already has the correct access assignments' state = 'success' } + Write-LogMessage -headers $Request.Headers -API $APIName -tenant 'Global' -message 'GDAP access assignments already correct; no changes applied' -Sev 'Info' } else { $Results = @() } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPInviteApproved.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPInviteApproved.ps1 index 39f5b4e3a32dd..1fd5f61191811 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPInviteApproved.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPInviteApproved.ps1 @@ -7,12 +7,24 @@ Function Invoke-ExecGDAPInviteApproved { #> [CmdletBinding()] param($Request, $TriggerMetadata) - Set-CIPPGDAPInviteGroups - $body = @{Results = @('Processing recently activated GDAP relationships') } + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + try { + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message 'Started processing recently activated GDAP relationships' -Sev 'Info' + Set-CIPPGDAPInviteGroups + $body = @{Results = @('Processing recently activated GDAP relationships') } + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "Failed to process recently activated GDAP relationships: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + $body = @{Results = @("Failed to process recently activated GDAP relationships: $($ErrorMessage.NormalizedError)") } + $StatusCode = [HttpStatusCode]::InternalServerError + } return ([HttpResponseContext]@{ - StatusCode = [HttpStatusCode]::OK + StatusCode = $StatusCode Body = $body }) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPRepairRoleMappings.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPRepairRoleMappings.ps1 index 2421e3a14218c..fc93ef2cd8476 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPRepairRoleMappings.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPRepairRoleMappings.ps1 @@ -15,16 +15,27 @@ function Invoke-ExecGDAPRepairRoleMappings { $Results = [System.Collections.Generic.List[object]]::new() try { - # Fetch the partner tenant security groups once and reuse them for every store we repair - $PartnerGroups = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$filter=securityEnabled eq true&$select=id,displayName&$top=999' -tenantid $env:TenantID -NoAuthCheck $true -AsApp $true + # Fetch the partner tenant security groups once and reuse them for every store we repair. + # Groups recreated by one pass are appended so later passes re-link instead of creating duplicates. + $PartnerGroups = [System.Collections.Generic.List[object]]::new() + foreach ($Group in (New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$filter=securityEnabled eq true&$select=id,displayName&$top=999' -tenantid $env:TenantID -NoAuthCheck $true -AsApp $true)) { + $PartnerGroups.Add($Group) + } + $AddCreatedGroups = { + param($Check) + foreach ($Created in ($Check.Results | Where-Object { $_.Status -eq 'Created' })) { + $PartnerGroups.Add([PSCustomObject]@{ id = $Created.GroupId; displayName = $Created.GroupName }) + } + } # Repair the GDAPRoles registry (stale group ids are remapped to the existing "M365 GDAP" group) $RolesTable = Get-CIPPTable -TableName 'GDAPRoles' $StoredRoles = Get-CIPPAzDataTableEntity @RolesTable -Filter "PartitionKey eq 'Roles'" if (($StoredRoles | Measure-Object).Count -gt 0) { - $RoleCheck = Test-CIPPGDAPGroupMappings -RoleMappings $StoredRoles -PartnerGroups $PartnerGroups -WriteBack -APIName $APIName -Headers $Headers + $RoleCheck = Test-CIPPGDAPGroupMappings -RoleMappings $StoredRoles -PartnerGroups @($PartnerGroups) -CreateMissing -WriteBack -APIName $APIName -Headers $Headers + & $AddCreatedGroups $RoleCheck foreach ($Result in $RoleCheck.Results) { - if ($Result.Status -eq 'Stale') { + if ($Result.Status -in @('Stale', 'Created')) { $Results.Add(@{ resultText = "GDAP Roles: $($Result.Message)"; state = 'success' }) } elseif ($Result.Status -eq 'Missing') { $Results.Add(@{ resultText = "GDAP Roles: $($Result.Message)"; state = 'error' }) @@ -43,9 +54,10 @@ function Invoke-ExecGDAPRepairRoleMappings { } if (($TemplateMappings | Measure-Object).Count -eq 0) { continue } - $TemplateCheck = Test-CIPPGDAPGroupMappings -RoleMappings $TemplateMappings -PartnerGroups $PartnerGroups -TemplateId $Template.RowKey -APIName $APIName -Headers $Headers + $TemplateCheck = Test-CIPPGDAPGroupMappings -RoleMappings $TemplateMappings -PartnerGroups @($PartnerGroups) -CreateMissing -TemplateId $Template.RowKey -APIName $APIName -Headers $Headers + & $AddCreatedGroups $TemplateCheck foreach ($Result in $TemplateCheck.Results) { - if ($Result.Status -eq 'Stale') { + if ($Result.Status -in @('Stale', 'Created')) { $Results.Add(@{ resultText = "Template '$($Template.RowKey)': $($Result.Message)"; state = 'success' }) } elseif ($Result.Status -eq 'Missing') { $Results.Add(@{ resultText = "Template '$($Template.RowKey)': $($Result.Message)"; state = 'error' }) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPRoleTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPRoleTemplate.ps1 index abb2b84eabdb7..cbc3e88350db7 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPRoleTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPRoleTemplate.ps1 @@ -18,10 +18,10 @@ function Invoke-ExecGDAPRoleTemplate { if ($Request.Query.TemplateId) { $Template = $Templates | Where-Object -Property RowKey -EQ $Request.Query.TemplateId if (!$Template) { - Write-LogMessage -headers $Headers -API $APIName -message "GDAP role template '$($Request.Query.TemplateId)' not found" -sev 'Warning' + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "GDAP role template '$($Request.Query.TemplateId)' not found" -sev 'Warning' $Body = @{} } else { - Write-LogMessage -headers $Headers -API $APIName -message "Retrieved GDAP role template '$($Request.Query.TemplateId)'" -Sev 'Info' + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "Retrieved GDAP role template '$($Request.Query.TemplateId)'" -Sev 'Info' $Body = @{ TemplateId = $Template.RowKey RoleMappings = @($Template.RoleMappings | ConvertFrom-Json) @@ -37,12 +37,52 @@ function Invoke-ExecGDAPRoleTemplate { $RoleMappings = $Request.Body.RoleMappings } Write-Information ($RoleMappings | ConvertTo-Json) - Add-CIPPGDAPRoleTemplate -TemplateId $RowKey -RoleMappings $RoleMappings - Write-LogMessage -headers $Headers -API $APIName -message "Added role mappings to GDAP template '$RowKey'" -Sev 'Info' + Add-CIPPGDAPRoleTemplate -Headers $Request.Headers -TemplateId $RowKey -RoleMappings $RoleMappings + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "Added role mappings to GDAP template '$RowKey'" -Sev 'Info' $Body = @{ Results = "Added role mappings to template $RowKey" } } + 'Save' { + # Template-first save: creates any group mappings the editor asked for, then + # writes the full mapping set to the template in one shot. + $NewTemplateId = $Request.Body.TemplateId + $OriginalTemplateId = $Request.Body.OriginalTemplateId + $SaveResults = [System.Collections.Generic.List[string]]::new() + $RoleMappings = [System.Collections.Generic.List[object]]::new() + + foreach ($Mapping in @($Request.Body.RoleMappings)) { + if ($Mapping) { $RoleMappings.Add($Mapping) } + } + + $NewRoles = @($Request.Body.NewRoles | Where-Object { $_ }) + if ($NewRoles.Count -gt 0) { + $NewMappings = New-CIPPGDAPRoleMapping -Roles $NewRoles -CustomSuffix $Request.Body.CustomSuffix + foreach ($Message in $NewMappings.Results) { + $SaveResults.Add([string]$Message) + } + foreach ($Mapping in $NewMappings.RoleMappings) { + if ($Mapping.GroupId -notin $RoleMappings.GroupId) { + $RoleMappings.Add($Mapping) + } + } + } + + if ($OriginalTemplateId -and $OriginalTemplateId -ne $NewTemplateId) { + $OldTemplate = $Templates | Where-Object -Property RowKey -EQ $OriginalTemplateId + if ($OldTemplate) { + Remove-CIPPAzDataTableEntity -Force @Table -Entity $OldTemplate + $SaveResults.Add("Renamed template $OriginalTemplateId to $NewTemplateId") + } + } + + Add-CIPPGDAPRoleTemplate -Headers $Request.Headers -TemplateId $NewTemplateId -RoleMappings @($RoleMappings | Select-Object -Property RoleName, GroupName, GroupId, roleDefinitionId) -Overwrite + $SaveResults.Add("Saved template $NewTemplateId") + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "Saved GDAP role template '$NewTemplateId' with $($RoleMappings.Count) role mappings" -Sev 'Info' + $Body = @{ + Results = @($SaveResults) + } + } 'Edit' { # Use OriginalTemplateId if provided (for rename), otherwise use TemplateId $OriginalRowKey = $Request.Body.OriginalTemplateId ?? $Request.Body.TemplateId @@ -54,21 +94,21 @@ function Invoke-ExecGDAPRoleTemplate { # If the template ID is being changed, delete the old one and create a new one if ($OriginalRowKey -ne $NewRowKey) { Remove-CIPPAzDataTableEntity -Force @Table -Entity $Template - Add-CIPPGDAPRoleTemplate -TemplateId $NewRowKey -RoleMappings $RoleMappings -Overwrite - Write-LogMessage -headers $Headers -API $APIName -message "Renamed GDAP template from '$OriginalRowKey' to '$NewRowKey' and updated role mappings" -Sev 'Info' + Add-CIPPGDAPRoleTemplate -Headers $Request.Headers -TemplateId $NewRowKey -RoleMappings $RoleMappings -Overwrite + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "Renamed GDAP template from '$OriginalRowKey' to '$NewRowKey' and updated role mappings" -Sev 'Info' $Body = @{ Results = "Renamed template from $OriginalRowKey to $NewRowKey and updated role mappings" } } else { # Just update the existing template - Add-CIPPGDAPRoleTemplate -TemplateId $NewRowKey -RoleMappings $RoleMappings -Overwrite - Write-LogMessage -headers $Headers -API $APIName -message "Updated role mappings for GDAP template '$NewRowKey'" -Sev 'Info' + Add-CIPPGDAPRoleTemplate -Headers $Request.Headers -TemplateId $NewRowKey -RoleMappings $RoleMappings -Overwrite + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "Updated role mappings for GDAP template '$NewRowKey'" -Sev 'Info' $Body = @{ Results = "Updated role mappings for template $NewRowKey" } } } else { - Write-LogMessage -headers $Headers -API $APIName -message "GDAP role template '$OriginalRowKey' not found for editing" -sev 'Warning' + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "GDAP role template '$OriginalRowKey' not found for editing" -sev 'Warning' $Body = @{ Results = "Template $OriginalRowKey not found" } @@ -79,19 +119,19 @@ function Invoke-ExecGDAPRoleTemplate { $Template = $Templates | Where-Object -Property RowKey -EQ $RowKey if ($Template) { Remove-CIPPAzDataTableEntity -Force @Table -Entity $Template - Write-LogMessage -headers $Headers -API $APIName -message "Deleted GDAP role template '$RowKey'" -Sev 'Info' + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "Deleted GDAP role template '$RowKey'" -Sev 'Info' $Body = @{ Results = "Deleted template $RowKey" } } else { - Write-LogMessage -headers $Headers -API $APIName -message "GDAP role template '$RowKey' not found for deletion" -sev 'Warning' + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "GDAP role template '$RowKey' not found for deletion" -sev 'Warning' $Body = @{ Results = "Template $RowKey not found" } } } default { - Write-LogMessage -headers $Headers -API $APIName -message "Retrieved $($Templates.Count) GDAP role templates" -Sev 'Info' + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "Retrieved $($Templates.Count) GDAP role templates" -Sev 'Info' $Results = foreach ($Template in $Templates) { [PSCustomObject]@{ TemplateId = $Template.RowKey diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ListGDAPRoles.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ListGDAPRoles.ps1 index 6adf7f039471d..1b425430e32ff 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ListGDAPRoles.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ListGDAPRoles.ps1 @@ -6,9 +6,14 @@ Function Invoke-ListGDAPRoles { Tenant.Relationship.Read .DESCRIPTION Lists the configured GDAP role-to-security-group mappings used for delegated admin access. + Pass ?validate=true to annotate each mapping with the state of its partner tenant group. #> [CmdletBinding()] param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $Table = Get-CIPPTable -TableName 'GDAPRoles' $Groups = Get-CIPPAzDataTableEntity @Table @@ -21,6 +26,48 @@ Function Invoke-ListGDAPRoles { } } + # Opt-in only: other consumers of this endpoint depend on the unannotated shape. + if ($Request.Query.validate -eq $true -and ($MappedGroups | Measure-Object).Count -gt 0) { + try { + # The helper fetches the partner tenant groups itself; keeping Graph out of this + # entrypoint keeps the documented response shape to the mapping fields. + $Check = Test-CIPPGDAPGroupMappings -RoleMappings $MappedGroups -APIName $APIName -Headers $Headers + + # A read-only check reports one result per mapping; Valid keeps the original id, the + # other states carry it as OldGroupId. + $StatusLookup = @{} + foreach ($Result in $Check.Results) { + $Key = if ($Result.OldGroupId) { $Result.OldGroupId } else { $Result.GroupId } + if ($Key) { $StatusLookup[[string]$Key] = $Result } + } + + $MappedGroups = foreach ($Group in $MappedGroups) { + $Status = $StatusLookup[[string]$Group.GroupId] + [PSCustomObject]@{ + GroupName = $Group.GroupName + GroupId = $Group.GroupId + RoleName = $Group.RoleName + roleDefinitionId = $Group.roleDefinitionId + GroupStatus = $Status.Status ?? 'Unknown' + GroupStatusMessage = $Status.Message ?? '' + } + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -message "Could not validate GDAP group mappings: $($ErrorMessage.NormalizedError)" -Sev 'Warning' -LogData $ErrorMessage + $MappedGroups = foreach ($Group in $MappedGroups) { + [PSCustomObject]@{ + GroupName = $Group.GroupName + GroupId = $Group.GroupId + RoleName = $Group.RoleName + roleDefinitionId = $Group.roleDefinitionId + GroupStatus = 'Unknown' + GroupStatusMessage = '' + } + } + } + } + return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::OK Body = @($MappedGroups) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Reports/Invoke-ExecLicensePricing.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Reports/Invoke-ExecLicensePricing.ps1 new file mode 100644 index 0000000000000..00ce81108b753 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Reports/Invoke-ExecLicensePricing.ps1 @@ -0,0 +1,76 @@ +function Invoke-ExecLicensePricing { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + Tenant.Directory.ReadWrite + .DESCRIPTION + Manage MSP-global license price overrides used by the license optimization report. + SetPrice upserts a per-SKU monthly price; RemovePrice deletes an override so the SKU falls + back to the shipped MSRP estimate. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $Table = Get-CIPPTable -TableName 'LicensePricing' + + try { + # SetPrice or RemovePrice + $Action = $Request.Body.Action + if ([string]::IsNullOrWhiteSpace($Action)) { throw 'Action is required.' } + # The SKU GUID (skuId) the price applies to + $SkuId = ([string]$Request.Body.skuId).ToLowerInvariant() + if ([string]::IsNullOrWhiteSpace($SkuId)) { throw 'skuId is required.' } + + # Overrides are currency-scoped: one row per (skuId, currency), so an AUD override and a + # USD override for the same SKU coexist. RowKey = "{skuId}-{currency}". + $Currency = if ($Request.Body.Currency) { [string]$Request.Body.Currency } else { 'USD' } + $RowKey = '{0}-{1}' -f $SkuId, $Currency.ToLowerInvariant() + + switch ($Action) { + 'SetPrice' { + # Monthly price per seat, in the given currency + $MonthlyPrice = $Request.Body.MonthlyPrice -as [double] + if ($null -eq $MonthlyPrice) { throw 'MonthlyPrice must be a number.' } + + $Entity = @{ + PartitionKey = 'Price' + RowKey = $RowKey + 'skuId' = $SkuId + 'skuPartNumber' = [string]$Request.Body.skuPartNumber + 'Product_Display_Name' = [string]$Request.Body.Product_Display_Name + 'MonthlyPrice' = [double]$MonthlyPrice + 'Currency' = $Currency + } + Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force + $Result = "Success. Set price for $SkuId to $Currency $MonthlyPrice per month." + Write-LogMessage -API $APIName -headers $Headers -message $Result -Sev 'Info' + } + 'RemovePrice' { + $Filter = "PartitionKey eq 'Price' and RowKey eq '{0}'" -f $RowKey + $Entity = Get-CIPPAzDataTableEntity @Table -Filter $Filter -Property PartitionKey, RowKey + if ($Entity) { + Remove-CIPPAzDataTableEntity -Force @Table -Entity $Entity + } + $Result = "Success. Removed the $Currency price override for $SkuId. It will fall back to the shipped estimate." + Write-LogMessage -API $APIName -headers $Headers -message $Result -Sev 'Info' + } + default { + $StatusCode = [HttpStatusCode]::BadRequest + $Result = "Invalid action specified: $Action" + } + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $StatusCode = [HttpStatusCode]::InternalServerError + $Result = "Failed to update license pricing. $($ErrorMessage.NormalizedError)" + Write-LogMessage -API $APIName -headers $Headers -message $Result -Sev 'Error' -LogData $ErrorMessage + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode ?? [HttpStatusCode]::OK + Body = [pscustomobject]@{ 'Results' = $Result } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Reports/Invoke-ListLicenseOptimization.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Reports/Invoke-ListLicenseOptimization.ps1 new file mode 100644 index 0000000000000..d39e7e6331b23 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Reports/Invoke-ListLicenseOptimization.ps1 @@ -0,0 +1,57 @@ +function Invoke-ListLicenseOptimization { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + Tenant.Directory.Read + .DESCRIPTION + License cost-optimization report for a tenant: a monetary summary plus reclaim + opportunities across five waste tiers (unassigned seats, disabled and inactive licensed + accounts, mailbox-only downgrade candidates, and redundant overlapping SKUs). Computed from + the reporting-DB cache. For tenantFilter=AllTenants it returns a per-tenant summary money + map (ranked by reclaimable spend) instead of the full opportunity detail. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + # The tenant to report on, or AllTenants for the cross-tenant summary money map + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + # Sign-in age in days past which an enabled licensed user counts as inactive (default 90) + $InactiveDays = ($Request.Query.inactiveDays ?? $Request.Body.inactiveDays) -as [int] + if (-not $InactiveDays -or $InactiveDays -le 0) { $InactiveDays = 90 } + # Currency the money figures are resolved in (ISO code); defaults to USD + $Currency = $Request.Query.currency ?? $Request.Body.currency + if ([string]::IsNullOrWhiteSpace($Currency)) { $Currency = 'USD' } + + try { + if ($TenantFilter -eq 'AllTenants') { + $Summaries = [System.Collections.Generic.List[object]]::new() + foreach ($Tenant in (Get-Tenants -IncludeErrors)) { + try { + $Report = Get-CIPPLicenseOptimization -TenantFilter $Tenant.defaultDomainName -InactiveDays $InactiveDays -Currency $Currency + if ($Report.Summary.DataAvailable) { $Summaries.Add($Report.Summary) } + } catch { + Write-Information "License optimization failed for $($Tenant.defaultDomainName): $($_.Exception.Message)" + } + } + $Results = @($Summaries | Sort-Object -Property ReclaimableMonthly -Descending) + } else { + if ([string]::IsNullOrWhiteSpace($TenantFilter)) { throw 'tenantFilter is required.' } + $Results = Get-CIPPLicenseOptimization -TenantFilter $TenantFilter -InactiveDays $InactiveDays -Currency $Currency + } + $StatusCode = [System.Net.HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $StatusCode = [System.Net.HttpStatusCode]::InternalServerError + $Results = "Failed to build license optimization report. $($ErrorMessage.NormalizedError)" + Write-LogMessage -API $APIName -headers $Headers -message $Results -Sev 'Error' -LogData $ErrorMessage + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = [pscustomobject]@{ 'Results' = $Results } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Reports/Invoke-ListLicensePricing.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Reports/Invoke-ListLicensePricing.ps1 new file mode 100644 index 0000000000000..efbb7bd4814f1 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Reports/Invoke-ListLicensePricing.ps1 @@ -0,0 +1,41 @@ +function Invoke-ListLicensePricing { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + Tenant.Directory.Read + .DESCRIPTION + Lists the resolved monthly price for every known license SKU: MSP price overrides merged + over the shipped MSRP estimates. Consumed by the license optimization report and its + price-management UI. Each row carries a Source of Override, Estimate, or Unknown. + + Prices are resolved in the requested currency (?currency=, default USD). The response also + carries the list of currencies present in the price data so the UI can offer a selector. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + # Currency to resolve prices in (ISO code); defaults to USD + $Currency = $Request.Query.currency ?? $Request.Body.currency + if ([string]::IsNullOrWhiteSpace($Currency)) { $Currency = 'USD' } + + try { + $Results = @(Get-CIPPLicensePrice -Currency $Currency) + $Currencies = @(Get-CIPPLicensePrice -ListCurrencies) + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $StatusCode = [HttpStatusCode]::InternalServerError + $Results = "Failed to list license pricing. $($ErrorMessage.NormalizedError)" + $Currencies = @('USD') + Write-LogMessage -API $APIName -headers $Headers -message $Results -Sev 'Error' -LogData $ErrorMessage + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = [pscustomobject]@{ 'Results' = $Results; 'Currencies' = $Currencies; 'Currency' = $Currency } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Reports/Invoke-ListServiceHealth.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Reports/Invoke-ListServiceHealth.ps1 index 949257de04a87..14aadf4e6d60b 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Reports/Invoke-ListServiceHealth.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Reports/Invoke-ListServiceHealth.ps1 @@ -19,8 +19,10 @@ Function Invoke-ListServiceHealth { $TenantName = $_.displayName Write-Host "Processed Service Health for $TenantName via AllTenants" $prop = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/admin/serviceAnnouncement/issues?`$filter=endDateTime eq null" -tenantid $_.defaultDomainName - $prop | Add-Member -NotePropertyName 'tenant' -NotePropertyValue $TenantName - $prop | Add-Member -NotePropertyName 'defaultDomainName' -NotePropertyValue $_.defaultDomainName + $prop | Add-Member -NotePropertyMembers ([ordered]@{ + tenant = $TenantName + defaultDomainName = $_.defaultDomainName + }) $prop } } else { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-AddBPATemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-AddBPATemplate.ps1 index 7fd804076cf2c..1c7b8da46100f 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-AddBPATemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-AddBPATemplate.ps1 @@ -19,11 +19,11 @@ Function Invoke-AddBPATemplate { PartitionKey = 'BPATemplate' GUID = $Request.body.name } - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Created BPA named $($Request.body.name)" -Sev 'Debug' + Write-LogMessage -headers $Request.Headers -API $APINAME -tenant 'Global' -message "Created BPA named $($Request.body.name)" -Sev 'Info' $body = [pscustomobject]@{'Results' = 'Successfully added template' } } catch { - Write-LogMessage -headers $Request.Headers -API $APINAME -message "BPA Template Creation failed: $($_.Exception.Message)" -Sev 'Error' + Write-LogMessage -headers $Request.Headers -API $APINAME -tenant 'Global' -message "BPA Template Creation failed: $($_.Exception.Message)" -Sev 'Error' $body = [pscustomobject]@{'Results' = "BPA Template Creation failed: $($_.Exception.Message)" } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecDriftClone.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecDriftClone.ps1 index ff43f91dd0f01..ab232e5ed1de2 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecDriftClone.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecDriftClone.ps1 @@ -8,6 +8,9 @@ function Invoke-ExecDriftClone { [CmdletBinding()] param($Request, $TriggerMetadata) + $APIName = $Request.Params.CIPPEndpoint ?? 'ExecDriftClone' + $Headers = $Request.Headers + try { $TemplateId = $Request.Body.id @@ -22,9 +25,22 @@ function Invoke-ExecDriftClone { }) return } - $CloneResult = New-CippStandardsDriftClone -TemplateId $TemplateId -UpgradeToDrift -Headers $Request.Headers + $CloneResult = New-CippStandardsDriftClone -TemplateId $TemplateId -UpgradeToDrift + if ($CloneResult -like 'Failed*') { + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $CloneResult -Sev 'Error' + $Results = [pscustomobject]@{ + 'Results' = $CloneResult + 'Success' = $false + } + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::InternalServerError + Body = $Results + }) + } + + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $CloneResult -Sev 'Info' $Results = [pscustomobject]@{ - 'Results' = $CloneResult + 'Results' = 'Clone Completed successfully' 'Success' = $true } @@ -33,6 +49,8 @@ function Invoke-ExecDriftClone { Body = $Results }) } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message "Failed to create drift clone: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage $Results = [pscustomobject]@{ 'Results' = "Failed to create drift clone: $($_.Exception.Message)" 'Success' = $false diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecStandardConvert.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecStandardConvert.ps1 index e69cf05ed25e4..7f331705a2382 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecStandardConvert.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecStandardConvert.ps1 @@ -8,6 +8,9 @@ function Invoke-ExecStandardConvert { [CmdletBinding()] param($Request, $TriggerMetadata) + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + function Convert-SingleStandardItem { param( [Parameter(Mandatory)] @@ -198,10 +201,12 @@ function Invoke-ExecStandardConvert { foreach ($OldStd in $StandardsToConvert) { $Converted = Convert-OldStandardToNewFormat $OldStd ($AllTenantsExclusions) $GUID = [guid]::NewGuid() - $Converted | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $GUID -Force - $Converted | Add-Member -NotePropertyName 'createdAt' -NotePropertyValue ((Get-Date).ToUniversalTime()) -Force - $Converted | Add-Member -NotePropertyName 'updatedBy' -NotePropertyValue 'System' -Force - $Converted | Add-Member -NotePropertyName 'updatedAt' -NotePropertyValue (Get-Date).ToUniversalTime() -Force + $Converted | Add-Member -NotePropertyMembers ([ordered]@{ + GUID = $GUID + createdAt = ((Get-Date).ToUniversalTime()) + updatedBy = 'System' + updatedAt = (Get-Date).ToUniversalTime() + }) -Force $JSON = ConvertTo-Json -Depth 100 -InputObject $Converted -Compress $Table = Get-CippTable -tablename 'templates' @@ -229,8 +234,12 @@ function Invoke-ExecStandardConvert { } } + $Result = "Successfully converted $($StandardsToConvert.Count) legacy standard(s) to new format" + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Result -Sev 'Info' + return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::OK Body = 'Successfully converted legacy standards to new format' }) } + diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecStandardsRun.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecStandardsRun.ps1 index 4c5e977b8dc0a..3294de543ffd9 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecStandardsRun.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecStandardsRun.ps1 @@ -26,11 +26,14 @@ function Invoke-ExecStandardsRun { $_.guid -like $TemplateId } + # [bool] over an array (or the string 'false') is always $true, which flipped wildcard runs to manual-only. + $RunManually = @($Templates).Count -eq 1 -and ("$(@($Templates)[0].runManually)" -eq 'True') + # Call the wrapper - it handles queuing internally via Start-CIPPOrchestrator try { - $null = New-CIPPStandardsRun -TenantFilter $TenantFilter -TemplateID $TemplateId -runManually ([bool]$Templates.runManually) -Force + $null = New-CIPPStandardsRun -TenantFilter $TenantFilter -TemplateID $TemplateId -runManually $RunManually -Force $TemplateName = if ($TemplateId -eq '*') { 'All' } else { "$($Templates.templateName) ($($Templates.GUID))" } - $RunMode = if ([bool]$Templates.runManually) { ' (Manual Only)' } else { '' } + $RunMode = if ($RunManually) { ' (Manual Only)' } else { '' } $Results = "Successfully started Standards Run for tenant: $TenantFilter - Template: $TemplateName$RunMode" Write-LogMessage -headers $Headers -tenant $TenantFilter -API $APIName -message $Results -Sev 'Info' } catch { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecUpdateBaselineDeviation.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecUpdateBaselineDeviation.ps1 index 7042137b20eb2..87139a9149f72 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecUpdateBaselineDeviation.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecUpdateBaselineDeviation.ps1 @@ -40,14 +40,17 @@ function Invoke-ExecUpdateBaselineDeviation { $Now = [int64]([datetimeoffset]::UtcNow.ToUnixTimeSeconds()) $Table.Force = $true foreach ($TaskEntity in $Entities) { - $TaskEntity | Add-Member -NotePropertyName 'Status' -NotePropertyValue 'Compliant' -Force - $TaskEntity | Add-Member -NotePropertyName 'Compliant' -NotePropertyValue $true -Force - $TaskEntity | Add-Member -NotePropertyName 'LastRemediated' -NotePropertyValue $Now -Force + $TaskProps = [ordered]@{ + Status = 'Compliant' + Compliant = $true + LastRemediated = $Now + } if ($TaskEntity.CurrentValue) { $CurrentTask = $TaskEntity.CurrentValue | ConvertFrom-Json $CurrentTask | Add-Member -NotePropertyName 'completed' -NotePropertyValue $true -Force - $TaskEntity | Add-Member -NotePropertyName 'CurrentValue' -NotePropertyValue (ConvertTo-Json -Compress -Depth 20 -InputObject $CurrentTask) -Force + $TaskProps['CurrentValue'] = (ConvertTo-Json -Compress -Depth 20 -InputObject $CurrentTask) } + $TaskEntity | Add-Member -NotePropertyMembers $TaskProps -Force Add-CIPPAzDataTableEntity @Table -Entity $TaskEntity $null = Add-CIPPBaselineHistoryEvent -TenantFilter $TaskEntity.PartitionKey -Standard $Standard -Mode 'triage' -TriggeredBy $User -Outcome 'Task Completed' -Detail 'Marked completed for all tenants from the standard view' } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecUpdateDriftDeviation.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecUpdateDriftDeviation.ps1 index b50e0163fd80d..49b3236cea62e 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecUpdateDriftDeviation.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecUpdateDriftDeviation.ps1 @@ -85,12 +85,7 @@ function Invoke-ExecUpdateDriftDeviation { try { $user = $request.headers.'x-ms-client-principal' $username = ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($user)) | ConvertFrom-Json).userDetails - $Result = Set-CIPPDriftDeviation -TenantFilter $TenantFilter -StandardName $Deviation.standardName -Status $Deviation.status -Reason $Reason -user $username - [PSCustomObject]@{ - success = $true - result = $Result - } - Write-LogMessage -tenant $TenantFilter -Headers $Request.Headers -API $APINAME -message "Updated drift deviation status for $($Deviation.standardName) to $($Deviation.status) with reason: $Reason" -Sev 'Info' + # The status is written at the end of this block, after the action it implies succeeds. if ($Deviation.status -eq 'DeniedRemediate') { $Setting = $Deviation.standardName -replace 'standards\.', '' $StandardTemplate = Get-CIPPTenantAlignment -TenantFilter $TenantFilter | Where-Object -Property standardType -EQ 'drift' @@ -110,8 +105,10 @@ function Invoke-ExecUpdateDriftDeviation { if (-not $MatchedTemplate) { Write-LogMessage -tenant $TenantFilter -Headers $Request.Headers -API $APINAME -message "Could not find IntuneTemplate $TemplateId in drift standard settings for remediation" -Sev 'Warning' } else { - $MatchedTemplate | Add-Member -MemberType NoteProperty -Name 'remediate' -Value $true -Force - $MatchedTemplate | Add-Member -MemberType NoteProperty -Name 'report' -Value $true -Force + $MatchedTemplate | Add-Member -NotePropertyMembers ([ordered]@{ + remediate = $true + report = $true + }) -Force $Settings = $MatchedTemplate } } elseif ($Setting -like '*ConditionalAccessTemplate*') { @@ -127,8 +124,10 @@ function Invoke-ExecUpdateDriftDeviation { if (-not $MatchedTemplate) { Write-LogMessage -tenant $TenantFilter -Headers $Request.Headers -API $APINAME -message "Could not find ConditionalAccessTemplate $TemplateId in drift standard settings for remediation" -Sev 'Warning' } else { - $MatchedTemplate | Add-Member -MemberType NoteProperty -Name 'remediate' -Value $true -Force - $MatchedTemplate | Add-Member -MemberType NoteProperty -Name 'report' -Value $true -Force + $MatchedTemplate | Add-Member -NotePropertyMembers ([ordered]@{ + remediate = $true + report = $true + }) -Force $Settings = $MatchedTemplate } } elseif ($Setting -like '*QuarantineTemplate*') { @@ -150,8 +149,10 @@ function Invoke-ExecUpdateDriftDeviation { if (-not $MatchedTemplate) { Write-LogMessage -tenant $TenantFilter -Headers $Request.Headers -API $APINAME -message "Could not find QuarantineTemplate '$PolicyName' in drift standard settings for remediation" -Sev 'Warning' } else { - $MatchedTemplate | Add-Member -MemberType NoteProperty -Name 'remediate' -Value $true -Force - $MatchedTemplate | Add-Member -MemberType NoteProperty -Name 'report' -Value $true -Force + $MatchedTemplate | Add-Member -NotePropertyMembers ([ordered]@{ + remediate = $true + report = $true + }) -Force $Settings = $MatchedTemplate } } elseif ($Setting -like '*ReusableSettingsTemplate*') { @@ -161,8 +162,10 @@ function Invoke-ExecUpdateDriftDeviation { if (-not $MatchedTemplate) { Write-LogMessage -tenant $TenantFilter -Headers $Request.Headers -API $APINAME -message "Could not find ReusableSettingsTemplate $TemplateId in drift standard settings for remediation" -Sev 'Warning' } else { - $MatchedTemplate | Add-Member -MemberType NoteProperty -Name 'remediate' -Value $true -Force - $MatchedTemplate | Add-Member -MemberType NoteProperty -Name 'report' -Value $true -Force + $MatchedTemplate | Add-Member -NotePropertyMembers ([ordered]@{ + remediate = $true + report = $true + }) -Force $Settings = $MatchedTemplate } } else { @@ -175,8 +178,10 @@ function Invoke-ExecUpdateDriftDeviation { } $StandardTemplate.PSObject.Properties.Remove('standards') } - $StandardTemplate | Add-Member -MemberType NoteProperty -Name 'remediate' -Value $true -Force - $StandardTemplate | Add-Member -MemberType NoteProperty -Name 'report' -Value $true -Force + $StandardTemplate | Add-Member -NotePropertyMembers ([ordered]@{ + remediate = $true + report = $true + }) -Force $Settings = $StandardTemplate } if ($Settings) { @@ -235,6 +240,7 @@ function Invoke-ExecUpdateDriftDeviation { success = $false error = "The deviation status was updated, but no remediation task was scheduled: '$Setting' could not be resolved from the drift template settings. Verify the template still exists in the template library and is included in the drift template, or re-save the drift template." } + Write-LogMessage -tenant $TenantFilter -Headers $Request.Headers -API $APINAME -message "Could not find standard $Setting in drift standard settings for remediation" -Sev 'Warning' } } if ($Deviation.status -eq 'deniedDelete') { @@ -296,6 +302,14 @@ function Invoke-ExecUpdateDriftDeviation { } + + # Task queued / policy gone; a throw above leaves the row untouched. + $Result = Set-CIPPDriftDeviation -TenantFilter $TenantFilter -StandardName $Deviation.standardName -Status $Deviation.status -Reason $Reason -user $username + [PSCustomObject]@{ + success = $true + result = $Result + } + Write-LogMessage -tenant $TenantFilter -Headers $Request.Headers -API $APINAME -message "Updated drift deviation status for $($Deviation.standardName) to $($Deviation.status) with reason: $Reason" -Sev 'Info' } catch { [PSCustomObject]@{ standardName = $Deviation.standardName diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListStandardsCompare.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListStandardsCompare.ps1 index a85cf546adb2d..2767a8d4c5cab 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListStandardsCompare.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListStandardsCompare.ps1 @@ -23,6 +23,8 @@ function Invoke-ListStandardsCompare { $ScopedTemplateGuids = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $ScopedQuarantineNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + # Plain standard keys in scope (standards., and one key per reusable settings template). + $ScopedStandardKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($Entry in $StandardList) { switch ($Entry.Standard) { { $_ -in @('IntuneTemplate', 'ConditionalAccessTemplate') } { @@ -32,16 +34,35 @@ function Invoke-ListStandardsCompare { $DisplayName = $Entry.Settings.displayName.value ?? $Entry.Settings.displayName if ($DisplayName) { $null = $ScopedQuarantineNames.Add($DisplayName) } } + 'ReusableSettingsTemplate' { + foreach ($Item in @($Entry.Settings.TemplateList)) { + $Id = if ($Item.value) { [string]$Item.value } else { [string]$Item } + if ($Id) { $null = $ScopedStandardKeys.Add("standards.ReusableSettingsTemplate.$Id") } + } + } + default { + if ($Entry.Standard) { $null = $ScopedStandardKeys.Add("standards.$($Entry.Standard)") } + } } } + # A report row carries the id of the template whose settings last ran the standard. With the + # three-tier merge that is the tenant-specific or group template, so filtering rows on that id + # hid every standard an AllTenants template shares with a more specific one and the page said + # the data had never been collected. Rows are matched on the standard key instead, and the + # selected template's own definition is read so its overridden standards still count as in scope + # (Get-CIPPStandards only emits the template that won the merge). + if ($TemplateFilter) { + $TemplateScope = Get-CIPPStandardsTemplateScope -TemplateId $TemplateFilter + $ScopedStandardKeys.UnionWith($TemplateScope.StandardKeys) + $ScopedTemplateGuids.UnionWith($TemplateScope.TemplateGuids) + $ScopedQuarantineNames.UnionWith($TemplateScope.QuarantineNames) + } + $Filters = [system.collections.generic.list[string]]::new() if ($TenantFilter) { $Filters.Add("PartitionKey eq '{0}'" -f $TenantFilter) } - if ($TemplateFilter) { - $Filters.Add("TemplateId eq '{0}'" -f $TemplateFilter) - } $Filter = $Filters -join ' and ' $Tenants = Get-Tenants -IncludeErrors @@ -68,6 +89,9 @@ function Invoke-ListStandardsCompare { } $DecodedName = -join $Chars if (-not $ScopedQuarantineNames.Contains($DecodedName)) { continue } + } elseif ($TemplateFilter -and $Standard.TemplateId -ne $TemplateFilter -and -not $ScopedStandardKeys.Contains($FieldName)) { + # Not written by this template and not one of its standards: belongs to another template. + continue } # decode field names that are hex encoded (e.g. QuarantineTemplates) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-listStandardTemplates.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-listStandardTemplates.ps1 index eb71fa0dcbfb2..5cc6f94bdc0d0 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-listStandardTemplates.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-listStandardTemplates.ps1 @@ -40,12 +40,14 @@ function Invoke-listStandardTemplates { } } if ($Data) { - $Data | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $_.GUID -Force - $Data | Add-Member -NotePropertyName 'source' -NotePropertyValue $_.Source -Force - $Data | Add-Member -NotePropertyName 'isSynced' -NotePropertyValue (![string]::IsNullOrEmpty($_.SHA)) -Force + $DataProps = [ordered]@{ + GUID = $_.GUID + source = $_.Source + isSynced = (![string]::IsNullOrEmpty($_.SHA)) + } if (!$Data.excludedTenants) { - $Data | Add-Member -NotePropertyName 'excludedTenants' -NotePropertyValue @() -Force + $DataProps['excludedTenants'] = @() } else { if ($Data.excludedTenants -and $Data.excludedTenants -ne 'excludedTenants') { $Data.excludedTenants = @($Data.excludedTenants) @@ -53,6 +55,7 @@ function Invoke-listStandardTemplates { $Data.excludedTenants = @() } } + $Data | Add-Member -NotePropertyMembers $DataProps -Force # Re-expand TemplateList-Tags live so stale addedFields snapshots don't show removed templates if ($Data.standards) { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Tests/Invoke-AddTestReport.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Tests/Invoke-AddTestReport.ps1 index aebb578e1f419..b7bd9969cc268 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Tests/Invoke-AddTestReport.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Tests/Invoke-AddTestReport.ps1 @@ -56,14 +56,16 @@ function Invoke-AddTestReport { # Save to table Add-CIPPAzDataTableEntity -Entity $Report @ReportTable -Force + $Result = if ($IsUpdate) { "Successfully updated custom report '$($Body.name)'" } else { "Successfully created custom report '$($Body.name)'" } + Write-LogMessage -user $Request.Headers.'x-ms-client-principal' -API $APIName -tenant 'Global' -message $Result -Sev 'Info' $Body = [PSCustomObject]@{ - Results = if ($IsUpdate) { 'Successfully updated custom report' } else { 'Successfully created custom report' } + Results = $Result ReportId = $ReportId } $StatusCode = [HttpStatusCode]::OK } catch { $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -user $Request.Headers.'x-ms-client-principal' -API $APIName -message "Failed to save report: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + Write-LogMessage -user $Request.Headers.'x-ms-client-principal' -API $APIName -tenant 'Global' -message "Failed to save report: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage $Body = [PSCustomObject]@{ Results = "Failed to save report: $($ErrorMessage.NormalizedError)" } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Tests/Invoke-DeleteTestReport.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Tests/Invoke-DeleteTestReport.ps1 index 6f82effad9a89..888c676e94bdf 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Tests/Invoke-DeleteTestReport.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Tests/Invoke-DeleteTestReport.ps1 @@ -17,13 +17,15 @@ function Invoke-DeleteTestReport { $ExistingReport = Get-CIPPAzDataTableEntity @Table -Filter "RowKey eq '$ReportId'" Remove-CIPPAzDataTableEntity @Table -Entity $ExistingReport + $Result = 'Successfully deleted custom report' + Write-LogMessage -user $Request.Headers.'x-ms-client-principal' -API $APIName -tenant 'Global' -message $Result -Sev 'Info' $Body = [PSCustomObject]@{ - Results = 'Successfully deleted custom report' + Results = $Result } $StatusCode = [HttpStatusCode]::OK } catch { $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -user $Request.Headers.'x-ms-client-principal' -API $APIName -message "Failed to delete report: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + Write-LogMessage -user $Request.Headers.'x-ms-client-principal' -API $APIName -tenant 'Global' -message "Failed to delete report: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage $Body = [PSCustomObject]@{ Results = "Failed to delete report: $($ErrorMessage.NormalizedError)" } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Tests/Invoke-ExecTestRefresh.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Tests/Invoke-ExecTestRefresh.ps1 index 19f79b929263e..8ba6fe35f1dd9 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Tests/Invoke-ExecTestRefresh.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Tests/Invoke-ExecTestRefresh.ps1 @@ -20,6 +20,7 @@ function Invoke-ExecTestRefresh { Add-CIPPAzDataTableEntity @Table -Entity $TestResult -Force $StatusCode = [HttpStatusCode]::OK $Body = [PSCustomObject]@{ Results = "Successfully updated test $TestName for tenant $TenantFilter"; Metadata = $TestResult } + Write-LogMessage -headers $Request.Headers -API $APIName -tenant $TenantFilter -message "Successfully refreshed test $TestName for tenant $TenantFilter" -Sev 'Info' } else { return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::NotFound @@ -28,9 +29,11 @@ function Invoke-ExecTestRefresh { } } catch { $StatusCode = [HttpStatusCode]::BadRequest + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Request.Headers -API $APIName -tenant $TenantFilter -message "Failed to refresh test $TestName for ${TenantFilter}: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage $Body = @{ Message = "Failed to update test $TestName for $TenantFilter" - Error = Get-CippException -Exception $_ + Error = $ErrorMessage } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Tests/Invoke-ListTests.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Tests/Invoke-ListTests.ps1 index 567ff124015ea..77408930154e9 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Tests/Invoke-ListTests.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Tests/Invoke-ListTests.ps1 @@ -194,9 +194,11 @@ function Invoke-ListTests { $ScriptGuid = ($TestResult.RowKey -replace '^CustomScript-', '') if (-not [string]::IsNullOrWhiteSpace($ScriptGuid) -and $CustomScriptMetadataLookup.ContainsKey($ScriptGuid)) { $CustomMetadata = $CustomScriptMetadataLookup[$ScriptGuid] - $TestResult | Add-Member -NotePropertyName 'Description' -NotePropertyValue ($CustomMetadata.Description) -Force - $TestResult | Add-Member -NotePropertyName 'ReturnType' -NotePropertyValue ($CustomMetadata.ReturnType) -Force - $TestResult | Add-Member -NotePropertyName 'MarkdownTemplate' -NotePropertyValue ($CustomMetadata.MarkdownTemplate) -Force + $TestResult | Add-Member -NotePropertyMembers ([ordered]@{ + Description = ($CustomMetadata.Description) + ReturnType = ($CustomMetadata.ReturnType) + MarkdownTemplate = ($CustomMetadata.MarkdownTemplate) + }) -Force } } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Tools/Invoke-ExecGraphExplorerPreset.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Tools/Invoke-ExecGraphExplorerPreset.ps1 index bcaebf761c298..c49bae8d88350 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Tools/Invoke-ExecGraphExplorerPreset.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Tools/Invoke-ExecGraphExplorerPreset.ps1 @@ -108,6 +108,15 @@ function Invoke-ExecGraphExplorerPreset { $Message = $_.Exception.Message $StatusCode = [HttpStatusCode]::BadRequest } + + if ($Action -in @('Save', 'Delete', 'Copy')) { + if ($Success) { + Write-LogMessage -headers $Headers -API ($Request.Params.CIPPEndpoint) -tenant 'Global' -message $Message -Sev 'Info' + } else { + Write-LogMessage -headers $Headers -API ($Request.Params.CIPPEndpoint) -tenant 'Global' -message $Message -Sev 'Error' + } + } + return ([HttpResponseContext]@{ StatusCode = $StatusCode Body = @{ diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ExecCommunityRepo.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ExecCommunityRepo.ps1 index 335a5d0b684e7..16802f16f4431 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ExecCommunityRepo.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ExecCommunityRepo.ps1 @@ -12,6 +12,9 @@ function Invoke-ExecCommunityRepo { [CmdletBinding()] param($Request, $TriggerMetadata) + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $Action = $Request.Body.Action $Id = $Request.Body.Id if ($Request.Body.Id) { @@ -371,6 +374,14 @@ function Invoke-ExecCommunityRepo { } } + if ($Results) { + if ($Results.state -eq 'success') { + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Results.resultText -Sev 'Info' + } elseif ($Results.state -eq 'error') { + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Results.resultText -Sev 'Error' + } + } + $Body = @{ Results = @($Results) } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ExecGitHubAction.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ExecGitHubAction.ps1 index 709e3a6b076f5..7ee5c0d77fbf9 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ExecGitHubAction.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ExecGitHubAction.ps1 @@ -12,6 +12,9 @@ function Invoke-ExecGitHubAction { [CmdletBinding()] param($Request, $TriggerMetadata) + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $Action = $Request.Query.Action ?? $Request.Body.Action if ($Request.Query.Action) { @@ -50,7 +53,18 @@ function Invoke-ExecGitHubAction { $Results = @($Files) } 'ImportTemplate' { - $Results = Import-CommunityTemplate @SplatParams + try { + $Results = Import-CommunityTemplate @SplatParams + $ResultText = if ($Results -is [string]) { $Results } elseif ($Results.resultText) { $Results.resultText } else { 'Template imported' } + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $ResultText -Sev 'Info' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Results = @{ + resultText = "Error importing template: $($ErrorMessage.NormalizedError)" + state = 'error' + } + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Results.resultText -Sev 'Error' -LogData $ErrorMessage + } } 'CreateRepo' { try { @@ -77,6 +91,7 @@ function Invoke-ExecGitHubAction { resultText = "Repository '$($Repo.name)' created" state = 'success' } + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Results.resultText -Sev 'Info' } } catch { Write-Information (Get-CippException -Exception $_ | ConvertTo-Json) @@ -84,6 +99,7 @@ function Invoke-ExecGitHubAction { resultText = 'You may not have permission to create repositories, check your PAT scopes and try again - {0}' -f $_.Exception.Message state = 'error' } + Write-LogMessage -headers $Headers -API $APIName -tenant 'Global' -message $Results.resultText -Sev 'Error' } } default { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ListCommunityRepoTemplates.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ListCommunityRepoTemplates.ps1 index b4e30f5613999..31ec0dac5c756 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ListCommunityRepoTemplates.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ListCommunityRepoTemplates.ps1 @@ -93,7 +93,8 @@ function Invoke-ListCommunityRepoTemplates { 'ExConnectorTemplate', 'AppTemplate', 'ContactTemplate', 'JITAdminTemplate', 'UserDefaultTemplate', 'AssignmentFilterTemplate', 'IntuneReusableSettingTemplate', 'SharePointTemplate', 'DlpCompliancePolicyTemplate', 'RetentionCompliancePolicyTemplate', - 'SensitivityLabelTemplate', 'SensitiveInfoTypeTemplate', 'BaselineTemplate' + 'SensitivityLabelTemplate', 'SensitiveInfoTypeTemplate', 'BaselineTemplate', + 'PIMRoleSettingsTemplate' ) $Warnings = [System.Collections.Generic.List[string]]::new() diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ListCommunityRepos.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ListCommunityRepos.ps1 index ab34213ee8185..70e8aa932e901 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ListCommunityRepos.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ListCommunityRepos.ps1 @@ -51,10 +51,12 @@ function Invoke-ListCommunityRepos { $DefaultsChanged = $true } elseif ($Existing.TemplateTypes -ne $TemplateTypesJson -or $Existing.BuiltIn -ne $Repo.BuiltIn -or $Existing.Description -ne $Repo.Description -or $Existing.Name -ne $Repo.Name) { # Upgrade path: sync built-in metadata onto rows seeded by older versions - $Existing | Add-Member -NotePropertyName 'TemplateTypes' -NotePropertyValue $TemplateTypesJson -Force - $Existing | Add-Member -NotePropertyName 'BuiltIn' -NotePropertyValue $Repo.BuiltIn -Force - $Existing | Add-Member -NotePropertyName 'Description' -NotePropertyValue $Repo.Description -Force - $Existing | Add-Member -NotePropertyName 'Name' -NotePropertyValue $Repo.Name -Force + $Existing | Add-Member -NotePropertyMembers ([ordered]@{ + TemplateTypes = $TemplateTypesJson + BuiltIn = $Repo.BuiltIn + Description = $Repo.Description + Name = $Repo.Name + }) -Force Add-CIPPAzDataTableEntity @Table -Entity $Existing -Force $DefaultsChanged = $true } diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAntiPhishPolicy.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAntiPhishPolicy.ps1 index c202a60b3ff68..968b9b5e38085 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAntiPhishPolicy.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAntiPhishPolicy.ps1 @@ -127,6 +127,13 @@ function Invoke-CIPPStandardAntiPhishPolicy { $CurrentState = $ExistingPolicy | Select-Object Name, Enabled, PhishThresholdLevel, EnableMailboxIntelligence, EnableMailboxIntelligenceProtection, EnableSpoofIntelligence, EnableFirstContactSafetyTips, EnableSimilarUsersSafetyTips, EnableSimilarDomainsSafetyTips, EnableUnusualCharactersSafetyTips, EnableUnauthenticatedSender, EnableViaTag, AuthenticationFailAction, SpoofQuarantineTag, MailboxIntelligenceProtectionAction, MailboxIntelligenceQuarantineTag, TargetedUserProtectionAction, TargetedUserQuarantineTag, TargetedDomainProtectionAction, TargetedDomainQuarantineTag, EnableOrganizationDomainsProtection, EnableTargetedDomainsProtection, EnableTargetedUserProtection + # Get-AntiPhishPolicy only populates Enabled for the built-in default policy; on a custom policy + # the active state lives on its rule's State (see Invoke-ListAntiPhishingFilters). Without this + # the compare shows Enabled = null and the policy reads Non-Compliant even while it is active. + if ($CurrentState -and $null -ne $ExistingRule.State) { + $CurrentState.Enabled = $ExistingRule.State -eq 'Enabled' + } + if ($MDOLicensed) { $StateIsCorrect = ($CurrentState.Name -eq $PolicyName) -and ($CurrentState.Enabled -eq $true) -and diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAppDeploy.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAppDeploy.ps1 index 4c3aaea63d830..828d5c9103017 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAppDeploy.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAppDeploy.ps1 @@ -17,7 +17,7 @@ function Invoke-CIPPStandardAppDeploy { Automatically deploys approved business applications across all company locations and users, ensuring consistent access to essential tools and maintaining standardized software configurations. This streamlines application management and reduces IT deployment overhead. ADDEDCOMPONENT {"type":"select","multiple":false,"creatable":false,"label":"App Approval Mode","name":"standards.AppDeploy.mode","options":[{"label":"Template","value":"template"},{"label":"Copy Permissions","value":"copy"}]} - {"type":"autoComplete","multiple":true,"creatable":false,"label":"Select Applications","name":"standards.AppDeploy.templateIds","api":{"url":"/api/ListAppApprovalTemplates","labelField":"TemplateName","valueField":"TemplateId","queryKey":"StdAppApprovalTemplateList","addedField":{"AppId":"AppId"}},"condition":{"field":"standards.AppDeploy.mode","compareType":"is","compareValue":"template"}} + {"type":"autoComplete","multiple":true,"creatable":false,"label":"Select Applications","name":"standards.AppDeploy.templateIds","api":{"url":"/api/ListAppApprovalTemplates","labelField":"TemplateName","valueField":"TemplateId","queryKey":"StdAppApprovalTemplateList","addedField":{"AppId":"AppId"},"templateView":{"title":"App Approval Template"}},"condition":{"field":"standards.AppDeploy.mode","compareType":"is","compareValue":"template"}} {"type":"textField","name":"standards.AppDeploy.appids","label":"Application IDs, comma separated","condition":{"field":"standards.AppDeploy.mode","compareType":"isNot","compareValue":"template"}} IMPACT Low Impact diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAuthenticationMethods.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAuthenticationMethods.ps1 index 542041def8ca8..f16f32e0c1d37 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAuthenticationMethods.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAuthenticationMethods.ps1 @@ -7,8 +7,8 @@ function Invoke-CIPPStandardAuthenticationMethods { .SYNOPSIS (Label) Configure Authentication Methods .DESCRIPTION - (Helptext) Configures all authentication methods for the tenant including Microsoft Authenticator, FIDO2, SMS, Voice, Email OTP, Temporary Access Pass, Software OATH, Hardware OATH, Certificate-based, and QR Code Pin. Enable or disable each method and optionally target specific groups. - (DocsDescription) Unified standard to configure all authentication method policies in a single place. Each method can be independently enabled or disabled, targeted to all users or specific groups using group name wildcards, and configured with method-specific settings such as TAP lifetime, QR code pin length, Authenticator software OTP, and Email OTP external user access with exclude group targeting. + (Helptext) Configures all authentication methods for the tenant including Microsoft Authenticator, FIDO2, SMS, Voice, Email OTP, Temporary Access Pass, Software OATH, Hardware OATH, Certificate-based, and QR Code Pin. Set each method to Enabled, Disabled or Not Configured and optionally target specific groups. Methods set to Not Configured (or left blank) keep the tenant's current setting. + (DocsDescription) Unified standard to configure all authentication method policies in a single place. Each method can be independently set to Enabled, Disabled or Not Configured (leaving the tenant's current configuration untouched), targeted to all users or specific groups using group name wildcards, and configured with method-specific settings such as TAP lifetime, QR code pin length, Authenticator software OTP, and Email OTP external user access with exclude group targeting. .NOTES CAT Entra (AAD) Standards @@ -16,39 +16,39 @@ function Invoke-CIPPStandardAuthenticationMethods { EXECUTIVETEXT Provides centralized control over all tenant authentication methods from a single standard. Administrators can enable phishing-resistant methods like FIDO2 and Microsoft Authenticator while disabling less secure options like SMS and Voice. Each method supports group-level targeting using wildcard group names, allowing staged rollouts and granular control. ADDEDCOMPONENT - {"type":"switch","name":"standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled","label":"Microsoft Authenticator","defaultValue":false} - {"type":"textField","name":"standards.AuthenticationMethods.MicrosoftAuthenticatorGroup","label":"Target Group Name (wildcard supported, blank = All Users)","required":false,"condition":{"field":"standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled","compareType":"is","compareValue":true}} - {"type":"switch","name":"standards.AuthenticationMethods.MicrosoftAuthenticatorSoftwareOath","label":"Enable Software OTP in Authenticator","defaultValue":false,"condition":{"field":"standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled","compareType":"is","compareValue":true}} - {"type":"autoComplete","multiple":false,"creatable":false,"label":"Show Application Name in Push Notifications","name":"standards.AuthenticationMethods.MicrosoftAuthenticatorDisplayAppInfo","options":[{"label":"Microsoft managed","value":"default"},{"label":"Enabled","value":"enabled"},{"label":"Disabled","value":"disabled"}],"condition":{"field":"standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled","compareType":"is","compareValue":true}} - {"type":"autoComplete","multiple":false,"creatable":false,"label":"Show Geographic Location in Push Notifications","name":"standards.AuthenticationMethods.MicrosoftAuthenticatorDisplayLocation","options":[{"label":"Microsoft managed","value":"default"},{"label":"Enabled","value":"enabled"},{"label":"Disabled","value":"disabled"}],"condition":{"field":"standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled","compareType":"is","compareValue":true}} - {"type":"autoComplete","multiple":false,"creatable":false,"label":"Companion App (Authenticator Lite)","name":"standards.AuthenticationMethods.MicrosoftAuthenticatorCompanionApp","options":[{"label":"Microsoft managed","value":"default"},{"label":"Enabled","value":"enabled"},{"label":"Disabled","value":"disabled"}],"condition":{"field":"standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled","compareType":"is","compareValue":true}} - {"type":"switch","name":"standards.AuthenticationMethods.FIDO2Enabled","label":"FIDO2 Security Keys","defaultValue":false} - {"type":"textField","name":"standards.AuthenticationMethods.FIDO2Group","label":"Target Group Name (wildcard supported, blank = All Users)","required":false,"condition":{"field":"standards.AuthenticationMethods.FIDO2Enabled","compareType":"is","compareValue":true}} - {"type":"switch","name":"standards.AuthenticationMethods.TAPEnabled","label":"Temporary Access Pass","defaultValue":false} - {"type":"textField","name":"standards.AuthenticationMethods.TAPGroup","label":"Target Group Name (wildcard supported, blank = All Users)","required":false,"condition":{"field":"standards.AuthenticationMethods.TAPEnabled","compareType":"is","compareValue":true}} - {"type":"autoComplete","multiple":false,"creatable":false,"label":"TAP Usage Mode","name":"standards.AuthenticationMethods.TAPUsableOnce","options":[{"label":"Only Once","value":"true"},{"label":"Multiple Logons","value":"false"}],"condition":{"field":"standards.AuthenticationMethods.TAPEnabled","compareType":"is","compareValue":true}} - {"type":"number","name":"standards.AuthenticationMethods.TAPDefaultLifetime","label":"TAP Default Lifetime (minutes)","defaultValue":60,"condition":{"field":"standards.AuthenticationMethods.TAPEnabled","compareType":"is","compareValue":true}} - {"type":"number","name":"standards.AuthenticationMethods.TAPMinLifetime","label":"TAP Minimum Lifetime (minutes)","defaultValue":60,"condition":{"field":"standards.AuthenticationMethods.TAPEnabled","compareType":"is","compareValue":true}} - {"type":"number","name":"standards.AuthenticationMethods.TAPMaxLifetime","label":"TAP Maximum Lifetime (minutes)","defaultValue":480,"condition":{"field":"standards.AuthenticationMethods.TAPEnabled","compareType":"is","compareValue":true}} - {"type":"number","name":"standards.AuthenticationMethods.TAPDefaultLength","label":"TAP Length (characters)","defaultValue":8,"condition":{"field":"standards.AuthenticationMethods.TAPEnabled","compareType":"is","compareValue":true}} - {"type":"switch","name":"standards.AuthenticationMethods.SoftwareOathEnabled","label":"Third-Party Software OATH Tokens","defaultValue":false} - {"type":"textField","name":"standards.AuthenticationMethods.SoftwareOathGroup","label":"Target Group Name (wildcard supported, blank = All Users)","required":false,"condition":{"field":"standards.AuthenticationMethods.SoftwareOathEnabled","compareType":"is","compareValue":true}} - {"type":"switch","name":"standards.AuthenticationMethods.HardwareOathEnabled","label":"Hardware OATH Tokens","defaultValue":false} - {"type":"textField","name":"standards.AuthenticationMethods.HardwareOathGroup","label":"Target Group Name (wildcard supported, blank = All Users)","required":false,"condition":{"field":"standards.AuthenticationMethods.HardwareOathEnabled","compareType":"is","compareValue":true}} - {"type":"switch","name":"standards.AuthenticationMethods.SMSEnabled","label":"SMS","defaultValue":false} - {"type":"textField","name":"standards.AuthenticationMethods.SMSGroup","label":"Target Group Name (wildcard supported, blank = All Users)","required":false,"condition":{"field":"standards.AuthenticationMethods.SMSEnabled","compareType":"is","compareValue":true}} - {"type":"switch","name":"standards.AuthenticationMethods.VoiceEnabled","label":"Voice Call","defaultValue":false} - {"type":"textField","name":"standards.AuthenticationMethods.VoiceGroup","label":"Target Group Name (wildcard supported, blank = All Users)","required":false,"condition":{"field":"standards.AuthenticationMethods.VoiceEnabled","compareType":"is","compareValue":true}} - {"type":"switch","name":"standards.AuthenticationMethods.EmailEnabled","label":"Email OTP","defaultValue":false} - {"type":"textField","name":"standards.AuthenticationMethods.EmailGroup","label":"Target Group Name (wildcard supported, blank = All Users)","required":false,"condition":{"field":"standards.AuthenticationMethods.EmailEnabled","compareType":"is","compareValue":true}} - {"type":"autoComplete","multiple":false,"creatable":false,"label":"Allow external users to use Email OTP","name":"standards.AuthenticationMethods.EmailAllowExternalIdToUseEmailOtp","options":[{"label":"Microsoft managed (default)","value":"default"},{"label":"Enabled","value":"enabled"},{"label":"Disabled","value":"disabled"}],"condition":{"field":"standards.AuthenticationMethods.EmailEnabled","compareType":"is","compareValue":true}} - {"type":"textField","name":"standards.AuthenticationMethods.EmailExcludeGroup","label":"Exclude Group Name (wildcard supported, blank = no exclusions)","required":false,"condition":{"field":"standards.AuthenticationMethods.EmailEnabled","compareType":"is","compareValue":true}} - {"type":"switch","name":"standards.AuthenticationMethods.x509CertificateEnabled","label":"Certificate-Based Authentication","defaultValue":false} - {"type":"textField","name":"standards.AuthenticationMethods.x509CertificateGroup","label":"Target Group Name (wildcard supported, blank = All Users)","required":false,"condition":{"field":"standards.AuthenticationMethods.x509CertificateEnabled","compareType":"is","compareValue":true}} - {"type":"switch","name":"standards.AuthenticationMethods.QRCodePinEnabled","label":"QR Code Pin","defaultValue":false} - {"type":"textField","name":"standards.AuthenticationMethods.QRCodePinGroup","label":"Target Group Name (wildcard supported, blank = All Users)","required":false,"condition":{"field":"standards.AuthenticationMethods.QRCodePinEnabled","compareType":"is","compareValue":true}} - {"type":"number","name":"standards.AuthenticationMethods.QRCodeLifetimeInDays","label":"QR Code Lifetime (days, 1-395)","defaultValue":365,"condition":{"field":"standards.AuthenticationMethods.QRCodePinEnabled","compareType":"is","compareValue":true}} - {"type":"number","name":"standards.AuthenticationMethods.QRCodePinLength","label":"QR Code PIN Length (8-20)","defaultValue":8,"condition":{"field":"standards.AuthenticationMethods.QRCodePinEnabled","compareType":"is","compareValue":true}} + {"type":"autoComplete","multiple":false,"creatable":false,"required":false,"name":"standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled","label":"Microsoft Authenticator","helperText":"Not Configured or blank leaves the tenant's current setting untouched.","options":[{"label":"Enabled","value":true},{"label":"Disabled","value":false},{"label":"Not Configured","value":"notConfigured"}]} + {"type":"textField","name":"standards.AuthenticationMethods.MicrosoftAuthenticatorGroup","label":"Target Group Name (wildcard supported, blank = All Users)","required":false,"condition":{"field":"standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled","compareType":"valueEq","compareValue":true}} + {"type":"switch","name":"standards.AuthenticationMethods.MicrosoftAuthenticatorSoftwareOath","label":"Enable Software OTP in Authenticator","defaultValue":false,"condition":{"field":"standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled","compareType":"valueEq","compareValue":true}} + {"type":"autoComplete","multiple":false,"creatable":false,"label":"Show Application Name in Push Notifications","name":"standards.AuthenticationMethods.MicrosoftAuthenticatorDisplayAppInfo","options":[{"label":"Microsoft managed","value":"default"},{"label":"Enabled","value":"enabled"},{"label":"Disabled","value":"disabled"}],"condition":{"field":"standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled","compareType":"valueEq","compareValue":true}} + {"type":"autoComplete","multiple":false,"creatable":false,"label":"Show Geographic Location in Push Notifications","name":"standards.AuthenticationMethods.MicrosoftAuthenticatorDisplayLocation","options":[{"label":"Microsoft managed","value":"default"},{"label":"Enabled","value":"enabled"},{"label":"Disabled","value":"disabled"}],"condition":{"field":"standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled","compareType":"valueEq","compareValue":true}} + {"type":"autoComplete","multiple":false,"creatable":false,"label":"Companion App (Authenticator Lite)","name":"standards.AuthenticationMethods.MicrosoftAuthenticatorCompanionApp","options":[{"label":"Microsoft managed","value":"default"},{"label":"Enabled","value":"enabled"},{"label":"Disabled","value":"disabled"}],"condition":{"field":"standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled","compareType":"valueEq","compareValue":true}} + {"type":"autoComplete","multiple":false,"creatable":false,"required":false,"name":"standards.AuthenticationMethods.FIDO2Enabled","label":"FIDO2 Security Keys","helperText":"Not Configured or blank leaves the tenant's current setting untouched.","options":[{"label":"Enabled","value":true},{"label":"Disabled","value":false},{"label":"Not Configured","value":"notConfigured"}]} + {"type":"textField","name":"standards.AuthenticationMethods.FIDO2Group","label":"Target Group Name (wildcard supported, blank = All Users)","required":false,"condition":{"field":"standards.AuthenticationMethods.FIDO2Enabled","compareType":"valueEq","compareValue":true}} + {"type":"autoComplete","multiple":false,"creatable":false,"required":false,"name":"standards.AuthenticationMethods.TAPEnabled","label":"Temporary Access Pass","helperText":"Not Configured or blank leaves the tenant's current setting untouched.","options":[{"label":"Enabled","value":true},{"label":"Disabled","value":false},{"label":"Not Configured","value":"notConfigured"}]} + {"type":"textField","name":"standards.AuthenticationMethods.TAPGroup","label":"Target Group Name (wildcard supported, blank = All Users)","required":false,"condition":{"field":"standards.AuthenticationMethods.TAPEnabled","compareType":"valueEq","compareValue":true}} + {"type":"autoComplete","multiple":false,"creatable":false,"label":"TAP Usage Mode","name":"standards.AuthenticationMethods.TAPUsableOnce","options":[{"label":"Only Once","value":"true"},{"label":"Multiple Logons","value":"false"}],"condition":{"field":"standards.AuthenticationMethods.TAPEnabled","compareType":"valueEq","compareValue":true}} + {"type":"number","name":"standards.AuthenticationMethods.TAPDefaultLifetime","label":"TAP Default Lifetime (minutes)","defaultValue":60,"condition":{"field":"standards.AuthenticationMethods.TAPEnabled","compareType":"valueEq","compareValue":true}} + {"type":"number","name":"standards.AuthenticationMethods.TAPMinLifetime","label":"TAP Minimum Lifetime (minutes)","defaultValue":60,"condition":{"field":"standards.AuthenticationMethods.TAPEnabled","compareType":"valueEq","compareValue":true}} + {"type":"number","name":"standards.AuthenticationMethods.TAPMaxLifetime","label":"TAP Maximum Lifetime (minutes)","defaultValue":480,"condition":{"field":"standards.AuthenticationMethods.TAPEnabled","compareType":"valueEq","compareValue":true}} + {"type":"number","name":"standards.AuthenticationMethods.TAPDefaultLength","label":"TAP Length (characters)","defaultValue":8,"condition":{"field":"standards.AuthenticationMethods.TAPEnabled","compareType":"valueEq","compareValue":true}} + {"type":"autoComplete","multiple":false,"creatable":false,"required":false,"name":"standards.AuthenticationMethods.SoftwareOathEnabled","label":"Third-Party Software OATH Tokens","helperText":"Not Configured or blank leaves the tenant's current setting untouched.","options":[{"label":"Enabled","value":true},{"label":"Disabled","value":false},{"label":"Not Configured","value":"notConfigured"}]} + {"type":"textField","name":"standards.AuthenticationMethods.SoftwareOathGroup","label":"Target Group Name (wildcard supported, blank = All Users)","required":false,"condition":{"field":"standards.AuthenticationMethods.SoftwareOathEnabled","compareType":"valueEq","compareValue":true}} + {"type":"autoComplete","multiple":false,"creatable":false,"required":false,"name":"standards.AuthenticationMethods.HardwareOathEnabled","label":"Hardware OATH Tokens","helperText":"Not Configured or blank leaves the tenant's current setting untouched.","options":[{"label":"Enabled","value":true},{"label":"Disabled","value":false},{"label":"Not Configured","value":"notConfigured"}]} + {"type":"textField","name":"standards.AuthenticationMethods.HardwareOathGroup","label":"Target Group Name (wildcard supported, blank = All Users)","required":false,"condition":{"field":"standards.AuthenticationMethods.HardwareOathEnabled","compareType":"valueEq","compareValue":true}} + {"type":"autoComplete","multiple":false,"creatable":false,"required":false,"name":"standards.AuthenticationMethods.SMSEnabled","label":"SMS","helperText":"Not Configured or blank leaves the tenant's current setting untouched.","options":[{"label":"Enabled","value":true},{"label":"Disabled","value":false},{"label":"Not Configured","value":"notConfigured"}]} + {"type":"textField","name":"standards.AuthenticationMethods.SMSGroup","label":"Target Group Name (wildcard supported, blank = All Users)","required":false,"condition":{"field":"standards.AuthenticationMethods.SMSEnabled","compareType":"valueEq","compareValue":true}} + {"type":"autoComplete","multiple":false,"creatable":false,"required":false,"name":"standards.AuthenticationMethods.VoiceEnabled","label":"Voice Call","helperText":"Not Configured or blank leaves the tenant's current setting untouched.","options":[{"label":"Enabled","value":true},{"label":"Disabled","value":false},{"label":"Not Configured","value":"notConfigured"}]} + {"type":"textField","name":"standards.AuthenticationMethods.VoiceGroup","label":"Target Group Name (wildcard supported, blank = All Users)","required":false,"condition":{"field":"standards.AuthenticationMethods.VoiceEnabled","compareType":"valueEq","compareValue":true}} + {"type":"autoComplete","multiple":false,"creatable":false,"required":false,"name":"standards.AuthenticationMethods.EmailEnabled","label":"Email OTP","helperText":"Not Configured or blank leaves the tenant's current setting untouched.","options":[{"label":"Enabled","value":true},{"label":"Disabled","value":false},{"label":"Not Configured","value":"notConfigured"}]} + {"type":"textField","name":"standards.AuthenticationMethods.EmailGroup","label":"Target Group Name (wildcard supported, blank = All Users)","required":false,"condition":{"field":"standards.AuthenticationMethods.EmailEnabled","compareType":"valueEq","compareValue":true}} + {"type":"autoComplete","multiple":false,"creatable":false,"label":"Allow external users to use Email OTP","name":"standards.AuthenticationMethods.EmailAllowExternalIdToUseEmailOtp","options":[{"label":"Microsoft managed (default)","value":"default"},{"label":"Enabled","value":"enabled"},{"label":"Disabled","value":"disabled"}],"condition":{"field":"standards.AuthenticationMethods.EmailEnabled","compareType":"valueEq","compareValue":true}} + {"type":"textField","name":"standards.AuthenticationMethods.EmailExcludeGroup","label":"Exclude Group Name (wildcard supported, blank = no exclusions)","required":false,"condition":{"field":"standards.AuthenticationMethods.EmailEnabled","compareType":"valueEq","compareValue":true}} + {"type":"autoComplete","multiple":false,"creatable":false,"required":false,"name":"standards.AuthenticationMethods.x509CertificateEnabled","label":"Certificate-Based Authentication","helperText":"Not Configured or blank leaves the tenant's current setting untouched.","options":[{"label":"Enabled","value":true},{"label":"Disabled","value":false},{"label":"Not Configured","value":"notConfigured"}]} + {"type":"textField","name":"standards.AuthenticationMethods.x509CertificateGroup","label":"Target Group Name (wildcard supported, blank = All Users)","required":false,"condition":{"field":"standards.AuthenticationMethods.x509CertificateEnabled","compareType":"valueEq","compareValue":true}} + {"type":"autoComplete","multiple":false,"creatable":false,"required":false,"name":"standards.AuthenticationMethods.QRCodePinEnabled","label":"QR Code Pin","helperText":"Not Configured or blank leaves the tenant's current setting untouched.","options":[{"label":"Enabled","value":true},{"label":"Disabled","value":false},{"label":"Not Configured","value":"notConfigured"}]} + {"type":"textField","name":"standards.AuthenticationMethods.QRCodePinGroup","label":"Target Group Name (wildcard supported, blank = All Users)","required":false,"condition":{"field":"standards.AuthenticationMethods.QRCodePinEnabled","compareType":"valueEq","compareValue":true}} + {"type":"number","name":"standards.AuthenticationMethods.QRCodeLifetimeInDays","label":"QR Code Lifetime (days, 1-395)","defaultValue":365,"condition":{"field":"standards.AuthenticationMethods.QRCodePinEnabled","compareType":"valueEq","compareValue":true}} + {"type":"number","name":"standards.AuthenticationMethods.QRCodePinLength","label":"QR Code PIN Length (8-20)","defaultValue":8,"condition":{"field":"standards.AuthenticationMethods.QRCodePinEnabled","compareType":"valueEq","compareValue":true}} IMPACT High Impact ADDEDDATE @@ -83,11 +83,18 @@ function Invoke-CIPPStandardAuthenticationMethods { @{ Id = 'QRCodePin'; RemediationId = 'QRCodePin'; SettingKey = 'QRCodePin'; Label = 'QR Code Pin' } ) - # Determine which methods the user has explicitly configured + # Determine which methods the user has explicitly configured. The Enabled fields were + # switches (raw booleans) and are now autoCompletes ({label, value} wrappers) offering + # Enabled/Disabled/Not Configured - accept both shapes so existing templates keep their + # behaviour. Anything unrecognised (blank, 'notConfigured') means the method is not + # managed by this standard and the tenant's current configuration is left untouched. $ConfiguredMethods = foreach ($Method in $AuthMethods) { $EnabledKey = "$($Method.SettingKey)Enabled" - $EnabledValue = $Settings.$EnabledKey - if ($null -eq $EnabledValue) { continue } + $EnabledValue = $Settings.$EnabledKey.value ?? $Settings.$EnabledKey + $EnabledState = if ("$EnabledValue" -eq 'True' -or "$EnabledValue" -eq 'enabled') { $true } + elseif ("$EnabledValue" -eq 'False' -or "$EnabledValue" -eq 'disabled') { $false } + else { $null } + if ($null -eq $EnabledState) { continue } $GroupName = $Settings."$($Method.SettingKey)Group" $ExcludeGroupName = $Settings."$($Method.SettingKey)ExcludeGroup" [PSCustomObject]@{ @@ -95,7 +102,7 @@ function Invoke-CIPPStandardAuthenticationMethods { RemediationId = $Method.RemediationId Key = $Method.SettingKey Label = $Method.Label - Enabled = [bool]$EnabledValue + Enabled = $EnabledState GroupName = if ([string]::IsNullOrWhiteSpace($GroupName)) { $null } else { $GroupName } ExcludeGroupName = if ([string]::IsNullOrWhiteSpace($ExcludeGroupName)) { $null } else { $ExcludeGroupName } } diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 index 9f40cdbce9b6e..79a7326274e46 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 @@ -121,8 +121,11 @@ function Invoke-CIPPStandardConditionalAccessTemplate { $Policy = if ($JSONObj) { $JSONObj | ConvertFrom-Json -Depth 100 } else { $null } if ($null -eq $Policy) { - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Conditional Access template '$($Settings.TemplateList.label)' ($($Settings.TemplateList.value)) could not be loaded from the template store - skipping." -Sev 'Error' - Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = "Template '$($Settings.TemplateList.label)' could not be loaded from the template store." } -ExpectedValue @{ Differences = @() } -Tenant $Tenant + # Same wording as Invoke-CIPPCATemplateBatch, so the report row says which template is + # gone and what to do about it instead of a bare "could not be loaded". + $MissingText = "Template '$($Settings.TemplateList.label)' ($($Settings.TemplateList.value)) no longer exists in the template library. Remove it from the standards template or select the template again." + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Conditional Access template '$($Settings.TemplateList.label)' ($($Settings.TemplateList.value)) could not be loaded from the template store - skipping. $MissingText" -Sev 'Error' + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = $MissingText } -ExpectedValue @{ Differences = @() } -Tenant $Tenant return } diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDefaultPlatformRestrictions.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDefaultPlatformRestrictions.ps1 index 19218bfbaff55..2d3c9052d4855 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDefaultPlatformRestrictions.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDefaultPlatformRestrictions.ps1 @@ -7,8 +7,8 @@ function Invoke-CIPPStandardDefaultPlatformRestrictions { .SYNOPSIS (Label) Device enrollment restrictions .DESCRIPTION - (Helptext) Sets the default platform restrictions for enrolling devices into Intune. Note: Do not block personally owned if platform is blocked. - (DocsDescription) Sets the default platform restrictions for enrolling devices into Intune. Note: Do not block personally owned if platform is blocked. + (Helptext) Sets the default platform restrictions for enrolling devices into Intune, including optional minimum and maximum OS version limits per platform (Android Enterprise, Android, iOS/iPadOS and Windows). Note: Do not block personally owned if platform is blocked. + (DocsDescription) Sets the default platform restrictions for enrolling devices into Intune, including optional minimum and maximum OS version limits per platform (Android Enterprise, Android, iOS/iPadOS and Windows). Note: Do not block personally owned if platform is blocked. .NOTES CAT Intune Standards @@ -19,14 +19,22 @@ function Invoke-CIPPStandardDefaultPlatformRestrictions { ADDEDCOMPONENT {"type":"switch","name":"standards.DefaultPlatformRestrictions.platformAndroidForWorkBlocked","label":"Block platform Android Enterprise (work profile)","default":false} {"type":"switch","name":"standards.DefaultPlatformRestrictions.personalAndroidForWorkBlocked","label":"Block personally owned Android Enterprise (work profile)","default":false} + {"type":"textField","name":"standards.DefaultPlatformRestrictions.osMinimumVersionAndroidForWork","label":"Android Enterprise (work profile) minimum OS version","helperText":"Example: 11.0. Leave blank to not enforce a minimum.","required":false} + {"type":"textField","name":"standards.DefaultPlatformRestrictions.osMaximumVersionAndroidForWork","label":"Android Enterprise (work profile) maximum OS version","helperText":"Example: 14.0. Leave blank to not enforce a maximum.","required":false} {"type":"switch","name":"standards.DefaultPlatformRestrictions.platformAndroidBlocked","label":"Block platform Android","default":false} {"type":"switch","name":"standards.DefaultPlatformRestrictions.personalAndroidBlocked","label":"Block personally owned Android","default":false} + {"type":"textField","name":"standards.DefaultPlatformRestrictions.osMinimumVersionAndroid","label":"Android minimum OS version","helperText":"Example: 10.0. Leave blank to not enforce a minimum.","required":false} + {"type":"textField","name":"standards.DefaultPlatformRestrictions.osMaximumVersionAndroid","label":"Android maximum OS version","helperText":"Example: 13.0. Leave blank to not enforce a maximum.","required":false} {"type":"switch","name":"standards.DefaultPlatformRestrictions.platformiOSBlocked","label":"Block platform iOS","default":false} {"type":"switch","name":"standards.DefaultPlatformRestrictions.personaliOSBlocked","label":"Block personally owned iOS","default":false} + {"type":"textField","name":"standards.DefaultPlatformRestrictions.osMinimumVersioniOS","label":"iOS/iPadOS minimum OS version","helperText":"Example: 16.1. Leave blank to not enforce a minimum.","required":false} + {"type":"textField","name":"standards.DefaultPlatformRestrictions.osMaximumVersioniOS","label":"iOS/iPadOS maximum OS version","helperText":"Example: 18.0. Leave blank to not enforce a maximum.","required":false} {"type":"switch","name":"standards.DefaultPlatformRestrictions.platformMacOSBlocked","label":"Block platform macOS","default":false} {"type":"switch","name":"standards.DefaultPlatformRestrictions.personalMacOSBlocked","label":"Block personally owned macOS","default":false} {"type":"switch","name":"standards.DefaultPlatformRestrictions.platformWindowsBlocked","label":"Block platform Windows","default":false} {"type":"switch","name":"standards.DefaultPlatformRestrictions.personalWindowsBlocked","label":"Block personally owned Windows","default":false} + {"type":"textField","name":"standards.DefaultPlatformRestrictions.osMinimumVersionWindows","label":"Windows minimum OS version","helperText":"Example: 10.0.19045.0. Leave blank to not enforce a minimum.","required":false} + {"type":"textField","name":"standards.DefaultPlatformRestrictions.osMaximumVersionWindows","label":"Windows maximum OS version","helperText":"Example: 10.0.22631.0. Leave blank to not enforce a maximum.","required":false} IMPACT Low Impact ADDEDDATE @@ -87,6 +95,22 @@ function Invoke-CIPPStandardDefaultPlatformRestrictions { personalWindowsBlocked = [bool]$Settings.personalWindowsBlocked } + # Optional minimum/maximum OS version per platform. macOS is intentionally absent - the Intune + # enrollment restriction for macOS carries no version limit. Each is enforced ONLY when the + # operator supplied it: a blank field means 'no opinion', so it is left out of the compare, + # the report and the remediation body. osMinimumVersion/osMaximumVersion are free-form strings + # on Graph, compared as strings. + $VersionMap = @( + @{ Setting = 'osMinimumVersionAndroidForWork'; Restriction = 'androidForWorkRestriction'; Property = 'osMinimumVersion' } + @{ Setting = 'osMaximumVersionAndroidForWork'; Restriction = 'androidForWorkRestriction'; Property = 'osMaximumVersion' } + @{ Setting = 'osMinimumVersionAndroid'; Restriction = 'androidRestriction'; Property = 'osMinimumVersion' } + @{ Setting = 'osMaximumVersionAndroid'; Restriction = 'androidRestriction'; Property = 'osMaximumVersion' } + @{ Setting = 'osMinimumVersioniOS'; Restriction = 'iosRestriction'; Property = 'osMinimumVersion' } + @{ Setting = 'osMaximumVersioniOS'; Restriction = 'iosRestriction'; Property = 'osMaximumVersion' } + @{ Setting = 'osMinimumVersionWindows'; Restriction = 'windowsRestriction'; Property = 'osMinimumVersion' } + @{ Setting = 'osMaximumVersionWindows'; Restriction = 'windowsRestriction'; Property = 'osMaximumVersion' } + ) + $StateIsCorrect = ($CurrentState.androidForWorkRestriction.platformBlocked -eq $DesiredState.platformAndroidForWorkBlocked) -and ($CurrentState.androidForWorkRestriction.personalDeviceEnrollmentBlocked -eq $DesiredState.personalAndroidForWorkBlocked) -and ($CurrentState.androidRestriction.platformBlocked -eq $DesiredState.platformAndroidBlocked) -and @@ -111,46 +135,65 @@ function Invoke-CIPPStandardDefaultPlatformRestrictions { personalWindowsBlocked = $CurrentState.windowsRestriction.personalDeviceEnrollmentBlocked } + # Fold in the configured version limits: grade only the fields the operator set, and surface + # both the desired and current value on the compare/report objects so a version drift is visible. + foreach ($Check in $VersionMap) { + $DesiredVersion = "$($Settings.($Check.Setting))" + if ([string]::IsNullOrWhiteSpace($DesiredVersion)) { continue } + $CurrentVersion = "$($CurrentState.($Check.Restriction).($Check.Property))" + $DesiredState | Add-Member -NotePropertyName $Check.Setting -NotePropertyValue $DesiredVersion -Force + $CompareField | Add-Member -NotePropertyName $Check.Setting -NotePropertyValue $CurrentVersion -Force + if ($CurrentVersion -ne $DesiredVersion) { $StateIsCorrect = $false } + } + $ExpectedValue = $DesiredState if ($Settings.remediate -eq $true) { if ($StateIsCorrect -eq $true) { Write-LogMessage -API 'Standards' -Tenant $Tenant -Message 'DefaultPlatformRestrictions is already applied correctly.' -Sev Info } else { + $RemediationBody = [PSCustomObject]@{ + '@odata.type' = '#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration' + androidForWorkRestriction = [PSCustomObject]@{ + '@odata.type' = 'microsoft.graph.deviceEnrollmentPlatformRestriction' + platformBlocked = $DesiredState.platformAndroidForWorkBlocked + personalDeviceEnrollmentBlocked = $DesiredState.personalAndroidForWorkBlocked + } + androidRestriction = [PSCustomObject]@{ + '@odata.type' = 'microsoft.graph.deviceEnrollmentPlatformRestriction' + platformBlocked = $DesiredState.platformAndroidBlocked + personalDeviceEnrollmentBlocked = $DesiredState.personalAndroidBlocked + } + iosRestriction = [PSCustomObject]@{ + '@odata.type' = 'microsoft.graph.deviceEnrollmentPlatformRestriction' + platformBlocked = $DesiredState.platformiOSBlocked + personalDeviceEnrollmentBlocked = $DesiredState.personaliOSBlocked + } + macOSRestriction = [PSCustomObject]@{ + '@odata.type' = 'microsoft.graph.deviceEnrollmentPlatformRestriction' + platformBlocked = $DesiredState.platformMacOSBlocked + personalDeviceEnrollmentBlocked = $DesiredState.personalMacOSBlocked + } + windowsRestriction = [PSCustomObject]@{ + '@odata.type' = 'microsoft.graph.deviceEnrollmentPlatformRestriction' + platformBlocked = $DesiredState.platformWindowsBlocked + personalDeviceEnrollmentBlocked = $DesiredState.personalWindowsBlocked + } + } + # Only write a version limit the operator set; a blank field is left off the payload + # so an unconfigured platform keeps whatever version limit it already has. + foreach ($Check in $VersionMap) { + $DesiredVersion = "$($Settings.($Check.Setting))" + if ([string]::IsNullOrWhiteSpace($DesiredVersion)) { continue } + $RemediationBody.($Check.Restriction) | Add-Member -NotePropertyName $Check.Property -NotePropertyValue $DesiredVersion -Force + } $cmdParam = @{ tenantid = $Tenant uri = "https://graph.microsoft.com/beta/deviceManagement/deviceEnrollmentConfigurations/$($CurrentState.id)" AsApp = $false Type = 'PATCH' ContentType = 'application/json; charset=utf-8' - Body = [PSCustomObject]@{ - '@odata.type' = '#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration' - androidForWorkRestriction = [PSCustomObject]@{ - '@odata.type' = 'microsoft.graph.deviceEnrollmentPlatformRestriction' - platformBlocked = $DesiredState.platformAndroidForWorkBlocked - personalDeviceEnrollmentBlocked = $DesiredState.personalAndroidForWorkBlocked - } - androidRestriction = [PSCustomObject]@{ - '@odata.type' = 'microsoft.graph.deviceEnrollmentPlatformRestriction' - platformBlocked = $DesiredState.platformAndroidBlocked - personalDeviceEnrollmentBlocked = $DesiredState.personalAndroidBlocked - } - iosRestriction = [PSCustomObject]@{ - '@odata.type' = 'microsoft.graph.deviceEnrollmentPlatformRestriction' - platformBlocked = $DesiredState.platformiOSBlocked - personalDeviceEnrollmentBlocked = $DesiredState.personaliOSBlocked - } - macOSRestriction = [PSCustomObject]@{ - '@odata.type' = 'microsoft.graph.deviceEnrollmentPlatformRestriction' - platformBlocked = $DesiredState.platformMacOSBlocked - personalDeviceEnrollmentBlocked = $DesiredState.personalMacOSBlocked - } - windowsRestriction = [PSCustomObject]@{ - '@odata.type' = 'microsoft.graph.deviceEnrollmentPlatformRestriction' - platformBlocked = $DesiredState.platformWindowsBlocked - personalDeviceEnrollmentBlocked = $DesiredState.personalWindowsBlocked - } - } | ConvertTo-Json -Compress -Depth 10 + Body = $RemediationBody | ConvertTo-Json -Compress -Depth 10 } try { $null = New-GraphPostRequest @cmdParam diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDeployContactTemplates.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDeployContactTemplates.ps1 index 75154d84641ee..0fa614dc1e356 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDeployContactTemplates.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDeployContactTemplates.ps1 @@ -153,7 +153,7 @@ function Invoke-CIPPStandardDeployContactTemplates { @{ Template = 'jobTitle'; Current = $ExtendedContact.Title } @{ Template = 'city'; Current = $ExtendedContact.City } @{ Template = 'postalCode'; Current = $ExtendedContact.PostalCode } - @{ Template = 'country'; Current = $ExtendedContact.CountryOrRegion } + @{ Template = 'country'; Current = $ExtendedContact.CountryOrRegion; IsCountry = $true } @{ Template = 'mobilePhone'; Current = $ExtendedContact.MobilePhone } ) @@ -172,9 +172,13 @@ function Invoke-CIPPStandardDeployContactTemplates { # Only compare if template specifies a value; empty template fields are not enforced. if ([string]::IsNullOrWhiteSpace($TemplateValue)) { continue } + # country: the template stores an ISO code ('US') but Exchange returns the + # full name ('United States'), so normalise both to a code before comparing. # Case-insensitive compare for email; exact for everything else. $IsEmail = $Field.Template -eq 'email' - $Mismatch = if ($IsEmail) { + $Mismatch = if ($Field.IsCountry) { + [string]::IsNullOrWhiteSpace($CurrentValue) -or (ConvertTo-CIPPCountryCode $TemplateValue) -ne (ConvertTo-CIPPCountryCode $CurrentValue) + } elseif ($IsEmail) { [string]::IsNullOrWhiteSpace($CurrentValue) -or -not $TemplateValue.Equals($CurrentValue, [System.StringComparison]::OrdinalIgnoreCase) } else { [string]::IsNullOrWhiteSpace($CurrentValue) -or $TemplateValue -ne $CurrentValue diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableGuests.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableGuests.ps1 index beb979a11cadf..3f7068abcda74 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableGuests.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableGuests.ps1 @@ -7,8 +7,8 @@ function Invoke-CIPPStandardDisableGuests { .SYNOPSIS (Label) Disable Guest accounts that have not logged on for a number of days .DESCRIPTION - (Helptext) Blocks login for guest users that have not logged in for a number of days - (DocsDescription) Blocks login for guest users that have not logged in for a number of days + (Helptext) Blocks login for guest users whose most recent sign-in attempt, interactive or non-interactive, is older than the number of days. Guests that have never signed in are only included when 'Disable accounts that have not yet signed in' is enabled. Accounts an administrator re-enabled in the last 7 days are left alone. + (DocsDescription) Blocks login for guest users whose most recent sign-in attempt, interactive or non-interactive, is older than the number of days. Guests that have never signed in are only included when 'Disable accounts that have not yet signed in' is enabled. Accounts an administrator re-enabled in the last 7 days are left alone. .NOTES CAT Entra (AAD) Standards @@ -18,6 +18,7 @@ function Invoke-CIPPStandardDisableGuests { Automatically disables external guest accounts that haven't been used for a number of days, reducing security risks from dormant accounts while maintaining access for active external collaborators. This helps maintain a clean user directory and reduces potential attack vectors. ADDEDCOMPONENT {"type":"number","name":"standards.DisableGuests.days","required":true,"defaultValue":90,"label":"Days of inactivity"} + {"type":"switch","name":"standards.DisableGuests.IncludeNeverSignedIn","label":"Disable accounts that have not yet signed in","defaultValue":false} IMPACT Medium Impact ADDEDDATE @@ -48,6 +49,8 @@ function Invoke-CIPPStandardDisableGuests { } #we're done. $checkDays = if ($Settings.days) { $Settings.days } else { 90 } # Default to 90 days if not set. Pre v8.5.0 compatibility + # Off unless the template turns it on, so templates that predate the switch keep skipping guests with no sign-in on record. + $IncludeNeverSignedIn = $Settings.IncludeNeverSignedIn -eq $true $Days = (Get-Date).AddDays(-$checkDays).ToUniversalTime() $Lookup = $Days.ToString('o') $AuditLookup = (Get-Date).AddDays(-7).ToUniversalTime().ToString('o') @@ -55,18 +58,29 @@ function Invoke-CIPPStandardDisableGuests { try { $GraphRequest = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users?`$filter=createdDateTime le $Lookup and userType eq 'Guest' and accountEnabled eq true &`$select=id,UserPrincipalName,signInActivity,mail,userType,accountEnabled,createdDateTime,externalUserState" -scope 'https://graph.microsoft.com/.default' -tenantid $Tenant - $EnrichedGuests = foreach ($guest in $GraphRequest) { - if ($guest.signInActivity -and $guest.signInActivity.lastSuccessfulSignInDateTime) { - $lastSignIn = [datetime]$guest.signInActivity.lastSuccessfulSignInDateTime - if ($lastSignIn.ToUniversalTime() -le $Days) { + $StaleGuests = foreach ($guest in $GraphRequest) { + # Newest of the interactive, non-interactive and successful sign-in timestamps - the view the + # Entra portal and the inactive-guest alert give - rather than successful sign-ins alone, which + # stay old while a blocked or disabled guest keeps trying. + $LastSignIn = Get-CIPPLastSignInDateTime -SignInActivity $guest.signInActivity + if ($LastSignIn) { + if ($LastSignIn -le $Days) { + $guest | Add-Member -NotePropertyMembers ([ordered]@{ + LastSignInDateTime = $LastSignIn + NeverSignedIn = $false + }) -Force $guest } - } elseif ($guest.externalUserState -eq 'PendingAcceptance') { - # Never accepted the invite; createdDateTime is already <= $Days due to the server-side filter + } elseif ($IncludeNeverSignedIn) { + # No sign-in attempt on record; createdDateTime is already <= $Days due to the server-side filter + $guest | Add-Member -NotePropertyMembers ([ordered]@{ + LastSignInDateTime = $null + NeverSignedIn = $true + }) -Force $guest } } - $GraphRequest = @($EnrichedGuests) + $GraphRequest = @($StaleGuests) } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the DisableGuests state for $Tenant. Error: $ErrorMessage" -Sev Error @@ -76,7 +90,7 @@ function Invoke-CIPPStandardDisableGuests { $AuditResults = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/auditLogs/directoryAudits?`$filter=activityDisplayName eq 'Enable account' and activityDateTime ge $AuditLookup&`$select=targetResources" -scope 'https://graph.microsoft.com/.default' -tenantid $Tenant $RecentlyReactivatedUsers = @(foreach ($AuditEntry in $AuditResults) { $AuditEntry.targetResources[0].id }) | Select-Object -Unique - $GraphRequest = $GraphRequest | Where-Object { -not ($RecentlyReactivatedUsers -contains $_.id) } + $GraphRequest = @($GraphRequest | Where-Object { -not ($RecentlyReactivatedUsers -contains $_.id) }) if ($Settings.remediate -eq $true) { if ($GraphRequest.Count -gt 0) { @@ -100,17 +114,12 @@ function Invoke-CIPPStandardDisableGuests { $result = $BulkResults[$i] $guest = $GraphRequest[$i] - $lastSignIn = $guest.signInActivity?.lastSuccessfulSignInDateTime - if (-not $lastSignIn -and $guest.EnrichedLastSignInDateTime) { - $lastSignIn = $guest.EnrichedLastSignInDateTime - } - if ($result.status -eq 200 -or $result.status -eq 204) { $guest.accountEnabled = $false - $reason = if ($guest.externalUserState -eq 'PendingAcceptance') { - "unredeemed invite created $($guest.createdDateTime)" + $reason = if ($guest.NeverSignedIn) { + "never signed in, created $($guest.createdDateTime)" } else { - "last sign-in: $lastSignIn" + "last sign-in: $($guest.LastSignInDateTime.ToString('o'))" } Write-LogMessage -API 'Standards' -tenant $tenant -message "Disabled guest $($guest.UserPrincipalName) ($($guest.id)). Reason: $reason" -sev Info } else { @@ -123,16 +132,16 @@ function Invoke-CIPPStandardDisableGuests { Write-LogMessage -API 'Standards' -tenant $tenant -message "Failed to process bulk disable guests request: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } else { - Write-LogMessage -API 'Standards' -tenant $tenant -message "No guests accounts with a login longer than $checkDays days ago - all guest accounts are already compliant." -sev Info + Write-LogMessage -API 'Standards' -tenant $tenant -message "No guest accounts without a sign-in in the last $checkDays days - all guest accounts are already compliant." -sev Info } } if ($Settings.alert -eq $true) { if ($GraphRequest.Count -gt 0) { - $Filtered = $GraphRequest | Select-Object -Property UserPrincipalName, id, signInActivity, mail, userType, accountEnabled, externalUserState, createdDateTime - $PendingCount = @($Filtered | Where-Object { $_.externalUserState -eq 'PendingAcceptance' }).Count - $StaleCount = $Filtered.Count - $PendingCount - $AlertMessage = "Stale guest accounts found: $($GraphRequest.Count) total ($StaleCount inactive >$checkDays days, $PendingCount unredeemed invites >$checkDays days old)" + $Filtered = @($GraphRequest | Select-Object -Property UserPrincipalName, id, signInActivity, LastSignInDateTime, NeverSignedIn, mail, userType, accountEnabled, externalUserState, createdDateTime) + $NeverSignedInCount = @($Filtered | Where-Object { $_.NeverSignedIn }).Count + $StaleCount = $Filtered.Count - $NeverSignedInCount + $AlertMessage = "Stale guest accounts found: $($GraphRequest.Count) total ($StaleCount with no sign-in attempt in $checkDays days, $NeverSignedInCount never signed in and created more than $checkDays days ago)" Write-StandardsAlert -message $AlertMessage -object $Filtered -tenant $tenant -standardName 'DisableGuests' -standardId $Settings.standardId Write-LogMessage -API 'Standards' -tenant $tenant -message $AlertMessage -sev Info } else { @@ -140,26 +149,28 @@ function Invoke-CIPPStandardDisableGuests { } } if ($Settings.report -eq $true) { - $Filtered = $GraphRequest | Where-Object { $_.accountEnabled } | Select-Object -Property UserPrincipalName, id, signInActivity, EnrichedLastSignInDateTime, mail, userType, accountEnabled, externalUserState, createdDateTime - $PendingInvites = @($Filtered | Where-Object { $_.externalUserState -eq 'PendingAcceptance' }) - $StaleSignIns = @($Filtered | Where-Object { $_.externalUserState -ne 'PendingAcceptance' }) + $Filtered = @($GraphRequest | Where-Object { $_.accountEnabled } | Select-Object -Property UserPrincipalName, id, signInActivity, LastSignInDateTime, NeverSignedIn, mail, userType, accountEnabled, externalUserState, createdDateTime) + $NeverSignedIn = @($Filtered | Where-Object { $_.NeverSignedIn }) + $StaleSignIns = @($Filtered | Where-Object { -not $_.NeverSignedIn }) $CurrentValue = [PSCustomObject]@{ - GuestsDisabledAfterDays = $checkDays - GuestsDisabledAccountCount = $Filtered.Count - GuestsStaleSignInCount = $StaleSignIns.Count - GuestsPendingAcceptanceCount = $PendingInvites.Count - GuestsDisabledAccountDetails = @($Filtered) - GuestsPendingAcceptanceDetails = $PendingInvites + GuestsDisabledAfterDays = $checkDays + GuestsIncludeNeverSignedIn = $IncludeNeverSignedIn + GuestsDisabledAccountCount = $Filtered.Count + GuestsStaleSignInCount = $StaleSignIns.Count + GuestsNeverSignedInCount = $NeverSignedIn.Count + GuestsDisabledAccountDetails = $Filtered + GuestsNeverSignedInDetails = $NeverSignedIn } $ExpectedValue = [PSCustomObject]@{ - GuestsDisabledAfterDays = $checkDays - GuestsDisabledAccountCount = 0 - GuestsStaleSignInCount = 0 - GuestsPendingAcceptanceCount = 0 - GuestsDisabledAccountDetails = @() - GuestsPendingAcceptanceDetails = @() + GuestsDisabledAfterDays = $checkDays + GuestsIncludeNeverSignedIn = $IncludeNeverSignedIn + GuestsDisabledAccountCount = 0 + GuestsStaleSignInCount = 0 + GuestsNeverSignedInCount = 0 + GuestsDisabledAccountDetails = @() + GuestsNeverSignedInDetails = @() } Set-CIPPStandardsCompareField -FieldName 'standards.DisableGuests' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -TenantFilter $Tenant diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableInactiveUsers.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableInactiveUsers.ps1 index 2bc0aec72ac66..19a1e9fd792c2 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableInactiveUsers.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableInactiveUsers.ps1 @@ -68,14 +68,18 @@ function Invoke-CIPPStandardDisableInactiveUsers { if ($user.signInActivity.lastSuccessfulSignInDateTime) { $lastSignIn = [datetime]$user.signInActivity.lastSuccessfulSignInDateTime if ($lastSignIn.ToUniversalTime() -le $Days) { - $user | Add-Member -NotePropertyName 'EnrichedLastSignInDateTime' -NotePropertyValue $user.signInActivity.lastSuccessfulSignInDateTime -Force - $user | Add-Member -NotePropertyName 'NeverSignedIn' -NotePropertyValue $false -Force + $user | Add-Member -NotePropertyMembers ([ordered]@{ + EnrichedLastSignInDateTime = $user.signInActivity.lastSuccessfulSignInDateTime + NeverSignedIn = $false + }) -Force $user } } else { # signInActivity present but no successful sign-in; createdDateTime already <= $Days via server-side filter - $user | Add-Member -NotePropertyName 'EnrichedLastSignInDateTime' -NotePropertyValue $null -Force - $user | Add-Member -NotePropertyName 'NeverSignedIn' -NotePropertyValue $true -Force + $user | Add-Member -NotePropertyMembers ([ordered]@{ + EnrichedLastSignInDateTime = $null + NeverSignedIn = $true + }) -Force $user } } diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableSharedMailbox.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableSharedMailbox.ps1 index 1e2f629a31e50..2eb5ae184ff72 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableSharedMailbox.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableSharedMailbox.ps1 @@ -36,16 +36,35 @@ function Invoke-CIPPStandardDisableSharedMailbox { param($Tenant, $Settings) + # Separate catches so a cold user cache and an Exchange failure read differently. try { - $AllUsers = New-CIPPDbRequest -TenantFilter $Tenant -Type 'Users' - $UserList = $AllUsers | Where-Object { - $_.accountEnabled -eq $true -and - $_.onPremisesSyncEnabled -ne $true - } - $SharedMailboxList = (New-GraphGetRequest -uri "https://outlook.office365.com/adminapi/beta/$($Tenant)/Mailbox" -Tenantid $Tenant -scope ExchangeOnline | Where-Object { $_.RecipientTypeDetails -eq 'SharedMailbox' -or $_.RecipientTypeDetails -eq 'SchedulingMailbox' -and $_.UserPrincipalName -in $UserList.UserPrincipalName }) + $AllUsers = @(New-CIPPDbRequest -TenantFilter $Tenant -Type 'Users') } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message - Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the DisableSharedMailbox state for $Tenant. Error: $ErrorMessage" -Sev Error + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the DisableSharedMailbox state for $Tenant, could not read the cached user list. Error: $ErrorMessage" -Sev Error + return + } + + if ($AllUsers.Count -eq 0) { + # No user cache means enabled and disabled look alike; reporting nothing beats reporting everything. + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the DisableSharedMailbox state for $Tenant, the cached user list is empty. Run a user data collection for this tenant first." -Sev Warning + return + } + + $UserList = $AllUsers | Where-Object { + $_.accountEnabled -eq $true -and + $_.onPremisesSyncEnabled -ne $true + } + + try { + # (A -or B) -and C: same meaning as before, parentheses for readability. + $SharedMailboxList = @(New-GraphGetRequest -uri "https://outlook.office365.com/adminapi/beta/$($Tenant)/Mailbox" -Tenantid $Tenant -scope ExchangeOnline | Where-Object { + ($_.RecipientTypeDetails -eq 'SharedMailbox' -or $_.RecipientTypeDetails -eq 'SchedulingMailbox') -and + $_.UserPrincipalName -in $UserList.UserPrincipalName + }) + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the DisableSharedMailbox state for $Tenant, could not list mailboxes from Exchange. Error: $ErrorMessage" -Sev Error return } @@ -65,6 +84,7 @@ function Invoke-CIPPStandardDisableSharedMailbox { } } + $DisabledKeys = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) try { $BulkResults = New-GraphBulkRequest -tenantid $Tenant -Requests @($BulkRequests) @@ -74,12 +94,16 @@ function Invoke-CIPPStandardDisableSharedMailbox { if ($result.status -eq 200 -or $result.status -eq 204) { Write-LogMessage -API 'Standards' -tenant $Tenant -message "Entra account for shared mailbox $($Mailbox.DisplayName) ($($Mailbox.ObjectKey)) disabled." -sev Info + $null = $DisabledKeys.Add("$($Mailbox.ObjectKey)") $UpdateDB = $true } else { $errorMsg = if ($result.body.error.message) { $result.body.error.message } else { "Unknown error (Status: $($result.status))" } Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to disable Entra account for shared mailbox $($Mailbox.DisplayName) ($($Mailbox.ObjectKey)): $errorMsg" -sev Error } } + + # Report what is left after remediation, not what was found. + $SharedMailboxList = @($SharedMailboxList | Where-Object { -not $DisabledKeys.Contains("$($_.ObjectKey)") }) } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to process bulk disable shared mailboxes request: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEnableMailboxAuditing.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEnableMailboxAuditing.ps1 index 9f34f19c89e07..3d0f4dade4237 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEnableMailboxAuditing.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEnableMailboxAuditing.ps1 @@ -66,73 +66,51 @@ function Invoke-CIPPStandardEnableMailboxAuditing { try { New-ExoRequest -tenantid $Tenant -cmdlet 'Set-OrganizationConfig' -cmdParams @{AuditDisabled = $false } -useSystemMailbox $true Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Tenant level mailbox audit enabled' -sev Info - $LogMessage = 'Tenant level mailbox audit enabled. ' } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to enable tenant level mailbox audit. Error: $ErrorMessage" -sev Error } } else { - $LogMessage = 'Tenant level mailbox audit already enabled. ' + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Tenant level mailbox audit already enabled' -sev Info } - # Commented out because MS recommends NOT doing this anymore. From docs: https://learn.microsoft.com/en-us/purview/audit-mailboxes#verify-mailbox-auditing-on-by-default-is-turned-on - # When you turn on mailbox auditing on by default for the organization, the AuditEnabled property for affected mailboxes doesn't change from False to True. In other words, mailbox auditing on by default ignores the AuditEnabled property on mailboxes. - # Auditing is automatically turned on when you create a new mailbox. You don't need to manually enable mailbox auditing for new users. - # You don't need to manage the mailbox actions that are audited. A predefined set of mailbox actions are audited by default for each sign-in type (Admin, Delegate, and Owner). - # When Microsoft releases a new mailbox action, the action might be added automatically to the list of mailbox actions that are audited by default (subject to the user having the appropriate license). This result means you don't need to add new actions on mailboxes as they're released. - # You have a consistent mailbox auditing policy across your organization because you're auditing the same actions for all mailboxes. - #$Mailboxes = New-ExoRequest -tenantid $Tenant -cmdlet 'Get-Mailbox' -cmdParams @{filter = "auditenabled -eq 'False'" } -useSystemMailbox $true -Select 'AuditEnabled,UserPrincipalName' - #$Request = $mailboxes | ForEach-Object { - # @{ - # CmdletInput = @{ - # CmdletName = 'Set-Mailbox' - # Parameters = @{Identity = $_.UserPrincipalName; AuditEnabled = $true } - # } - #} - #} - - #$BatchResults = New-ExoBulkRequest -tenantid $tenant -cmdletArray @($Request) - #$BatchResults | ForEach-Object { - # if ($_.error) { - # $ErrorMessage = Get-NormalizedError -Message $_.error - # Write-Host "Failed to enable user level mailbox audit for $($_.target). Error: $ErrorMessage" - # Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to enable user level mailbox audit for $($_.target). Error: $ErrorMessage" -sev Error - # } - #} - - # Disable audit bypass for all mailboxes that have it enabled + # Per-mailbox AuditEnabled is intentionally not set here. With mailbox auditing on by default + # (AuditDisabled = $false, set above) Microsoft applies the default per-sign-in-type audit + # action sets and Get-Mailbox reports AuditEnabled = True on supported mailboxes, so enabling + # each mailbox individually is redundant. + # https://learn.microsoft.com/en-us/purview/audit-mailboxes + + # Disable audit bypass for any mailbox that has it enabled, so no user is excluded from + # auditing. The bypass flag is not a Get-Mailbox property - it lives on the association. + try { + $BypassMailboxes = @(New-ExoRequest -tenantid $Tenant -cmdlet 'Get-MailboxAuditBypassAssociation' -useSystemMailbox $true | Where-Object { $_.AuditBypassEnabled -eq $true }) + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to retrieve mailbox audit bypass associations. Error: $ErrorMessage" -sev Error + $BypassMailboxes = @() + } - #$BypassMailboxes = New-ExoRequest -tenantid $Tenant -cmdlet 'Get-MailboxAuditBypassAssociation' -select 'GUID, AuditBypassEnabled, Name' -useSystemMailbox $true | Where-Object { $_.AuditBypassEnabled -eq $true } - $Request = foreach ($Mailbox in $BypassMailboxes) { - @{ - CmdletInput = @{ - CmdletName = 'Set-MailboxAuditBypassAssociation' - Parameters = @{Identity = $Mailbox.Guid; AuditBypassEnabled = $false } + if ($BypassMailboxes.Count -gt 0) { + $Request = foreach ($Mailbox in $BypassMailboxes) { + @{ + CmdletInput = @{ + CmdletName = 'Set-MailboxAuditBypassAssociation' + Parameters = @{Identity = $Mailbox.Guid; AuditBypassEnabled = $false } + } } } - } - $BatchResults = New-ExoBulkRequest -tenantid $tenant -cmdletArray @($Request) - foreach ($Result in $BatchResults) { - if ($Result.error) { - $ErrorMessage = Get-NormalizedError -Message $Result.error - Write-LogMessage -API 'Standards' -tenant $tenant -message "Failed to disable mailbox audit bypass for $($Result.target). Error: $ErrorMessage" -sev Error + $BatchResults = New-ExoBulkRequest -tenantid $tenant -cmdletArray @($Request) + foreach ($Result in $BatchResults) { + if ($Result.error) { + $ErrorMessage = Get-NormalizedError -Message $Result.error + Write-LogMessage -API 'Standards' -tenant $tenant -message "Failed to disable mailbox audit bypass for $($Result.target). Error: $ErrorMessage" -sev Error + } } - } - - $LogMessage = if ($Mailboxes.Count -eq 0 -and $BypassMailboxes.Count -eq 0) { - # Make log message smaller if both are already in the desired state - 'User level mailbox audit already enabled and mailbox audit bypass already disabled for all mailboxes' + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Disabled mailbox audit bypass for $($BypassMailboxes.Count) mailbox(es)" -sev Info } else { - if ($Mailboxes.Count -eq 0) { - 'User level mailbox audit already enabled for all mailboxes. ' - } - if ($BypassMailboxes.Count -eq 0) { - 'Mailbox audit bypass already disabled for all mailboxes' - } + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'No mailboxes have audit bypass enabled' -sev Info } - - Write-LogMessage -API 'Standards' -tenant $Tenant -message $LogMessage -sev Info } if ($Settings.alert -eq $true) { diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardExternalComplianceTrusted.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardExternalComplianceTrusted.ps1 new file mode 100644 index 0000000000000..9f3f8d50348e2 --- /dev/null +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardExternalComplianceTrusted.ps1 @@ -0,0 +1,97 @@ +function Invoke-CIPPStandardExternalComplianceTrusted { + <# + .FUNCTIONALITY + Internal + .COMPONENT + (APIName) ExternalComplianceTrusted + .SYNOPSIS + (Label) Sets the Cross-tenant access setting to trust external compliant devices + .DESCRIPTION + (Helptext) Sets the state of the Cross-tenant access setting to trust external compliant devices. This allows guest users to use a compliant device from their home tenant to access your tenant. + (DocsDescription) Sets the state of the Cross-tenant access setting to trust external compliant devices. This allows guest users to use a compliant device from their home tenant to access your tenant. + .NOTES + CAT + Entra (AAD) Standards + ADDEDCOMPONENT + {"type":"autoComplete","multiple":false,"creatable":false,"label":"Select value","name":"standards.ExternalComplianceTrusted.state","options":[{"label":"Enabled","value":"true"},{"label":"Disabled","value":"false"}]} + IMPACT + Low Impact + ADDEDDATE + 2026-08-25 + POWERSHELLEQUIVALENT + Update-MgBetaPolicyCrossTenantAccessPolicyDefault + RECOMMENDEDBY + REQUIREDCAPABILITIES + "AAD_PREMIUM" + "AAD_PREMIUM_P2" + UPDATECOMMENTBLOCK + Run the Tools\Update-StandardsComments.ps1 script to update this comment block + .LINK + https://docs.cipp.app/user-documentation/tenant/standards/alignment/templates/available-standards + #> + + param($Tenant, $Settings) + $TestResult = Test-CIPPStandardLicense -StandardName 'ExternalComplianceTrusted' -TenantFilter $Tenant -Preset Entra + + if ($TestResult -eq $false) { + return $true + } #we're done. + + try { + $ExternalComplianceTrusted = (New-GraphGetRequest -uri 'https://graph.microsoft.com/v1.0/policies/crossTenantAccessPolicy/default?$select=inboundTrust' -tenantid $Tenant) + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the ExternalComplianceTrusted state for $Tenant. Error: $ErrorMessage" -Sev Error + return + } + + # Get state value using null-coalescing operator + $state = $Settings.state.value ?? $Settings.state + $WantedState = if ($state -eq 'true') { $true } else { $false } + $StateMessage = if ($WantedState) { 'enabled' } else { 'disabled' } + + # Input validation + if (([string]::IsNullOrWhiteSpace($state) -or $state -eq 'Select a value') -and ($Settings.remediate -eq $true -or $Settings.alert -eq $true)) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'ExternalComplianceTrusted: Invalid state parameter set' -sev Error + return + } + + if ($Settings.remediate -eq $true) { + if ($ExternalComplianceTrusted.inboundTrust.isCompliantDeviceAccepted -eq $WantedState ) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "External Compliance Trusted is already $StateMessage." -sev Info + } else { + try { + $NewBody = $ExternalComplianceTrusted + $NewBody.inboundTrust.isCompliantDeviceAccepted = $WantedState + $NewBody = ConvertTo-Json -Depth 10 -InputObject $NewBody -Compress + $null = New-GraphPostRequest -tenantid $Tenant -Uri 'https://graph.microsoft.com/v1.0/policies/crossTenantAccessPolicy/default' -Type patch -Body $NewBody -ContentType 'application/json' + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Set External Compliance Trusted to $StateMessage." -sev Info + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to set External Compliance Trusted to $StateMessage. Error: $ErrorMessage" -sev Error + } + } + } + + if ($Settings.report -eq $true) { + $CurrentValue = @{ + isCompliantDeviceAccepted = $ExternalComplianceTrusted.inboundTrust.isCompliantDeviceAccepted + } + $ExpectedValue = @{ + isCompliantDeviceAccepted = $WantedState + } + + Set-CIPPStandardsCompareField -FieldName 'standards.ExternalComplianceTrusted' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -TenantFilter $Tenant + Add-CIPPBPAField -FieldName 'ExternalComplianceTrusted' -FieldValue $ExternalComplianceTrusted.inboundTrust.isCompliantDeviceAccepted -StoreAs bool -Tenant $Tenant + } + + if ($Settings.alert -eq $true) { + + if ($ExternalComplianceTrusted.inboundTrust.isCompliantDeviceAccepted -eq $WantedState) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "External Compliance Trusted is $StateMessage." -sev Info + } else { + Write-StandardsAlert -message "External Compliance Trusted is not $StateMessage" -object $ExternalComplianceTrusted.inboundTrust -tenant $Tenant -standardName 'ExternalComplianceTrusted' -standardId $Settings.standardId + Write-LogMessage -API 'Standards' -tenant $Tenant -message "External Compliance Trusted is not $StateMessage." -sev Info + } + } +} diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardFIDO2PasskeyProfiles.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardFIDO2PasskeyProfiles.ps1 index 583299a88cb46..6604da3920db0 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardFIDO2PasskeyProfiles.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardFIDO2PasskeyProfiles.ps1 @@ -55,6 +55,16 @@ function Invoke-CIPPStandardFIDO2PasskeyProfiles { return } + # An AAGUID allow/block list only takes effect while key restrictions are enforced + # (keyRestrictions.isEnforced = $true). With the 'Enforce AAGUID Key Restrictions' switch left off, + # isEnforced was $false, so AAGUIDs an operator added were stored but never applied - the profile + # kept no active key restriction and any authenticator could still register, which reads as "the + # AAGUIDs did not add to the profile". Supplying AAGUIDs is an implicit request to restrict to them, + # so enable enforcement whenever AAGUIDs are present. (Confirmed live against Graph beta.) + if ($AAGUIDs.Count -gt 0) { + $EnforceKeyRestrictions = $true + } + # Get current FIDO2 configuration try { $CurrentConfig = New-GraphGetRequest -Uri 'https://graph.microsoft.com/beta/policies/authenticationmethodspolicy/authenticationMethodConfigurations/Fido2' -tenantid $Tenant -AsApp $true diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardGroupTemplate.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardGroupTemplate.ps1 index 562bb79e29ee3..9d8be26df013d 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardGroupTemplate.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardGroupTemplate.ps1 @@ -47,7 +47,24 @@ function Invoke-CIPPStandardGroupTemplate { $Table = Get-CippTable -tablename 'templates' $Filter = "PartitionKey eq 'GroupTemplate' and (RowKey eq '$($Settings.TemplateList.value -join "' or RowKey eq '")')" - $GroupTemplates = (Get-CIPPAzDataTableEntity @Table -Filter $Filter).JSON | ConvertFrom-Json + # Resolve %variables% (e.g. %tenantname%) in the template body before any comparison. Groups are + # created through New-GraphPostRequest, which substitutes these tokens, so the tenant's actual + # group ends up named with the resolved value. Comparing the raw token-bearing name against it + # never matched, which recreated the group on every run and left the report permanently + # non-compliant. Replacement runs against the serialized JSON (escaped for that context), exactly + # as Push-CIPPStandard does for the settings. + $TemplateRows = @(Get-CIPPAzDataTableEntity @Table -Filter $Filter) + $GroupTemplates = foreach ($TemplateJSON in $TemplateRows.JSON) { + if ($TemplateJSON -match '%') { + $TemplateJSON = Get-CIPPTextReplacement -TenantFilter $Tenant -Text $TemplateJSON -EscapeForJson + } + $TemplateJSON | ConvertFrom-Json + } + + # Referenced ids may no longer exist (deleted, or recreated by the library sync); report that instead of passing. + $RequestedIds = @(@($Settings.TemplateList.value) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + $ResolvedIds = @(@($TemplateRows.RowKey) + @($GroupTemplates.GUID) | Where-Object { $_ } | Select-Object -Unique) + $MissingIds = @($RequestedIds | Where-Object { $_ -notin $ResolvedIds }) if ('dynamicDistribution' -in $GroupTemplates.groupType) { try { @@ -59,6 +76,10 @@ function Invoke-CIPPStandardGroupTemplate { } } + if ($MissingIds.Count -gt 0) { + Write-LogMessage -API 'Standards' -tenant $tenant -message "Group Template: $($MissingIds.Count) of $($RequestedIds.Count) selected group templates no longer exist (ids: $($MissingIds -join ', ')). Re-select them in the standards template." -sev 'Error' + } + if ($Settings.remediate -eq $true) { #Because the list name changed from TemplateList to groupTemplate by someone :@, we'll need to set it back to TemplateList foreach ($Template in $GroupTemplates) { @@ -127,8 +148,10 @@ function Invoke-CIPPStandardGroupTemplate { # Only update if the template specifies this should be a dynamic group if ($NormalizedGroupType -eq 'Dynamic' -and $groupobj.membershipRules) { if ($CheckExisting.membershipRule -ne $groupobj.membershipRules) { - $PatchBody | Add-Member -NotePropertyName 'membershipRule' -NotePropertyValue $groupobj.membershipRules - $PatchBody | Add-Member -NotePropertyName 'membershipRuleProcessingState' -NotePropertyValue 'On' + $PatchBody | Add-Member -NotePropertyMembers ([ordered]@{ + membershipRule = $groupobj.membershipRules + membershipRuleProcessingState = 'On' + }) $ChangesNeeded.Add("membershipRule: '$($CheckExisting.membershipRule)' → '$($groupobj.membershipRules)'") } } @@ -257,10 +280,12 @@ function Invoke-CIPPStandardGroupTemplate { } $CurrentValue = @{ - MissingGroups = $MissingGroups ? @($MissingGroups) : @() + MissingGroups = $MissingGroups ? @($MissingGroups) : @() + MissingTemplates = @($MissingIds) } $ExpectedValue = @{ - MissingGroups = @() + MissingGroups = @() + MissingTemplates = @() } Set-CIPPStandardsCompareField -FieldName 'standards.GroupTemplate' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -TenantFilter $Tenant diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardIntuneAppTemplateDeploy.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardIntuneAppTemplateDeploy.ps1 index 2e917798fffae..92210a6de1849 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardIntuneAppTemplateDeploy.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardIntuneAppTemplateDeploy.ps1 @@ -16,7 +16,7 @@ function Invoke-CIPPStandardIntuneAppTemplateDeploy { EXECUTIVETEXT Automatically deploys approved Intune applications across all managed tenants, ensuring consistent software availability and reducing manual deployment overhead. Supports WinGet, Office, Chocolatey, Win32, and MSP application types. ADDEDCOMPONENT - {"type":"autoComplete","multiple":true,"creatable":false,"label":"Select Application Templates","name":"standards.IntuneAppTemplateDeploy.templateIds","api":{"url":"/api/ListAppTemplates","labelField":"Displayname","valueField":"GUID","queryKey":"StdIntuneAppTemplateList"}} + {"type":"autoComplete","multiple":true,"creatable":false,"label":"Select Application Templates","name":"standards.IntuneAppTemplateDeploy.templateIds","api":{"url":"/api/ListAppTemplates","labelField":"displayName","valueField":"GUID","queryKey":"StdIntuneAppTemplateList","templateView":{"title":"Application Template"}}} IMPACT Medium Impact ADDEDDATE @@ -52,9 +52,10 @@ function Invoke-CIPPStandardIntuneAppTemplateDeploy { $Table = Get-CIPPTable -TableName 'templates' $MissingApps = [System.Collections.Generic.List[PSCustomObject]]::new() $CurrentAppNames = @($CurrentApps.displayName) - # Office is a singleton per tenant that Graph always names 'Microsoft 365 Apps for Windows 10 - # and later', which never matches the name the template stores, so track it by type instead. + # Office and Edge are singletons per tenant whose Graph display name may differ from the + # template, so track them by type instead. $OfficeDeployed = @($CurrentApps | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.officeSuiteApp' }).Count -gt 0 + $EdgeDeployed = @($CurrentApps | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.windowsMicrosoftEdgeApp' }).Count -gt 0 foreach ($TemplateId in $TemplateIds) { $Entity = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'AppTemplate' and RowKey eq '$TemplateId'" @@ -75,7 +76,11 @@ function Invoke-CIPPStandardIntuneAppTemplateDeploy { $AppType = [string]$AppTypes[$i] $DisplayName = [string]($Config.ApplicationName ?? $Config.displayName ?? $AppNames[$i]) - $IsDeployed = if ($AppType -eq 'officeApp') { $OfficeDeployed } else { $DisplayName -in $CurrentAppNames } + $IsDeployed = switch ($AppType) { + 'officeApp' { $OfficeDeployed } + 'edgeApp' { $EdgeDeployed } + default { $DisplayName -in $CurrentAppNames } + } if (-not $IsDeployed) { $MissingApps.Add([PSCustomObject]@{ @@ -109,17 +114,20 @@ function Invoke-CIPPStandardIntuneAppTemplateDeploy { 'win32ScriptApp' { 'Win32ScriptApp' } 'mspApp' { 'MSPApp' } 'officeApp' { 'OfficeApp' } + 'edgeApp' { 'EdgeApp' } default { $App.AppType } } # Build AppConfig in the same format as the apps queue # Assignment info comes from the template's per-app config $DeployConfig = $App.Config | ConvertTo-Json -Depth 100 | ConvertFrom-Json -Depth 100 - $DeployConfig | Add-Member -NotePropertyName 'type' -NotePropertyValue $QueueType -Force - $DeployConfig | Add-Member -NotePropertyName 'Applicationname' -NotePropertyValue $App.AppName -Force # Compute assignTo the same way the HTTP handlers do $AppAssignTo = if ($DeployConfig.AssignTo -eq 'customGroup') { $DeployConfig.CustomGroup } else { $DeployConfig.AssignTo } - $DeployConfig | Add-Member -NotePropertyName 'assignTo' -NotePropertyValue $AppAssignTo -Force + $DeployConfig | Add-Member -NotePropertyMembers ([ordered]@{ + type = $QueueType + Applicationname = $App.AppName + assignTo = $AppAssignTo + }) -Force $null = New-CIPPIntuneAppDeployment -AppConfig $DeployConfig -TenantFilter $Tenant -APIName 'Standards' Write-LogMessage -API 'Standards' -tenant $Tenant -message "Deployed Intune app '$($App.AppName)' ($($App.AppType)) from template '$($App.TemplateName)'." -sev Info diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardIntuneTemplate.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardIntuneTemplate.ps1 index 11d2b55f1956a..605cf571ccb19 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardIntuneTemplate.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardIntuneTemplate.ps1 @@ -46,12 +46,20 @@ function Invoke-CIPPStandardIntuneTemplate { $Table = Get-CippTable -tablename 'templates' $Filter = "PartitionKey eq 'IntuneTemplate'" - $Template = (Get-CIPPAzDataTableEntity @Table -Filter $Filter | Where-Object -Property RowKey -Like "$($Settings.TemplateList.value)*").JSON | ConvertFrom-Json -ErrorAction SilentlyContinue + # The template picker surfaces the row's GUID column while the engine keys on RowKey (built-in + # templates are keyed '.IntuneTemplate.json'). CIPP writes both to the same value, but a + # template re-synced from a repo by an older release can carry a JSON GUID that no longer matches + # its RowKey - accept either rather than reporting a template that is sitting in the table as gone. + $TemplateRef = [string]$Settings.TemplateList.value + $Template = (Get-CIPPAzDataTableEntity @Table -Filter $Filter | Where-Object { $_.RowKey -like "$TemplateRef*" -or $_.GUID -eq $TemplateRef } | Select-Object -First 1).JSON | ConvertFrom-Json -ErrorAction SilentlyContinue Write-Information "[IntuneTemplate][$Tenant] TableLoad: $([int]($sw.Elapsed - $lap).TotalMilliseconds)ms" $lap = $sw.Elapsed if ($null -eq $Template) { - Write-LogMessage -API 'Standards' -tenant $tenant -message "Failed to find template $($Settings.TemplateList.value). Has this Intune Template been deleted?" -sev 'Error' + # Name the template the standard still points at: the id alone sends people searching the + # template table for a row that was deleted, when the fix is in the standards template. + $TemplateLabel = if ($Settings.TemplateList.label) { "'$($Settings.TemplateList.label)' " } else { '' } + Write-LogMessage -API 'Standards' -tenant $tenant -message "Intune template $TemplateLabel($TemplateRef) no longer exists in the template library. Remove it from the standards or drift template, or select the template again." -sev 'Error' return $true } diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMDMScope.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMDMScope.ps1 index 0a5c332fa0e3c..5b28217010e33 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMDMScope.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMDMScope.ps1 @@ -45,7 +45,7 @@ function Invoke-CIPPStandardMDMScope { } #we're done. try { - $CurrentInfo = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/policies/mobileDeviceManagementPolicies/0000000a-0000-0000-c000-000000000000?$select=termsOfUseUrl,discoveryUrl,complianceUrl,appliesTo&$expand=includedGroups($select=displayName)' -tenantid $Tenant + $CurrentInfo = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/policies/mobileDeviceManagementPolicies/0000000a-0000-0000-c000-000000000000?$select=termsOfUseUrl,discoveryUrl,complianceUrl,appliesTo&$expand=includedGroups($select=id,displayName)' -tenantid $Tenant } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the MDM Scope state for $Tenant. Error: $ErrorMessage" -Sev Error diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardOneDriveLicensedQuota.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardOneDriveLicensedQuota.ps1 index 2bc839171ddee..ce5e8eae86efc 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardOneDriveLicensedQuota.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardOneDriveLicensedQuota.ps1 @@ -119,18 +119,15 @@ function Invoke-CIPPStandardOneDriveLicensedQuota { if ($BelowQuota.Count -eq 0) { Write-LogMessage -API 'Standards' -tenant $Tenant -message 'All entitled users already have a OneDrive quota of 5 TB or more.' -sev Info } else { + # One concurrent batch instead of ~2s per drive serially. + $BulkSites = @($BelowQuota | ForEach-Object { @{ SiteUrl = $_.siteUrl; Properties = @{ StorageMaximumLevel = $TargetQuotaMB; StorageWarningLevel = $WarningLevelMB } } }) + $BulkResults = @(Set-CIPPSPOSiteBulk -TenantFilter $Tenant -Sites $BulkSites -UseCertificate) foreach ($Drive in $BelowQuota) { - try { - $SetResponse = Set-CIPPSPOSite -TenantFilter $Tenant -SiteUrl $Drive.siteUrl -Properties @{ - StorageMaximumLevel = $TargetQuotaMB - StorageWarningLevel = $WarningLevelMB - } - $CsomError = ($SetResponse | Where-Object { $_.ErrorInfo } | Select-Object -First 1).ErrorInfo.ErrorMessage - if ($CsomError) { throw $CsomError } + $BulkResult = $BulkResults | Where-Object { $_.SiteUrl -eq $Drive.siteUrl } | Select-Object -First 1 + if ($BulkResult -and $BulkResult.Success) { Write-LogMessage -API 'Standards' -tenant $Tenant -message "Raised OneDrive quota for $($Drive.userPrincipalName) from $($Drive.currentQuotaGB)GB to $($Drive.targetQuotaGB)GB" -sev Info - } catch { - $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to raise OneDrive quota for $($Drive.userPrincipalName): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + } else { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to raise OneDrive quota for $($Drive.userPrincipalName): $($BulkResult.Error)" -sev Error } } } diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardOutBoundSpamAlert.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardOutBoundSpamAlert.ps1 index e7879f4e62adf..42cbee0630acc 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardOutBoundSpamAlert.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardOutBoundSpamAlert.ps1 @@ -16,6 +16,8 @@ function Invoke-CIPPStandardOutBoundSpamAlert { "CIS M365 5.0 (2.1.6)" ADDEDCOMPONENT {"type":"textField","name":"standards.OutBoundSpamAlert.OutboundSpamContact","label":"Outbound spam contact"} + {"type":"switch","name":"standards.OutBoundSpamAlert.BccSuspiciousOutboundMail","label":"BCC suspicious outbound mail to a mailbox"} + {"type":"textField","name":"standards.OutBoundSpamAlert.BccSuspiciousOutboundContact","label":"BCC recipient for suspicious outbound mail"} IMPACT Low Impact ADDEDDATE @@ -51,28 +53,58 @@ function Invoke-CIPPStandardOutBoundSpamAlert { return } + $Contacts = $Settings.OutboundSpamContact + # BccSuspiciousOutboundMail is opt-in: only graded/remediated when the operator enables the + # toggle. CIS 2.1.6 requires both the flag AND a recipient, so a compliant state needs the + # additional recipients too when a BCC contact is supplied. + $ManageBcc = $Settings.BccSuspiciousOutboundMail -eq $true + $BccContacts = $Settings.BccSuspiciousOutboundContact + + $CurrentNotifyRecipients = @($CurrentInfo.NotifyOutboundSpamRecipients) -join ', ' + $NotifyIsCorrect = ($CurrentInfo.NotifyOutboundSpam -eq $true) -and ($CurrentNotifyRecipients -eq "$Contacts") + + $CurrentBccRecipients = @($CurrentInfo.BccSuspiciousOutboundAdditionalRecipients) -join ', ' + if (-not $ManageBcc) { + $BccIsCorrect = $true + } elseif ([string]::IsNullOrWhiteSpace($BccContacts)) { + $BccIsCorrect = $CurrentInfo.BccSuspiciousOutboundMail -eq $true + } else { + $BccIsCorrect = ($CurrentInfo.BccSuspiciousOutboundMail -eq $true) -and ($CurrentBccRecipients -eq "$BccContacts") + } + $StateIsCorrect = $NotifyIsCorrect -and $BccIsCorrect + if ($Settings.remediate -eq $true) { - if ($CurrentInfo.NotifyOutboundSpam -ne $true -or $CurrentInfo.NotifyOutboundSpamRecipients -ne $settings.OutboundSpamContact) { - $Contacts = $settings.OutboundSpamContact + if ($StateIsCorrect -eq $true) { + Write-LogMessage -API 'Standards' -tenant $tenant -message "Outbound spam filter alert is already set to $($CurrentInfo.NotifyOutboundSpamRecipients)" -sev Info + } else { + $cmdParams = @{ + Identity = 'Default' + NotifyOutboundSpam = $true + NotifyOutboundSpamRecipients = $Contacts + } + if ($ManageBcc) { + $cmdParams.BccSuspiciousOutboundMail = $true + if (-not [string]::IsNullOrWhiteSpace($BccContacts)) { + $cmdParams.BccSuspiciousOutboundAdditionalRecipients = $BccContacts + } + } try { - New-ExoRequest -tenantid $tenant -cmdlet 'Set-HostedOutboundSpamFilterPolicy' -cmdParams @{ Identity = 'Default'; NotifyOutboundSpam = $true; NotifyOutboundSpamRecipients = $Contacts } -useSystemMailbox $true + New-ExoRequest -tenantid $tenant -cmdlet 'Set-HostedOutboundSpamFilterPolicy' -cmdParams $cmdParams -useSystemMailbox $true Write-LogMessage -API 'Standards' -tenant $tenant -message "Set outbound spam filter alert to $($Contacts)" -sev Info } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message Write-LogMessage -API 'Standards' -tenant $tenant -message "Could not set outbound spam contact to $($Contacts). $ErrorMessage" -sev Error } - } else { - Write-LogMessage -API 'Standards' -tenant $tenant -message "Outbound spam filter alert is already set to $($CurrentInfo.NotifyOutboundSpamRecipients)" -sev Info } } if ($Settings.alert -eq $true) { - if ($CurrentInfo.NotifyOutboundSpam -eq $true) { + if ($StateIsCorrect -eq $true) { Write-LogMessage -API 'Standards' -tenant $tenant -message "Outbound spam filter alert is set to $($CurrentInfo.NotifyOutboundSpamRecipients)" -sev Info } else { - $Object = $CurrentInfo | Select-Object -Property NotifyOutboundSpamRecipients, NotifyOutboundSpam + $Object = $CurrentInfo | Select-Object -Property NotifyOutboundSpam, NotifyOutboundSpamRecipients, BccSuspiciousOutboundMail, BccSuspiciousOutboundAdditionalRecipients Write-StandardsAlert -message 'Outbound spam filter alert is not set' -object $Object -tenant $tenant -standardName 'OutBoundSpamAlert' -standardId $Settings.standardId Write-LogMessage -API 'Standards' -tenant $tenant -message 'Outbound spam filter alert is not set' -sev Info } @@ -82,11 +114,17 @@ function Invoke-CIPPStandardOutBoundSpamAlert { Add-CIPPBPAField -FieldName 'OutboundSpamAlert' -FieldValue $CurrentInfo.NotifyOutboundSpam -StoreAs bool -Tenant $tenant $CurrentValue = @{ NotifyOutboundSpam = $CurrentInfo.NotifyOutboundSpam - NotifyOutboundSpamRecipients = ($CurrentInfo.NotifyOutboundSpamRecipients -join ', ') + NotifyOutboundSpamRecipients = $CurrentNotifyRecipients } $ExpectedValue = @{ NotifyOutboundSpam = $true - NotifyOutboundSpamRecipients = $settings.OutboundSpamContact + NotifyOutboundSpamRecipients = $Contacts + } + if ($ManageBcc) { + $CurrentValue.BccSuspiciousOutboundMail = $CurrentInfo.BccSuspiciousOutboundMail + $CurrentValue.BccSuspiciousOutboundAdditionalRecipients = $CurrentBccRecipients + $ExpectedValue.BccSuspiciousOutboundMail = $true + $ExpectedValue.BccSuspiciousOutboundAdditionalRecipients = $BccContacts } Set-CIPPStandardsCompareField -FieldName 'standards.OutBoundSpamAlert' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $tenant } diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardPIMRoleSettings.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardPIMRoleSettings.ps1 new file mode 100644 index 0000000000000..3d583f23f4c70 --- /dev/null +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardPIMRoleSettings.ps1 @@ -0,0 +1,197 @@ +function Invoke-CIPPStandardPIMRoleSettings { + <# + .FUNCTIONALITY + Internal + .COMPONENT + (APIName) PIMRoleSettings + .SYNOPSIS + (Label) PIM Role Settings Template + .DESCRIPTION + (Helptext) Deploys a Privileged Identity Management role settings template to the tenant: activation limits, MFA or authentication context, justification, approval, eligibility and active-assignment expiry and notification rules for the roles the template covers. Templates cannot weaken settings below CIPP's secure floor. + (DocsDescription) Deploys a Privileged Identity Management role settings template to the tenant. The template defines, for a set of roles, the maximum activation duration, whether activation requires MFA or an authentication context, justification, ticket and approval requirements, the maximum lifetime of eligible and active assignments, and additional notification recipients. Templates are validated against CIPP's secure floor (activation within 24 hours with MFA or an authentication context and a justification; eligible and active assignments must expire within a year; active assignments require a justification) and are refused, not adjusted, when they fall below it. Requires Entra ID P2. + .NOTES + CAT + Templates + MULTIPLE + True + DISABLEDFEATURES + {"report":false,"warn":false,"remediate":false} + IMPACT + High Impact + ADDEDDATE + 2026-08-23 + TAG + EXECUTIVETEXT + Enforces consistent Privileged Identity Management settings so that administrator roles can only be used for a limited time, after strong authentication and with a recorded reason. This keeps standing administrative access to a minimum and makes every use of privilege visible and accountable. + ADDEDCOMPONENT + {"type":"autoComplete","name":"TemplateList","multiple":false,"required":true,"creatable":false,"label":"Select PIM Role Settings Template","api":{"url":"/api/ListPIMRoleSettingsTemplates","labelField":"templateName","valueField":"GUID","queryKey":"ListPIMRoleSettingsTemplates","showRefresh":true,"templateView":{"title":"PIM Role Settings Template"}}} + POWERSHELLEQUIVALENT + Update-MgBetaPolicyRoleManagementPolicyRule + RECOMMENDEDBY + "CIPP" + REQUIREDCAPABILITIES + "AAD_PREMIUM_P2" + UPDATECOMMENTBLOCK + Run the tools\Update-StandardsComments.ps1 script to update this comment block + .LINK + https://docs.cipp.app/user-documentation/tenant/standards/alignment/templates/available-standards + #> + + param($Tenant, $Settings) + + $TemplateId = $Settings.TemplateList.value ?? $Settings.TemplateList + $FieldName = "standards.PIMRoleSettings.$TemplateId" + + $TestResult = Test-CIPPStandardLicense -StandardName 'PIMRoleSettings' -TenantFilter $Tenant -Preset EntraP2 + if ($TestResult -eq $false) { + Set-CIPPStandardsCompareField -FieldName $FieldName -FieldValue 'This tenant does not have the Entra ID P2 license required for Privileged Identity Management.' -LicenseAvailable $false -TenantFilter $Tenant + return $true + } + + if ([string]::IsNullOrWhiteSpace($TemplateId)) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'PIMRoleSettings: no template selected.' -sev Error + return + } + + # Load and re-validate the template: a template row edited by hand must not be able to push a + # tenant below the floor, so the check runs at deploy time as well as at save time. + $Table = Get-CippTable -tablename 'templates' + $SafeId = ConvertTo-CIPPODataFilterValue -Value $TemplateId -Type String + $TemplateRow = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'PIMRoleSettingsTemplate' and (RowKey eq '$SafeId' or GUID eq '$SafeId')" | Select-Object -First 1 + if (-not $TemplateRow) { + $Message = "PIM role settings template $TemplateId could not be found." + Write-LogMessage -API 'Standards' -tenant $Tenant -message $Message -sev Error + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = $Message } -ExpectedValue @{ Differences = @() } -TenantFilter $Tenant + return + } + $Template = $TemplateRow.JSON | ConvertFrom-Json -Depth 100 + $TemplateSettings = ConvertTo-CIPPPIMRoleSettings -InputObject $Template.settings + $Floor = Test-CIPPPIMRoleSettingsFloor -Settings $TemplateSettings + if (-not $Floor.Valid) { + $Message = "PIM role settings template '$($Template.templateName)' is below the secure floor and was not applied: $($Floor.Errors -join ' ')" + Write-LogMessage -API 'Standards' -tenant $Tenant -message $Message -sev Error + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = $Message } -ExpectedValue @{ Differences = @() } -TenantFilter $Tenant + return + } + foreach ($Warning in $Floor.Warnings) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "PIM role settings template '$($Template.templateName)': $Warning" -sev Warning + } + + try { + $Policies = @(Get-CIPPPIMRolePolicies -TenantFilter $Tenant) + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Could not read PIM role policies: $ErrorMessage" -sev Error + return + } + if ($Policies.Count -eq 0) { + $Message = 'No PIM role management policies were returned. Privileged Identity Management may not be onboarded in this tenant yet; open PIM once in the Entra admin center.' + Write-LogMessage -API 'Standards' -tenant $Tenant -message $Message -sev Warning + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = $Message } -ExpectedValue @{ Differences = @() } -TenantFilter $Tenant + return + } + + # Role names for messages; PIM's roleDefinitionId is the template id for built-in roles. + $RoleNames = @{} + foreach ($Entry in (Get-CIPPPrivilegedRoleTemplateIds -WithNames)) { $RoleNames[$Entry.Id] = $Entry.DisplayName } + try { + foreach ($Definition in @(New-GraphGetRequest -uri 'https://graph.microsoft.com/v1.0/roleManagement/directory/roleDefinitions?$select=id,templateId,displayName' -tenantid $Tenant)) { + if ($Definition.id) { $RoleNames[$Definition.id] = $Definition.displayName } + if ($Definition.templateId) { $RoleNames[$Definition.templateId] = $Definition.displayName } + } + } catch { + Write-Information "Could not list role definitions for $Tenant`: $($_.Exception.Message)" + } + + $RoleIds = switch ("$($Template.roleScope)") { + 'AllRoles' { @($Policies.RoleDefinitionId) } + 'Custom' { @($Template.roles | ForEach-Object { $_.value ?? $_ } | Where-Object { $_ }) } + default { @(Get-CIPPPrivilegedRoleTemplateIds -Set Privileged) } + } + + # Approvers are stored by name/UPN so a template works across tenants; resolve them here. + $ResolvedApprovers = @() + if ($TemplateSettings.activationRequiresApproval) { + foreach ($Approver in @("$($TemplateSettings.approvers)" -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ })) { + try { + if ($Approver -match '@') { + $SafeUpn = ConvertTo-CIPPODataFilterValue -Value $Approver -Type String + $User = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/users?`$filter=userPrincipalName eq '$SafeUpn'&`$select=id,userPrincipalName" -tenantid $Tenant | Select-Object -First 1 + if ($User) { $ResolvedApprovers += [ordered]@{ '@odata.type' = '#microsoft.graph.singleUser'; userId = $User.id; description = $User.userPrincipalName } } + else { Write-LogMessage -API 'Standards' -tenant $Tenant -message "PIMRoleSettings: approver '$Approver' was not found." -sev Warning } + } else { + $SafeName = ConvertTo-CIPPODataFilterValue -Value $Approver -Type String + $Group = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/groups?`$filter=displayName eq '$SafeName'&`$select=id,displayName" -tenantid $Tenant | Select-Object -First 1 + if ($Group) { $ResolvedApprovers += [ordered]@{ '@odata.type' = '#microsoft.graph.groupMembers'; groupId = $Group.id; description = $Group.displayName } } + else { Write-LogMessage -API 'Standards' -tenant $Tenant -message "PIMRoleSettings: approver group '$Approver' was not found." -sev Warning } + } + } catch { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "PIMRoleSettings: could not resolve approver '$Approver': $($_.Exception.Message)" -sev Warning + } + } + if ($ResolvedApprovers.Count -eq 0) { + $Message = "PIM role settings template '$($Template.templateName)' requires approval but none of its approvers exist in this tenant; the template was not applied." + Write-LogMessage -API 'Standards' -tenant $Tenant -message $Message -sev Error + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = $Message } -ExpectedValue @{ Differences = @() } -TenantFilter $Tenant + return + } + } + + function Get-RoleDifferences { + param($PolicySet) + $PolicyByRole = @{} + foreach ($Policy in $PolicySet) { $PolicyByRole[$Policy.RoleDefinitionId] = $Policy } + $Found = [System.Collections.Generic.List[object]]::new() + foreach ($RoleId in $RoleIds) { + $RoleLabel = $RoleNames[$RoleId] ?? $RoleId + $Policy = $PolicyByRole[$RoleId] + if (-not $Policy) { + $Found.Add([PSCustomObject]@{ Role = $RoleLabel; RoleDefinitionId = $RoleId; Policy = $null; Desired = $null; Differences = @([PSCustomObject]@{ Role = $RoleLabel; Rule = '-'; Property = 'policy'; Expected = 'present'; Current = 'no PIM policy for this role' }) }) + continue + } + $Desired = ConvertTo-CIPPPIMPolicyRules -Settings $TemplateSettings -CurrentRules $Policy.Rules -ResolvedApprovers $ResolvedApprovers + $Differences = @(Compare-CIPPPIMRoleSettings -DesiredRules $Desired -CurrentRules $Policy.Rules -RoleName $RoleLabel) + $Found.Add([PSCustomObject]@{ Role = $RoleLabel; RoleDefinitionId = $RoleId; Policy = $Policy; Desired = $Desired; Differences = $Differences }) + } + return $Found + } + + $RoleStates = Get-RoleDifferences -PolicySet $Policies + $Drifted = @($RoleStates | Where-Object { $_.Differences.Count -gt 0 }) + + if ($Settings.remediate -eq $true) { + if ($Drifted.Count -eq 0) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "PIM role settings already match template '$($Template.templateName)' for $($RoleStates.Count) role(s)." -sev Info + } else { + foreach ($State in $Drifted) { + if (-not $State.Policy) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "PIMRoleSettings: no PIM policy exists for $($State.Role); cannot apply the template to it." -sev Warning + continue + } + $null = Set-CIPPPIMRoleSettings -TenantFilter $Tenant -PolicyId $State.Policy.PolicyId -DesiredRules $State.Desired -CurrentRules $State.Policy.Rules -RoleName $State.Role -APIName 'Standards' + } + # Re-read so the report reflects the post-remediation state. + try { + $RoleStates = Get-RoleDifferences -PolicySet @(Get-CIPPPIMRolePolicies -TenantFilter $Tenant) + $Drifted = @($RoleStates | Where-Object { $_.Differences.Count -gt 0 }) + } catch { + Write-Information "Could not re-read PIM policies after remediation: $($_.Exception.Message)" + } + } + } + + $DifferenceText = @($Drifted | ForEach-Object { $State = $_; $State.Differences | ForEach-Object { "$($State.Role): $($_.Rule).$($_.Property) expected '$($_.Expected)', found '$($_.Current)'" } }) + + if ($Settings.alert -eq $true) { + if ($Drifted.Count -eq 0) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "PIM role settings match template '$($Template.templateName)'." -sev Info + } else { + Write-StandardsAlert -message "PIM role settings for $($Drifted.Count) role(s) differ from template '$($Template.templateName)'" -object @{ Template = $Template.templateName; Differences = $DifferenceText } -tenant $Tenant -standardName 'PIMRoleSettings' -standardId $Settings.standardId + Write-LogMessage -API 'Standards' -tenant $Tenant -message "PIM role settings for $($Drifted.Count) role(s) differ from template '$($Template.templateName)'." -sev Info + } + } + + if ($Settings.report -eq $true) { + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = @($DifferenceText) } -ExpectedValue @{ Differences = @() } -TenantFilter $Tenant + } +} diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardQuarantineRequestAlert.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardQuarantineRequestAlert.ps1 index 144141a671cce..0726c64b08447 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardQuarantineRequestAlert.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardQuarantineRequestAlert.ps1 @@ -63,11 +63,22 @@ function Invoke-CIPPStandardQuarantineRequestAlert { return } - $StateIsCorrect = if ($State -eq 'removed') { - !$CurrentState - } else { - ($CurrentState.NotifyUser -contains $Settings.NotifyUser) + # Expected recipients, normalised (blanks removed, sorted, de-duplicated) so the alert's actual + # recipients and the configured recipients can be compared as values, order-insensitively. + $ExpectedNotify = if ($State -eq 'removed') { @() } else { @($Settings.NotifyUser | Where-Object { $_ } | Sort-Object -Unique) } + + # State is a value comparison of the alert's recipients against the expected recipients. Wrapped in a + # scriptblock so it can be re-evaluated against a fresh read after remediation (see below), and so the + # report's Current/Expected fields below are the exact values this comparison is made on. + $GetStateIsCorrect = { + if ($State -eq 'removed') { + -not $CurrentState + } else { + $CurrentNotify = @($CurrentState.NotifyUser | Where-Object { $_ } | Sort-Object -Unique) + ($CurrentNotify -join "`n") -eq ($ExpectedNotify -join "`n") + } } + $StateIsCorrect = & $GetStateIsCorrect if ($Settings.remediate -eq $true) { if ($StateIsCorrect -eq $true) { @@ -115,6 +126,17 @@ function Invoke-CIPPStandardQuarantineRequestAlert { } } } + # Re-read the live state after remediating so the alert and report modes below reflect what was + # actually applied this run, not the pre-remediation snapshot. Without this, a freshly created or + # updated alert still reports its old (usually empty) recipients until the next scheduled run, + # which reads as "nothing changed" immediately after a force run. + try { + $CurrentState = New-ExoRequest -TenantId $Tenant -cmdlet 'Get-ProtectionAlert' -Compliance | Where-Object { $_.Name -eq $PolicyName } + $StateIsCorrect = & $GetStateIsCorrect + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not re-check the QuarantineRequestAlert state after remediation for $Tenant. Error: $ErrorMessage" -Sev Error + } } if ($Settings.alert -eq $true) { @@ -134,11 +156,14 @@ function Invoke-CIPPStandardQuarantineRequestAlert { if ($Settings.report -eq $true) { Add-CIPPBPAField -FieldName 'QuarantineRequestAlert' -FieldValue $StateIsCorrect -StoreAs bool -Tenant $Tenant + # Both sides are normalised the same way and kept as arrays (a bare @() around an if-expression + # unrolls a single element to a scalar, which would never match the array-shaped Current value). + # Compliance is then decided by CurrentValue -eq ExpectedValue in Get-CIPPTenantAlignment. $CurrentValue = @{ - NotifyUser = @($CurrentState.NotifyUser | Where-Object { $_ }) + NotifyUser = @($CurrentState.NotifyUser | Where-Object { $_ } | Sort-Object -Unique) } $ExpectedValue = @{ - NotifyUser = if ($State -eq 'removed') { @() } else { @($Settings.NotifyUser) } + NotifyUser = @($ExpectedNotify) } Set-CIPPStandardsCompareField -FieldName 'standards.QuarantineRequestAlert' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant } diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPGuestPeoplePicker.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPGuestPeoplePicker.ps1 new file mode 100644 index 0000000000000..c4c2541501253 --- /dev/null +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPGuestPeoplePicker.ps1 @@ -0,0 +1,136 @@ +function Invoke-CIPPStandardSPGuestPeoplePicker { + <# + .FUNCTIONALITY + Internal + .COMPONENT + (APIName) SPGuestPeoplePicker + .SYNOPSIS + (Label) Show guest users in the SharePoint People Picker + .DESCRIPTION + (Helptext) Controls whether guest (external) users already in the tenant appear as suggestions in the SharePoint and OneDrive People Picker. Enforces the wanted state on BOTH the tenant default and every existing site collection - they are set independently, so changing the tenant default does not update existing sites. The per-site picture is read from the SharePoint reporting cache (refreshed daily), so a large tenant is never enumerated live during a run; a 24h rerun guard stops the write sweep from repeating before that cache refreshes and re-evaluates the result. + (DocsDescription) Enforces ShowPeoplePickerSuggestionsForGuestUsers at both levels it is set independently: the tenant default (Set-SPOTenant) and each site collection (Set-SPOSite). Which sites differ is decided from the SPOSites reporting cache (populated by the daily SharePoint cache run) rather than the live site enumeration, which on a large tenant is hundreds of CSOM calls and lags a just-applied write. The tenant default is read through the cached SharePoint tenant configuration. Remediation sets the tenant default and sweeps every differing site once, then records a 24-hour rerun guard: the write is not repeated until the next daily cache run reflects the change, at which point the standard re-evaluates from the refreshed cache. Guests are not shown by default even when they exist in the tenant, and changing the tenant default does not retroactively change existing sites, so both are covered. + .NOTES + CAT + SharePoint Standards + TAG + EXECUTIVETEXT + Makes existing external collaborators discoverable (or hidden) when sharing SharePoint and OneDrive content, consistently across the tenant default and every existing site. This keeps the sharing experience predictable and prevents individual sites from drifting away from the agreed collaboration posture. + ADDEDCOMPONENT + {"type":"autoComplete","multiple":false,"creatable":false,"label":"Guest People Picker suggestions","name":"standards.SPGuestPeoplePicker.state","options":[{"label":"Show guests in the People Picker","value":"true"},{"label":"Hide guests in the People Picker","value":"false"}]} + IMPACT + Low Impact + ADDEDDATE + 2026-09-03 + POWERSHELLEQUIVALENT + Set-SPOTenant / Set-SPOSite -ShowPeoplePickerSuggestionsForGuestUsers \$true or \$false + RECOMMENDEDBY + "CIPP" + REQUIREDCAPABILITIES + "SHAREPOINTWAC" + "SHAREPOINTSTANDARD" + "SHAREPOINTENTERPRISE" + "SHAREPOINTENTERPRISE_EDU" + "ONEDRIVE_BASIC" + "ONEDRIVE_ENTERPRISE" + UPDATECOMMENTBLOCK + Run the Tools\Update-StandardsComments.ps1 script to update this comment block + .LINK + https://docs.cipp.app/user-documentation/tenant/standards/alignment/templates/available-standards + #> + + param($Tenant, $Settings) + $TestResult = Test-CIPPStandardLicense -StandardName 'SPGuestPeoplePicker' -TenantFilter $Tenant -Preset SharePoint + + if ($TestResult -eq $false) { + return $true + } #we're done. + + # Input validation + $StateValue = $Settings.state.value ?? $Settings.state + if (([string]::IsNullOrWhiteSpace($StateValue) -or $StateValue -eq 'Select a value') -and ($Settings.remediate -eq $true -or $Settings.alert -eq $true)) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'SPGuestPeoplePicker: Invalid state parameter set' -sev Error + return + } + $WantedState = [System.Convert]::ToBoolean($StateValue) + $HumanReadableState = if ($WantedState -eq $true) { 'shown' } else { 'hidden' } + + # Decide what to change from cache, not live. The per-site picture comes from the SPOSites + # reporting cache (refreshed by the daily SharePoint CIPPDB run) - never the live enumeration, + # which on a large tenant is hundreds of CSOM calls and lags a just-applied write by minutes. + # The tenant default is a single setting read through Get-CIPPSPOTenant's own 1h cache - unlike + # the delegated SPOTenant reporting collector, this works app-only with the certificate on + # tenants without a SharePoint-admin GDAP user - and the same object is the write handle below. + try { + $CurrentTenant = Get-CIPPSPOTenant -TenantFilter $Tenant -UseCertificate | Select-Object -First 1 + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not read the SharePoint tenant configuration for SPGuestPeoplePicker on $Tenant. Error: $($ErrorMessage.NormalizedError)" -Sev Error -LogData $ErrorMessage + return + } + if (-not $CurrentTenant) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'SPGuestPeoplePicker: no SharePoint tenant configuration available yet - it will populate on the next SharePoint cache run' -sev Info + return + } + + $Sites = @(New-CIPPDbRequest -TenantFilter $Tenant -Type 'SPOSites' | Where-Object { $_ -and $_.Url }) + $TenantValue = [bool]$CurrentTenant.ShowPeoplePickerSuggestionsForGuestUsers + $NonCompliantSites = @($Sites | Where-Object { [bool]$_.ShowPeoplePickerSuggestionsForGuestUsers -ne $WantedState }) + $TenantIsCorrect = ($TenantValue -eq $WantedState) + $StateIsCorrect = $TenantIsCorrect -and ($NonCompliantSites.Count -eq 0) + + if ($Settings.remediate -eq $true) { + if ($StateIsCorrect -eq $true) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Guest People Picker suggestions are already correctly set to $HumanReadableState on the tenant default and all $($Sites.Count) sites" -sev Info + } elseif (Test-CIPPRerun -Tenant $Tenant -API 'SPGuestPeoplePicker' -Interval 86400) { + # The write sweep already ran for this tenant within the last 24h (Test-CIPPRerun records + # it, and the baseline executor shares this key). Its input, the SPOSites cache, only + # refreshes daily, so re-running now would re-issue the same writes against a stale picture + # and push SharePoint into throttling. Wait for the next daily cache run to reflect the + # change and re-evaluate the true result. Alert/report below still run every time. + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'SPGuestPeoplePicker: write sweep already ran within the last 24h - skipping it until the next cache run re-evaluates the result' -sev Info + } else { + if (-not $TenantIsCorrect) { + try { + $null = $CurrentTenant | Set-CIPPSPOTenant -Properties @{ ShowPeoplePickerSuggestionsForGuestUsers = $WantedState } -UseCertificate + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to set the tenant default guest People Picker suggestions to $HumanReadableState. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + } + } + if ($NonCompliantSites.Count -gt 0) { + $BulkSites = @($NonCompliantSites | ForEach-Object { @{ SiteUrl = $_.Url; Properties = @{ ShowPeoplePickerSuggestionsForGuestUsers = $WantedState } } }) + $Results = @(Set-CIPPSPOSiteBulk -TenantFilter $Tenant -Sites $BulkSites -UseCertificate) + $FailedSites = @($Results | Where-Object { -not $_.Success }) + foreach ($Bad in $FailedSites) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to set guest People Picker to $HumanReadableState on $($Bad.SiteUrl)$(if ($Bad.Error) { ": $($Bad.Error)" })" -sev Error + } + $Succeeded = $Results.Count - $FailedSites.Count + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Set guest People Picker to $HumanReadableState on $Succeeded of $($NonCompliantSites.Count) site(s)" -sev Info + } + } + } + + if ($Settings.alert -eq $true) { + if ($StateIsCorrect -eq $true) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Guest People Picker suggestions are correctly set to $HumanReadableState everywhere" -sev Info + } else { + $TenantPart = if ($TenantIsCorrect) { 'the tenant default is correct' } else { "the tenant default is not $HumanReadableState" } + $Message = "Guest People Picker suggestions are not set to ${HumanReadableState}: $TenantPart and $($NonCompliantSites.Count) site(s) differ" + Write-StandardsAlert -message $Message -object @{ TenantDefault = $TenantValue; NonCompliantSiteCount = $NonCompliantSites.Count } -tenant $Tenant -standardName 'SPGuestPeoplePicker' -standardId $Settings.standardId + Write-LogMessage -API 'Standards' -tenant $Tenant -message $Message -sev Info + } + } + + if ($Settings.report -eq $true) { + $CurrentValue = @{ + TenantDefault = $TenantValue + NonCompliantSiteCount = $NonCompliantSites.Count + } + $ExpectedValue = @{ + TenantDefault = $WantedState + NonCompliantSiteCount = 0 + } + Set-CIPPStandardsCompareField -FieldName 'standards.SPGuestPeoplePicker' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant + Add-CIPPBPAField -FieldName 'SPGuestPeoplePicker' -FieldValue $StateIsCorrect -StoreAs bool -Tenant $Tenant + } +} diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPOVersionControl.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPOVersionControl.ps1 index b1f147d96ce6b..47c9d681bebe7 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPOVersionControl.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPOVersionControl.ps1 @@ -63,7 +63,8 @@ function Invoke-CIPPStandardSPOVersionControl { } try { - $CurrentState = Get-CIPPSPOTenant -TenantFilter $Tenant | Select-Object -Property _ObjectIdentity_, TenantFilter, EnableAutoExpirationVersionTrim, MajorVersionLimit, ExpireVersionsAfterDays + # SharePoint app-only requires the SAM certificate; delegated is not available on every tenant. + $CurrentState = Get-CIPPSPOTenant -TenantFilter $Tenant -UseCertificate | Select-Object -Property _ObjectIdentity_, TenantFilter, EnableAutoExpirationVersionTrim, MajorVersionLimit, ExpireVersionsAfterDays } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API 'Standards' -Tenant $Tenant -message "Could not get the SPOVersionControl state for $Tenant. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage @@ -72,21 +73,39 @@ function Invoke-CIPPStandardSPOVersionControl { if ($DesiredAutoTrim) { $StateIsCorrect = $CurrentState.EnableAutoExpirationVersionTrim -eq $true + + # With automatic trimming on, SharePoint manages the version limit and expiry itself, so + # they are not part of the desired state. The compare report and drift detection grade + # the current and expected objects as a whole, so the tenant-managed values must be left + # out of both sides - a null placeholder on the expected side never matches and marked + # every auto-trim tenant non-compliant. + $CurrentValue = [PSCustomObject]@{ + EnableAutoExpirationVersionTrim = $CurrentState.EnableAutoExpirationVersionTrim + } + $ExpectedValue = [PSCustomObject]@{ + EnableAutoExpirationVersionTrim = $true + } } else { $StateIsCorrect = ($CurrentState.EnableAutoExpirationVersionTrim -eq $false) -and ($CurrentState.MajorVersionLimit -eq $DesiredMajorVersionLimit) -and ($CurrentState.ExpireVersionsAfterDays -eq $DesiredExpireVersionsAfterDays) - } - $CurrentValue = [PSCustomObject]@{ - EnableAutoExpirationVersionTrim = $CurrentState.EnableAutoExpirationVersionTrim - MajorVersionLimit = $CurrentState.MajorVersionLimit - ExpireVersionsAfterDays = $CurrentState.ExpireVersionsAfterDays + $CurrentValue = [PSCustomObject]@{ + EnableAutoExpirationVersionTrim = $CurrentState.EnableAutoExpirationVersionTrim + MajorVersionLimit = $CurrentState.MajorVersionLimit + ExpireVersionsAfterDays = $CurrentState.ExpireVersionsAfterDays + } + $ExpectedValue = [PSCustomObject]@{ + EnableAutoExpirationVersionTrim = $false + MajorVersionLimit = $DesiredMajorVersionLimit + ExpireVersionsAfterDays = $DesiredExpireVersionsAfterDays + } } - $ExpectedValue = [PSCustomObject]@{ - EnableAutoExpirationVersionTrim = $DesiredAutoTrim - MajorVersionLimit = if ($DesiredAutoTrim) { $null } else { $DesiredMajorVersionLimit } - ExpireVersionsAfterDays = if ($DesiredAutoTrim) { $null } else { $DesiredExpireVersionsAfterDays } + + $ExpectedDescription = if ($DesiredAutoTrim) { + 'AutoTrim=True (version limit and expiry managed by Microsoft)' + } else { + "AutoTrim=False, MajorVersionLimit=$DesiredMajorVersionLimit, ExpireVersionsAfterDays=$DesiredExpireVersionsAfterDays" } if ($Settings.remediate -eq $true) { @@ -109,8 +128,8 @@ function Invoke-CIPPStandardSPOVersionControl { @{ Type = 'Int32'; Value = $DesiredExpireVersionsAfterDays } ) } - $CurrentState | Set-CIPPSPOTenant -MethodName 'SetFileVersionPolicy' -MethodParameters $MethodParams - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Successfully configured SharePoint version control (AutoTrim: $DesiredAutoTrim, MajorVersionLimit: $DesiredMajorVersionLimit, ExpireVersionsAfterDays: $DesiredExpireVersionsAfterDays)" -sev Info + $CurrentState | Set-CIPPSPOTenant -MethodName 'SetFileVersionPolicy' -MethodParameters $MethodParams -UseCertificate + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Successfully configured SharePoint version control ($ExpectedDescription)" -sev Info # Apply to all existing sites and their document libraries if ($Settings.ApplyToExistingSites -eq $true) { @@ -128,15 +147,14 @@ function Invoke-CIPPStandardSPOVersionControl { $SiteProperties.ExpireVersionsAfterDays = $DesiredExpireVersionsAfterDays } - foreach ($Site in $Sites) { - try { - Set-CIPPSPOSite -TenantFilter $Tenant -SiteUrl $Site.webUrl -Properties $SiteProperties - } catch { - $SiteError = Get-CippException -Exception $_ - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to set version policy for site $($Site.webUrl): $($SiteError.NormalizedError)" -sev Error -LogData $SiteError - } + # One concurrent batch instead of ~2s per site serially. + $BulkSites = @($Sites | ForEach-Object { @{ SiteUrl = $_.webUrl; Properties = $SiteProperties } }) + $BulkResults = @(Set-CIPPSPOSiteBulk -TenantFilter $Tenant -Sites $BulkSites -UseCertificate) + $FailedSites = @($BulkResults | Where-Object { -not $_.Success }) + foreach ($FailedSite in $FailedSites) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to set version policy for site $($FailedSite.SiteUrl): $($FailedSite.Error)" -sev Error } - Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Finished applying version policy to existing sites' -sev Info + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Finished applying version policy to $($Sites.Count - $FailedSites.Count) of $($Sites.Count) existing sites" -sev Info } } catch { $ErrorMessage = Get-CippException -Exception $_ @@ -149,7 +167,7 @@ function Invoke-CIPPStandardSPOVersionControl { if ($StateIsCorrect) { Write-LogMessage -API 'Standards' -tenant $Tenant -message 'SharePoint version control settings are configured correctly' -sev Info } else { - $Message = "SharePoint version control is not configured correctly. Current: AutoTrim=$($CurrentState.EnableAutoExpirationVersionTrim), MajorVersionLimit=$($CurrentState.MajorVersionLimit), ExpireVersionsAfterDays=$($CurrentState.ExpireVersionsAfterDays). Expected: AutoTrim=$DesiredAutoTrim, MajorVersionLimit=$DesiredMajorVersionLimit, ExpireVersionsAfterDays=$DesiredExpireVersionsAfterDays" + $Message = "SharePoint version control is not configured correctly. Current: AutoTrim=$($CurrentState.EnableAutoExpirationVersionTrim), MajorVersionLimit=$($CurrentState.MajorVersionLimit), ExpireVersionsAfterDays=$($CurrentState.ExpireVersionsAfterDays). Expected: $ExpectedDescription" Write-StandardsAlert -message $Message -object $CurrentState -tenant $Tenant -standardName 'SPOVersionControl' -standardId $Settings.standardId } } diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSpamFilterPolicy.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSpamFilterPolicy.ps1 index 08ca43604c1d8..569ba58f16ac3 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSpamFilterPolicy.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSpamFilterPolicy.ps1 @@ -39,6 +39,7 @@ function Invoke-CIPPStandardSpamFilterPolicy { {"type":"autoComplete","required":true,"multiple":false,"creatable":true,"label":"High Confidence Spam Quarantine Tag","name":"standards.SpamFilterPolicy.HighConfidenceSpamQuarantineTag","options":[{"label":"AdminOnlyAccessPolicy","value":"AdminOnlyAccessPolicy"},{"label":"DefaultFullAccessPolicy","value":"DefaultFullAccessPolicy"},{"label":"DefaultFullAccessWithNotificationPolicy","value":"DefaultFullAccessWithNotificationPolicy"}]} {"type":"autoComplete","required":true,"multiple":false,"creatable":false,"label":"Bulk Spam Action","name":"standards.SpamFilterPolicy.BulkSpamAction","options":[{"label":"Quarantine the message","value":"Quarantine"},{"label":"Move message to Junk Email folder","value":"MoveToJmf"}]} {"type":"autoComplete","required":true,"multiple":false,"creatable":true,"label":"Bulk Quarantine Tag","name":"standards.SpamFilterPolicy.BulkQuarantineTag","options":[{"label":"AdminOnlyAccessPolicy","value":"AdminOnlyAccessPolicy"},{"label":"DefaultFullAccessPolicy","value":"DefaultFullAccessPolicy"},{"label":"DefaultFullAccessWithNotificationPolicy","value":"DefaultFullAccessWithNotificationPolicy"}]} + {"type":"autoComplete","required":false,"multiple":false,"creatable":false,"label":"Bulk moves enabled (deliver bulk mail below the threshold to the Promotions folder - Preview)","name":"standards.SpamFilterPolicy.BulkMovesEnabled","options":[{"label":"On","value":"On"},{"label":"Off","value":"Off"}]} {"type":"autoComplete","required":true,"multiple":false,"creatable":false,"label":"Phish Spam Action","name":"standards.SpamFilterPolicy.PhishSpamAction","options":[{"label":"Quarantine the message","value":"Quarantine"},{"label":"Move message to Junk Email folder","value":"MoveToJmf"}]} {"type":"autoComplete","required":true,"multiple":false,"creatable":true,"label":"Phish Quarantine Tag","name":"standards.SpamFilterPolicy.PhishQuarantineTag","options":[{"label":"AdminOnlyAccessPolicy","value":"AdminOnlyAccessPolicy"},{"label":"DefaultFullAccessPolicy","value":"DefaultFullAccessPolicy"},{"label":"DefaultFullAccessWithNotificationPolicy","value":"DefaultFullAccessWithNotificationPolicy"}]} {"type":"autoComplete","required":true,"multiple":false,"creatable":true,"label":"High Confidence Phish Quarantine Tag","name":"standards.SpamFilterPolicy.HighConfidencePhishQuarantineTag","options":[{"label":"AdminOnlyAccessPolicy","value":"AdminOnlyAccessPolicy"},{"label":"DefaultFullAccessPolicy","value":"DefaultFullAccessPolicy"},{"label":"DefaultFullAccessWithNotificationPolicy","value":"DefaultFullAccessWithNotificationPolicy"}]} @@ -131,6 +132,10 @@ function Invoke-CIPPStandardSpamFilterPolicy { $PhishSpamAction = $Settings.PhishSpamAction.value ?? $Settings.PhishSpamAction $PhishQuarantineTag = $Settings.PhishQuarantineTag.value ?? $Settings.PhishQuarantineTag $HighConfidencePhishQuarantineTag = $Settings.HighConfidencePhishQuarantineTag.value ?? $Settings.HighConfidencePhishQuarantineTag + # BulkMovesEnabled is in Preview and not available in every organization, so it is only + # compared and written when explicitly configured On or Off. + $BulkMovesEnabled = $Settings.BulkMovesEnabled.value ?? $Settings.BulkMovesEnabled + $BulkMovesConfigured = $BulkMovesEnabled -in @('On', 'Off') # Normalize list settings to clean string arrays. Values may arrive as a proper array or as a # single comma-delimited string; splitting and trimming makes Compare-Object and remediation reliable. @@ -186,6 +191,7 @@ function Invoke-CIPPStandardSpamFilterPolicy { ($CurrentState.MarkAsSpamFromAddressAuthFail -eq 'Off') -and ($CurrentState.MarkAsSpamNdrBackscatter -eq 'Off') -and ($CurrentState.MarkAsSpamBulkMail -eq 'On') -and + ((-not $BulkMovesConfigured) -or ($CurrentState.BulkMovesEnabled -eq $BulkMovesEnabled)) -and ($CurrentState.InlineSafetyTipsEnabled -eq $true) -and ($CurrentState.PhishZapEnabled -eq $true) -and ($CurrentState.SpamZapEnabled -eq $true) -and @@ -261,6 +267,9 @@ function Invoke-CIPPStandardSpamFilterPolicy { } else { $cmdParams.Add('EnableRegionBlockList', $false) } + if ($BulkMovesConfigured) { + $cmdParams.Add('BulkMovesEnabled', $BulkMovesEnabled) + } if ($CurrentState.Name -eq $PolicyName) { @@ -390,6 +399,10 @@ function Invoke-CIPPStandardSpamFilterPolicy { $CurrentValue['RegionBlockList'] = $CurrentState.RegionBlockList $ExpectedValue['RegionBlockList'] = $RegionBlockList } + if ($BulkMovesConfigured) { + $CurrentValue['BulkMovesEnabled'] = "$($CurrentState.BulkMovesEnabled)" + $ExpectedValue['BulkMovesEnabled'] = $BulkMovesEnabled + } Set-CIPPStandardsCompareField -FieldName 'standards.SpamFilterPolicy' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant } diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsGlobalMeetingPolicy.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsGlobalMeetingPolicy.ps1 index 1882b76f6b7a7..62ca8f2ca0a1d 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsGlobalMeetingPolicy.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsGlobalMeetingPolicy.ps1 @@ -7,8 +7,8 @@ function Invoke-CIPPStandardTeamsGlobalMeetingPolicy { .SYNOPSIS (Label) Define Global Meeting Policy for Teams .DESCRIPTION - (Helptext) Defines the CIS recommended global meeting policy for Teams. This includes AllowAnonymousUsersToJoinMeeting, AllowAnonymousUsersToStartMeeting, AutoAdmittedUsers, AllowPSTNUsersToBypassLobby, MeetingChatEnabledType, DesignatedPresenterRoleMode, AllowExternalParticipantGiveRequestControl, AllowParticipantGiveRequestControl - (DocsDescription) Defines the CIS recommended global meeting policy for Teams. This includes AllowAnonymousUsersToJoinMeeting, AllowAnonymousUsersToStartMeeting, AutoAdmittedUsers, AllowPSTNUsersToBypassLobby, MeetingChatEnabledType, DesignatedPresenterRoleMode, AllowExternalParticipantGiveRequestControl, AllowParticipantGiveRequestControl + (Helptext) Defines the CIS recommended global meeting policy for Teams. This includes AllowAnonymousUsersToJoinMeeting, AllowAnonymousUsersToStartMeeting, AutoAdmittedUsers, AllowPSTNUsersToBypassLobby, MeetingChatEnabledType, DesignatedPresenterRoleMode, AllowExternalParticipantGiveRequestControl, AllowParticipantGiveRequestControl, AllowExternalNonTrustedMeetingChat, AllowCloudRecording + (DocsDescription) Defines the CIS recommended global meeting policy for Teams. This includes AllowAnonymousUsersToJoinMeeting, AllowAnonymousUsersToStartMeeting, AutoAdmittedUsers, AllowPSTNUsersToBypassLobby, MeetingChatEnabledType, DesignatedPresenterRoleMode, AllowExternalParticipantGiveRequestControl, AllowParticipantGiveRequestControl, AllowExternalNonTrustedMeetingChat, AllowCloudRecording .NOTES CAT Teams Standards @@ -30,12 +30,14 @@ function Invoke-CIPPStandardTeamsGlobalMeetingPolicy { {"type":"autoComplete","required":true,"multiple":false,"creatable":false,"name":"standards.TeamsGlobalMeetingPolicy.MeetingChatEnabledType","label":"Meeting chat policy","options":[{"label":"On for everyone","value":"Enabled"},{"label":"On for everyone but anonymous users","value":"EnabledExceptAnonymous"},{"label":"Off for everyone","value":"Disabled"}]} {"type":"switch","name":"standards.TeamsGlobalMeetingPolicy.AllowParticipantGiveRequestControl","label":"Participants can give or request control"} {"type":"switch","name":"standards.TeamsGlobalMeetingPolicy.AllowExternalParticipantGiveRequestControl","label":"External participants can give or request control"} + {"type":"autoComplete","required":false,"multiple":false,"creatable":false,"name":"standards.TeamsGlobalMeetingPolicy.AllowExternalNonTrustedMeetingChat","label":"External meeting chat","helperText":"CIS 8.5.8 recommends Off. Leave blank to keep the tenant's current value.","options":[{"label":"Off (CIS recommended)","value":false},{"label":"On","value":true}]} + {"type":"autoComplete","required":false,"multiple":false,"creatable":false,"name":"standards.TeamsGlobalMeetingPolicy.AllowCloudRecording","label":"Meeting cloud recording","helperText":"CIS 8.5.9 recommends Off. Leave blank to keep the tenant's current value.","options":[{"label":"Off (CIS recommended)","value":false},{"label":"On","value":true}]} IMPACT Low Impact ADDEDDATE 2024-11-12 POWERSHELLEQUIVALENT - Set-CsTeamsMeetingPolicy -AllowAnonymousUsersToJoinMeeting \$false -AllowAnonymousUsersToStartMeeting \$false -AutoAdmittedUsers \$AutoAdmittedUsers -AllowPSTNUsersToBypassLobby \$false -MeetingChatEnabledType EnabledExceptAnonymous -DesignatedPresenterRoleMode \$DesignatedPresenterRoleMode -AllowExternalParticipantGiveRequestControl \$false -AllowParticipantGiveRequestControl \$false + Set-CsTeamsMeetingPolicy -AllowAnonymousUsersToJoinMeeting \$false -AllowAnonymousUsersToStartMeeting \$false -AutoAdmittedUsers \$AutoAdmittedUsers -AllowPSTNUsersToBypassLobby \$false -MeetingChatEnabledType EnabledExceptAnonymous -DesignatedPresenterRoleMode \$DesignatedPresenterRoleMode -AllowExternalParticipantGiveRequestControl \$false -AllowParticipantGiveRequestControl \$false -AllowExternalNonTrustedMeetingChat \$false -AllowCloudRecording \$false RECOMMENDEDBY "CIS" REQUIREDCAPABILITIES @@ -58,7 +60,7 @@ function Invoke-CIPPStandardTeamsGlobalMeetingPolicy { try { $CurrentState = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsMeetingPolicy' -Action Get -Identity 'Global' | - Select-Object AllowAnonymousUsersToJoinMeeting, AllowAnonymousUsersToStartMeeting, AutoAdmittedUsers, AllowPSTNUsersToBypassLobby, MeetingChatEnabledType, DesignatedPresenterRoleMode, AllowExternalParticipantGiveRequestControl, AllowParticipantGiveRequestControl + Select-Object AllowAnonymousUsersToJoinMeeting, AllowAnonymousUsersToStartMeeting, AutoAdmittedUsers, AllowPSTNUsersToBypassLobby, MeetingChatEnabledType, DesignatedPresenterRoleMode, AllowExternalParticipantGiveRequestControl, AllowParticipantGiveRequestControl, AllowExternalNonTrustedMeetingChat, AllowCloudRecording } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the TeamsGlobalMeetingPolicy state for $Tenant. Error: $ErrorMessage" -Sev Error @@ -84,6 +86,15 @@ function Invoke-CIPPStandardTeamsGlobalMeetingPolicy { $AllowExternalParticipantGiveRequestControl = $Settings.AllowExternalParticipantGiveRequestControl ?? $false $AllowParticipantGiveRequestControl = $Settings.AllowParticipantGiveRequestControl ?? $false + # Opt-in booleans (autoComplete Off/On, or blank to keep the tenant's current value). Unlike the + # switches above, a blank here means "do not manage" so existing deployments are never surprised + # into disabling external chat or cloud recording. The option value can arrive as a real bool or + # as "true"/"false", so ToBoolean handles both; blank/absent falls back to the current state. + $RawExternalChat = $Settings.AllowExternalNonTrustedMeetingChat.value ?? $Settings.AllowExternalNonTrustedMeetingChat + $AllowExternalNonTrustedMeetingChat = if ($null -eq $RawExternalChat -or "$RawExternalChat" -eq '') { $CurrentState.AllowExternalNonTrustedMeetingChat } else { [System.Convert]::ToBoolean($RawExternalChat) } + $RawCloudRecording = $Settings.AllowCloudRecording.value ?? $Settings.AllowCloudRecording + $AllowCloudRecording = if ($null -eq $RawCloudRecording -or "$RawCloudRecording" -eq '') { $CurrentState.AllowCloudRecording } else { [System.Convert]::ToBoolean($RawCloudRecording) } + $StateIsCorrect = ($CurrentState.AllowAnonymousUsersToJoinMeeting -eq $AllowAnonymousUsersToJoinMeeting) -and ($CurrentState.AllowAnonymousUsersToStartMeeting -eq $AllowAnonymousUsersToStartMeeting) -and ($CurrentState.AutoAdmittedUsers -eq $AutoAdmittedUsers) -and @@ -91,7 +102,9 @@ function Invoke-CIPPStandardTeamsGlobalMeetingPolicy { ($CurrentState.MeetingChatEnabledType -eq $MeetingChatEnabledType) -and ($CurrentState.DesignatedPresenterRoleMode -eq $DesignatedPresenterRoleMode) -and ($CurrentState.AllowExternalParticipantGiveRequestControl -eq $AllowExternalParticipantGiveRequestControl) -and - ($CurrentState.AllowParticipantGiveRequestControl -eq $AllowParticipantGiveRequestControl) + ($CurrentState.AllowParticipantGiveRequestControl -eq $AllowParticipantGiveRequestControl) -and + ($CurrentState.AllowExternalNonTrustedMeetingChat -eq $AllowExternalNonTrustedMeetingChat) -and + ($CurrentState.AllowCloudRecording -eq $AllowCloudRecording) if ($Settings.remediate -eq $true) { @@ -108,6 +121,8 @@ function Invoke-CIPPStandardTeamsGlobalMeetingPolicy { DesignatedPresenterRoleMode = $DesignatedPresenterRoleMode AllowExternalParticipantGiveRequestControl = $AllowExternalParticipantGiveRequestControl AllowParticipantGiveRequestControl = $AllowParticipantGiveRequestControl + AllowExternalNonTrustedMeetingChat = $AllowExternalNonTrustedMeetingChat + AllowCloudRecording = $AllowCloudRecording } try { @@ -140,6 +155,8 @@ function Invoke-CIPPStandardTeamsGlobalMeetingPolicy { DesignatedPresenterRoleMode = $CurrentState.DesignatedPresenterRoleMode AllowExternalParticipantGiveRequestControl = $CurrentState.AllowExternalParticipantGiveRequestControl AllowParticipantGiveRequestControl = $CurrentState.AllowParticipantGiveRequestControl + AllowExternalNonTrustedMeetingChat = $CurrentState.AllowExternalNonTrustedMeetingChat + AllowCloudRecording = $CurrentState.AllowCloudRecording } $ExpectedValue = @{ AllowAnonymousUsersToJoinMeeting = $AllowAnonymousUsersToJoinMeeting @@ -150,6 +167,8 @@ function Invoke-CIPPStandardTeamsGlobalMeetingPolicy { DesignatedPresenterRoleMode = $DesignatedPresenterRoleMode AllowExternalParticipantGiveRequestControl = $AllowExternalParticipantGiveRequestControl AllowParticipantGiveRequestControl = $AllowParticipantGiveRequestControl + AllowExternalNonTrustedMeetingChat = $AllowExternalNonTrustedMeetingChat + AllowCloudRecording = $AllowCloudRecording } Set-CIPPStandardsCompareField -FieldName 'standards.TeamsGlobalMeetingPolicy' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant Add-CIPPBPAField -FieldName 'TeamsGlobalMeetingPolicy' -FieldValue $StateIsCorrect -StoreAs bool -Tenant $Tenant diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsZAP.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsZAP.ps1 index 7b3c19c146d28..6beea759304c8 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsZAP.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsZAP.ps1 @@ -42,10 +42,25 @@ function Invoke-CIPPStandardTeamsZAP { $TestResult = Test-CIPPStandardLicense -StandardName 'TeamsZAP' -TenantFilter $Tenant -Preset Exchange if ($TestResult -eq $false) { return $true } + # The Teams protection policy only exists on tenants with Defender for Office 365 - Exchange + # Online returns 403 for Get-TeamsProtectionPolicy on any other tenant. Gate on the license + # (which records a 'License Missing' report row) instead of logging an error every run. + $MDOTestResult = Test-CIPPStandardLicense -StandardName 'TeamsZAP' -TenantFilter $Tenant -Preset DefenderForOffice365 + if ($MDOTestResult -eq $false) { return $true } + try { $CurrentState = (New-ExoRequest -tenantid $Tenant -cmdlet 'Get-TeamsProtectionPolicy' -cmdParams @{ Identity = 'Teams Protection Policy' }).ZapEnabled } catch { $ErrorMessage = Get-CippException -Exception $_ + if ($ErrorMessage.NormalizedError -match '\b403\b') { + # The capability check passed but Exchange still refused the cmdlet: the plan carries the + # capability without Teams protection (or the tenant has not been onboarded yet). Report + # it as a licensing gap rather than a failure so it does not surface as an error every run. + $LicenseMessage = 'License Missing: Exchange Online returned 403 for Get-TeamsProtectionPolicy. Teams protection requires Defender for Office 365 on this tenant.' + Write-LogMessage -API 'Standards' -tenant $Tenant -message "TeamsZAP: $LicenseMessage" -sev Info + Set-CIPPStandardsCompareField -FieldName 'standards.TeamsZAP' -FieldValue $LicenseMessage -LicenseAvailable $false -TenantFilter $Tenant + return $true + } Write-LogMessage -API 'Standards' -tenant $Tenant -message "TeamsZAP: Failed to get Teams Protection Policy. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage return } diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardUserSubmissions.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardUserSubmissions.ps1 index 13f53548e564d..cf8e2f4a6ae13 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardUserSubmissions.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardUserSubmissions.ps1 @@ -8,7 +8,7 @@ function Invoke-CIPPStandardUserSubmissions { (Label) Set the state of the built-in Report button in Outlook .DESCRIPTION (Helptext) Set the state of the spam submission button in Outlook - (DocsDescription) Set the state of the built-in Report button in Outlook. This gives the users the ability to report emails as spam or phish. + (DocsDescription) Set the state of the built-in Report button in Outlook. This gives the users the ability to report emails as spam or phish. When a destination email address is set, the 'Send reported items to' setting controls whether reported messages go to Microsoft and the reporting mailbox, or to the reporting mailbox only (for third-party phishing report services). .NOTES CAT Exchange Standards @@ -18,6 +18,7 @@ function Invoke-CIPPStandardUserSubmissions { ADDEDCOMPONENT {"type":"autoComplete","multiple":false,"label":"Select value","name":"standards.UserSubmissions.state","options":[{"label":"Enabled","value":"enable"},{"label":"Disabled","value":"disable"}]} {"type":"textField","name":"standards.UserSubmissions.email","required":false,"label":"Destination email address"} + {"type":"autoComplete","multiple":false,"label":"Send reported items to (when a destination email address is set)","name":"standards.UserSubmissions.reportDestination","options":[{"label":"Microsoft and my reporting mailbox","value":"Both"},{"label":"My reporting mailbox only","value":"Mailbox"}]} IMPACT Medium Impact ADDEDDATE @@ -48,6 +49,11 @@ function Invoke-CIPPStandardUserSubmissions { $state = $Settings.state.value ?? $Settings.state $Email = Get-CIPPTextReplacement -TenantFilter $Tenant -Text $Settings.email + # 'Send reported items to' only applies when a destination email address is set. + # Missing/blank keeps the pre-existing behavior: report to Microsoft as well as the mailbox. + $Destination = $Settings.reportDestination.value ?? $Settings.reportDestination + $ReportToMicrosoft = [string]::IsNullOrWhiteSpace($Email) -or $Destination -ne 'Mailbox' + # Input validation if ($Settings.remediate -eq $true -or $Settings.alert -eq $true) { if (!($state -eq 'enable' -or $state -eq 'disable')) { @@ -82,7 +88,7 @@ function Invoke-CIPPStandardUserSubmissions { ($PolicyState.ReportPhishAddresses.Count -eq 0) $RuleIsCorrect = ($RuleState.length -eq 0) -or ($RuleState.State -ne 'Enabled') } else { - $PolicyIsCorrect = ($PolicyState.EnableReportToMicrosoft -eq $true) -and + $PolicyIsCorrect = ($PolicyState.EnableReportToMicrosoft -eq $ReportToMicrosoft) -and ($PolicyState.ReportJunkToCustomizedAddress -eq $true) -and ($PolicyState.ReportJunkAddresses -eq $Email) -and ($PolicyState.ReportNotJunkToCustomizedAddress -eq $true) -and @@ -128,7 +134,7 @@ function Invoke-CIPPStandardUserSubmissions { } } else { $PolicyParams = @{ - EnableReportToMicrosoft = $true + EnableReportToMicrosoft = $ReportToMicrosoft ReportJunkToCustomizedAddress = $true ReportJunkAddresses = $Email ReportNotJunkToCustomizedAddress = $true @@ -209,7 +215,7 @@ function Invoke-CIPPStandardUserSubmissions { if ($StateIsCorrect -eq $true) { Write-LogMessage -API 'Standards' -tenant $Tenant -message 'User Submission policy is properly configured.' -sev Info } else { - if ($Policy.EnableReportToMicrosoft -eq $true) { + if ($PolicyState.EnableReportToMicrosoft -eq $true) { Write-StandardsAlert -message 'User Submission policy is enabled but incorrectly configured' -object $PolicyState -tenant $Tenant -standardName 'UserSubmissions' -standardId $Settings.standardId Write-LogMessage -API 'Standards' -tenant $Tenant -message 'User Submission policy is enabled but incorrectly configured' -sev Info } else { @@ -243,7 +249,7 @@ function Invoke-CIPPStandardUserSubmissions { } } $ExpectedValue = @{ - EnableReportToMicrosoft = $state -eq 'enable' + EnableReportToMicrosoft = ($state -eq 'enable') -and $ReportToMicrosoft ReportJunkToCustomizedAddress = if ([string]::IsNullOrWhiteSpace($Email)) { $false } else { $true } ReportNotJunkToCustomizedAddress = if ([string]::IsNullOrWhiteSpace($Email)) { $false } else { $true } ReportPhishToCustomizedAddress = if ([string]::IsNullOrWhiteSpace($Email)) { $false } else { $true } diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardcalDefault.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardcalDefault.ps1 index 6a7d9836a3927..d4d50f185a4f5 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardcalDefault.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardcalDefault.ps1 @@ -64,16 +64,42 @@ function Invoke-CIPPStandardcalDefault { } # Filter to only Default user permissions that don't match target level - $DefaultPermissions = $CalendarPermissions | Where-Object { $_.User -eq 'Default' } + $DefaultPermissions = @($CalendarPermissions | Where-Object { $_.User -eq 'Default' }) $NeedsUpdate = @($DefaultPermissions | Where-Object { $currentRights = if ($_.AccessRights -is [array]) { $_.AccessRights -join ',' } else { $_.AccessRights } $currentRights -ne $permissionLevel }) - $CurrentValue = if ($NeedsUpdate.Count -eq 0) { + # Coverage is graded separately from compliance: a mailbox with no cached Default row can + # never enter $NeedsUpdate, so an incomplete collection used to read as a clean sweep. + # Matched by identity, not counts - a stale row for a deleted mailbox would offset a newly + # uncovered one. Identity is ":\", keyed by UPN, alias or Exchange GUID. + $Mailboxes = @(New-CIPPDbRequest -TenantFilter $Tenant -Type 'Mailboxes' -Fields 'UPN', 'primarySmtpAddress', 'Id', 'ExternalDirectoryObjectId') + + $GradedIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Permission in $DefaultPermissions) { + $MailboxId = ("$($Permission.Identity)" -split ':\\')[0] + # An empty key would match every mailbox missing that field and hide real gaps. + if ($MailboxId) { $null = $GradedIds.Add($MailboxId) } + } + + $UncheckedMailboxes = @($Mailboxes | Where-Object { + $Keys = [string[]]@($_.UPN, $_.primarySmtpAddress, $_.Id, $_.ExternalDirectoryObjectId | Where-Object { $_ }) + -not $GradedIds.Overlaps($Keys) + }) + $Unchecked = $UncheckedMailboxes.Count + + $UncheckedNames = @($UncheckedMailboxes | ForEach-Object { $_.UPN ? $_.UPN : $_.primarySmtpAddress }) + $UncheckedSample = ($UncheckedNames | Select-Object -First 10) -join ', ' + $CoverageWarning = "$Unchecked of $($Mailboxes.Count) mailboxes have no cached Default calendar permission and were NOT evaluated ($UncheckedSample). The calendar permission cache is incomplete." + + $CurrentValue = if ($NeedsUpdate.Count -eq 0 -and $Unchecked -eq 0) { [PSCustomObject]@{ state = 'Configured correctly' } } else { - [PSCustomObject]@{ NonCompliantCalendars = $NeedsUpdate | Select-Object -Property Identity, AccessRights } + [PSCustomObject]@{ + NonCompliantCalendars = @($NeedsUpdate | Select-Object -Property Identity, AccessRights) + UncheckedMailboxes = $Unchecked + } } $ExpectedValue = [PSCustomObject]@{ state = 'Configured correctly' @@ -81,7 +107,11 @@ function Invoke-CIPPStandardcalDefault { if ($Settings.remediate -eq $true) { if ($NeedsUpdate.Count -eq 0) { - Write-LogMessage -API 'Standards' -tenant $Tenant -message 'All calendars already have the correct default permission level.' -sev Info + if ($Unchecked -gt 0) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "All $($DefaultPermissions.Count) checked calendars already have the correct default permission level, but $CoverageWarning" -sev Warning + } else { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "All $($DefaultPermissions.Count) calendars already have the correct default permission level." -sev Info + } } else { $UpdateDB = $false try { @@ -124,7 +154,11 @@ function Invoke-CIPPStandardcalDefault { if ($Settings.alert -eq $true) { if ($NeedsUpdate.Count -eq 0) { - Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Default calendar permissions are correctly configured for all mailboxes' -sev Info + if ($Unchecked -gt 0) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Default calendar permissions are correctly configured for all $($DefaultPermissions.Count) checked mailboxes, but $CoverageWarning" -sev Warning + } else { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Default calendar permissions are correctly configured for all mailboxes' -sev Info + } } else { Write-StandardsAlert -message "Default calendar permission is not set to $permissionLevel for $($NeedsUpdate.Count) calendars" -object ($NeedsUpdate | Select-Object -Property Identity, AccessRights) -tenant $Tenant -standardName 'calDefault' -standardId $Settings.standardId Write-LogMessage -API 'Standards' -tenant $Tenant -message "Default calendar permission is not set to $permissionLevel for $($NeedsUpdate.Count) calendars" -sev Info diff --git a/Modules/CIPPTests/Public/Helpers/ConvertTo-CippMarkdownCell.ps1 b/Modules/CIPPTests/Public/Helpers/ConvertTo-CippMarkdownCell.ps1 index c6cbc88b51981..e1788d2b40563 100644 --- a/Modules/CIPPTests/Public/Helpers/ConvertTo-CippMarkdownCell.ps1 +++ b/Modules/CIPPTests/Public/Helpers/ConvertTo-CippMarkdownCell.ps1 @@ -38,6 +38,8 @@ function ConvertTo-CippMarkdownCell { $Text = [string]$Value if ([string]::IsNullOrEmpty($Text)) { return '' } - return ($Text -replace '\|', '\|' -replace '\r?\n', ' ').Trim() + # Backslashes first: they are the escape character the frontend table parser honours, + # so a literal one (CONTOSO\jdoe) has to be doubled before the pipe escape adds new ones. + return ($Text -replace '\\', '\\' -replace '\|', '\|' -replace '\r?\n', ' ').Trim() } } diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_1.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_1.ps1 index 545d6281a5893..f0daa4b672546 100644 --- a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_1.ps1 +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_1.ps1 @@ -9,7 +9,14 @@ function Invoke-CippTestCIS_2_1_1 { $SafeLinks = Get-CIPPTestData -TenantFilter $Tenant -Type 'ExoSafeLinksPolicies' if (-not $SafeLinks) { - Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_1' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ExoSafeLinksPolicies cache not found. Please refresh the cache for this tenant.' -Risk 'High' -Name 'Safe Links for Office Applications is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + # A Count marker means the cache was collected but the tenant has no Safe Links policy - + # that is a real failure (Safe Links is not enabled), not a missing cache. No marker + # means the type was never collected, so a Cache refresh is the correct guidance. + if (Get-CIPPDbItem -TenantFilter $Tenant -Type 'ExoSafeLinksPolicies' -CountsOnly) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_1' -TestType 'Identity' -Status 'Failed' -ResultMarkdown 'No Safe Links policy exists for this tenant, so Safe Links for Office applications is not enabled.' -Risk 'High' -Name 'Safe Links for Office Applications is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + } else { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_1' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ExoSafeLinksPolicies has not been collected for this tenant. Run a Cache refresh (Cache & Tests); if it persists, the tenant may not have Defender for Office 365.' -Risk 'High' -Name 'Safe Links for Office Applications is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + } return } diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_11.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_11.ps1 index 26dcfb8d7d145..cc7ba1a7da29c 100644 --- a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_11.ps1 +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_11.ps1 @@ -13,16 +13,21 @@ function Invoke-CippTestCIS_2_1_11 { return } - $Default = $Malware | Where-Object { $_.IsDefault -eq $true } | Select-Object -First 1 - if (-not $Default) { $Default = $Malware | Select-Object -First 1 } - + # Comprehensive filtering is enforced by whichever malware policy applies via its rule, not + # only the built-in Default (which is capped at ~50 file types) - CIPP's own standard creates + # a non-default policy for exactly this. Grade the best policy that meets the bar. # CIS v7 defines a comprehensive list of 186 extensions and requires at least 90% adoption (>= 168). - $FileTypeCount = ($Default.FileTypes | Measure-Object).Count + $Policies = @($Malware) + $Compliant = $Policies | Where-Object { $_.EnableFileFilter -eq $true -and (($_.FileTypes | Measure-Object).Count -ge 168) } | Select-Object -First 1 - if ($Default.EnableFileFilter -eq $true -and $FileTypeCount -ge 168) { + if ($Compliant) { + $FileTypeCount = ($Compliant.FileTypes | Measure-Object).Count $Status = 'Passed' - $Result = "Comprehensive attachment filtering is applied — $FileTypeCount file types blocked on '$($Default.Identity)'." + $Result = "Comprehensive attachment filtering is applied — $FileTypeCount file types blocked on '$($Compliant.Identity)'." } else { + $Default = $Policies | Where-Object { $_.IsDefault -eq $true } | Select-Object -First 1 + if (-not $Default) { $Default = $Policies | Select-Object -First 1 } + $FileTypeCount = ($Default.FileTypes | Measure-Object).Count $Status = 'Failed' $Result = "Attachment filter on '$($Default.Identity)' is not comprehensive (EnableFileFilter: $($Default.EnableFileFilter), FileTypes count: $FileTypeCount, expected >= 168 — 90% of the CIS v7 186-extension list)." } diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_3.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_3.ps1 index adac4d1ec2536..7ec17f0236226 100644 --- a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_3.ps1 +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_3.ps1 @@ -13,15 +13,18 @@ function Invoke-CippTestCIS_2_1_3 { return } - $Default = $Malware | Where-Object { $_.IsDefault -eq $true } | Select-Object -First 1 - if (-not $Default) { $Default = $Malware | Select-Object -First 1 } + # The CIPP malware standard sets these notifications on the non-default policy it applies via + # a rule, not on the built-in Default - so accept any policy that has them configured, not + # only the IsDefault one (which otherwise makes a compliant tenant false-fail). + $Policies = @($Malware) + $Compliant = $Policies | Where-Object { $_.EnableInternalSenderAdminNotifications -eq $true -and -not [string]::IsNullOrWhiteSpace($_.InternalSenderAdminAddress) } | Select-Object -First 1 - $HasRecipients = $Default.EnableInternalSenderAdminNotifications -eq $true -and -not [string]::IsNullOrWhiteSpace($Default.InternalSenderAdminAddress) - - if ($HasRecipients) { + if ($Compliant) { $Status = 'Passed' - $Result = "Internal sender admin notifications enabled on '$($Default.Identity)'. Recipient: $($Default.InternalSenderAdminAddress)." + $Result = "Internal sender admin notifications enabled on '$($Compliant.Identity)'. Recipient: $($Compliant.InternalSenderAdminAddress)." } else { + $Default = $Policies | Where-Object { $_.IsDefault -eq $true } | Select-Object -First 1 + if (-not $Default) { $Default = $Policies | Select-Object -First 1 } $Status = 'Failed' $Result = "Internal sender admin notifications are not configured on '$($Default.Identity)'.`n`n- EnableInternalSenderAdminNotifications: $($Default.EnableInternalSenderAdminNotifications)`n- InternalSenderAdminAddress: '$($Default.InternalSenderAdminAddress)'" } diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_4.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_4.ps1 index 935c5ec645b83..f76297dcf85be 100644 --- a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_4.ps1 +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_4.ps1 @@ -9,7 +9,14 @@ function Invoke-CippTestCIS_2_1_4 { $SA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ExoSafeAttachmentPolicies' if (-not $SA) { - Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_4' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ExoSafeAttachmentPolicies cache not found. Please refresh the cache for this tenant.' -Risk 'High' -Name 'Safe Attachments policy is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + # A Count marker means the cache was collected but the tenant has no Safe Attachments + # policy - that is a real failure, not a missing cache. No marker means the type was + # never collected, so a Cache refresh is the correct guidance. + if (Get-CIPPDbItem -TenantFilter $Tenant -Type 'ExoSafeAttachmentPolicies' -CountsOnly) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_4' -TestType 'Identity' -Status 'Failed' -ResultMarkdown 'No Safe Attachments policy exists for this tenant, so Safe Attachments is not enabled.' -Risk 'High' -Name 'Safe Attachments policy is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + } else { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_4' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ExoSafeAttachmentPolicies has not been collected for this tenant. Run a Cache refresh (Cache & Tests); if it persists, the tenant may not have Defender for Office 365.' -Risk 'High' -Name 'Safe Attachments policy is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + } return } diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_5.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_5.ps1 index c5a9c4c94d74f..7c7f7f9c535f5 100644 --- a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_5.ps1 +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_5.ps1 @@ -9,7 +9,14 @@ function Invoke-CippTestCIS_2_1_5 { $Atp = Get-CIPPTestData -TenantFilter $Tenant -Type 'ExoAtpPolicyForO365' if (-not $Atp) { - Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_5' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ExoAtpPolicyForO365 cache not found. Please refresh the cache for this tenant.' -Risk 'High' -Name 'Safe Attachments for SharePoint, OneDrive, and Teams is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + # A Count marker means the cache was collected but the tenant has no ATP policy - that is + # a real failure, not a missing cache. No marker means the type was never collected, so a + # Cache refresh is the correct guidance. + if (Get-CIPPDbItem -TenantFilter $Tenant -Type 'ExoAtpPolicyForO365' -CountsOnly) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_5' -TestType 'Identity' -Status 'Failed' -ResultMarkdown 'No ATP policy for Office 365 exists for this tenant, so Safe Attachments for SharePoint, OneDrive and Teams is not enabled.' -Risk 'High' -Name 'Safe Attachments for SharePoint, OneDrive, and Teams is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + } else { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_5' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ExoAtpPolicyForO365 has not been collected for this tenant. Run a Cache refresh (Cache & Tests); if it persists, the tenant may not have Defender for Office 365.' -Risk 'High' -Name 'Safe Attachments for SharePoint, OneDrive, and Teams is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + } return } diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_1_2.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_1_2.ps1 index bbd546c7f849c..18a3a3d6a3808 100644 --- a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_1_2.ps1 +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_1_2.ps1 @@ -14,14 +14,21 @@ function Invoke-CippTestCIS_6_1_2 { } $User = $Mailboxes | Where-Object { $_.RecipientTypeDetails -eq 'UserMailbox' } - $Failures = $User | Where-Object { $_.AuditEnabled -eq $false -or -not $_.AuditOwner -or $_.AuditOwner.Count -eq 0 } + # CIS 6.1.2 requires per-mailbox audit ACTIONS to be configured. With mailbox auditing on by + # default Microsoft applies the default action sets and Get-Mailbox reports AuditEnabled = True, + # so the reliable signal is a non-empty AuditOwner (the effective owner actions) or a + # DefaultAuditSet that still lists the Owner sign-in type (Microsoft-managed defaults). + # AuditEnabled itself can arrive as a string from EXO REST and is not graded directly. + $Failures = $User | Where-Object { + @($_.AuditOwner).Count -eq 0 -and ("$($_.DefaultAuditSet)" -notmatch 'Owner') + } if ($Failures.Count -eq 0) { $Status = 'Passed' - $Result = "All $($User.Count) user mailbox(es) have auditing enabled with audit actions configured." + $Result = "All $($User.Count) user mailbox(es) have owner audit actions configured." } else { $Status = 'Failed' - $Result = "$($Failures.Count) of $($User.Count) user mailbox(es) have auditing disabled or no audit actions configured." + $Result = "$($Failures.Count) of $($User.Count) user mailbox(es) have no owner audit actions configured." } Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_6_1_2' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'Mailbox audit actions are configured' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Audit & Compliance' diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_11.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_11.ps1 index f2c3acda87f14..8f5673303bf20 100644 --- a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_11.ps1 +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_11.ps1 @@ -15,12 +15,22 @@ function Invoke-CippTestCIS_7_2_11 { $Cfg = $SPO | Select-Object -First 1 - if ($Cfg.DefaultLinkPermission -eq 'View') { + # The SPOTenant cache comes from the SharePoint CSOM endpoint, which returns + # DefaultLinkPermission as a numeric SharingPermissionType (None=0, View=1, Edit=2) - not the + # friendly name Get-SPOTenant shows. Normalise before comparing, or every tenant false-fails. + $PermissionName = switch ("$($Cfg.DefaultLinkPermission)") { + '0' { 'None' } + '1' { 'View' } + '2' { 'Edit' } + default { "$($Cfg.DefaultLinkPermission)" } + } + + if ($PermissionName -eq 'View') { $Status = 'Passed' $Result = 'DefaultLinkPermission is set to View.' } else { $Status = 'Failed' - $Result = "DefaultLinkPermission is set to $($Cfg.DefaultLinkPermission). CIS requires View." + $Result = "DefaultLinkPermission is set to $PermissionName. CIS requires View." } Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_2_11' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'The SharePoint default sharing link permission is set' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Data Protection' diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_7.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_7.ps1 index 7622e8f0968d1..90e8c4a7afcc4 100644 --- a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_7.ps1 +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_7.ps1 @@ -14,14 +14,26 @@ function Invoke-CippTestCIS_7_2_7 { } $Cfg = $SPO | Select-Object -First 1 + + # The SPOTenant cache comes from the SharePoint CSOM endpoint, which returns + # DefaultSharingLinkType as a numeric SharingLinkType (None=0, Direct=1, Internal=2, + # AnonymousAccess=3) - not the friendly name Get-SPOTenant shows. Normalise before comparing, + # or every tenant false-fails. + $LinkTypeName = switch ("$($Cfg.DefaultSharingLinkType)") { + '0' { 'None' } + '1' { 'Direct' } + '2' { 'Internal' } + '3' { 'AnonymousAccess' } + default { "$($Cfg.DefaultSharingLinkType)" } + } $Acceptable = @('Direct', 'Internal') - if ($Cfg.DefaultSharingLinkType -in $Acceptable) { + if ($LinkTypeName -in $Acceptable) { $Status = 'Passed' - $Result = "DefaultSharingLinkType is restricted ($($Cfg.DefaultSharingLinkType))." + $Result = "DefaultSharingLinkType is restricted ($LinkTypeName)." } else { $Status = 'Failed' - $Result = "DefaultSharingLinkType is too permissive ($($Cfg.DefaultSharingLinkType)). Set to Direct or Internal." + $Result = "DefaultSharingLinkType is too permissive ($LinkTypeName). Set to Direct or Internal." } Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_2_7' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'Link sharing is restricted in SharePoint and OneDrive' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'External Collaboration' diff --git a/Modules/CIPPTests/Public/Tests/GenericTests/Identity/Invoke-CippTestGenericTest011.ps1 b/Modules/CIPPTests/Public/Tests/GenericTests/Identity/Invoke-CippTestGenericTest011.ps1 index 3930ff44c207b..a6236c9921308 100644 --- a/Modules/CIPPTests/Public/Tests/GenericTests/Identity/Invoke-CippTestGenericTest011.ps1 +++ b/Modules/CIPPTests/Public/Tests/GenericTests/Identity/Invoke-CippTestGenericTest011.ps1 @@ -43,8 +43,10 @@ function Invoke-CippTestGenericTest011 { $AllIntuneTemplates = @($RawIntuneTemplates | ForEach-Object { $JSONData = $_.JSON | ConvertFrom-Json -Depth 10 $data = $JSONData.RAWJson | ConvertFrom-Json -Depth 10 - $data | Add-Member -NotePropertyName 'displayName' -NotePropertyValue $JSONData.Displayname -Force - $data | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $_.RowKey -Force + $data | Add-Member -NotePropertyMembers ([ordered]@{ + displayName = $JSONData.Displayname + GUID = $_.RowKey + }) -Force $data }) } catch { $AllIntuneTemplates = @() } diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21811.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21811.ps1 index 3f937afc706e1..4df1dcf46c521 100644 --- a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21811.ps1 +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21811.ps1 @@ -13,7 +13,17 @@ function Invoke-CippTestZTNA21811 { return } - $misconfiguredDomains = $domains | Where-Object { $_.passwordValidityPeriodInDays -ne 2147483647 } + # Subdomains cannot carry their own password policy (Graph returns null and blocks updates), + # so evaluate root-level domains only — same derivation as Invoke-CIPPStandardPasswordExpireDisabled. + $DomainIds = @($domains.id) + $SubDomains = foreach ($id in $DomainIds) { + foreach ($parent in $DomainIds) { + if ($id -ne $parent -and $id.EndsWith(".$parent")) { + $id; break + } + } + } + $misconfiguredDomains = $domains | Where-Object { $_.id -notin $SubDomains -and $null -ne $_.passwordValidityPeriodInDays -and $_.passwordValidityPeriodInDays -ne 2147483647 } $users = Get-CIPPTestData -TenantFilter $Tenant -Type 'Users' @@ -21,7 +31,8 @@ function Invoke-CippTestZTNA21811 { if ($users) { $misconfiguredUsers = foreach ($user in $users) { $userDomain = $user.userPrincipalName.Split('@')[-1] - $domainPolicy = $misconfiguredDomains | Where-Object { $_.id -eq $userDomain } + # Subdomain UPNs inherit the root domain's policy, so match on the suffix too + $domainPolicy = $misconfiguredDomains | Where-Object { $_.id -eq $userDomain -or $userDomain.EndsWith(".$($_.id)") } if (($user.passwordPolicies -notlike '*DisablePasswordExpiration*') -and ($domainPolicy)) { [PSCustomObject]@{ id = $user.id diff --git a/Modules/CippExtensions/Public/Extension Functions/Register-CippExtensionScheduledTasks.ps1 b/Modules/CippExtensions/Public/Extension Functions/Register-CippExtensionScheduledTasks.ps1 index e514464ceac84..cda40b544fca5 100644 --- a/Modules/CippExtensions/Public/Extension Functions/Register-CippExtensionScheduledTasks.ps1 +++ b/Modules/CippExtensions/Public/Extension Functions/Register-CippExtensionScheduledTasks.ps1 @@ -31,6 +31,18 @@ function Register-CIPPExtensionScheduledTasks { $ExtensionConfig = $Config.$Extension if ($ExtensionConfig.Enabled -eq $true -or $Extension -eq 'CustomData') { if ($Extension -eq 'Sherweb') { + # Mapping a tenant for CSP licensing must not enrol it into daily migration checks. + # Only schedule migration tasks when automated migration is explicitly enabled; when + # it is off, clean up any tasks that were previously created so they stop firing. + if ($ExtensionConfig.AutoMigrations -ne $true) { + $SherwebMigTasks | ForEach-Object { + Write-Information "Sherweb automated migration disabled: Cleaning up scheduled task $($_.Name) for tenant $($_.Tenant)" + $Entity = $_ | Select-Object -Property PartitionKey, RowKey + Remove-CIPPAzDataTableEntity -Force @ScheduledTasksTable -Entity $Entity + } + $SherwebMigTasks = @() # Clear the list since we removed them all + continue + } # Sherweb migration tasks - schedule per mapped tenant $SherwebMappings = Get-CIPPAzDataTableEntity @MappingsTable -Filter "PartitionKey eq 'SherwebMapping'" foreach ($Mapping in $SherwebMappings) { diff --git a/Modules/CippExtensions/Public/Halo/Get-HaloPriority.ps1 b/Modules/CippExtensions/Public/Halo/Get-HaloPriority.ps1 index 5b99bf0dfa0f1..629b97c3c2422 100644 --- a/Modules/CippExtensions/Public/Halo/Get-HaloPriority.ps1 +++ b/Modules/CippExtensions/Public/Halo/Get-HaloPriority.ps1 @@ -36,23 +36,14 @@ function Get-HaloPriority { } $Headers = @{ Authorization = "Bearer $($Token.access_token)" } - $TicketTypeRecord = Invoke-RestMethod -Uri "$($Configuration.ResourceURL)/tickettype/$TicketType" -ContentType 'application/json' -Method GET -Headers $Headers - - # Halo's /tickettype/{id} response uses different field names for the linked SLA across - # versions. Check the known variants in priority order, take the first non-zero match. - $SlaIdCandidates = @('default_sla', 'default_sla_id', 'sla_id', 'slaid', 'sla') - $SlaId = $null - foreach ($Field in $SlaIdCandidates) { - $Value = $TicketTypeRecord.$Field - if ($Value -and ([int]$Value) -gt 0) { - $SlaId = [int]$Value - break - } - } + $SlaId = Get-HaloTicketTypeSlaId -TicketType $TicketType -Configuration $Configuration -Token $Token if (-not $SlaId) { + # New-HaloPSATicket applies the same test and omits priority_id entirely for this + # ticket type, so the message describes what will actually happen rather than just + # explaining an empty list. return @(@{ - name = 'The selected Ticket Type has no SLA attached, so there are no priorities to pick from. Attach an SLA to the ticket type in HaloPSA, or leave this blank.' + name = 'The selected Ticket Type has no SLA attached, so there are no priorities to pick from. Tickets will be created without a priority and HaloPSA will apply its own. Attach an SLA to the ticket type in HaloPSA to choose one here.' priorityid = -1 }) } diff --git a/Modules/CippExtensions/Public/Halo/Get-HaloTicketTypeSlaId.ps1 b/Modules/CippExtensions/Public/Halo/Get-HaloTicketTypeSlaId.ps1 new file mode 100644 index 0000000000000..1211f599654dc --- /dev/null +++ b/Modules/CippExtensions/Public/Halo/Get-HaloTicketTypeSlaId.ps1 @@ -0,0 +1,53 @@ +function Get-HaloTicketTypeSlaId { + <# + .SYNOPSIS + Resolve the SLA id attached to a HaloPSA ticket type, or $null when it has none. + .DESCRIPTION + Priorities in HaloPSA are defined per priority per SLA - the same priority_id means a + different thing under a different SLA (response and resolution targets are set on the + SLA/priority pair). A ticket type with no SLA therefore has no priority set that can be + meaningfully chosen from, which is why both the settings dropdown and the ticket writer + need to agree on whether one is attached. + + Shared by Get-HaloPriority (to decide whether there is anything to offer) and + New-HaloPSATicket (to decide whether to send priority_id at all), so the two cannot drift + apart and start disagreeing about the same ticket type. + .PARAMETER TicketType + The ticket type id to resolve. + .PARAMETER Configuration + The HaloPSA extension configuration, for ResourceURL. + .PARAMETER Token + An existing Halo token, so callers that already hold one do not fetch a second. + .OUTPUTS + [int] the SLA id, or $null when the ticket type has no SLA or could not be read. + #> + [CmdletBinding()] + param ( + $TicketType, + $Configuration, + $Token + ) + + if (-not $TicketType) { return $null } + + try { + $Headers = @{ Authorization = "Bearer $($Token.access_token)" } + $TicketTypeRecord = Invoke-RestMethod -Uri "$($Configuration.ResourceURL)/tickettype/$TicketType" -ContentType 'application/json' -Method GET -Headers $Headers + + # Halo's /tickettype/{id} response uses different field names for the linked SLA across + # versions. Check the known variants in order and take the first usable match. Halo uses + # -1 for "none", so anything not greater than zero counts as no SLA. + foreach ($Field in @('default_sla', 'default_sla_id', 'sla_id', 'slaid', 'sla')) { + $Value = $TicketTypeRecord.$Field + if ($Value -and ([int]$Value) -gt 0) { + return [int]$Value + } + } + return $null + } catch { + # Callers treat $null as "no SLA" and omit the priority, which is the safe direction: + # a transient lookup failure should not put an arbitrary priority on a ticket. + Write-Information "Could not resolve the SLA for HaloPSA ticket type $TicketType : $($_.Exception.Message)" + return $null + } +} diff --git a/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 b/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 index 5cddec481ebe8..21b79d3fd87cf 100644 --- a/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 +++ b/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 @@ -6,7 +6,12 @@ function New-HaloPSATicket { $client, [string]$UserUPN, [string]$AzureOID, - [string]$DisplayName + [string]$DisplayName, + # Per-alert priority override. Left untyped so callers can hand over either a raw Halo + # priority id or the {label, value} shape the alert form stores, matching how the + # integration-wide DefaultPriority is read below. + $TicketPriority, + [int]$TicketId ) #Get HaloPSA Token based on the config we have. $Table = Get-CIPPTable -TableName Extensionsconfig @@ -16,8 +21,12 @@ function New-HaloPSATicket { # Resolve affected user to a HaloPSA contact when the integration is configured for it. # Unmatched users fall through to userlookup.id = -1 (the client's General User contact). + # An explicit TicketId means the caller already knows which ticket the work belongs to, so there + # is nothing to resolve - the target ticket carries its own user. This is the case that matters + # for onboarding: a user created seconds ago is never a HaloPSA contact yet, so matching would + # always miss and stamp the ticket with the General User fallback. $MatchedUser = $null - $UserLinkActive = $Configuration.LinkTicketsToUsers -and ($UserUPN -or $AzureOID) + $UserLinkActive = $TicketId -le 0 -and $Configuration.LinkTicketsToUsers -and ($UserUPN -or $AzureOID) if ($UserLinkActive) { $MatchedUser = Get-HaloUser -AzureOID $AzureOID -Email $UserUPN -ClientId $client -Configuration $Configuration -Token $token if (-not $MatchedUser) { @@ -37,67 +46,85 @@ function New-HaloPSATicket { # from the General User (id = -1). $SiteId = if ($MatchedUser) { $MatchedUser.site_id } else { $null } - if ($Configuration.ConsolidateTickets) { + # A caller-supplied TicketId targets a ticket CIPP did not open - a scheduled task carrying a PSA + # reference back to the request it came from - so it bypasses the consolidation table entirely. + # Otherwise fall back to the ticket CIPP opened for this title, when consolidation is enabled. + $TargetTicketId = $null + if ($TicketId -gt 0) { + $TargetTicketId = $TicketId + Write-Information "Targeting caller-supplied HaloPSA ticket: $TargetTicketId" + } elseif ($Configuration.ConsolidateTickets) { $ExistingTicket = Get-CIPPAzDataTableEntity @TicketTable -Filter "PartitionKey eq 'HaloPSA' and RowKey eq '$($client)-$($TitleHash)'" if ($ExistingTicket) { Write-Information "Ticket already exists in HaloPSA: $($ExistingTicket.TicketID)" + $TargetTicketId = $ExistingTicket.TicketID + } + } - $Ticket = Invoke-RestMethod -Uri "$($Configuration.ResourceURL)/Tickets/$($ExistingTicket.TicketID)?includedetails=true&includelastaction=false&nocache=undefined&includeusersassets=false&isdetailscreen=true" -ContentType 'application/json; charset=utf-8' -Method Get -Headers @{Authorization = "Bearer $($token.access_token)" } -SkipHttpErrorCheck - if ($Ticket.id) { - if (!$Ticket.hasbeenclosed) { - Write-Information 'Ticket is still open, adding new note' - # Halo won't take a note without an outcome - it answers "An Outcome must be entered - # for this Action" - so fall back to 7, the built-in Internal Note outcome, when the - # integration hasn't been given one. The failure this used to hit was the API user not - # having rights to the action, which is caught below and falls back to a new ticket so - # the alert still lands somewhere. - $Outcome = if ($Configuration.Outcome) { - $Configuration.Outcome.value ?? $Configuration.Outcome - } else { - 7 + if ($TargetTicketId) { + $Ticket = Invoke-RestMethod -Uri "$($Configuration.ResourceURL)/Tickets/$($TargetTicketId)?includedetails=true&includelastaction=false&nocache=undefined&includeusersassets=false&isdetailscreen=true" -ContentType 'application/json; charset=utf-8' -Method Get -Headers @{Authorization = "Bearer $($token.access_token)" } -SkipHttpErrorCheck + if ($Ticket.id) { + if (!$Ticket.hasbeenclosed) { + Write-Information 'Ticket is still open, adding new note' + # Halo won't take a note without an outcome - it answers "An Outcome must be entered + # for this Action" - so fall back to 7, the built-in Internal Note outcome, when the + # integration hasn't been given one. The failure this used to hit was the API user not + # having rights to the action, which is caught below and falls back to a new ticket so + # the alert still lands somewhere. + $Outcome = if ($Configuration.Outcome) { + $Configuration.Outcome.value ?? $Configuration.Outcome + } else { + 7 + } + $Object = [PSCustomObject]@{ + ticket_id = $TargetTicketId + outcome_id = $Outcome + hiddenfromuser = $true + note_html = $description + } + + $body = ConvertTo-Json -Compress -Depth 10 -InputObject @($Object) + $NoteAdded = $false + try { + if ($PSCmdlet.ShouldProcess('Add note to HaloPSA ticket', 'Add note')) { + $Action = Invoke-RestMethod -Uri "$($Configuration.ResourceURL)/actions" -ContentType 'application/json; charset=utf-8' -Method Post -Body $body -Headers @{Authorization = "Bearer $($token.access_token)" } + Write-Information "Note added to ticket in HaloPSA: $TargetTicketId" + $NoteAdded = $true } - $Object = [PSCustomObject]@{ - ticket_id = $ExistingTicket.TicketID - outcome_id = $Outcome - hiddenfromuser = $true - note_html = $description + } + catch { + $Message = if ($_.ErrorDetails.Message) { + Get-NormalizedError -Message $_.ErrorDetails.Message } - - $body = ConvertTo-Json -Compress -Depth 10 -InputObject @($Object) - $NoteAdded = $false - try { - if ($PSCmdlet.ShouldProcess('Add note to HaloPSA ticket', 'Add note')) { - $Action = Invoke-RestMethod -Uri "$($Configuration.ResourceURL)/actions" -ContentType 'application/json; charset=utf-8' -Method Post -Body $body -Headers @{Authorization = "Bearer $($token.access_token)" } - Write-Information "Note added to ticket in HaloPSA: $($ExistingTicket.TicketID)" - $NoteAdded = $true - } + else { + $_.Exception.message } - catch { - $Message = if ($_.ErrorDetails.Message) { - Get-NormalizedError -Message $_.ErrorDetails.Message - } - else { - $_.Exception.message - } - # Don't return here - if appending a note failed (e.g. permissions on the action, - # invalid outcome_id) we still want to create a fresh ticket so the alert isn't lost. - $OutcomeHint = if ($Configuration.Outcome) { - "Outcome $Outcome is set for this integration - check the HaloPSA API user can run that action." - } else { - "No Outcome is configured, so the built-in Internal Note action ($Outcome) was used. If it has been removed or the API user cannot run it, pick a different Outcome on the HaloPSA integration page." - } - Write-LogMessage -message "Failed to add note to HaloPSA ticket $($ExistingTicket.TicketID): $Message - falling back to creating a new ticket. $OutcomeHint" -API 'HaloPSATicket' -sev Warning -LogData (Get-CippException -Exception $_) - Write-Information "Failed to add note to HaloPSA ticket: $Message; creating a new ticket instead" - Write-Information "Body we tried to ship: $body" + # Don't return here - if appending a note failed (e.g. permissions on the action, + # invalid outcome_id) we still want to create a fresh ticket so the alert isn't lost. + $OutcomeHint = if ($Configuration.Outcome) { + "Outcome $Outcome is set for this integration - check the HaloPSA API user can run that action." + } else { + "No Outcome is configured, so the built-in Internal Note action ($Outcome) was used. If it has been removed or the API user cannot run it, pick a different Outcome on the HaloPSA integration page." } + Write-LogMessage -message "Failed to add note to HaloPSA ticket $($TargetTicketId): $Message - falling back to creating a new ticket. $OutcomeHint" -API 'HaloPSATicket' -sev Warning -LogData (Get-CippException -Exception $_) + Write-Information "Failed to add note to HaloPSA ticket: $Message; creating a new ticket instead" + Write-Information "Body we tried to ship: $body" + } - if ($NoteAdded) { - return "Note added to ticket in HaloPSA: $($ExistingTicket.TicketID)" - } + if ($NoteAdded) { + return "Note added to ticket in HaloPSA: $TargetTicketId" } } else { - Write-Information 'Existing ticket could not be found. Creating a new ticket instead.' + # Falling through to a new ticket keeps the result from being lost, but silently doing so + # reads as CIPP ignoring the reference, so say which ticket was skipped and why. + Write-LogMessage -message "HaloPSA ticket $TargetTicketId is closed - the update was raised as a new ticket instead." -API 'HaloPSATicket' -sev Warning + } + } + else { + Write-Information 'Existing ticket could not be found. Creating a new ticket instead.' + if ($TicketId -gt 0) { + Write-LogMessage -message "HaloPSA ticket $TargetTicketId could not be found - the update was raised as a new ticket instead. Check the reference on the scheduled task." -API 'HaloPSATicket' -sev Warning } } } @@ -137,15 +164,45 @@ function New-HaloPSATicket { $TicketType = $Configuration.TicketType.value ?? $Configuration.TicketType $object | Add-Member -MemberType NoteProperty -Name 'tickettype_id' -Value $TicketType -Force } - if ($Configuration.DefaultPriority) { - $Priority = $Configuration.DefaultPriority.value ?? $Configuration.DefaultPriority - $PriorityInt = $Priority -as [int] + # Priority sources in precedence order: the per-alert override configured on the alert, then the + # integration-wide default. Both can be stored as a {label, value} autocomplete object or as a + # raw id depending on where they were saved, so unwrap .value first. A value that isn't a usable + # Halo priority id falls through to the next source rather than failing the ticket - Halo applies + # the SLA default when priority_id is absent. + # + # This only runs on the create path. The note path above (a caller-supplied TicketId or a + # ConsolidateTickets match) returns before here, so appending a note to an existing ticket + # deliberately leaves its priority alone - the same way tickettype_id is not re-applied. + $PrioritySources = @( + @{ Label = 'alert'; Value = ($TicketPriority.value ?? $TicketPriority) } + @{ Label = 'HaloPSA.DefaultPriority'; Value = ($Configuration.DefaultPriority.value ?? $Configuration.DefaultPriority) } + ) + $ResolvedPriority = $null + $PrioritySource = $null + foreach ($Source in $PrioritySources) { + if ([string]::IsNullOrWhiteSpace([string]$Source.Value)) { continue } + $PriorityInt = $Source.Value -as [int] if ($PriorityInt -and $PriorityInt -gt 0) { - $object | Add-Member -MemberType NoteProperty -Name 'priority_id' -Value $PriorityInt -Force + $ResolvedPriority = $PriorityInt + $PrioritySource = $Source.Label + break + } + # Value isn't a valid Halo priority id (legacy data, hint-row selection, etc.). Skip it rather + # than crashing the cast and try the next source. + Write-LogMessage -message "HaloPSA priority value '$($Source.Value)' from $($Source.Label) is not a valid priority id - falling back" -API 'HaloPSATicket' -sev Warning + } + + # A priority id only means something within an SLA - the same id maps to a different priority + # under a different SLA. When the ticket type has no SLA there is nothing for it to resolve + # against, so send no priority and let Halo apply its own rather than gambling on whichever SLA + # it happens to pick. This is the same test Get-HaloPriority uses to decide it has nothing to + # offer, so a priority can never be sent that the settings page would not have let you choose. + # Only checked when there is a priority to send, so the common path costs no extra API call. + if ($ResolvedPriority) { + if (Get-HaloTicketTypeSlaId -TicketType ($Configuration.TicketType.value ?? $Configuration.TicketType) -Configuration $Configuration -Token $token) { + $object | Add-Member -MemberType NoteProperty -Name 'priority_id' -Value $ResolvedPriority -Force } else { - # Stored value isn't a valid Halo priority id (legacy data, hint-row selection, etc.). - # Skip priority_id rather than crashing the cast - Halo will fall back to its default. - Write-LogMessage -message "HaloPSA.DefaultPriority value '$Priority' is not a valid integer - omitting priority_id from ticket payload" -API 'HaloPSATicket' -sev Warning + Write-Information "Ticket type has no SLA attached - omitting priority_id ($ResolvedPriority from $PrioritySource) so HaloPSA applies its own priority" } } # Halo records tickets created over the API as 'Manual' unless the payload carries a source, so diff --git a/Modules/CippExtensions/Public/New-CippExtAlert.ps1 b/Modules/CippExtensions/Public/New-CippExtAlert.ps1 index afd594eeff913..e32ab469f2278 100644 --- a/Modules/CippExtensions/Public/New-CippExtAlert.ps1 +++ b/Modules/CippExtensions/Public/New-CippExtAlert.ps1 @@ -15,11 +15,13 @@ function New-CippExtAlert { if ($Configuration.HaloPSA.enabled) { $MappingFile = Get-CIPPAzDataTableEntity @MappingTable -Filter "PartitionKey eq 'HaloMapping'" $TenantId = (Get-Tenants -TenantFilter $Alert.TenantId).customerId - Write-Host "TenantId: $TenantId" $MappedId = ($MappingFile | Where-Object { $_.RowKey -eq $TenantId }).IntegrationId - Write-Host "MappedId: $MappedId" - if (!$mappedId) { $MappedId = 1 } - Write-Host "MappedId: $MappedId" + if (!$MappedId) { + # Unmapped tenants land on client 1; say so instead of doing it silently. + $MappedId = 1 + Write-LogMessage -API 'HaloPSATicket' -tenant $Alert.TenantId -message "No HaloPSA client mapping for tenant $($Alert.TenantId) - the ticket was raised against Halo client id 1. Map the tenant under Settings > Integrations > HaloPSA." -sev Warning + } + Write-Information "HaloPSA client for tenant $($Alert.TenantId): $MappedId" $TicketParams = @{ Title = $Alert.AlertTitle @@ -27,7 +29,52 @@ function New-CippExtAlert { Client = $MappedId } - if ($Alert.AffectedUser -and $Configuration.HaloPSA.LinkTicketsToUsers) { + # A task can name the ticket the work came from, so the PSA copy lands as a note + # on that ticket instead of opening a second one. Two sources, in order: + # + # PsaTicketId - the ticket box on the user/offboarding/scheduler forms, shown + # only when this integration is enabled. Unambiguous, so it wins. + # Reference - free text, and only an [ID:nnnn] token in it counts. That is + # HaloPSA's own subject token, which is also what makes the + # emailed copy thread onto the same ticket. A bare number is + # deliberately NOT accepted: the reference legitimately holds + # order numbers, asset tags and change ids, and treating one as + # a ticket id would append a starter's password to an unrelated + # ticket. + # + # Reading both is this extension's job: the formats are Halo's, and the scheduler + # passes the values through untouched. + $ReferencedTicketId = 0 + $TicketCandidate = if ($Alert.PsaTicketId) { + "$($Alert.PsaTicketId)".Trim() + } elseif ("$($Alert.Reference)" -match '\[ID:(\d+)\]') { + $Matches[1] + } else { + $null + } + if ($TicketCandidate) { + # TryParse rather than a cast: a long run of digits is a valid match but an + # invalid ticket id, and an overflow here would cost the alert entirely. + $ParsedTicketId = 0 + if ([int]::TryParse($TicketCandidate, [ref]$ParsedTicketId) -and $ParsedTicketId -gt 0) { + $ReferencedTicketId = $ParsedTicketId + $TicketParams.TicketId = $ReferencedTicketId + Write-Information "Alert targets HaloPSA ticket $ReferencedTicketId - adding a note to it instead of creating a ticket" + # Setting both and disagreeing is easy to do by accident, and the effect is + # confusing: the reference is what the notification title shows, so the note + # lands on a ticket the title never mentions and it looks like nothing + # happened. Say where it actually went. + if ($Alert.PsaTicketId -and "$($Alert.Reference)" -match '\[ID:(\d+)\]' -and $Matches[1] -ne "$ReferencedTicketId") { + Write-LogMessage -API 'HaloPSATicket' -tenant $Alert.TenantId -message "Task targets HaloPSA ticket $ReferencedTicketId from its ticket field, but its reference names ticket $($Matches[1]). The note was added to $ReferencedTicketId." -sev Warning + } + } else { + Write-LogMessage -API 'HaloPSATicket' -tenant $Alert.TenantId -message "'$TicketCandidate' is not a usable HaloPSA ticket id - raising a new ticket instead." -sev Warning + } + } + + # A referenced ticket already carries its own end user, so skip the contact lookup + # (and the Graph call under it) entirely. + if ($ReferencedTicketId -le 0 -and $Alert.AffectedUser -and $Configuration.HaloPSA.LinkTicketsToUsers) { $UPN = $Alert.AffectedUser.UPN $OID = $Alert.AffectedUser.AzureOID $Display = $Alert.AffectedUser.DisplayName @@ -50,6 +97,13 @@ function New-CippExtAlert { if ($Display) { $TicketParams.DisplayName = $Display } } + # Per-alert priority beats the integration-wide DefaultPriority. Unlike the + # user fields above this is NOT gated on LinkTicketsToUsers - priority applies + # to every ticket, and it must also work when no global default is configured. + if ($Alert.PsaTicketPriority) { + $TicketParams.TicketPriority = $Alert.PsaTicketPriority + } + New-HaloPSATicket @TicketParams } } diff --git a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 index b6bdf1b9097f5..4b740b1d2eb46 100644 --- a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 +++ b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 @@ -583,14 +583,6 @@ function Invoke-NinjaOneTenantSync { DeviceLink = $ParsedDeviceName } - Add-CIPPAzDataTableEntity @DeviceTable -Entity @{ - PartitionKey = $Customer.CustomerId - RowKey = $device.AzureADDeviceId - RawDevice = "$($ParsedDevice | ConvertTo-Json -Depth 100 -Compress)" - } -Force - - $ParsedDevices.add($ParsedDevice) - ### Update NinjaOne Device Fields if ($MatchedNinjaDevice) { $NinjaDeviceUpdate = [PSCustomObject]@{} @@ -607,8 +599,8 @@ function Invoke-NinjaOneTenantSync { Icon = 'fas fa-laptop' }, @{ - Name = 'View Devices in CIPP' - Link = "https://$($CIPPURL)/endpoint/MEM/devices?tenantFilter=$($Customer.defaultDomainName)" + Name = 'View Device in CIPP' + Link = "https://$($CIPPURL)/endpoint/MEM/devices/device?deviceId=$($Device.id)&tenantFilter=$($Customer.defaultDomainName)" Icon = 'far fa-eye' } ) @@ -713,15 +705,30 @@ function Invoke-NinjaOneTenantSync { } - # Update Device + # Update Device. Default to success so devices with no mapped fields are still cached. + $DeviceFieldsUpdated = $true if ($MappedFields.DeviceSummary -or $MappedFields.DeviceLinks -or $MappedFields.DeviceCompliance) { + $DeviceFieldsUpdated = $false try { $UpdateBody = $NinjaDeviceUpdate | ConvertTo-Json -Depth 100 $Result = Invoke-WebRequest -Uri "https://$($Configuration.Instance)/api/v2/device/$($MatchedNinjaDevice.id)/custom-fields" -Method PATCH -Headers @{Authorization = "Bearer $($token.access_token)" } -ContentType 'application/json; charset=utf-8' -Body $UpdateBody + $DeviceFieldsUpdated = $true } catch { - Write-Verbose "Error details: $($_ | ConvertTo-Json -Depth 5)" + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -tenant $TenantFilter -API 'NinjaOneSync' -message "Failed to update NinjaOne custom fields for device '$($Device.deviceName)' ($($MatchedNinjaDevice.id)): $($ErrorMessage.NormalizedError)" -Sev 'Warning' -LogData $ErrorMessage } } + + # Only cache the device once its fields have been written, so a failed update is retried on the next sync instead of being skipped permanently. + if ($DeviceFieldsUpdated) { + Add-CIPPAzDataTableEntity @DeviceTable -Entity @{ + PartitionKey = $Customer.CustomerId + RowKey = $device.AzureADDeviceId + RawDevice = "$($ParsedDevice | ConvertTo-Json -Depth 100 -Compress)" + } -Force + + $ParsedDevices.add($ParsedDevice) + } } # Enable Device Updates Subscription if needed. diff --git a/Modules/CippExtensions/Public/Sherweb/Invoke-SherwebMigration.ps1 b/Modules/CippExtensions/Public/Sherweb/Invoke-SherwebMigration.ps1 index 89c3d749d3175..3d5dd1cd2a3ad 100644 --- a/Modules/CippExtensions/Public/Sherweb/Invoke-SherwebMigration.ps1 +++ b/Modules/CippExtensions/Public/Sherweb/Invoke-SherwebMigration.ps1 @@ -13,6 +13,11 @@ function Invoke-SherwebMigration { return } + if ($Config.AutoMigrations -ne $true) { + Write-Information "Sherweb automated migration is disabled, skipping migration check for $TenantFilter" + return + } + # Get licenses within the transfer window (renewing within 7 days) $Licenses = Get-CIPPLicenseOverview -TenantFilter $TenantFilter | Where-Object { $null -ne $_.TermInfo -and ($_.TermInfo | Where-Object { $_.DaysUntilRenew -le 7 -and $_.DaysUntilRenew -ge 0 }) @@ -43,7 +48,14 @@ function Invoke-SherwebMigration { if (-not $LicencesToMigrate) { return } - switch -wildcard ($Config.migrationMethods) { + # migrationMethods and migrateToLicense are autoComplete fields, stored as { label, value } + # objects (older configs may hold a bare string). Match on the value only: wildcard-matching + # the whole object stringifies the label too, and every method label contains 'cancellation + # window', so notify-only and buy-and-notify would both wrongly trigger the cancel branch. + $MigrationMethod = if ($null -ne $Config.migrationMethods.value) { $Config.migrationMethods.value } else { $Config.migrationMethods } + $MigrateToLicense = if ($null -ne $Config.migrateToLicense.value) { $Config.migrateToLicense.value } else { $Config.migrateToLicense } + + switch -wildcard ($MigrationMethod) { '*notify*' { $Subject = "Sherweb Migration: $($TenantFilter) - $($LicencesToMigrate.Count) licenses to migrate" $HTMLContent = New-CIPPAlertTemplate -Data $LicencesToMigrate -Format 'html' -InputObject 'sherwebmig' @@ -55,7 +67,7 @@ function Invoke-SherwebMigration { '*buy*' { try { foreach ($MigLicense in $LicencesToMigrate) { - $PotentialLicense = Get-SherwebCatalog -TenantFilter $TenantFilter | Where-Object { $_.microsoftSkuId -eq $MigLicense.SkuId -and $_.sku -like "*$($Config.migrateToLicense)" } | Select-Object -First 1 + $PotentialLicense = Get-SherwebCatalog -TenantFilter $TenantFilter | Where-Object { $_.microsoftSkuId -eq $MigLicense.SkuId -and $_.sku -like "*$MigrateToLicense" } | Select-Object -First 1 if (-not $PotentialLicense) { throw "Cannot buy new license: no matching license found in catalog for SKU $($MigLicense.SkuId)" } diff --git a/Modules/DNSHealth/1.1.8/DNSHealth.psd1 b/Modules/DNSHealth/1.1.10/DNSHealth.psd1 similarity index 89% rename from Modules/DNSHealth/1.1.8/DNSHealth.psd1 rename to Modules/DNSHealth/1.1.10/DNSHealth.psd1 index a9d8c70eac4bc..033942650dc6d 100644 --- a/Modules/DNSHealth/1.1.8/DNSHealth.psd1 +++ b/Modules/DNSHealth/1.1.10/DNSHealth.psd1 @@ -12,7 +12,7 @@ RootModule = 'DNSHealth.psm1' # Version number of this module. - ModuleVersion = '1.1.8' + ModuleVersion = '1.1.10' # Supported PSEditions # CompatiblePSEditions = @() @@ -53,8 +53,14 @@ # Modules that must be imported into the global environment prior to importing this module #RequiredModules = @('') - # Assemblies that must be loaded prior to importing this module - # RequiredAssemblies = @() + # Assemblies that must be loaded prior to importing this module. + # Preloads the precompiled SevenTinyRsa.dll (Source/SevenTinyRsa/) so + # Get-RsaPublicKeyInfo finds [SevenTiny.Bantina.Security.RSACommon] + # already registered — its existing Add-Type guard then short-circuits + # via the `-as [type]` check. Required for hosts where runtime + # `Add-Type -Language CSharp` fails (e.g. embedded-PowerShell hosts + # with no $PSHOME/ref reference-assemblies directory). + RequiredAssemblies = @('SevenTinyRsa.dll') # Script files (.ps1) that are run in the caller's environment prior to importing this module. ScriptsToProcess = @() @@ -78,7 +84,7 @@ #VariablesToExport = '*' # Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. - #AliasesToExport = '*' + AliasesToExport = @() # DSC resources to export from this module # DscResourcesToExport = @() diff --git a/Modules/DNSHealth/1.1.8/DNSHealth.psm1 b/Modules/DNSHealth/1.1.10/DNSHealth.psm1 similarity index 99% rename from Modules/DNSHealth/1.1.8/DNSHealth.psm1 rename to Modules/DNSHealth/1.1.10/DNSHealth.psm1 index 113996bb69e9d..47dd5f93e5454 100644 --- a/Modules/DNSHealth/1.1.8/DNSHealth.psm1 +++ b/Modules/DNSHealth/1.1.10/DNSHealth.psm1 @@ -2069,7 +2069,14 @@ function Read-SpfRecord { # Look for expected include record and report pass or fail if ($ExpectedInclude -ne '') { - if ($RecordList.Domain -notcontains $ExpectedInclude) { + $SpfMatch = $SpfResults.MailProvider.SpfMatch + $RegexMatchedDomain = if ($SpfMatch) { + $RecordList.Domain | Where-Object { $_ -match $SpfMatch } | Select-Object -First 1 + } + + if ($RegexMatchedDomain) { + $ValidationPasses.Add('The expected mail provider entry is part of the record.') | Out-Null + } elseif ($RecordList.Domain -notcontains $ExpectedInclude) { $ExpectedIncludeSpf = Read-SpfRecord -Domain $ExpectedInclude -Level ExpectedInclude $ExpectedIPCount = $ExpectedIncludeSpf.IPAddresses | Measure-Object | Select-Object -ExpandProperty Count $FoundIPCount = Compare-Object $IPAddresses $ExpectedIncludeSpf.IPAddresses -IncludeEqual | Where-Object -Property SideIndicator -EQ '==' | Measure-Object | Select-Object -ExpandProperty Count @@ -2228,7 +2235,7 @@ function Read-SpfRecord { # Output SpfResults object $SpfResults } -#EndRegion './Public/Records/Read-SPFRecord.ps1' 577 +#EndRegion './Public/Records/Read-SPFRecord.ps1' 584 #Region './Public/Records/Read-TlsRptRecord.ps1' -1 function Read-TlsRptRecord { @@ -3036,3 +3043,4 @@ function Test-MtaSts { $MtaSts } #EndRegion './Public/Tests/Test-MtaSts.ps1' 58 + diff --git a/Modules/DNSHealth/1.1.8/MailProviders/AppRiver.json b/Modules/DNSHealth/1.1.10/MailProviders/AppRiver.json similarity index 100% rename from Modules/DNSHealth/1.1.8/MailProviders/AppRiver.json rename to Modules/DNSHealth/1.1.10/MailProviders/AppRiver.json diff --git a/Modules/DNSHealth/1.1.8/MailProviders/BarracudaESS.json b/Modules/DNSHealth/1.1.10/MailProviders/BarracudaESS.json similarity index 100% rename from Modules/DNSHealth/1.1.8/MailProviders/BarracudaESS.json rename to Modules/DNSHealth/1.1.10/MailProviders/BarracudaESS.json diff --git a/Modules/DNSHealth/1.1.8/MailProviders/Google.json b/Modules/DNSHealth/1.1.10/MailProviders/Google.json similarity index 100% rename from Modules/DNSHealth/1.1.8/MailProviders/Google.json rename to Modules/DNSHealth/1.1.10/MailProviders/Google.json diff --git a/Modules/DNSHealth/1.1.8/MailProviders/HornetSecurity.json b/Modules/DNSHealth/1.1.10/MailProviders/HornetSecurity.json similarity index 100% rename from Modules/DNSHealth/1.1.8/MailProviders/HornetSecurity.json rename to Modules/DNSHealth/1.1.10/MailProviders/HornetSecurity.json diff --git a/Modules/DNSHealth/1.1.8/MailProviders/Intermedia.json b/Modules/DNSHealth/1.1.10/MailProviders/Intermedia.json similarity index 100% rename from Modules/DNSHealth/1.1.8/MailProviders/Intermedia.json rename to Modules/DNSHealth/1.1.10/MailProviders/Intermedia.json diff --git a/Modules/DNSHealth/1.1.8/MailProviders/Microsoft365.json b/Modules/DNSHealth/1.1.10/MailProviders/Microsoft365.json similarity index 100% rename from Modules/DNSHealth/1.1.8/MailProviders/Microsoft365.json rename to Modules/DNSHealth/1.1.10/MailProviders/Microsoft365.json diff --git a/Modules/DNSHealth/1.1.8/MailProviders/Mimecast.json b/Modules/DNSHealth/1.1.10/MailProviders/Mimecast.json similarity index 84% rename from Modules/DNSHealth/1.1.8/MailProviders/Mimecast.json rename to Modules/DNSHealth/1.1.10/MailProviders/Mimecast.json index 1ab6820344b00..79553389bfc22 100644 --- a/Modules/DNSHealth/1.1.8/MailProviders/Mimecast.json +++ b/Modules/DNSHealth/1.1.10/MailProviders/Mimecast.json @@ -1,6 +1,6 @@ { "Name": "Mimecast", - "MxMatch": "(?[a-z]{2})-smtp-inbound-[0-9].mimecast.com", + "MxMatch": "^(?[a-z]{2,3})-smtp-inbound-[0-9]\\.mimecast\\.(?:com|co\\.za)", "SpfInclude": "{0}._netblocks.mimecast.com", "SpfReplace": [ "Prefix" ], "Selectors": [], diff --git a/Modules/DNSHealth/1.1.10/MailProviders/MimecastOffshore.json b/Modules/DNSHealth/1.1.10/MailProviders/MimecastOffshore.json new file mode 100644 index 0000000000000..371d416997ca8 --- /dev/null +++ b/Modules/DNSHealth/1.1.10/MailProviders/MimecastOffshore.json @@ -0,0 +1,10 @@ +{ + "Name": "Mimecast Offshore", + "MxMatch": "^(?[a-z]{2,3})-smtp-inbound-[0-9]\\.mimecast-offshore\\.com", + "SpfInclude": "", + "SpfReplace": [], + "Selectors": [], + "_MxComment": "https://community.mimecast.com/s/article/Connect-Application-Modifying-Your-MX-Records-673313100", + "_SpfComment": "Offshore (Jersey) region netblock include is not publicly documented; leave empty so no incorrect SPF include is asserted.", + "_DkimComment": "https://community.mimecast.com/s/article/DNS-Authentication-Configuration-Guide-345109074" +} diff --git a/Modules/DNSHealth/1.1.8/MailProviders/Null.json b/Modules/DNSHealth/1.1.10/MailProviders/Null.json similarity index 100% rename from Modules/DNSHealth/1.1.8/MailProviders/Null.json rename to Modules/DNSHealth/1.1.10/MailProviders/Null.json diff --git a/Modules/DNSHealth/1.1.8/MailProviders/Proofpoint.json b/Modules/DNSHealth/1.1.10/MailProviders/Proofpoint.json similarity index 100% rename from Modules/DNSHealth/1.1.8/MailProviders/Proofpoint.json rename to Modules/DNSHealth/1.1.10/MailProviders/Proofpoint.json diff --git a/Modules/DNSHealth/1.1.8/MailProviders/Reflexion.json b/Modules/DNSHealth/1.1.10/MailProviders/Reflexion.json similarity index 100% rename from Modules/DNSHealth/1.1.8/MailProviders/Reflexion.json rename to Modules/DNSHealth/1.1.10/MailProviders/Reflexion.json diff --git a/Modules/DNSHealth/1.1.8/MailProviders/Sophos.json b/Modules/DNSHealth/1.1.10/MailProviders/Sophos.json similarity index 68% rename from Modules/DNSHealth/1.1.8/MailProviders/Sophos.json rename to Modules/DNSHealth/1.1.10/MailProviders/Sophos.json index e246ef694a116..491bd2bcc925f 100644 --- a/Modules/DNSHealth/1.1.8/MailProviders/Sophos.json +++ b/Modules/DNSHealth/1.1.10/MailProviders/Sophos.json @@ -1,8 +1,9 @@ { "Name": "Sophos", "MxMatch": "mx-[0-9]{2}-(?(us|eu))-(?(central|east|west))-(?([0-9])).prod.hydra.sophos.com", + "SpfMatch": "^_spf[._][a-z0-9]+.*\\.sophos\\.com$", "SpfInclude": "_spf.prod.hydra.sophos.com", "_MxComment": "https://docs.sophos.com/central/Customer/help/en-us/central/Customer/learningContents/Configure365.html", - "_SpfComment": "https://docs.sophos.com/central/Customer/help/en-us/central/Customer/tasks/updatingspf.html", + "_SpfComment": "https://docs.sophos.com/central/customer/help/en-us/ManageYourProducts/EmailSecurity/EmailDomainInfo/index.html#emailspfrecords", "_DkimComment": "https://docs.sophos.com/central/Customer/help/en-us/central/Customer/common/learningContents/DkimKeys.html" } \ No newline at end of file diff --git a/Modules/DNSHealth/1.1.8/MailProviders/SpamTitan.json b/Modules/DNSHealth/1.1.10/MailProviders/SpamTitan.json similarity index 100% rename from Modules/DNSHealth/1.1.8/MailProviders/SpamTitan.json rename to Modules/DNSHealth/1.1.10/MailProviders/SpamTitan.json diff --git a/Modules/DNSHealth/1.1.8/MailProviders/SymantecCloud.json b/Modules/DNSHealth/1.1.10/MailProviders/SymantecCloud.json similarity index 100% rename from Modules/DNSHealth/1.1.8/MailProviders/SymantecCloud.json rename to Modules/DNSHealth/1.1.10/MailProviders/SymantecCloud.json diff --git a/Modules/DNSHealth/1.1.8/MailProviders/_template.json b/Modules/DNSHealth/1.1.10/MailProviders/_template.json similarity index 100% rename from Modules/DNSHealth/1.1.8/MailProviders/_template.json rename to Modules/DNSHealth/1.1.10/MailProviders/_template.json diff --git a/Modules/DNSHealth/1.1.8/PSGetModuleInfo.xml b/Modules/DNSHealth/1.1.10/PSGetModuleInfo.xml similarity index 71% rename from Modules/DNSHealth/1.1.8/PSGetModuleInfo.xml rename to Modules/DNSHealth/1.1.10/PSGetModuleInfo.xml index a5693b78886a9..c9b3a0ec124d6 100644 --- a/Modules/DNSHealth/1.1.8/PSGetModuleInfo.xml +++ b/Modules/DNSHealth/1.1.10/PSGetModuleInfo.xml @@ -7,20 +7,34 @@ DNSHealth - 1.1.8 + 1.1.10 Module CIPP DNS Health Check Module John Duprey johnduprey 2023 John Duprey -
2026-05-08T15:46:09-04:00
- +
2026-08-24T16:52:31+08:00
+ +
2026-08-25T00:59:14.3697426+08:00
+ + + + Microsoft.PowerShell.Commands.DisplayHintType + System.Enum + System.ValueType + System.Object + + DateTime + 2 + + +
https://github.com/johnduprey/DNSHealth - - + + System.Object[] System.Array System.Object @@ -29,23 +43,23 @@ PSModule - - + + System.Collections.Hashtable System.Object - Workflow - - + Cmdlet + + - Function - - + Command + + Read-DmarcPolicy Read-MtaStsPolicy @@ -69,17 +83,9 @@ - RoleCapability - - - - DscResource - - - - Command - - + Function + + Read-DmarcPolicy Read-MtaStsPolicy @@ -103,22 +109,30 @@ - Cmdlet - + DscResource + + + + RoleCapability + + + + Workflow + - - + + https://www.powershellgallery.com/api/v2 PSGallery NuGet - - + + System.Management.Automation.PSCustomObject System.Object @@ -129,24 +143,24 @@ True True 0 - 495 - 32431 - 5/8/2026 3:46:09 PM -04:00 - 5/8/2026 3:46:09 PM -04:00 - 5/8/2026 3:46:09 PM -04:00 + 606 + 35781 + 24/08/2026 4:52:31 PM +08:00 + 24/08/2026 4:52:31 PM +08:00 + 24/08/2026 4:52:31 PM +08:00 PSModule PSFunction_Read-DmarcPolicy PSCommand_Read-DmarcPolicy PSFunction_Read-MtaStsPolicy PSCommand_Read-MtaStsPolicy PSFunction_Add-MailProvider PSCommand_Add-MailProvider PSFunction_Get-MailProvider PSCommand_Get-MailProvider PSFunction_Read-AutoDiscoverRecord PSCommand_Read-AutoDiscoverRecord PSFunction_Read-DkimRecord PSCommand_Read-DkimRecord PSFunction_Read-MtaStsRecord PSCommand_Read-MtaStsRecord PSFunction_Read-MXRecord PSCommand_Read-MXRecord PSFunction_Read-NSRecord PSCommand_Read-NSRecord PSFunction_Read-SPFRecord PSCommand_Read-SPFRecord PSFunction_Read-TlsRptRecord PSCommand_Read-TlsRptRecord PSFunction_Read-WhoisRecord PSCommand_Read-WhoisRecord PSFunction_Remove-MailProvider PSCommand_Remove-MailProvider PSFunction_Resolve-DnsHttpsQuery PSCommand_Resolve-DnsHttpsQuery PSFunction_Set-DnsResolver PSCommand_Set-DnsResolver PSFunction_Test-DNSSEC PSCommand_Test-DNSSEC PSFunction_Test-HttpsCertificate PSCommand_Test-HttpsCertificate PSFunction_Test-MtaSts PSCommand_Test-MtaSts PSIncludes_Function False - 2026-05-08T15:46:09Z - 1.1.8 + 2026-08-24T16:52:31Z + 1.1.10 John Duprey false Module - DNSHealth.nuspec|MailProviders\SymantecCloud.json|MailProviders\Microsoft365.json|MailProviders\Sophos.json|DNSHealth.psd1|MailProviders\Intermedia.json|MailProviders\SpamTitan.json|MailProviders\AppRiver.json|DNSHealth.psm1|MailProviders\Reflexion.json|MailProviders\_template.json|MailProviders\BarracudaESS.json|MailProviders\Null.json|MailProviders\HornetSecurity.json|MailProviders\Google.json|MailProviders\Proofpoint.json|MailProviders\Mimecast.json + DNSHealth.nuspec|MailProviders\Mimecast.json|MailProviders\BarracudaESS.json|MailProviders\SpamTitan.json|DNSHealth.psd1|MailProviders\AppRiver.json|MailProviders\Null.json|MailProviders\HornetSecurity.json|MailProviders\MimecastOffshore.json|MailProviders\Sophos.json|MailProviders\_template.json|SevenTinyRsa.dll|MailProviders\Proofpoint.json|MailProviders\Microsoft365.json|MailProviders\Reflexion.json|DNSHealth.psm1|MailProviders\Intermedia.json|MailProviders\SymantecCloud.json|MailProviders\Google.json a300d2b0-d468-46d1-88a3-e442a76b655b 7.0
- /Users/johnduprey/GitHub/CIPP Workspace/CIPP-API/Modules/DNSHealth/1.1.8 + C:\Users\Zac\Documents\PowerShell\Modules\DNSHealth\1.1.10 diff --git a/Modules/DNSHealth/1.1.10/SevenTinyRsa.dll b/Modules/DNSHealth/1.1.10/SevenTinyRsa.dll new file mode 100644 index 0000000000000000000000000000000000000000..1770c9c9f3e7e8a0a55e02b03a7ed2c77652c6a9 GIT binary patch literal 5120 zcmeHLO>7&-6@I%b{)qaqmy#v_#5H5Zj%vwUQluy`juVk0Wh=I9MU))dO<^y|k+jWn zm)Tt@rh&9}nxyR^MvcZr5g0wRK#`_B7)}BhX@eq{q5obL=HYug1!{W!YY=#A42h6*H_@{&+D~wJS{P>WbXga6MZfIw~o& zX@B3}nxnl(yJH&_}Ni;U2>^fs1If;AKB<4)FLzy8sHDH-*kV$4&Wn?cNYM zgmxBwk8@^%=-a#yceb;B7PG9(e(1Q?!ZH?Ed8f#@}dnKlf0$>=OD?kkF`rA-j!T13IV$m@#6W0NUBFYYF~ zhQK9({^w*zyp7&QXMELNn_lPzx93+7+6KFbB9R8PR~|$T5qA?vN65weL^}Jr!nO*g zt34LKNR(_3+lW81cW4`cXMCVX0O8)Ajz2Lt4e#1yV}wX>5ZMOz3w(y-O-IU|j+Bj# z?hzgMP{YDOXRCux63HJ>rN)STJ#BqGf%cfp$Bp<2^t27_2Eqr9Hjuz=jTrGIIN0u& zs!wgZx~E?aT__it=SN#mSt;77hMwLk0JZ(;ZD7>Sp2!|GVs8fz2fg8847dZrA49{zkk6?d`w(9? z{IjUgbzBkfyrKAXKdvjle$s;X#1$I$^{RZyP%aso+_$eUB6sw44n;%Z%S!Mv zM}qw)BjNs&o#D_WW90As`*Ouca#CXj^7Xu$(&Ab&o=jvQV2zrfFJsVwFA?=(&-P+Z z3=}=bu$EkIF&Of<$-qL9hQaggKX7PaI*9H$vteq$FFDXeV zIZOzJ5ZjIOT!o1C2yh;xCqiQ>9icA?eZI- zuSlFO3Hq#{lEB9V|F-b(P32WNLS=dve!efi3=e++jnHX@BVl3lKD`P*3o@ri6h7kX z=oO@!0{@=ieg6g0WeDl6PpdTvx=`Hf)QK|zuKo>!?pnK^M=%exx&;*|87b&Y0=^eyy z3jA;7v-BiMQiZ-wb;SUE3G_)?R;u)S`Vcj}pFU59q)`W5A&q+IJD@udzeWS}GtePw zgBFVe^Z@2lqe+T`9uasEw2V2bND|?xhIO-rT)Kg$t3+)V{51l&(XQj(`gS-^?+ML% z>|MU#;7Q!(?P<$Pj?kGiMNjvPa@KM5`m|+u^Yt1l8t2);#3;=%y|MtiL|U*u_u!0O zSuxo|^x&*xtm+<{uGUOeWfu2iTY2W`hUq>;bHyxWMn^|dlbJ*!J5|VKlBq&I5zmh2 z$ELE$(NuaeKU&CTQ@N>pJRdKNXA{|6ZX}h;jE`p1>8X)?a*B#|*JD*}`ZyKYDzoMd zt3K!IRHEs;;nr+dFPV(yRxHn`vMGa^mBYGK0o3T%(9j9pTwy#j^1CsbbQnUXNxNDz zOy(S77IOepvK}^mX$7i0E3GUo@d4Jbtn0FB$*j*CUMoDWJ4?))!YPG4Wjjx7w4cHl zCz#`+J{y(O)}rlHMe%g=W347_YtdL*L6Ga5%(2CyzR0}#9OlGbbM7Z5jOUoCpA%e* zzM09agV`*5YaN>k?m)NdYl=oK1@0Lo!!*2BEtX2Ij}pPTSvuuE zT&cEFGL7;PR&U6yQ>;}?lV(`ecDR=5)rRBS{@J3*%DfhQp&_U$xLL-Ym|WH!${CjK z)cNvdT<}o5RpxjQw6bSAO(%InU$Sf$%inE6jVv|4e#|K{XVoY(x3L_M2bMoX?wjWe z&R>0pnOVaO^Jr#uz7jB+OV}H>Z{ zOng%sx`{0*_Gn|}Vi#cQRt+S`>pI&kWiyW=E3Y`1A#KvB*F1iLt}WNsHj$Rov1cq@ zYubtD1yR?@U%xZ`&-2G#`Q_`s%P#akPHizs3M(;^0$@~?i{1U9uAPDC!Du!V2!wa? zuUd#^)Ix}4DSAr%LV%>5fo|SmXMocvgM$#R0Inbq^a+y8y8;N~bsNnnLEly_1Vakf zgs(k(=F94DA`d7*bu&ChGu`kn?+idEHJ)~#;V+tbx$iM#cg(g5=gO?cm&E+CV?X6e zu#-hz2PN979V^UF+78RsYQv31d2n@Hi^D;8@9nM29}lKpKZf}W^Byr+s1kLyE=`<@ zB?@6zF)U_ZNZ>?)56)x~X+jt(r88_SHC`_1W2NNSct+RL$;HL7L^?H^%&>SuABk7e zY_yCgGJnA%R(^E9vx%P?E#TK0=W7mqm-8{sZEVeRxUV^b#Pg;(qZ<}g-7>1c#Qx&i zTYKPRbG$3qzVG?N&wtP*Nm5{=zyA)A#90YP#0%^D`=>FKYXi>h_-}`k6F6Im7Zpc1 zj{_r}pdy_CEzlgeX}pz>L7oPk@_%*Fn{DrZ-?8y9F5-b_?1hLyX1#@6EC^Y$sXnE#)_YyJ;`PkHeF5BI;Wz`p_I CQzm2p literal 0 HcmV?d00001 diff --git a/Shared/CIPPSharp/CIPPRestClient.cs b/Shared/CIPPSharp/CIPPRestClient.cs index b4fa13e6d5326..f0cd53d979e00 100644 --- a/Shared/CIPPSharp/CIPPRestClient.cs +++ b/Shared/CIPPSharp/CIPPRestClient.cs @@ -37,6 +37,45 @@ public sealed class HttpResult public Dictionary ResponseHeaders { get; init; } = new(); } + // ===================================================================== + // CIPPConcurrentRequest / CIPPConcurrentResult + // ===================================================================== + // One PowerShell call, many async HTTP requests. PowerShell builds the + // request list (and acquires any auth token once, passing it in Headers), + // then calls CIPPRestClient.SendConcurrent — the .NET side fans the requests + // out concurrently, bounded by a semaphore, with Retry-After / backoff on + // 429 and 5xx. This keeps concurrency in .NET (robust async) rather than + // ForEach-Object -Parallel runspaces in the PowerShell worker. Per-hostname + // connection caps (see the pool design above) still apply, so a burst to one + // host cannot exceed that host's connection budget. + // ===================================================================== + public sealed class CIPPConcurrentRequest + { + public string Uri { get; set; } = string.Empty; + public string Method { get; set; } = "GET"; + public string? Body { get; set; } + public Dictionary? Headers { get; set; } + public string? ContentType { get; set; } + public int TimeoutSec { get; set; } = 100; + public int MaximumRedirection { get; set; } = -1; + } + + /// + /// Per-request outcome, returned in the same order as the input requests. + /// A failed request never aborts the batch: transport failures land in + /// , HTTP responses (including 4xx/5xx after retries) land + /// in . Index maps back to the caller's request array. + /// + public sealed class CIPPConcurrentResult + { + public int Index { get; init; } + public bool IsSuccess { get; init; } + public int StatusCode { get; init; } + public HttpResult? Result { get; init; } + public string? Error { get; init; } + public int Attempts { get; init; } + } + // ===================================================================== // CIPPResponseHeaders / CIPPHttpResponse / CIPPHttpRequestException // ===================================================================== @@ -141,10 +180,11 @@ public CIPPHttpRequestException(string message, int statusCode, Dictionary + /// SharePoint / OneDrive client — dedicated lane for SPO admin CSOM (ProcessQuery) and + /// SPO REST (_api) against *.sharepoint.com, which have no server-side $batch. Concurrent + /// per-site writes (Set-CIPPSPOSiteBulk via SendConcurrent) fan out here. HTTP/2 is disabled + /// so MaxConnectionsPerServer is a true concurrency ceiling (5) rather than a connection count + /// that H2 stream-multiplexing could exceed. Measured on a 526-site tenant, 5 in flight throttled + /// far less than 8-10 (43 vs 56 x HTTP 429) and finished sooner — SPO's CSOM-admin throttle is a + /// sustained-rate limit, so a lower ceiling plus the once-per-24h sweep guard is the throttle-safe + /// combination. Cap: 5 connections. + /// + private static HttpClient BuildSpoClient() => new HttpClient(new SocketsHttpHandler + { + AutomaticDecompression = DecompressionMethods.All, + PooledConnectionLifetime = TimeSpan.FromMinutes(30), + PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2), + EnableMultipleHttp2Connections = false, + AllowAutoRedirect = true, + MaxAutomaticRedirections = 10, + MaxConnectionsPerServer = 5, + }) { Timeout = Timeout.InfiniteTimeSpan }; + /// /// DNS client — dedicated lane for DoH (DNS-over-HTTPS) providers. /// Covers dns.google.com and cloudflare-dns.com. These services @@ -405,6 +467,7 @@ _loginClient is not null && _complianceClient is not null && _partnerCenterClient is not null && _adminPlaneClient is not null && + _spoClient is not null && _dnsClient is not null && _defaultClient is not null) return; @@ -421,6 +484,7 @@ _dnsClient is not null && _complianceClient = BuildComplianceClient(); _partnerCenterClient = BuildPartnerCenterClient(); _adminPlaneClient = BuildAdminPlaneClient(); + _spoClient = BuildSpoClient(); _dnsClient = BuildDnsClient(); _defaultClient = BuildDefaultClient(); } @@ -506,6 +570,12 @@ var h when h.EndsWith(".partnercenter.microsoft.com", // Rule 6 — Microsoft admin/reporting/security lanes var h when IsAdminPlaneHost(h) => (_adminPlaneClient!, "AdminPlane", host), + // Rule 6b — SharePoint / OneDrive (CSOM ProcessQuery + _api REST). Covers the + // tenant, -admin and -my hosts. No server-side $batch, so concurrent per-site + // requests fan out here, capped at 10 connections. + var h when h.EndsWith(".sharepoint.com", + StringComparison.OrdinalIgnoreCase) => (_spoClient!, "SPO", host), + // Rule 7 — DNS-over-HTTPS providers (low connection cap) var h when h.Equals("dns.google.com", StringComparison.OrdinalIgnoreCase) => (_dnsClient!, "DNS", host), @@ -800,6 +870,112 @@ public static async Task SendAsync( } } + // ----------------------------------------------------------------- + // Concurrent fan-out — one PowerShell call, many async requests + // ----------------------------------------------------------------- + // PowerShell passes a list of requests (each already carrying its auth + // header) and gets back one result per request, in order. Concurrency is + // bounded by maxConcurrency here AND by each destination's + // MaxConnectionsPerServer cap, whichever is tighter. 429/5xx are retried + // with Retry-After (when present) or exponential backoff with jitter. A + // single request's failure is captured, never thrown, so the batch always + // completes and the caller can act on partial success. + // + // Synchronous shim for PowerShell (cannot await Tasks natively). Safe here + // for the same reason Send() is — no SynchronizationContext to deadlock on. + // ----------------------------------------------------------------- + public static CIPPConcurrentResult[] SendConcurrent( + IEnumerable requests, + int maxConcurrency = 8, + int maxRetries = 3) + { + return SendConcurrentAsync(requests, maxConcurrency, maxRetries) + .GetAwaiter().GetResult(); + } + + public static async Task SendConcurrentAsync( + IEnumerable requests, + int maxConcurrency = 8, + int maxRetries = 3) + { + var list = requests is null ? new List() : requests.ToList(); + if (list.Count == 0) return Array.Empty(); + if (maxConcurrency < 1) maxConcurrency = 1; + if (maxRetries < 0) maxRetries = 0; + + using var gate = new SemaphoreSlim(maxConcurrency, maxConcurrency); + var tasks = new Task[list.Count]; + for (int i = 0; i < list.Count; i++) + { + int index = i; + var req = list[i]; + tasks[i] = Task.Run(async () => + { + await gate.WaitAsync().ConfigureAwait(false); + try { return await SendOneWithRetryAsync(index, req, maxRetries).ConfigureAwait(false); } + finally { gate.Release(); } + }); + } + return await Task.WhenAll(tasks).ConfigureAwait(false); + } + + private static async Task SendOneWithRetryAsync( + int index, CIPPConcurrentRequest req, int maxRetries) + { + var attempt = 0; + while (true) + { + attempt++; + try + { + // skipErrorCheck: 429/5xx must come back as results (not thrown) so we can + // decide whether to retry; the caller inspects StatusCode/Content itself. + var res = await SendAsync(req.Uri, req.Method, req.Body, req.Headers, + req.ContentType, skipErrorCheck: true, + timeoutSec: req.TimeoutSec, + maximumRedirection: req.MaximumRedirection).ConfigureAwait(false); + + var retryable = res.StatusCode == 429 || (res.StatusCode >= 500 && res.StatusCode <= 599); + if (retryable && attempt <= maxRetries) + { + await Task.Delay(RetryDelay(res, attempt)).ConfigureAwait(false); + continue; + } + return new CIPPConcurrentResult + { + Index = index, IsSuccess = res.IsSuccess, StatusCode = res.StatusCode, + Result = res, Attempts = attempt, + }; + } + catch (Exception ex) + { + if (attempt <= maxRetries) + { + await Task.Delay(RetryDelay(null, attempt)).ConfigureAwait(false); + continue; + } + return new CIPPConcurrentResult + { + Index = index, IsSuccess = false, StatusCode = 0, + Error = ex.Message, Attempts = attempt, + }; + } + } + } + + private static TimeSpan RetryDelay(HttpResult? res, int attempt) + { + // Honour a sane Retry-After (seconds) when the server sends one. + if (res is not null && + res.ResponseHeaders.TryGetValue("Retry-After", out var ra) && + ra.Length > 0 && int.TryParse(ra[0], out var secs) && secs > 0 && secs <= 300) + return TimeSpan.FromSeconds(secs); + + // Otherwise exponential backoff (1s, 2s, 4s, capped 8s) with jitter. + var baseMs = Math.Min(8000, 1000 * (int)Math.Pow(2, attempt - 1)); + return TimeSpan.FromMilliseconds(baseMs + Random.Shared.Next(0, 1000)); + } + // ----------------------------------------------------------------- // Content decoding // ----------------------------------------------------------------- diff --git a/Shared/CIPPSharp/bin/CIPPSharp.dll b/Shared/CIPPSharp/bin/CIPPSharp.dll index f66102fb695bf864c453fa3b9eb897f3457efaca..4b71bbc8b79b04afe1265b688d8ecfb8e8788c38 100644 GIT binary patch literal 71168 zcmdqKd3+RA)<1l!s=Ip4Qr$^+XCom=ha%nC00l$@1jPjv6f}S%;7&vf(%=?41VKSX zkpb6XRNNPwQOA8>P{$D+868(ZP#pJd#8K3V@AsU0JE^GS`2L>f{r&Owhn#!1d+xd0 zxwoor6$eebNEkv05B`4mL5Ta1(r+}wUj|(e=T|s!t?znpEx^HHMiY4 zp?%iLRmaRaSX8G;~Mkwg0;%@#C?RApc&%Yf1Gnp zuC#5Ux+WxqsM3E_9i{h;@A(T($uJAFRrt}y(CjO(iW&Z&Vt zv#~wXehe7w8+_!779w^1M#KBY_T%O_P$XXkeC9SN`ib7&u566%N9~Fq`KAl6C|oAQ z`_~E4Hz%w7D$q~whzk*{N1CAYaNRCJsvB8i>z*ugg@`sAVhk*z9Lrb$SQgaH#vx|9 z$u1>Dwqi1L%D_m+Z8Sgll;33NQ~<4y)yM1qR(0~C@by&#r%btink&204+^b{-J9E~ zBz8Ud;1fr4y@gfO4j<+wF35*V$(SrY7!k>N7+7U12sC)=ic+EO7~xJ8Ok9(UOLsyM zEjiCidIWNMgiAfUKRJpPsa9!?N^4b`RB4?`2T=OAo|dC*XgSKId$W{D&-Y|0lb-L* zQYJm$%yK4f*BenMgWDC8huU?-rQn#uR|2Z$A(JNiBU|@0)w)!M*+Gz7H$B6)$$SSD zYBF>N0!@#%(SG2Q6DC6^Mf@f^&3tmo>>%hg5I=4=GM}6@I|w>W0Ni`*^F{EDepMrj z+b`|YljQ2p^d$SpJ*l*N9d!^odiuTBPp8Gj?sd}{f;0YfepBxMFu#W(!1vB?%KvNn zjtu^{zUyh3-Jwh^GgPI+R61Oxd#H4TO7~RhNJ^>AbTLj&1JN`2$tIeWOk(nrMVkC% zktRP`q{&Yf^?YBJEb96GEL)@)y9M?T1<(gr1UVKwPn_XT0jgL)C72xqxhIC-LLwP1 zsZ^7z!<```h@5ETiP=F=MugK;L?F-14uUcw%uo@5JT*HA%7`#tMFjHP>>$Vy0TE;` z;Cctu#AN7<0*VMSn)y^SvxA^B20%s-Io=+DToHH?Q`}M>e zs>!bzw^X(NHf|~AP+fmb%%RHvnwUea@vCAEwaW~ z;FPS8Z5Z`Me93u5q(~sANVq$S$?;N#vEoM?cSk!Di10x(oo{wr2uv@tF%M~M_0u!& zV!G6evSyEvE~rMKK(u)m1=THJFkIC zvLRmAxeiFT`5DY-#v@0vizGJsJ(c|}u+BI@FcvZ+n!P{d(nfc*i+tT0^rcPHJ^=j9 z^%|zOE>02gvqg|fEJGYgA|+pcY^CVu3_w@f@QX;Pv~jd*lw^ZZ7pw2U*1_W(h|=nR zBZV3y!Bv+>4#?z&;ENXEH$4V|log z#HguIk43$)s2Pg}Qu|vTE5JoXgI2Jzu`vwT&}ahA7;MC&UMmo5>1PG)DX@|*ChhoC z>DuAMfr<%MYcya5R1t&L137{U^_iBB0-$Mm8+?{Id@Gt-DfMg3w33QH77e9NhS`vG zB5Z|XEkmuaEU%FS?(!NUZq8KlAZYn3tDI>->ny(&MimMH8ybUvGa5ZsIHmWsHAxBNUWDy!nobn+%I7R^r$w(_j}E20Hffo!os<-oW;g;pUdbR;wz{H9eXtCkO& z`BuJ_cj4?oPx<_#_UiuO2h;ftlx{te#xm>% zv{JCM1f6C&GboQwH=UVegvQV9(4(ImDW%Q2Uaz-bg-KZI%z{q3QjbYd$yEDjFxqc~ z%svK~eJt6~+i&erZUA+6FIBF$KiW&>>+R3>Q0c;)*@PTh$hoOk%1*r6OZRA_!)PSd z>-A;WKfZ#^BX(w1<|C@2nph!UI?vY|FB){S-WzW+`V|^_gO6uNp)hio=JVK1$Q<^k zgMQ~Ym?nK2<2;x-0U%G025A@#u!2_Tif9<4=R{J%hz$3HA!jyN7)){i1kqWW44JJ( zX3MkkWVW(^mFKi619>%9Tu#63qkd}O8CC9lX#j&j8aT?=LG$0rcTOTJhxn}g5*urz z>C6EgElAZy3ha{!Eeu$NJw+9&qF$FpLFW|8{?gX`NI`e3f`YgM#^yeiSQ>EVk`@{y zGmlJqGVPRjU9CGOY6hGPajjuzWA;ZZdp=P`k)lX(BxWxFVHHOsRxBD-u16wq=TxwV zPl3J_cTNL7Cz|MKP0O?rQx&ysRI8Pf0H5A!pH9|GknIdYN{8*l(2bPZXM#$0bP%ts zV<9Ew{&a<3dTNz9S+F|JrO1ik6&(Hua`ly60NI>qg;hSMW-$N09Ho4CF^9ZA%Bt{n z{1%EmzS@gmZThs(@0-i6QinR9gLDtvTlxXLSlS(f zQ>quy3Cl?4AY4FZb;qiDWQb48A!~^8oJnFq9)i)6y#%$Y*DstMx6w%G4j=T+17;go zO`_E_1!h{+R%lJM2JX~2DTwx7xE+)!G2u8hv-a+0iFsV8`V%wL3$s-~<5c7B-kBdmXa~armR(-yw>A)Vg zRUdD4E{DLvSc1zQrS4ooyn!(GJ0KP`g3AE6hypog#I9DOr<9O<*ur(m< z1BWM@>;cswSyaH2gvVDyIScTxAZ7{-iV;ZjksDt>N{WXBTt%}RM47@g=ev#$M3gmHKNs3%~`g>F*7Y~KQ@eLW18IyV3| zj0`Z#RJ>Wl8>kl5Sjzy*qw-?A@RQ7Gzt8X7h#YnPcx%`%UE|Hws3~ZDAJz=7s7Je? z4Xq3!hZ!{0qLD>5kJ&-6!Q_sqmz2)S1J)G3Pc9Ei%7ZlQGdphLdM4^jv)lZNMkW}+ zWYm)-f#tD%7!8K)r!jgfBzdWh%)g+9-H$imtOqB1b5GH{YFXZH^J_A68-#WM8Iujs zy39LZB`t3UX=gD>JyFvyU6kWeO60gSYq)Cxc)jZ#i~%_o&!n*!45SgeT_aIeP!7bt z<52ElPZ#IuT97P9(T*=74bhnCI3Lc))hW`x9o?xo)Tt>icYiSRF?p|dbdmFzex9V! zf$*ZT`6ZFkin5r49_S&gda{^yq=!)B@m7sSx7ovBC5urasBa2X20gaJA!a~sCy;^CL$(!nW zNmeu&@sySb88O~mhIL+-o!gPYxdR|?7&=Ns4Es(HJzHd%cdGp-FnulK)VP-1mJZ8f z$XSq{Yg)NQX4@!f8oGCp@zVe@cN2CGKx$uQBR$gcNLT!W2N{;fXAeeW(J1pZSHqg+ zo%2g};OMbIiCmP$W^H0*bpLIxL zW2mG&HiAy76Z71y`6X#S$eJXv5r|SYW@xHjT|4b59{NUxw`j8|cA!}c(Km8a+TfFo zr+NijTkb4H##=9#i|D9&P*I3nDZlCWU&%p2wf$%DywpNpEO4bSwkGPqwMptQJ$E%? zSYCCV(5=O~wW#SsqY`gTycL%pfHw9ML7-q+rlhWf#v04hM9Q!TSu1shD(+g#<7x4D z;;r>QGZe%%PF_GdzSrsK08?tM!B{i|#<{TYujg92!F`Dr(!;U5)HHwKil_&@oN(<0 zy{Wp0SMCK(pYtd@GzRHgQ@B`i9s|+fCu}A#Xq`fgP1q}V-Mc~d%a0}bYM;F5ka1v; z7<`=U54v_Ag}D-O`y%## z%|L@;`SKv4IPtT~meTZFo(2z0c`QFKRDM~`lW<2}F!^ysN*Z2xxdH4w4wiGu?2hf; zGp7Q{hCrQ#l8idbTW0y|0LN=AZgBZLNaPz3UE^^n~{s~&$c^dhDs@z4{o`I;&|D2nz3V^~lFmVN%NE zTGep#)-9NRKzZv@k7kkM_Bt{B2YCZ>_4e;jnRdg#&gJ=BF+r2ZuBRDSOR*@Z7~;fq}O zG8ewugx$qxcxYyI$h9MVjx$w9PuW;ct zE_|R1Z*t+gx$qG#e2fbp@4_d!@aZmmmJ2`0g=buNhYLU3gQ1_k$GLDk?e3W` z9WH#C3t#QR*SYXJT=;`7{3#dyybFKTg}>#(KXBn+y6{~tTr+$947l*93omowH7>lt zg%5S%qg?p@E__N4-h~+Bsjt?H>=B5M^z6V8w9PK&7=0ol4qv;bJ;sF%2F?^1_9hBN ze**~Wouw3;UIHHSIWHr@(;55kNE3r6XI?>?2l`c{^zg)fjSx>N9`V?(6N)H}#{zqz zSmzDUV84l^VH@^Lp6*y3));vy6>v6_x^HW-U#bJ8M+!bEwtT!EmDBb66j6JI+(+pm zE#|JxJ!3G$V$>;yIu!_ZFy83X?U7XD%oZ3Ag1r?f`TrKuP--NeWNZVLXQT@ZY~{BD z=Uct$8q;|jgyJE22Nc`NU?UxL-X)=C{{uuLodd)H^KyGWLo{Js zM5NF8hegQ~pSlmObaBo_2!wxuYZI{ze^`11+El<_F$^o9=u-oF`ovYh0+~jmZ-X1S zvoBrtaWx{opLN+QU4Bk(S=}ykSBB6;+?9byKz5}+6^r;~R|b7}=0MFUd(tm^ayv}< z-972!o~$nrXy*5*ef%)`K9G9<3N#Gvw6B;?NlYe1q-oe6Kq2i5tXM!IWcU#Dic^Uw z!XA(N{Xj8%X|CVbi<-YmoSspap@9-;*NEsYu0fuNsG{=`_D3*ccG38yhv3knNnm6d z0RvVggAp()atubm=$B(K0!C$y!N{q4=f@}_OX_;&6GDkEAC*&)RXKG=!04Z2Fakz( zj==~R@LxK`2pF|F1|wi3a|}knz$}tk7`an9MMdiEGY|x_RG40QES=Et=*4tE$K~Sy zP~0qxz#K+}WH188z#M}SFwl)sgAp)NIR+zOq;m{L`l%MVGIxleAuJ=zr)aXCaPt|p zUjt>N{SkZ?2wRqcM;;x-H6p_K94ZaCK59P!)iP~dPzUP|Fw%KS!@*vj9+Fai%(MTT z->e@bvpBel4)zc`iGcTw>UmB^ABh7)P&o(!h1nI%o#OWR>NGB^h; z4WgW9>PC?#^A!rQ{{^6Xy!oEYT*}5``3(3N_)Wje*HB3oJGfQ}CNUQ1x7TQU7f1&I zp@%0F?>pkfo$qDxZ%QhIPB*9hoF9;8aJDU&?9>1q9f0=ghWP)YyrN$yZxt5^0PN|Wt5?SG-Xn8WS)9}A*(JoMT|9{Hs+i$sX`HS`Y-ybK5UmPd@ zcl%Elh#CSb6V@!-z+}vBM1+Wl;_P#rE|jDl!BuQTFlBpt=Iog|ZipbSo-t`M<533g zwl?IO?I$BFmdWSOR_E@p)V_`aWyVig&GvhH#H9RhrUDS9tRTEjz>_72uTX9d$gqw^B$T<^BS1-Na@tj-aX=ZD@reysZx^V;DW_D+@_G}{Y;J)Pr;hf+tH?L+%=4lvuN_vH*T z+ZXrc)S2y1_T>~|oc86=?HVrME2g7-`gM+kgL=HJ^AAY*iRW!Vx=pNpW(E%dal@S? zP6%jS=NO;{l>gXQ8B1hNnIuwK-p{%~79)&wQ|DfgQBk+|&5gA+mzyLqcdDUtlv2N= zuR0dtoH|LAXL|$n!1kSem9bcx863+;HAv#*Q8nrx=o}4#T)`-E*Bap@XPYFdBGG2Q zz~)q7uWIQAZ9Ve6`DVV)UV?f%c(H*_@!17Psm{8{K)0WaPdMEl%;Lfh@k!6V!Ksmb zlT%N23#Z=fW=?SubgM+X*=-Urvu{bnm&Fw!$2d$4qzk1=_)gJ1+FK{kR@G&yOD*yDtZs1+3%ZUmy?~U(P)gS_YEP`UQmoS z;#23?6%fvz#`i1je!yd_;~s-i zP8UkCjb5PjhbX&-jYXS-;wh{@^y5XLh@bB86zseUdan70bLwWM7TJn?d2e4uEP^>ZB#~v^*H;J2U`~f5o%i<@BSPlN zB8e;u?dIuxpsylAO3n^RWLXdP)j_Pu>5!!Jp}t~F=UiDNk!6i0#fSSUVzTG#kVKaC zNM9Yy-kc6eIv?#T#-z@bMG{#SMZwO;`YK|g=M+goFUq7kAMdMU^_E2vzK)}F+o{SD zyNvXo=&O$zZDw$Y6k~uSbdEb^BM7BB_XgSdWM6Geax*iKwMk<4BgGAUbr1l|42~nf zB1t-*>MKShFf%C@lfN=dB2X`r-+eaZ3m0d6cw@vVxQO}htKi1$UF?g%U z_*r(s)8K0#i`;nTaRicUk|n)YzV7^``3MT?irB@Mm+y3z^3U+4mg1v4i=Aa(U~c;zH?uy1LEdTh#LN*nmr6+y302lsp%kkm@$J7XthlZ`3AKpGlsJV zm9S_+PhtP5+(rBKs7Qa}>`&5k5ED&jCAlceaaNI^^bG7U)G66hr-nR(>(=1)#M+uQ zRTCQNX3}sFBF&`7siTCQr=^&jcQA2G2gjF!iw@{B}azTsw*6$|#39ZIEqTy|KN zrLzMO*=gwlJjV>W%JRvwo_F6fHdR@9V(7TuvX-JO+{d!GovB{b&Zu=v=Mf@H{L38%h}Cyu$KcUBe*omQwHrvciHK%qM#Gn67fGBkKo=bZOwJp_ z*&7V3{nYmuhXQD>?=e=`B*qFZh=S>`>5M~Kx7DcIN_za12`9)FB+OEAX%5ylQUar@?;JoY!YDy6Lu(JSU`hahyT}ArN3@PehU~F=K5l zMP?P>gdUBP_&@FsychT&(ITdOj~OVkTWGjC}Od!8#i-AO89v{3*Z2L`v*S-+}tCK1~adi@Tc9cm=o7r%9CwY!zKR$#~^l-3~>ZD@| z9!hGlPO5CcIShnCCj;f#?vpQqEIJ$-YCM__mS1yHyxvTkx_vE5U35gRv}jTc15che z7%G~M&&^kln57BP(8u>WV*wn~LcRtgmcj!foQKBk50egu;Z#Q_^;AoE(bOK}^|E>n z-eAIe9%{;C#A3U%;nCevNVHctCxPA^^(+Cd7dgLOVY2Za= z16pRp=S&Bk-ha_#*u~)l3~Tc^`$+KWxbdLkm#tUACUI_+n~xe6r;2_2cy`(!cfky+f2k~|ZS~ZAhWD2Cu?C;lViuRNNMc!j1jKkWG=$&Q^8*xEwUzRwD35RHE|CY6|V=1=Bc^< z*TuOgKS1S^dBo}5+ehq=b2RE9+vpg=$gp!P)y?%f61$q4{5%DE2pXR~1P#X?f;ypx zps|!AdfUk_?B<-G24CYf!p{u$CT6fVdYgLh;+n-piU$iUO*iK_xQP=h*sG`$E7b@% ze1MPNNeHJ3G3`#q2;|8^Yb)qeKsUUHM{c_Nz!}{Sk#lE%QE|LLD4@R6MJLiG@(IH{ z7?i~xk77fqYM+DqWxPo`5h+jjbe@S4W)nFR`f5pirO%SeuH~h|ST}oI1I})J1h;Si z5_|jH0Z2YQN4{axuj4dc<0#o)x|k?RW=lU`Su_o;5s%qzC^)UvcY4uAyv})mbeq`w znwgUzOLmdOo&&V5zEM`@WDxa@+`%?2EA{QY_#73ko5xH~@HzAPQl2mMgBJ0uKLz@; zk(px@XKs`cN#Q&V70$tg@qcxaekYBUR_x?*hxM_(vLILJ)X~#iigTvNr3ZC-MO$m- zH*GWIGkEm4Xh#3&h-7W`qN;0YKhYM{V^||_y=;cInsu?Z&{mP_U)%^3(NbZegEczm z_O#CD+f{y#XF|MfYF=PU04s~vsH|!stNH@k+S8-vJ#I&F`hxi&_rPT<&?SWakx^`m zv^}n4^msxqXY_1BFJ$x_LXiR4<^pZ#&=)MDG@~zAK

Pg5{JRqc6ae)f%Sj3r?l< zFns}yfQAG01!#7yVXVGjIHe=>1?N#ZSYL2HrA_*R6_gHOhs)?VTtj6lRT*nbw5hYY zZPQxmSg=`NnP{u8tim5e-V56lS6?sy2Jq5kz{EWx+)OZ0c?3$V6zMZ7XSU%0s^g&3 zqrTLBh|P0>UXOm%a7xUqY;J4CTXP7{_B`;7c&mrIAjfR~5z{jPov&}Lf*s!4{c&rt zS|&C@B~&e*&BQ0F-of#z5CW+6ytblh!-+?<*$lfQrK%H zw>3tdMz2o|?*eHB+o}YzwoRwk+v06axLFv@kF`Y$@K=bxNGgr@;lfsAwv*}WaiLWZ zM->am@s@-YvGQB-c2NWtn}Sw>v^wZX3x=8z>QY{qDVQF$D)(!HO20PC!n->4prUzx zXFg0vqbb{pawA5Ith|1(jkmR|B3H}h^)ymcHqu|wNYS5bq`Z2xe@~of)~1}T6{EUn zh6Va=x9E)1c*k%QgU}3Bj{(6%;R6rO0=N@OP4hXY0tvfk0ea{NvkF67w6)?jwbRIN zOoVjY{JCM(8pKt~>Cow|Ly$LGs*kVi6A9ri$2B2#6GP|7{g;kYr^lZmV;o%r-hh4_ z41Z;?Is>MIn9rFr$){ll=)z-e2{f9+9_Md>UCMdiwc ziI~#?gvJP7Il_sXRtX{W_|BUi=C_9Y zRwz>7EQHmDlFW9@nIs0{>qw&o5aBJ$EJRj8lFodXQOmOMUeB9AiM5&1{8nd+qPJ2S zZgt*5nsbGAl4%^m#4&D9%0nlka}wNvF{6dpWp$#2bgfm0@#u|4BT;71-Y(yYfLa8x z-9Wyl}R2A0Yt; zVG#}BaUP=7mkZ`SD~R(5@$Fjx9SlJ;$qUL))t(@kYn5>}Fr#Osp<~2Qphbk2E$7cf z+jjucSzw5I79VFL(+C4f^#!6WFC9)rYC3;oIYNw*zf82>S%Ug@nthj2+)yzyOCd;h zk;KLVnX+pEoL7*E?k&xR8NRo~2pDZS1|wjclw&Xg#+)335iqbXkTw_rtBGipD=$w($VFZjba|}kn=*Te`0b^l~!N}3NO%vxQqo)1TQu_Wgd z;Q{$FUb-SwwhKuia}Ko=7A&C42)&52VIvXDEGK#u(HL&@`+O9CO+eM~c$w;StPZ!r zvScs28lZk(^^F2DE8q0bamYG<)1V&aD9%2}hOu7AZyr#N%-d{_?mNnsqDD*deaPOH zOY;3nNmKJB<&8?ophisQyELdNlli8+kLSrZ>AfkeE#Ucji=jqN=KFI>ep15L3A{iS z`z4QaF51K8C)X@KmG*;PfwmGu5c-YSps4XV5?J3&4w*{1S6RRpvs{Fn_9nn6?rkobN}Li-2WT0XV+} zfZf$d8m_{X22PJ+e5or9^(I8v?4XhDac07Kmw)mlK!acZ!~1G9)ZJZA;~ia}rNN%` zXD>kFtU)>Idh`)-T#bCUgznGnjF3cb7+y`mRg+lyXF zC_Smcg;3^GIMjunqXz|&S8a4qedg3x7ij#>ZGAtZ<5H>|(#uqOIj1T|yUwq3^2;4^ zl<}b87aw}e?q%@XzdFBGiLWQ*itFLuf$Y& z_q!^mbTuKlCd;K5R}^1|BE=s)r^N+BFdM*1P0!tfoEB&OoGz4XyAFd1&u58J&ztFf zFYOmQa(h2!zO!> zEX=1r(%vmQ$;O^wIqTq8%((`T9ALPx{Zso z)E4ZF5T!{F(ic+FT|655XuVkFf}I;+haW6AOtZ1Ln_YvUMqfuTyU2C=G=kZM8R)UG zj+o#wV z=joN*(slMZVBZMA)@Wa@eG{0N_hJm3(UIPdzhm%Mjy5kun)~^H3;+*%Z9a=S7S-Hc zwuWKfER9fK;h%t)=&KjtSJ2!Cv_LJy=lH8d3A*0#A|%c&$m84!kj0h{pZ6h1CtoRb z)`Q=0s?^jwHWBMKuwu?10O|8b_8)=JZ!z>4IbD)mob$DF$W`H=eEgm@9i?*Y;RC|iUDd&DF$~Wv`G#jqU z9{`o)uPp`bYY-getB$xfZ@-`Jt{(|j13p*K{$vj|64a(1Y80sLJ=7Rb@AXh)afJ^{ z_Cy#-?+#*6Y7f1G{E1~ABrp6a950siLz1q}(GN>HB57#ib$il``gKdzN!?P|V<^4; z$ivj~;f8{G5dx!u)a^%LC-W#k_GOU5Of(*(cMz)j;W3E*bMGf|-SD%$e>Ix$SM@>%+`UwLf2B z6Xg6GkNJi1C*l}7g-GcKRO|(C7ShL%CWxtiG%xYR9Pt|d8iA{M`ByZZVQ(PI7;jkT z-}VcEn1OluDnz)eG{KBdjgQ4-5cWcZ5g7n^Y*Pf}ImRJy42M9 zG|10~Wxaz0QHUsY1wqP(*-AkWUHv*JygHrTjuhk@lynebXW>HjZK4+v?06a(!U}(e zGE4TKf!f&mn#lbo^fJ$qj1BSG(ac{!XWxNDaP_94RpjHa=O93I*$h$oM437p!Hb%y zJ#qQ{JfXNV5eY^@_6r~^Og<}wVcbF1oWBwaw=kR+fiwj17BTQmz>)WF#CmXR1Vbhd zcRpeZdiHU82ks?!h8Lhu^$y%l@W_2Mj1qx!2D>l`>2_Gi7Ze1d_(D|s@Gu=tyn*KH{$V0^z*Q7RE>W|z0JF3}4tm)LvNrS`s?6V!&3OetHP5R=Q$%k+5Ux3| z0jv92krn0^tPA8C|8#fbhay4Q_@QVN9Z)1qM2oFrhe~KDv5NUU1Q{+~2P+wk{j49j zm-n+y;Ojg^(gW3Tdn}~5^mBcucf5u6iZ$a6GRzvn<#Wi)8z?u6>pL1_jWuRsM2h-X z?;uvP!Cz;;35Ar`-b{M$D!oZ+B;k{`#B#Pk7<0A)MoS_k_FIG`A_;pN5Vf$#IhV-3 z4}CS~5;}dxE42mhB4R|5Aezt@Qrca7Ms8u@IhU}vlgYSl#k2o_d~0?AB8*0BNGw%~ z&LMAUy`|K78@eei7V9>;tvc7VvlnBob(cm8(R^*g9On^eIcc;Z2&h~C2s?w!IY1O8QqwSmTmbCF>(RL zRoQrD_!LwB1y_J>2m|>YkfLrQrNve2b!?-eIjqgx;~wO7wVkTcKTtotgKBMeQ844S zafqAVx6)2z-UAk`j#Rh5M@{=as7STaZ$eEV7@XH5z&D{jgogbQtJxnTN#O=`Tw1A# z)U=Q2QLb@YsbO8Kzbc-3g5Tz$57PwClr2!URmIOw!HeQ+k<^RMCs1`h1&G$BYOGr4 zGeVPAc_e9n4iw23Ncam#&Od=cg+t5BDzwxhwAAK83tQwKKSG%&LhcWLTt*$rsFP*X z{l6*0)g}M6HyRFh^+&^)o|YUC8Q^Nk0q&L@z%7Yz#dY~nL%J#@cW^PA_r71EmXhJM zcQUTdl;JyNUxAKRwErF*SH@SC4Kb^tvcvfo_zfYT(;3B}i}uUxk0FFl*wAotMoXOi zpz%bXLdAX6GG`p8{pdau=yGR2q@gVrVtC4(4oytj=};zMz64WIoD zOklq=X0pb6rU>FP8b?e=8bO--g|RCLfuKDWVgfz5)qo0Y#qpI-oO1)=-h1I&qynKw z;L8O)cAzTo6#;&RQ|-aEF6eK`7=AfE-l*sIVkq#s`Nt}KDwhOTfc`zwnTYDR01!hd zx+9%v^7B;Dh1BiOU5dZsNBpI*b0PWY@7*JkorA#J0$Kf!_~>gf6&uLCU3{?UySU;o z@TTE!U*KcmC$9BgLvI=OF4@Mkr^H@JpeM09ehd+=%P)emo{8bUeD(|K+71an1)hC| z8J_{tszWJ`8;DfOoB0+N!+z&GBn=_@NL4Dd`cYF@5c#}_$^U-%{u7eE4n((D1 zG9FWQWkIAbbMe8|?9-s}E#@2Hmb8BXwm@a{q$;&vCQc7*@K{zF%Kinq_yUI~`)5Y* zjK}#N9%Y{ei5p<_IWFA5Ff;!KlRmGL!pAn~DUjunrU(5YK;K$Q#T@hmUK{BQB)0ySOX2s8c>{Sv;^5+PXL9 zAU?=TmF}QA(mSO+gt|`g(@f&hiezVSVV2+a!o=3HpOyM-6T47oFIP9tsqDEY3H%mwh-;el;ZK z_~9U5%m)aei}@fB)ji~>j*IzSl*+dta529dbx)f7?jQkO%v0K3d{(Y|C?6~#vhX8W z`lPm5bsMVCSc`$g6k@y>6wOgbz*@JCi=mV!hA=*S8EP$?L+`-)>uEm2Qq4mjaWw5b zSXS3)emvFZYcv{T>Kcu1L`dI#*>90u`Hl|;>`uPV<;EmI)Tb|`)OC%5cRH3!>hoV!J=)p*LiJX0&x%2U)-g6@zS14vzH|gbW&sM9${Zs^2Cq9ag z#fVc~r3R%}N+UR+Pipv5K55}g`J|-tslzPm?aMg6lZc{hi=1!F454B@WF=QDpW~vs z($BsDO#g-TxK+0q!2ko7&2z=@>FM6_+{^a6#xsU0?|IHbiZ+JN#8`x!y^Ix6tz8qah$ zUyWz%H4ZyqZ%uB8_>lOiLmHbK2R9FH8BQj=Vh-Tk@p!kZQ;5w-Pag#Qu=(w?PdOn& z8k5h&Jur-dnkk2gqUCa5R5O0c{^KC41-+phvc2cf?ByT))sDF8npA+C`&JuF4#Pb` zYg;4!&}w22{Lx;~1daA${&S%5kH!%1`KBXXhQBcWc%ep0avG4O_>15VJ%Zoe<-{jz zCzkX}n&KsfiNvtl0x>;F{PPoq_$bZ;C42WX#SaOB4>5e5;d16rWqdcz`|mi}Nu?Sq zOz~!XSgk4cf;CfI$1t5B={nZ@p}1c%D28}u1%qN7peYttFUK2hS9xazr;A`M-P5J89~O7kFC&KHdXmsRGA%Nq%v0C=hRtcGe`k}~>T zDqaMhFJ=!~R%wc4tg#|rJQ5|mCQZKe;~E`j%nHWDjYTJ?O2xFw%c@GnD-D-b^%J{Q z+*MjFF7`gyh}J+}yvEHnI=TFm0j5ZJ$ig)S;fpF7D@sLv(`8krxE#4mQ2}pFv4L|P z7bDFl(E>m4*>+PbH~J-g8l7`D#c`FS`4%{)XsaezSkaA77TpK0OtE)0;XjlvZ!Q(P z6_aoM0ZpNCuKQX4xFRa?a`Zw!QC@jhX;AFN?Yuu*nB4DtT1~yjIi;zvyo7wVLtm9$JN5gWR$QbvcUYB|rD;mYO&E@dqX~XCj z5z_{dKaz$7hWDG zi)xlt_7elcq;o+d>HM>V;1>+reT2_rcnLf)#i?B4H(cT&Y^|LwTX~HY5iuHVb&Qya zFZ>NAs>A_I{heh4#8jr14K5@0Q-?COUVQ7aa+~;`Ww;E1=F=jkk)QY!Tzj+W=3p9l+^z1V=I4 zy`Jz6<{z9q9c7(YK($+sdi59mql+Om>XrijX242VySaWbU{@W%(-}X=Cj9CF1g~Vy zzgYTldAF}cEHAzSdDm875BOp8ZGe-wo!?}g%bF;c+vc}y&CDbIv#F8jo!zPlKG{U@ z*$~0QN(s&^Cb(Oi;HS*roAJ$z*D-!R+I&)1SslT@mJwW-T8th!&7yv3WjKuCmHA0< zIvGC3u$=jGBcy+^MXD=2a4~a=!=sU_WxyuDjm;wguPY^; z3u_4eu9@KNs3X?K7Q#I5J5 zECzhVOZd4a!Tvtttnm?E<|jDQPn;c$Yl1lY>%^JGdGF-BL(L1pdD^@h@F3r9fUo%; zfN#&?kUrkGPLj7f+4DbDJp=wE&vStP#7Hy6%IX)3G%>>XyNmM%@Y1HY0dK8%4^rLx zG2l1eoq)HRyI8*)u*GW@_u450zP=yX3~}%0qh)~CPXOl`?%8j6%p4vho#OJ*7-JDH zLDMWjn7z-e1sv-e2zZZ=${p!%1WvwDS%ZO3V2&A}@fkC_T|SRQ>9VH(s2>k}Z42oP zFrH&?C%XKc23)p7Z_8++#!LDmjAKwri}&wE?nr{@jd()6Jf}E@6=_~^e=(xaMzpyN zbELm0DChv^%lwXF(&>WEb>eKmv&4BQOK{zDtxctm9kAL}ugi)jR$;jj=lI_UPsHNc zswiLaOk9x9lvJX)b~t3GGxfAMd%%swhImR**`n2&M|`iS`m*J?m)h1};CGJyNPD8; z743>TsBWgmE6!BZpt|+NUU8P9s_X9nb)KR=tG@@-Zxyvy@{ub=736Qd=?^~)2jOi7ys;(A4q z%|fwRQDn1F96(DnY@VN-XjtM)VCH*WBf3726WZg@}ngNm`KTE|oHc^*|q3Lh6 zGEq5DQuCvKDJ~au^oV4OqAwO#h?f;rTm4FLKar?snSWT*8=zVgHHoPM6xGJmv62$Y zOSeLE2HnCEt3-V@{;MmoP*J;ucNABN>zKMk46N7{sunXFxCAl1>{EQZ@+RztC||PX z+hSX+87yV1%Di|zcJmM^do2}?rNw=?$4Z)+WFpoqCgb)B)uFYhHa1v{;|uFc#3xND zd^>%IqP{2^92+J^?;$nUH17)SA=ZwNR9kX*Y=n4>sa4{orct1F>`Ag!iL%;P@MLP* z7^(SE<@nfM;@BQ)LTt1cwl`~v;-bT1W5sKV(n@E<_7TnFdadj$j$rB>|G5}T`-&Ng zx~%B<*uLUKMIBf_#TYN_7}fZ z)Yp}##P%1zQ`Fjlov{PNwTilZ;BrvwC55qMiUY-8lfef*JMfa&!NR)_wZu98g#)jO9U}4-^;&E> z-iAa&vF!a=A*f144GXT;CW!%x8jm}2lSH$kI{U5G4i&=`b#XsU94f|0ifeS3I7P|s zYhJA#CQen<6V1oR4ik$MMQuGsM>7;yFcatUo^1DqfZpdp%Wb?xF6DO%Ho`_8oEt10jMS5i6kz$;ptO3u&ej`p$n%~zyADbcOE2^<+wKhX6R@Avg4;0Q2 z=PD{xO0rdonmh0n6-Gn=R~vCDqigINl~MQq;#j(AZ~Z7qxA z=ZeXbWC;loGsLLvs<9d3iqHe1^HFlvGttem1d%=}rKShO9lt_MR+O*dq4@8_=ZdPWexrD; zII>l09v^ zNjF*HS$X^!5BS* zWk)OOhFZ$!D5|rb@^vVxjWw4kYO77M)r$HsOw?71x;joJY+-7ZxXk}HsP`2$r}Vw} zEyA89@LMHTm3|UmFA~Q}Dw_H#{zvg=rfAgt5WiELc>>AK@o!JA*6tK%De9j|P24HY zSJcCh{Yk7=)L$X{lUS>$OCh^UT&Jj8AiGQ4rl@&=mA<>h-HJLV;7Qyq9#PbRkliDm zR@70D-6Q_0sGA|XSG=mIKS6e{*ruo(YFBIbiT4$CcP;ko;tNG}*1s3LU+hxUvid;c zexaWz>(^HIUhn}CRMdjHK;i)rRn*n-mA(f>nWEOmJ&6ZJt)jNt?*$(cX+?c(2NDm7 z-4yj<_`Tr6Vx*$J2?r7ni+vR}CX}CeL>#QBBSIyhj!@LekUc79C~7fekBSo&b$Zi# z!NA0;Xr&F}MMXV4V72y?cui3o2Wa9cv0YK4&|^=F zZzaXPJS_@mbIU7gZXarGAL=uvmO!%*yVpRQ?6G0+^BGa5s7XvUOG*wZ!==9}zVAbgIGHthU-zmQ&s0wH5T=&ke&*D~tD;p= zduuZiuZdZTnj>Z>UKg_!b*4T)@rIbEs2e?t5^st#n36U(i^WP7)Xz(77FW7tTf_}Y z)@ZCuY!UakWLw1}N;Xtqnb;~`aLL{huPE6-{m8<%#OF-O7`aV+@6y~RJf~1?m*7ms z#ffbqsHofZixY2)LPb3Q*$xp?)Hjg*LzFA(1JCag?~7_h?ISKud?@I97i9A?ZC&DH z(W0nV^mU0(#2$)z#&c`pGci_C+q~-%pNk2KT5YaNd?BVND&@O1@ufIQQCIrbC3cDv z6qWR^OME5fDyq$YYvOBhhN3=&>>F{GqQYWbVwX5yQIqvs6W@t7ikbsoz8Ak&)Wh)Q z-{J;E?QPzj=oSwuYLmGk@q^f)s8-(#388IM)H}Y{61w&QQ?hRj?TbFt_e%3~?d^o2 zjdrM&wrh*)c)MrD>Jp%6h$(h_I_8d z%%^>$D3bZL=B2$dzqS`s=lE%t5YYBj6zviM+98UfT|!VhLQ%9!2x>DGH5{^#cA}yV zfGniVlN8Ufu(p&bIR?YpMSWzKF||b0XdfrS+Bzxoe_ayD%hPUF)EdN|Jnare4G4Xi z$kXmuRD!9;C57^KLH2=?wT7n{h1z#a$OGbC?y)-l9o;N*0oRef zs^QKjwQYV8we~YDg@qnirr!J}R#-^)JPQkb;_zT$p&xfj|4;P)0c$$$ZIb;i9?w7J z?BpK&Qx(-q>g-{HvdMzcv5h1`<-pSOsCL2Ajv!N>oJ32%Mk zZ-9PrE;s>EiI}G0`2yuykJ=gHVvYtfdfdvbO}HVR;QGpVvXW;FeM&a!E*Ep8 z=C#PHi9GnAi*1enQ~ZyxFMpaK+ztP?_->mL|Ec91XE~40{{(;9@6-;t5p@k_haq;y zILAz9{#>5R%VRZSm#D>G5`T608-Tw?`~^fYu74~*JRJfI;=7)-hi+jwoZ%RT`!k%x za4N%DfEu3SQ{KgZ#rVz~;i~}4#N`YLuM(D4Tw5n*0~U�n_3O-qC!)D`A+QJ%{<( z^E|dtBu1r=!vB7F_J9-dZG6JZ885;;`O|?DoX2&hN^u&6IpeNQt z_=)upequd@pIGn7cJ^dD;}{>u_yoo$Fg}^_$&ByGrHo@Zf#GC^3;D_SLVohSl%ISr z74+nLsh}s{O9egoUdm6tm-3VErGlP(FXboSE7<-Dw!en)HH@!d%WD|!($5~ysaXcK z)p|kq-tI!1T&0y5KNMZB^*0Wwc^Gg|dV}^5OE)mwrH?B5RvTzc9`u8@k=t;Wer4He zZI^!Vz(ReOKC-M>A8ssdDFZ&XBB{3+pWv=~i!rYz1As#m#t) zewQ9k?+!S6&?tSQSktl(^nXaD^p|n(a0)n9csiu_md(;98OtkXGh6_UH?>IL%wEkh z&Mfb5?11G)W0yW*(6`zSQB^S1SH^mT!dje7qzEWE383zyd@l0S3)9m?VZDY$s zV~9p|8KP19JjC8k*1j&8>6vG|7d_5XVt7+0dD0rS`6iwGNo&+2yY!(&7kQ{pmkWZI zcs6reQ7`-s{KwEco4GDWDj$w+DTcL`#Tz}xvL|!clle;eoAgf4LiTwB*KsLy#(P(= z&OBpC?XlhsT;Da&nde=|{;c5kS<4o#Q+A#zz1q88;e%3}y(Na#teMm@FB%)Utu}IB zJjA_!7uzSFCz*G#H5!d3#^lY>dFK6MT5O(KVtC@GnGdng8(5$CSD6p-$UN5=S~SVr zs7ide=@E06ep%@zbCz*nygz!UxOS)6U;Ay`^!3*=jfK8}+U?Pp?`7zlVxYF5rP|j5 z%LH$L^kuGB3w!c1xASnNS)Cf{8v{*(H#Cm)?T_~tmy6+AxNa=)uS*Jjmt*E0?Ay#1 z#;}EfT4ii8N*s)*A_KLoc`oobi&y$ewCBn+^xEV>m-}|GeS(b!!Rx@G@vuw3yW(Ns zG+%aTH0pNnnE4nw)AhxG&-)%niKckmc(G!O?^$hm-41XXV;=#|XeM~2{ZGJgDT0sN z8Z@WWXyRPt{o41saau*U?_BLLXb#jj!~_0;`ddu}fV0ah{M$JSZ8s9l%W;mJdYq!u zEd6`nS7{#ozW5}6SSP%Q@p8SiX1Tvse>^?WNW{J^u?Uu$>x<{7tR&VFEw4i^VLFqSlzf&VG^lR4L9&NZ2H9m)P2 z%do`Q)pB^C#F*Z4VPFn(<}+N#a4EwTfa8K|0EZj9xFvTPYs3G<5zfxiB|(o~TWyLW zhO1EPN5G*zDgrb`z_X-rQ*f}|vJVe2+z4ojmlNKt< zY1cQ82v5=;?KcL}q0I+{cfiAm#zwA1z+;793~ffkSmaPKlBOGb^fz@ydp2}W=JD>n>h}63~JTc9-4!*J*2ZyU(>QUFD&~J>(`aQ zsdvH#^+k~}q;v}4ff%2Yw38}s$}58AbhN;cz{7?);Lf~qaIVs7*}_8{h1N6N0N9Ag zOc6K@X`_)gXgwMNAF{@t2K8(aMhMQ@@(Qx2LA_iH4$9?dFvOs^y zO<#e#83XkN;oks%JdOCK?_W7Df1v&x<8<#~lQwtYP5Ebdc9#Aj|44&cev@`_@R9su zjn7k?^5+<(HSgrlH^|Opwlm+L8NErnF(?Wavd&W0S;0ChSXyFS79U!$25qvBXD#>3 zVo!1H;RUN0Ue0iT{Rscef-&g#;|tb#7NW!!Uh5~bhm+yqbbYc;9;Uh6o!lBbxqWtW z``ipWg<=vc6ct{_y>K1(!g}t7yA0YDOw#8L99($6=c9_@(4l@`&n;hKEUDV)*%??c&&M;43Sf!gICrg9{2D;<|6( zx^LvVZ{)hq)Bk|BqG)_);Z&WTkWOWrQ}ug_rWmtf?UCSY{Tw_8pRNBTys@xEFD}N2 z1x}oj>g8f8e0~W&Y_EB}@MUhj&D__UxtDkFuHX#MYcZeof#)8K_Fej$O#~k+%d^tl zdTDOGG`HRk9=$ubXLlI1^VtcGoNLEgcWAejcUVJo>g(^hE*=l{^*C;GkB9nt9NQen zHpj8ea@G%fXlxX*{sh)9V*NWbr{!~N0_U2*xh8O~N!pt7oAM}X>5-4Q^~%{oEnBGN zym>eQkcs5snf*dQ?7jh8#f1?+-cQ{O_;-d|#CG6ciGKjj(Jlp?tE~l`udM?-UAqBr zp>``^mv%egQtfWQ<=O*)E3`)eFVZ#uuF;+ayi9uma4qzM;%e=sNWOSV`zR6-&uae! ze4gPZKwbP@`x=_BYr6oqXx-3!mo-0Nolh8k!J1#O<}TL!H#GgitCNQT-H7_}!Ybj# zj8E15;LOwi3C;pN4|rCOM1$gNJr4X_y(-#YoFtwuONtJ$u6O|ckFE#G8*$3}x{^km z?!FDMOl$MqKZ$$#%gUc*{!d1CcvTXm z^k;qz^YNtv@W(J`EOW*(XC_-XhB?PDr;9mDbn20-8NZhKk1_rv` zTxa&Z^SU!2hQL4+MG=4o5-Gk!2q5^hDT;WKkVt|iK#7W_0tPb|z=(qxXdWaX#n~A} zUOS<@wj;Y@JNAZ*zvfc;l=$8+#okWM!Sil_+ahvPpTJ#QS}x z?+X}E6>sfU@eY*Sf>C>nC_Pw|w@LHTTxhD8)ap`xV+z`tD5Q<8WLt5a` z1k)cCd`9pY!RrFA2)rinhQM?S^KNOmtbVTZsNf?lY~76Db%9p|zKAqG-FZ#$8v@lj zk;XdaO$&Zh;Ece!z~?0P#dYNQ+PW9iROf4;6gzubnRifNMqpiFL*NyGR|Q@ZcwOKP zfj0%JBwK1ErR@TH1P%%uNwUO@;JGB_))4$Tp)*cy2v8 z)YpGfJ#%M6D9=gkOxq{b&)?Z-W6CSQzkcU6z;&Ci3*|-|YpB{ksfIS!+sXfmz-t0; z2vjMlzrdc<3+kJjds3Ix-)_#O$n!aYMu*fy;8lS=w~4(Hm=V~x?FAL;YzY1wVC~MU zLb)oG>w;ew{56TaDY&tLd@=$X09{G-xSe>9z`@&@e&%+zDW) z1qCO31ix`R+jUcLwUM}iQ&0DIrUmcWNIpje9~68>@QkFX3*Hd=6~V6xeogS}lIDis zH-)b55dL?NXIk(c!H)_)DEN%v8Nus@Vda~K!FYjeof$OLb)OMO-ZlrlzQGN^%VT5z`;8y;Tgd*5?dF%A@~)+uS%M0 zf?pS0Z5D2ug`418Hd6EIs{JP*b1Rw0= zm>ulon9X#utcJkr0*x-#Jl#co=;>l?MkrSS4|l!@%6mF*Aol8pn?hIJ#M1(Mx|x0p z;Cnic3T3eSvU;X-M(~EjUKRMFq`9H<-bD^Qcd^_pcQNmbP%?K>8V!M01ztxi%0FxOGR z2L+!Iye_cO!`faE{F=ZU0@dBZLEuq=GXm=ZuLxB4d{RBV`RF~I)9V7S2~=B1IVx~w z3tLwgctzkffj0!It&F`Q@S4CI0@b~wU%QueP}>NPZWGS|&xZI{haSdXQ27$xwQI*) zW;4dJ@gC!b@v!-R^SSWVuoX!~yQ6!e=b|r1{~)?O_C)N3*ekJ+b&vIS>w@($>({J5 zw*JPl;yv-#5`U5SZsP9}2U{jvKGyOFEs=HI>&oliyY8#&et+E^t$nRuZ*5BsBo8Eq zlb4c9>r?HYX^+EqH&h7!!g$INf%hC$9e74{J3^cBdi>p>Y{!ZIU3i=Nezjip;VB57 z%H!SZ4xBo^11EvEV=b~1r*IXX(p#JGC4ddBfc5RfXYM6Fa&HRo?K?LD{`JmIz@e=@ zfV=yc=8c_9^Zl*FFAKgb=_``{!R<`{fo)9xp`G^vzGK@iz{B_V0iG3D6!_)a_JZ=n z&Vzv6_a6rQ{$0d>YsXQ*SSKm5+X#QHpU~X-5a3_lPW<5n^M16O@UQxOKAPvB+&2mO zuiV3wU){;rEnVjT)vhN1U%j7{FW)u?xc*M!=FS43uEURQdpWVomf4h~Xd`;lb3;fCWv!Kih z{DQzQZeg0sJ6YE9y@a3L$TZ*CPWx`Ugvrt#@-1Xm3IOlVphFb*xl z#}J@nIOPE?Gof+qfbGyS6WX=`a3lVi(6~*2o1mK}-nHlg#5)#%op^&1XC<)KGgUX9 z-Ty6*J%KM{;T=st6DPwT20jC5stn3B@V?qf;8~Pus2m{9 zVxUY@J&DpxtRc?=7Epqz7EywUuV%dyumr9qo(4_>J`YZYdLN*PbIe)b?*}y12b2x` zgMg-b30zIQ;hP8i_uz^X1mK4!)!<~RPlJ<*Q$7{I&!`&UXVpc(&*A-K6YneYvn8(p zns_$xF5q7RG}SNR4P`_9GN7qm#j{XD{R*I|epUTA@N0mkx{fbc7&t@uZs1?WK_CPB zgzo|V>v(Ev;LPGr0RK&O8TfAjn(B45!%)8si1Q9;hoOE4&{SVRI}E(%_d(#li}#%k ze7W@n;J>Fn4E(>l!5vg;NJi=)qltC3Qjiwn(7bb zz3evtP4!3WD(HU8qC!y9LlxTaEt!{9Zulr15Fs+W}3r z+xQG%ukks+`*G^mRDH%T0`4)s04?5Q?gHFv?#A=z{lK@VgTS|{w*lX(4g#%bf6Q86wU z&l^9DUFEMD33I~S9NHFoDD+rpCUhb6-q6p5ej)T~==&iv91kA~p9((~z83!D@SleN zGQ2agKQbA~N1lni5c%E6L(vaJKN9^yv@jQt>(lGU*PmYB-L|#uzP6ue z`>D2{Y5R+|@3#%NPqaVU{{Hrlw12Jro9%m2Z%ZxRXFvll0sfc@*ZJH(`1?0$p33ll z6RC%Bx?nZ!GR}gnq@7E>2d80pmcajIQX!o4S($DkwHfDGR?|L`Zl--6r#n`s`z4&+ zSWWvvdNu7_Dk|@Dt!zhUQ=OkoeFFJ|y<&UpEW)cUp8KkZ#2G+xHl2EK9MoshwD0?ZAHy( z#&5JfWBg9*C)B3ofcZf3;m|@d8~PhuJJvrHikg>0Ep7i!?k~1o4qa>eTIf5WXN)85 zpHL6A|3m1Z_8;Jig|D`64F3tP@8Q~!+87>6Q9kPMkweqd0|%%3`_$oMc6B75nJ<LF5AWGU}d?OWlqElEuYR*r5y+i7tpFHyO=2oV^BsfW$p5k8$$I? zXXfoG`;tp>s@~s!i@GD?VZ=$Y{%K%qXanO{sT#V-$pz^FyQTD;R8-|#)j*dHaRm9S zkRF+=s(?;@F43T`1*bIM`*Ke{m^O*7)dCwM-#jUB;MkXHa;p>J>yx+Qpn!#T@j1k2*VEtm5V> z3$Qhm#f?8W4Q$Qf;-o+i0`nASMTaB8E@n$Peyi-rJ`7QHxL7)4=P=&0RfmwNqGiy0 z9nD@R_H4GI#IMA%~u!B*wyl~7de1PN0hhX40^UIs^h2a-+!xW z?3bn^etLScn!zkPST1Ll$BX$YRcJE*wEY0`Iz`@!3+UC-dAmqSF!gFp?4R*@^dGoY zRS-d&GNSbW{Z#el0}et&JJq-q2hhtlCj4T>W=)vk!1VORa%Sm(nzyUdXQ##vdQ5<= zIYgOab^Qls4ywUIe!h56jf@-`>Ki_EV02*5$nfC7y+dOoLt|s3d-v}f7#STq*mr2( z=*YgYp`qd7{RamQ9vB@O9NTxWzn{1L!w2>sV)k(~h(j)0t}V{0Ss%p|sT-SLv`e+>q@9&IP5D7I&y8`4@Mon;?=ib* zm-AVf(xytIj_Ft4Bp?xk75xQC8H+x}&Maz5^JrF&V$#%+Ygis(v4*lsu}yj{c6d$GNiXe4PGOkB zDI3qJtXMK29L7pZYbaB(Cob6K1(G;ChXx?Eba%T=Fz%&tz92Xi?Z zWZq8KW()bsf}K-K9tJCImoceByA{$*LT?sC7AKC zQW+!5OAEuAhimh+lb3?1NQk`s&~(X*I9AK&)L~j=C*zU)^z>NXE`Um>{cHtpZ-7QK zt}RC;K&9BB5dq2=3{Olp41kLOnexfJ4P3np=TB1oDlEgi>NGDu=Lz;~r_ zJaT?|noB!4V~)gE8^z`nVhNkczAcsR-lO~9mJt!Od!qm5r%IS$JN6c`9?43mB$)U$1#b(UN)EI z>dGw>j8`1n)|^)?NmIiH^%86Cvbed?l*3Wx=7>Qts>2veYviKDvCLgFPJEwcevU<$ zD4s3mFEvLGXRw=<8^mavmE8|jL{}w=*b?zVG@M>XJiU)D$`tXTYos zR{*n}gAOm$isv2tjE%j(tgXf?+~$}lKe~Wv8e0<}RSJ2)(Oe#TiEyhiRjc8^ge_xu zvQ#UB4!?tY33R)qZocJ^`UQflV<1Sc!_%<jnCu)1 zE$Yq76Ztut+}*{5Q<$T$*danG;+3&KT~J$Bu)y5SHaghY zZkw2W60Mg2y=*~PDQxZKirJSPl12Pm8!^ zjG*hwvLHhim@&Amvo$X>^~R1_i zcd=AH??BXsObYeaHOluH6cW(v1V<#x5C^Gg`9=Q5am^yaEh zK4Z^KV(n60K7&1iicgo#y-~eSz~K{!qFuh)r_WjgG^8LenQx79ncm+ z;}^KYsZAcXP4M^yD;Js&S*Bo4aJD@pFV-TAWz*#oT zm0ED_&XxivNF6b?U>Ap$N9;-#GbK~X4*LqqF&j%^z0Vu84IV9PLr%!)@OEU_>nIFk zrb>E=O}pw~leO83{Fw`NqBO6T@bqM&JXxsCV-5t+&xI78cP-EgpvN!Re8Qs2!2p)6 zLd-aK{S-!B%;%7jU2?)M&I5I3Dkp?ZlM1&hVW~3Zsz$k_g%>?yFM*yD=PL1pPTNNIei0JD_E=2t-oj=A*a3T!bTrnEGDOiWsN3X|wWSsqU)8;4iW z&x3`6m``pxEcNCPTEc{+j^(SzYqMv|;Ok){rEGmHH0WswG;yEVsM7 zZg)0!s5RKM!x(fn30y`eGe!6|^f-E51|`%>9!sFb`Y!8rdH~*bf}0&S(Rm&fd^V+@ z9r)8HpL+yCY;N#k*MF=c+MJ}QpL87Qdkbs#2z5^!G(xR{$&Mz%_JoN%utF^3^osC18Ey{To z{UiYlpsqu3;jAmLf)af42*1ePTI8&&AX$3a!4w-3{Uy9?t?`sqiQkgu^hqN_)TBC7}MmlAgg-Y?l?!132io-in1r zT*xzOkVg%g9z{Fq=RBB(b6TC&(+zS=V=9rE+*zW^;!Cez%CzHj&1748iE9`ed(!-q zQ*MGu?p0SS)E<{KUO9_R;_?cWN6>Zt0?*z-J!V&BpBgCK^in}j-taXsNy%LApn5;v z2|$|=>&(R9>#UI~r|mL)9Slo|@kHq&?gfDym!1;@85OgbRuq=YywN(!G%O4?CI)z= zQg{nPELxndagG*{fMx_j^0+Y&4m@`bPJ>Ov+&kg=(%Ectj31t=tsr=taoP;|K}_pzLK;06FWYnQ zcortl+ZSCDqfh1wc(@^3B^5d&{tT%%LD05pX^Fb-wbJzlX(YK`Vpn35qaqazSQRDf zD(7hccys*)jpY7Ag&jd_Siw_u{}hzMoW@7aut2)IwKdYXep=!fD6Io4NmP&(`;3xk zC535%n~hp{d_L(ip0)#pItHBeS+yTEnc>u`tLfTEHBINs3$ZNESn4=G$VhEAr3wx@ zy(kYnlwmuZVDN<3jtpS+bz zFYRGPiG!=NubMN|w5_|`ebS>9MnCnDYD?eJL#_Bd>janOc*>MrM%)sdR2(kR0oW*z z#Bo5?uoIHW7b^;@+){aY^b!mjm(qT1>6DzY=dr6$M)jOkgs$$2nM`37aKZB{bpn4* zbM(PIXi~xFcOk(*yd!VlJ=854Epiv)By4>i`bTrVCtz5$E@dvcE^9XM0Kt){yTL6< zz3pyD){9tag|+8MGr;L&atZ5am&AlvRfzlN(k8jd0#%^IsG*!8J=wYbORt_RbqW{f zG~wd(7-1mg5sXVCx79S}SxwWP=E}S>?a7=Bl@-TW&Ud)1brx|>UDEV|liaa($L#{< zN?qfX6ArqL!+tf9)%7-IMc9Fme$*)fn#8OOOG(HqbX_F1Af$Ni3p@bVlYzD{otR6W zcC0AKUo*ho29IZHCSE`bPg+=^%gDn*PGg*>l2SU>BQgZCnX1wb3${kN1L5xR$&!vs z1Y3JOSPF5Sqx@51Qh6*(OBbnxuKvl?QmjMmjLiO8VFM0Ew7fVekYUpiq&wV6&9KH_8OwSbcXrFx89jDra_ z!mykKLv{=}5Dnb@~m@l3# zWQtz8%xa3HJBE`{3;Ot(nw}2?Bo;eJ`Jx+J2m~Z{a;fCTVVpat#Ef2YgP1B@R7SiU z57AA*il~Y+H^Z3e0d>SQo>Mdth2==G;-<-Ys8;)6$xgR=Np+_?LC2m|ogfXTvoR_< zbEZ_Py5aF+wk-P&7;4^A4|%lZnp(-?%bq9=P$~~ zSr5|imI68yu#{@Sx0)*Jq^1VT(?j7Wi(5WR58!$RZxz1_ZwRIF24xBV3V4UHf^_pp*dkIy;@))mcmhq3c&uzdj)T6M0mX6>^wlDBMMA*` z^SF&?A74Q8*uObxW*$upc8e>9QH2qSR1`VRH3BXorOxXMjpATkP9QF;&f}d{c2%(C z{Z5KOM+q)~lbhP-bHEqt`dJ8#-A%!l@j|$kiV7{`lgw^=i7g-=f7Be`eQ~hZB~6Ji zl?vg)YxDRtbqF7Aj`gDcIi(`^ptdS@58jC5{c)tkr<+5>d-3KQUgV2ag`bL1rH~^w zhqkdE8IgsGv3^x>x`?)__(iFu4Qv*ejXbJ_SRU9ss0&ENHme9R6(a=KI7^|L>GJSL zKU#FT90D4rf|Bn=k>~=jIn_c51`_RQ8%?Nu+ zMOa(nkXNLn<^Y8sC};~Pgi=$aWG;~gC`?ZoF)wmSi=_Qd+Za=kI#y6G_L7QSKwBt< zC6vip*$C+va$*lt%cyf2{8O!U!8mKtKMWN8Hqo%QWP*`J1_B>+*V~tgeyhzb^%9og{ z_Z%J6Ocgr@`!)q#pq)?yy^tt-a0!y3E))^AG42*c*3@sxi;{3tstq*5t8=JPH-0rI zJeELuO46TpdO@M=@kA5<&ilN_mr&mS6HeRaQEmq9T6pW$v>fx&sU5wr5&xuI1FZSH zNOvt+vEL|9_MY1s)j~U~^QitGCO`X8-4o2gu|`d$mQh|T2YtJ1wN%-2bJCMGzR$29 z-(~P>6XT2mt=sxmdA!v48sO@5T5r_-y^!$_(Zdh2Px7b-o@^l>whK`AvS=OkllWQ^ z^=h*D^oo4L$l>NwhyJg*ZWx3#sq;m6R0SCa7)y8cLb|jtw19MG7#|o3u}P$!zz0fl zptB!1KH0}~vb-9{M${>MX@cLmVIS5|68;LI!*w@8I-3=MYOPi9X%N`AOI8 zE#-0#Z9)+jZ3TPF?|H8#Tite0?yT>csRrF1qCLr?9`uB&uxhjnuB_j(E{CZ5I<;$o znBLXEx3DccI5*9rZuGxs*>BmFjr4(-Y8h|I52CeNcZ|@FD%C-Y$Uc~Nc)%LMUs%FF zwRISt^=bT@LOjbEMjGw2oCJ0We%z2!_uo3z6!H|%pOg*9c@A7u=s{TCdHCo!D1v$J z66)lJjPHly*=V_+FdVRX0v;qNUb7Mz|YoUPk@!&61ueq~%(H8sWReX{0KFx=k6=+i>Ts zL9bBH=xI2} zsXw`sE1iFqx*c`$7X|bcci_7nxM!#nD|4+($+}SLX+X}kY+Dgn6?bh>skd71(ufT% zg`|WYtwmbD*Xkpse)pTd{LO!y={x%2&%b`dd^P@l6;2z51((){0BLV$Fe&H*L?cAI zG)zW;r|Q3_Z{M>vq%5;Tbr_*&hr(qfVunkKZAdkijShSVNI6(6Z8EWPVpy8hp`tP2 z0Du}K%?=~3LdjI4k?iVh+mKoZ#vMk7*%9gpcSKNxi7SLFj4P62aVwKJ%uVMWa_(X0 z9!bVla5Otmr;ynZHaj9{O`Ft3TA|U~Hl%JtCH*RNL_1YHGPWy~ zOFh0Jb=E+apebl~#v;W~z!IpwV!G;idvCM573HC2~1~%IrWoPat|cnLRNP$Ag8-5Neo^UG`%$wACDPkzCBkv0pVjT!hUd^ ziGn9L#uBN=lgZQ+|B=Kyg6MGKQCM*N?qJT;6RAs-eCiyVnmUL7RMylJ$+dKWRio)f zO{HI%59*V8g0^m|HI|^&LNEU)Dkj~YG}+@&hCtg)C=g6sXAEQ*i&XuC?e!0{1)c5n zkIMfKceZ1YNu~~{tqf+EI;2dt*WU+Ih71BfEf%p;@Q)^A;Z*$-?e(kj|5HMQT}0wf zaTKQ-uftX@(p)DyQM3-;6Kg>xr}UQ-?e$N447}RJ(uUdAUVj;KG20Tb9IcU7y$N`=4abz;-@`Jg=^YZejX_3DaTD)a{LLMd5wbztt+C z`nNc(n=*$tz8#JtLdts{cCNj#A=YOYMq9UvB#`BKv@ccvMr#Z`O65$|e?6Iudl=@d zq)7j}p=2V#j{Az4Omw6}G%g*K@OML^=^U@XEjN*sh-30>ufJiW(#&=f_qYt8H;@wp zMPj5{A3~_n&Ul3Z1`KwVRr?UrGbe10!>cQ`o#os#1R)O~!sH_qP`d<~_aj35xWjq7 z@cVcqh8fL*K+R;Ew)y{yngrv@-uM8N8yu37_5+@&OeT_!K?D|{Chl$vh1hxBMz?SB z^w87xrrS&;U}IqN6Ul_fBav=1kIjG>JV?9$9NX?;41e(>F-Ub=y3_dhXv z9D_L7jal{|`dH~7CM(CAWv%1bypm{dJb+t78Hu(;9GU=alrZ%A0gS5zd$REWAgG%$ ziGrIYZ75SFQ<8`l+ zuP4*+eQAxBWsGBpnp8ZbLFu)&iROOoSbnK)rpp*8YdGHiz>gQQ)ozwQGdBbnSk>D zTQVrYLVOFdiis1`VYH-CNr~uCEio99&q9!J*>r|3WJt%tGS38L_L3tr+>tcc4KKOf z@KQ{LtTxV63c~tEs!?OZ6FX^DK9Op?Bh@&YNOnaMsm5dNjVDr#3|o_G*i`fcsQFHl ziyP?PW6+RJ$h{L26oUEnu?SQhQ&3|O{}Z&u5;z^Zt;?;K9t)`x7OEo#_ht9)CGjfS zg{ey!=p&U7jZ};pWQ50mrym}%LNf2_L zJRxpO>cricf2V{Ugd^Ujt_qaT^(OV;##BnTnKx)Q{FI3J)E1d7Xkr2>X<{T~CE(|4 ztN4nT!&fl;P(xVBf56m(i3JE|VT?K>(PzS)?N)6xT&ARXUe7$O_s3c+OjVoE(zt=y zr4bPrhrmgT$Q>eQ8cu2Km1iFiN3+poB&pE2iC)3vW9EJnBx2BB$=^X2T-eIgf>NlJ zQd$emAd7`zZ>44;ABq7FNJr!B0jUEKsZ^_zNfLgbUA!j8?~ogd|Dr5_P-(jHO!(|5 z21%;1)d>Tue>AqkN~JNCO6Oo@M0rUUz-y8|p!7cogVU30>`2wWooeiMD#JT# zw>#D7$8d(uK)swcNfag4-q?r#-gwg0lLDGhq(cU`i!isM_RpS8HNJ~RfjOj272ypk z!qr~t*~egdNy4~mY)x9)@!0BY5qz6T>ffdrqX}RqZWU|sCiSBJCVT`(&}hMq_Gj&Y z=y$uK7lfkU?TdalW`2huH3BYApf5iZrSk&ch4p)?-M8Wp5xWLUW5b5w}!R;vD95k6xmeB2>24w5Yp5|a|&PQ%0WuvX75JDRoqcC9Qo z#m9EZ-3rSPodT;fT~4>sjR$igahBpX9w=NFc$5|*J5y7+v^|Kh7w+%+uI+<96%F#R}8Lo{k0fc!qp8AvyT=~&d&Tti3~@1~1<paQDG>a z3LgbMs-G=|5k|6bj`Sbk7v^_K!Ote38-6D;%dtRL2E4sFW0pKAjdb@Q!1>h*Xy+Lc-e zMKQ%hQw7#Y_MBLXw-wOp1oYB$MaWtak`lQpclnrXa2>Uz?~et7gTa^a3xM|<3?$mU zqFq1MkB0vm3%@G)Z{y`04nc$wN4wC@;BdQ}z5Fv;X#3C@oGf6<)pvDF+EM%}sOu~17 zp@C8)TT~>K>*8zypHji!!=?)s^DqIa9CkQVIJJP){JGXHq=bn^7Q|vz6ynM|rm#f9 zB8de}p&-?jnu7l&D^Lu)PO|Al0(gX2Y-7=lVS@RAcr0z)RsS4zVD#N|?%Mx@T6QHd z4>N@`l4PFeP>Rcj8!@yom9}&$^o~pKLIfIy)iWCNABXc{~Pg-IKevE;>EYgxC$R*S7)NlvN|`W`V< z25g{EjF=IEv{r}4Sp`YxKYt5NOR_(Z6mIsnF(0D)Fn5Yj68Mv9C|jmmGj&)x1ocax zW>yk)h1(sAg7{-Ha=s0r08q@`=2Mrorpbq*Pv4OR3QFfk)QF}=B{ zY%O5V4Hb~BOl%Utx4;6F5L*T4UYV$gVebhl1Uk^ zCrmK?7y97Pj!taJaN{Kb+qoV4wgd)&Nf06k6GRB21o$f*$_j0l%fvky-wy7gVwQs? zkq%>hJ9OsS0ZzPHfwv}**OfKdP{N>hMwstq%iw&3U$=pYYs$R+ zQ5-@(g@d{78?SgT1)qw*7vMPu;`e}|?&v)=IyH<}o$-3+ea_qP4_r9d+Xn{8+q{o8 zI3I`RM-MFKNFznkP;njumN&JPT4$)3`!Ng&^!N7hAC6}js#gNL_V*p=%j~oF?aA#u zFv|})?#a#VAIRpief_g@vvY^~_8yoU*gJ-e0sM^;{8@@M^R51nF~6j|>n%SmsL=EJ*TTR30*A!_;ZCg%F?ok+JQpAH z&Y`j!G4f&9m@lpKgn7u>a=-l(Or3D_3W=SJ%!?yDI^znQ95-otRG%Ra^Bi$ljv#y4 zrC>KZ>i~_wEskpO9Vl2XU8D)3QzV*%fJ;Xfw7CVhRX_TT!QbKg$X@>*A)a6F4@@$yKaa1w`b)gnG-Q?_mSWC8R1U@Le?F?ZuhbQ+XiJiDG|)W7`x8A-s9PQt>W zzOquHX*i+WOlP{|xXv9l)NLF_&hNGmj067i#w2?A5u8su18f{8YfkaQtAJxXWrN?V z;s5!MG8m}K^3!*mzK|b>oHo!70k;uc2DtLn#2B9P^8~{HIX)|YvuI_W)e`aLu}kfTWDelU zsC{VDKD7rW?FD5P_(4dnUnp}(H-Pf-p;@HuN9>$jhY(`wImGS-%u(FP$Nm^ZAMhN@ oB2w~%Xc`*1vS<8yuz&uU{?Z2i&XiIMhyR(@?_a9*|5XD22l=Ew1poj5 literal 64512 zcmd4434B!5**|{nGI!QYa%am%$U4a-GZR)7aDfPlC@Lt_09uKF3Zhps;1&{sEP|p) z;YC3dP~34xs}?O(v~}O8+6oAYw(hl}6}9;PKF_(6+^Fr__y7L>zt4X(&v~}g(y)QXnb7gS8UpknyRr&gSAPnp_~mlvpUOpiWBh*63r_WW?`ow?NZ zh^mU9(npB>;24towW$bE0lWgC5Cx2@(r;pr|NNT-Ir#L`#H59!O8<9-4oNKhHp1@7 zguw2FR1iP@4OK)4yv5M#@nhM)qbr2)x#T|3$GPZ+SyL~W1-jtx0FZHB9lt^GuR@4P z4K3N0$)MOb_{bUkTFP^9D5|TWW$FwYisY++&s+xI<}_qDREVRybA)({$`wEIO%)z- z{~YjE3L&{H`X7OwYHOhofqH~R6#hcBiV><>8?jZrjk!WZ`)HyOmXVIB%?2zDs7Av8 zBi-97Aw{-gFtkg-NEccte()*2!O$)PTA!#dtbeWI{JEi9D|(F^ck2XKbh{@MnjCgl zY`dJ;_2h$BoW=PTW~>E1Tv&8<7%n9fGWkG6Bxj;wl`bWa(Q9L=;K2mpb_Gn_oGg^? z1S3jvrib(hI8-bRts zu&0e8sbQmyBXPOjjy!2xuIM~eu7_U-jxlH%pi?|#(qMmN>0a!VE~R0#66DfNpJ`cS zzLgR+7}~vnrbk#PKk&&3gQ1-wes3$yd~(WYC1_`eUuZQjpPV#W3EI5@xc1oRYvCLH zDvpe|T-U88$BNA%>(JW?c(QzYC`ELFKaqMnE+IWtU(1agXmOU2GsJOJ6D)_e$sUHzNb zXr;{U>fh)E=->IU@}vGu>P9QU?){tM8*JO%zhe*VJi1$};D|r%-xT{_^zVTf;Jf-a z#s5$0jtu^Hb=O%kheDZ3<}fE5=!AouaIg~|?u17;;gJ+lnW;|H<^OH>!E15*+ zCyUhi$s%=rvPhkuEUIB|8(CDtzBaZGjn7ds;Yd1kZ{lq15K&WJ#s8m$E7h%mw#5y*3+ zl^~A@7(tE#uC`K642Jd)pcp}hGM{p0v=X!%0ptiG+uM_HoPHIVEgM#KYb~6)pSBjt zo@_1M#t_cbPsb3>(@)0`&eBiE5Y7>gA&%{?Y}rR6-Q2u16u2t$GmO~W4!q(Q7<0Tl z=3It$+p}}bp`83D?Uu6k-?dvBb11L>$(Tc#|4+soDvkeW%%Sr6Z^xWu57`i+M#MU#0v0x|q1TpN> z?{7m=y`3diG_MkwJiYFUI-9K1dWJdjf7mRaa_P5T6-R{tYGXNG?)m`^cAQ4EZ0y7(OVf^(*7od_gtSSs>apT*KUfIO=9Xdt11 zzok}R*c9~odWTi7JqAoGg#t={a#0BD$=jMp>N%M$C?0~qJ0z$`$9 zPKRd3XP5;tYhl<7n_)BWnrZ!>_WFh#b?}ED4Eqcy)lXNgUy^cx3a^4Hm8)8Q=0OFE zb~)c+o)K8xn3|PADFxb#QE7&KCdKiphCPXlQ2V(bdi0YmrKFFl)~hY+&H?hpFI_(N#8;{4`j~)$Wx;MYDRTtzznX5hR}M>B_*`TQ0FjYPXh~$ zNj880D(h8EM(Zo1<(YXhTB+a6v!^=-@~YRYrr+KnKQ-`sC*4`n02+Zb@Jnwi^?x&L zpGQ_s_L|{h3v;Al&j20GPt`>7t@8;j@S6plNfkIr{YfSToeL=X>$}5|{DX-y8pLfd zc6Q6e0>6!(&IOX4NhbAd3k4om>CT86emhHCbKpYs{)lPKA}SV%MdFc!H5-H(k4DTy zH0rn>i4@uwfjwv(^vy#1V&F5PMV+N-8fMXWhuSlw$;?TBPi(R-A?wA6b}1nx1NWop zMoO&9KqXsSiC5Y>hk_oybeT_jYL?n{*=|*5l)M4=owuC`QXM z#*{`X><+}MCETnq%bVl{UuxK&DW(!1=(Vl_W4LZ6q8pE6s?FSus$HJTF2x z7<)@UpcYFGCg7A)i>QPvNM>WWfXqCYsOXeod|C`yCdz##i3xcCT2C8zdcAthv_fkM zq%)#k=v@iS(lDDutEdZ1GONts%4jv*skWCwW?xOJrgvhr#?+%pvpQO9CQbgcuYstu zl&a6>-y1{B8i)7I5NjDUP;fHqxxQ0{zH@!{T8N^)>~ab)^0)sNoO4a=FT>&NuOLiz zki^0ukg`{ja@|0rE6h3@{6N&g5WEVAr=MpROga@Fv8?OBsa zvp%f%KB1Fs))zL}t06GamQYK&qi(MuUN0EC0Z6g67EskJ4_vVf0$9!(_D!TzXRkvr z_-v}EkXe`Zg2SCn)__wXZKzE>36Ix9xee8)*9*}&h1MTnpr^o~I2>UZA^nK|Eod+P zu+7(vPd)K^{FVKP??+K>K)kyUhR6th^enRnVII#FCnB-58mrgJPcQFnLpNaG3;_HV z!fC1sWH$l^6QO}91EF%M__#cht?2G@e$Hz}M@6L>wJmRB7V88`bYuKR%PvT* zTVb%oz6~&Qq@P)a!)qp9FK1Fs^sO^>CoYx?KZ%_7d42Znh*9e+Yzq0LYrMD`G7hEh z#hl?0^(YsVp_zRhH54$MNuv#QtQ)NaGX_^oy`)s09x$i)ymESQq})hDUZWL-fP5C! z8pc6m2er&Q;6O5}w~@fqEiYPwX8EX%-VI4!sx|vZsA2Wv@!OlgX+sNglIB^<^c*za zAVc>+Xkn?DN@k+9+24VcG(D}P-9{r>)bL3cWxJFT*)9#Io8fU2&BWCh{jx1yMr|?R zPh;$MwM3ag*$}(8L%D{%L|myV0n|C#j7PkJkk=Qj7+ZBYJ4IUdqB=Fnp-N48xcURx z=gE7u74wJQh7m?jYBWka(R`9fDX}(;IXd((-RNysNj(hMx~F0&s?8b*D{UBhNCi`f zo({`Uwib$yA&G>TQ_xVzgEou^#G&TchB1fmS%hOmARN<(-iDDzPwH(LQ*>@4bCFm+ zwUPT^A)xVEt)#cXi|Tqwmi5*OQ%X@#OYq_{r1HFM-;W6P0|0pg??eGbH0wbSol9gH zcf9ownBKm_opvp`eOpaklf58)p<(7GnLR_KY3ObyYb8oKCcZ35+++bYiq)6m<{4sCpy&DAbc3 zB(cyYQtj7u*Ljt69%CJnSZFFK-NK-gYQMg_=4+(+8`dO=g@Gt#p@*jGooA;b#FlU5 z@D}Z3h7_kZ{WW^TNE; z9B;yZlQ*$4s^i%tb*h?s8qrLT^PF%{NgPz7h8KlOyp@Gbc>Dmgu$~A21@cGw7b<}c^Yc1$W)kZvWEogY{-@-$!pSnARhb4O}h>VZ0Nx-0W0Wn_1 zTEXL94YFT8Ov&rL@}WbH1N}t*sj@!k*?kE5O2qA7+UGoqVj5BZ^)wRnw!GiDz|Uyj zRK7Ri<;p9TYy40wFBa8fQIEj`oV^V`MvZ}(B}BxqFdX6`(Dd?9As?Z9n4{&y5(CE8 zc?e)$+HZQJ{$1$e0+BlBDW4P?{)}dN^B|#d;%BEVN7HBO86Bo{)5jB)Pp0z}+;JY5 ze0U-y4G+BB4))-aWS=q)CJycFQ~qSeUuz;It=9C^oBmu|KSw-Pp`h_Y5oLQM$~NjQ z+l;~EX?^x-7!GFNMAr%V5^Y$0=mYY~pN7j?+X=KuSZt!K&A5 zR{-UuOFfE3w%d(j;tz5U=E}}J7&>|i$oBODVo=Rn7+o;-&>UBU@{ghXb9*bY@YR;@ zkeL=u!^-8wTrvTJ+pgXRkB^Cz82MLf=nC(X>syuh;a^ND?$XF?T(Ajbc(D^O8ceTrg45>03V9QKyqrtS++JkR=hd5} zZoh7PiyA!k4>U1dM*-uPEe5BfkZSX6VA=Tq$(DTBkPj@$mO>X^?!xO_cyAYemOW!kb8E_|vB$7y$Ge`$5$SGe#ME_{Ovf53(R#)Uua!e4aZZ@TbzT=<7B z{7V;pz=bPDm!EzY9(CcRF1*@>XI%JUE_{d!AL+u!b>bZuWAyqeHD(=-@sZ9BG(pSk zIFHfiV#MKR*R-x(lhJUeK(nw64P;*f2&nBPG&bR>FA((Fcv7R&84HhMfujCnvu_~G z1C1G#4^J#ic38Hj3M0COfg1}CzlmdkBap0(u{i+t+Xymyux8Q^CTcOq$VVx^y_3|v zyW>8o4wQ}*yi#m>c|Phv&+p?z&82c3rHZt;aBc34K@;JjE9$P=T0_PZb)9)kBkv8^l?(gFK@5-QdQ z2r`c&FUrA$)tMKRaBl9b6UWcYt@$+38}lMY`ka55l=Q;!$KXj9_gsX)@Gl<0-yZy7 z>JcbY0e{87ZvndHfKHz{3z#6&ScdL_8`#;GE_*o}5%15s?2#@%FPE%tm$@o~C?c*( zf5b1V(w9m^e6lJ7UYt2lamt$X$(q~?Q$BZ1dbuX+^973eFH}B082u}ddf!qM40hU= z;z3fWPskufnws??6w+S*Qmil)aIB9&FTIF}7}j`P?V$&L*X;IY_yRYijTBZJ$pP_0$l;pRSzxyWJz!gSWHsP%Tz6DcYdgpHP|7^X_)`KQ_53zk||)4-MWQBvp+Obs7Th0MTS_=()UBM=POO|y|S%LJ==-kITH@fX` zLa(~)T*W%O{;keAtds9^`STMy!&s-kTc?hEJ`!dF5T5!WiN83{r{rs-zsZ+fz z^EocpQM5fx3L9Jsc=)~Z-zu2x8H)3AR~(-+A7W31?l+riz-W1=%Z6SUOr37DcmthP zL7d}_mQ;7nK%-?$cTTO*vZ6aDX0*K6okLpzoW4g)MEdk=M@P}s!s+en5%Ou;Iv@v) zT_}?*4w$f+OAw6?uJPz}l(x+`NDms2K*RF-=sS5qSRY3bhWeITQxZtfoYovzp< zk+D;m_P&n#E#1}kbg7fX@oWH49kw@iSKdp?A^#aw{p0QZK#(i9c2|G5%Qi`z zj70nR1QtF5>mXCrXqpdu!baFjI~s-^hDqEIv+@yAo>h^BE>=`D_7s5G_B2dIgxdCd z4z;#FaHzLE$DyYU8)cB=mLayp2=TOG|A-Ky4ZBZ-c-x*~B;K~2!#UEV6_w)`QC6fg z{UJyn{!;o;N`DK-QQMy2P-}aV!+6_oIGiKJR!rI=%8EO-0^(_mh4kYAD#knA++^Bs zgPyCsyhkOMK(u5BNh~~jrP^=ruCuR8ha~NHbQix*;zBk-5(|$yDQ+|Ec$jmi^+A^% zlE}2~>aO#bE*+Az-`!pO*RHfkBGaM<*S@K{;)h*!NFvj^r@PKaT{{y=vz*5J7^B#BImdTslI-4#FYQY49m^>wQK zq3$|Zp6BY6B)rPE@F0>ZFQ%6aQtg|&>wiJ|C0v0dq3fA^ZAY5#S1@qQAQv6v(lI%q1}6Lc0cQ6O7El0v%@frb8O7^<*;t3;bIU(yI=K2w zJVN4?i_WJS-s0t5X}n>f{Vcmk&v;wDLu~B-<_E=vHz$j`o{HS@OTwr==V{5smydRI zuMA_KsGxCF-b*Z+dpLZxa0@d~OSg%PWP+|N8TIW0DGzu?>;+NgU#eL+x91&?6hed6 z^+rPD%PP~y2fh9JI%YIGO)1Phsxz^FmG0cZohs6wLiQ(V*x$jneF3>B)1g~X2A)kl z;#B0RvNKPaJdN{~@#u-^eH14rSv;p1NsS(OjHFIaI!2P6r>TRSxA8b<*co7RkLYwk zKGW5}epRRabj~CShjNt;&ONd#0_}ZuK)TsWp z4IH|3Z1q8Cl=97sbPek`%!n?N%%`iNu4H|Dfgtn5?tm|C_~f3J*SRN~5hG@G?O~0E zZ^)0C2y~?#Or^Y>c1WhBvI7xWY3Y2N=>%M9d1YEJy7$ivC#^hj*zm5j79g!8(&BQa zd{H?g*9nzdh)j)hk1f4paAHpR$2~SW5Ixbr##3L=+@^ff$MSeogTe=S=5MAE|OPls4pA~|LB)H7){i-4?<0;cjBG#n zlI$Rf-5=;&O!7{@(d+?W)YI9ZM)?)BU3HJP!Y0vH4uMiSWY~uytlh2E?iPr`XJE9u z zqcX3VkT(A~rNnd#cbd>&X>jNyD7{XClI$cX)lPyE&Jp$Pa(q1k);efJkt7aK107T@ zv{$T(8<27x%B(_&t&!;+Lm@!-p5wv zQlQ0*xlU+d)YC~S8_-;ud@tZ8Xj#b1+!2V&<7&lUH6(jR#zQv)jdscajjP8{YQHpW ztnrP^FWSj-T!HXnS92tC(oT6&(FKnswM08*)^Cph;h@I><=*avE(z7dCU!*q8(wo;-U!O!PiG*I#wftO(JooA0&SYz(eS(3{Z`DQuRH zM5+PJuzm%bAz%AYSnV5{dqSu2dYL`@c=FmwTk}~bkl$38)+k_EJkA+;oMS$OHFb1# z%Jjf%QFxp``$XssLMkrOJ_)?2EI`wUcsx=zCS}r`O_+;r7 zvq{|HanDG{VTR-9&kF zy_&(U<~l!jflh+jXD5N$?jooXIwjPWazs}-`GnOc=cgtvf}fC?*?iP_plkFt)UL_3 z?R_#7l&<3i2wW*N@G$6{-dAAU-d zF$mG8abgkQxvekZ{!-s>F8BJgpg#?fd5q%74RSO zLYt1=2H%h5)Y-E@4t|_bjKa#``xu>1=tf4*CUiBU3kbc2(S?LU3l=T}nrT&MW9m>c zS#|bo3eQt#UqRtyb@pNkC#tj2C6vsm>TGHOnG@96Cs8;|ojsbu!`0d7+DfLsI{Qir zd#kgrqOeY#JrOO4t_EewQ>pUA^rGp`y~XJhnylq0m-_Oe>GkCm_=CuE&2)#W&c=ML z-~}?jq3PC2QdDP;1q(NFFDsuk9hdR!$*Aqm@&%xOmD&)L>Qi} zso-mcO*&UWj(PTvnEEJGzPh^tcGgjsyl;2h^v-S~Yq&gvQPsIEYmyJ+zz+Ea6=5cm zwJ{it8Ys=Qz)2UVA!_kh>S4hE6jo(1^wKCcLt99{G)qkS;WSu30q&@RTwGI$e1P@IuR} z4o=v&ZoGY}FOyfctuC(eYoo!#L3(y`t(P$*f3|iZT%?PM0#MUg*@kh`st=T|v6~GnC_*bzmlCkI= zIf`ZC7B63q^IkJ;$XZs~1CC6X{ zjHx*WBVb_GlF2dx#yL3#BVe4HV=w~7v>byGFsA1ij2y3KFGXRs5#el{LFc@j4kKX9 z$T1iJ0}CVR2_s-!kYg}%mTFPQ`AMs(KVIeAlx<|#Ne^QTogH8jTh-|&FNBX>q6VR6dhmC(%imukV5t{DyMmjwi0?d zM?*~nvU7;WxSgjT(E>*ESb{jj9MZ&!bO@snznHJ?+mOik8+Fb(9Eb z^sxg zdupv$QRWaXb;7GTbYirq{Ayb(^*h2Cx$L&4BcY7V8!ubPTbPG-b_Df~vpmcNEBj zculpER5LeGx~(M0Q|C}{Fg_jba?MaI*trdMg1bhsBKhZ}dOO$&`wl?9NP8zC_}G+n z7m!S&T8OIUWKB=oQxr?4>rpLKY-(36nTYyBFk^^K!s{~Lk+^$Hv=MAOOjKcM0$fXfkXMOcI|_oIG^uX>2L@P`3R43+(I z3yMy&9wp1vFLm**kDj=xUv7s8yFH_f!tsT}{)i~nnHWRln8$6K20iX$JnA*_s7H}h zQ*YZrp}aAJ=@TPWYS&e;thSOM3K4~_agXAmx6-(Ws(uC(Uhiys2_eYeQ_xC;H3=@X zy-f5Rf~}7uLdd~?Ly;wW3sCw*ihVGl*u~O4jVBh=r{MdpnR!0B<@1 ze;PRAK0~bE?2e$xOI7SE|&cwY)FiUiRjbq<9GyKozP&EtN zUWa^l+w2oCG|(K9NR^;+$Xl9kDYV~!Zc0fc4r&L@T35HD?++OVOQIz|=Czceo@K{t z83L4FRy0epZ^F=JKVt#KLteDK0YIa>=`ifK5TWf&hubW*>_35-p5r29p;_nfK*ei- zibw^o0ixxRa=8d-RB0NJg;w777EC$=Rk=#v;Y#g#7fTVfl?2gpbq*GItFrJ)@wu4F*{eZkLO|94i5=8ZN}*GFt$3PnhP6q9u~Q~o zW#?q+&&Z$JO1Y-5JmF!E%El&c`q*--DEl_BXjP=DWgQjmPEe65M<0v66a*i43Gm&T z-O#Y!VKr+Hf)qA}3#FCnNOjAXoyyg2E7h!PRyxU3P4F8S*{?ASTqaAPbhk5penuk( zzG%mu?uDxTE#2E+xZb{grX&t{1+g^dabIS^P?86cDVoXWPLDx6pctRo{kZa1$7P$eh1@&(P_XU zH)SU^E$d_CZFl4U!iLxS1ST*(HjY*JStf#U8HFPzB8(u+{X$q31VPXm3u6Kus474O z7Gn4kA?`VWC_qR*;^TXXVlhIzUEOI1sse91^Vv$3j{P*y-;y!>a(uk?%kNIm!0YBu zar7x&608AzBf?1-)loj;FdE$vjy8B-OLQP~`?J~M@B0yd0qk5uK4L|dTO*R46T#aB zS^barA3`=B3&^8ge6Z--o#IsRCgAT_;6vdjp7owaZE4o0RL0ny{Szp@Pss=vf6g%& z0psl)gOS{QN>X=rLmILgw$?kakQRQ3)r6hJsLF+qCxW@_iQrP**TpOw3E3>49 zugsE?%C}V7SnpWIRm+Q{tk1~#2D-#BYkaoTX>>3)Bi~0Va;a7?jz0DHqZQc{_~P0{ z0~-1?^zYNZ?;wip5i#k2_WLX_alF zdc?Sq!yy|A`g&xb`sf+Rvf*-OVXK8X7)i)t_E?rS`TKe|7kSZ=wK#74eTqY%4Ovd4+G`)#dvW1kY!9CBs3?4>0~gf-Kw{Cs=DeUt)-*uw#hN8IDU3 zzKf-&a4Jtn&!`FrLto|(hy}A3;Nuf@9#-0J8awM!1*p zy?~R2(L;#&j^;u55Ef0fpZ5rhlT!rC0Iw56U?(DC*6PyhL+E<#@Y1<(-F*g{zaSx7}F#g!=ko?<}{!sDgS zmjpxsmvbrG`Ka{aq#@D`1ZPGGo?J=LW=1DWIKN; zBF>ZL1gDn~yaArnik4_8%BrQ3QvW(b{FSWJw~jo#o9!P{`+P~gI3!8ZR<=C8g77!k zaP$J9=Xs3qcgp;Z=$4vx1%ydX5##C`!}tHtX~eSx8K5bntS zk*NbH_dytq-(>0v*1Spl$z^4oc$;OHdW84_|GvprObv&XFK~MI52li^@&)`lz|^Uj zGX}xNcP=YC;U)Rf#IoJUU6-HlVyBm6hq5p4i+m*xKi)$l|3Z8qOr=u%Jva>Y;|q-d z+#VwQG{%RA?gIX&(7k{I^Bx535qbphggnwo=8=9s)>j!nkM+M~{Y@d#{}t<-d3}+} zoX{c4n2yQnd~-~P*H7>ki{Jx*eZ{lIi%e6Tn_2?=)T(8GZ`G|bD@Ako2Ec0zZUWp^ zu-aQGD)I@p3pM~+bp)HkcLDBV&0iGU2V82d2tNdyBkF$*IFI2}hTr!h&i#znFxBPvvy~tgHn-g8hGxz5TPY5uBpPYA@Nj2l@@l1Aup^1jjP|664qE zzX9h)<~%B%2EIroc!B;J;0h1nDTClrgE&7hevX$oo4my7<0D+*xD_gKsyXgBj=SIU z4mjr;UjRO890VNZ4dSykx5TNezAP__Dk7$pxj0q8x25U<8%ujb`j9>V@L|spfF~M_ ztbZ)v4*euoEZi~_u^wdeelF)7CY5uqx@PbVuDg3<#981coyUuBLMwVoXXu#*YZHuF zfcF}g0#hJ0>4GRMaj{c2dgGY&8wcId&Ff9 zb$0b{qaJa&Lk+Fl4(dvW`l#+%P``4hwyKvvt&tRbdC!mUk}@TI35o?+*9-BCAYVeF zqFPeqOGxyoA!?CtLwdB9Ck8syuDVHjo){`A&R?ErVoKV~7q>bT*(?w{9g1ugh~sI( zgU#Pojn+(YibJi1O;b#8sGE>KQ=H{cS0jI>nC?(hlh1-`ai~XXUIKNQL)}~VI;hJX z>f)NWKrNLN{2UcD#ZIQApT*)2yn>Q>FA*#0p$a8IewK)DuqqH(gY@h5tx_t=v0@-< zw7DzVL(tYfQ4`JgqGe)-LoF`|s)~XXLP0e=&TZq?V?SiXAHUIn=Ks z$HoSVq1f)B+?Hjo4-OV<50})PRik2ui>*v87cJ>iK7>q#4HW~AW=-+7d3J1=c*~(4ENqD#Bl-;QvU03Ajj2VxlhKxr6=yosFC&-6 zjuq!R)U>*B+6Zx>LtR`qFE&D4>QKv4i}5dv&2y+5QcGgTi7OpyY}q(%r1+IXomn<7 zHd5T+P>=aj2JSZveGPQfNztI6*wuN!=McLF{)lHhG zlf(drI;Q5a*vaBFN%{U!_uJSQF~y<&m|CHX5i=d??t zi9=ET#)##Tf@d#6_LL(lkN-I~Ry^M+dk54fOf46O)_)pn5~m+4>!F3I7aVFVQ%8)D zvJZNZ=0=CQj%6rB{FaL_QwtpGX{Po&)F(_$87VdY;{Os>);rWY^#?%dqonMv^tZ}H zvB068F7?Jw7oRxPtX_GzEWGeUsVORB@n*4^sa2xGERLTg_BhmI{uJ&7Orq@$c%}!A zj<<+SOsx`|D#yfUiMBB$TP1#j_Bl&zVM?~vS)%?Fk}dMxXO7cmiGB|Cm^m*tOC0V{ zqHM94ErvN%ylhEqwixA5Ety4ui^N!mT96qRzet=RDQ+(piwl`rC87;y#4i@#J5)i> zv*UBbE2m1&UWlC^Zx?-Vz(;;2iemA3Vv<9>*=tt(a`C-GEiV5wI$xYSPHG+3&rUUwWl~1ze22cC@P7?B6gb8q>@-H=sH5K-!2x{cB77KlA5o@<|5wd z4%KWej9)2=#!Fe&S{h#}uAjhG#N>up{2Ea-QOamkTqd?U)bq&0GBNgaDXR;98ogG0 z&(w0UqwGUa{mx*S?~fQUuN6l*)WPas#jh2|JJer%t3aLOP-QhFyTGBouB3Q$W0ywZ zwRI%B!l7+G+*QmDf4c7gZ~iHDaAZ zX^^cEcR1AZkli5ecc|S}3Z6h8bEqeL%e-sFHivr6w_+jn zLwyC=P2xR=`T??=#3v5*b>#|Wo!IYC9;CNU{L`V<)-4LG7oMpyN4M5(i?0`9hq|eH zQDB2eIMkih+u|EUxkLRTzRY{GsB@^Z;#=Z3i#`tJt6dbhMGSPPMD4ctEuzt(o(V1r zY!o9MYDaKee4{wUp^gbW6TekVaHxraS3ym7sF{%6CeCxHg^=AQW;s-OdQsqZF~^~L zr?A8@E_tN_&G4mB#hLb*%) z&Y>oz6>*n%-l6uOl7l)FSrfN#UE$nhrIBDLJ-2DNgMso5<9Be5c$@JSnC))Gu)Y z`&)6oL;V6fsawTthq_QrC$NIWh6?26BEyh9u9Q@WY3E#hk6UL7lq|eYxGkSFN+3;x*eLYiUAJwJ25`-x;WCI zu2sh;c8Fsg>Joi&;tg@KL*3{ZpLkO=In*J>_{3YH*`Zb$lM{ay(;dq19iMnxWF2ZI zsGVYtLp|c1oY*ZEI@Dm__{2Nn8ixwOmp$S-hf1lF6Yq-k4%Gl(-V=8^)Fk+_Pdw;Q zKX}ehyf1$5P+4P6;sf!DLp^0IO8iB9;!r1fuT6X?zF}&w;=_kfKN211Q(5m-j>0p2VFfx20K6R*6U_;^y@s&gMVCtWeLVCADmYhTRTjU!M9H;FUhcP8n-!Fzq8GE*0 zjAClOxE|*V`^B{mbrDW*_KWLXnqP_aj_fn#tNgEET@jB5>BQ*O2;m0{DMk8WL`xEX zC7u^GaS5O<#@Ep)*k^!-n1`pz?)s158AcU*A??oZjkqdyV93rrY-c&!nFjlK>aU^m z%m=~o3Hmo+yT`S#BdQ1qzrhZ#B6?*AUKb#kWDj>$5Z+iyFv1)UpeAkrUl&Ea3JN@8 zdl^Zm`pFaGV}=D(Atih(@~#SB3B`IQMzP9aA*WB4HSr?n>kZE19cDp+CLSvyo$_Qs zfyz?BKSg}c{A4jn$y=3mG%*sKoDO}S)Qzj+g-X&t89u1uW%ibESsMQu_JUVAJ6&bf zowJg2N1Dh*ifo2)7>=3+U&C$;<(K%mSogzERSd95^Eb#Zb~@NAH@=Xqkp(GLu){;; zu!Bp+XecPqVVO$)-dI5a?d=p4c(Kn@P~gLk5XJhR>TiRGs#D96gzXkrz&C0TB(0|f;NbV&1UZig}!?Z`+Akk*hi z=GakTM|vV{cHxS+t%70==xoob@N!9#4&MDo32WkVF5%5w+HTHIbN7FTBg=u>tt#F? z3(>@Ts3~2{Mz8S*FZk&7XbE0n1Ny{ZaQwKWLOC75?feY1d`%oc-ckEpw%;?Ka7{eH z`IS9)DEE;|xNP09xF=bYIufSY&Uo&*S8(h7-{6I?FMo<4+ztOv@!d8h{&SX3;+E-# zKdoiTsch${5gvyJ^SIZ}{CG(@&YLRmSBXErm|Cget(XRg)i7XMe8_Y7hdg(O_$(^K zXHhfRLQD+q^-KKw5%<-ci!VSD-h=TNb|WtVPH-mg#~o0;)-t@AKqpuO_ylW!pcAYC ze1bKAPp}5?3Dyy8=Lohloblm|k79fj<6{{g%lHwT%5a9G7>;E)hfl)h@JZMLJ_%bO z=p<}`pp&o#f=#v=t3MC@*js9#Bs%UZEUN zFR3k152&9N#nnODzDz0bJtaxCuQsM41^7q61M1_w7K_IaOA&pwK(Cbgm{@4`0YB2~ zP{6qjL(~_<{>(AZ|JF*WJFt5-4xDcS6CpjfxLF;e^(>vna5gx?nyc<)ubQ>;l1gnK zEH`Ke)TKD?NT!`is$3;GoHWCnXO%aJzq+ON-HpSN=!7eHr(XDRE<)D~1u@oeY( zu7u7^&m8t=DVNV$wy@E$b5YTH&n5@YSUWw%+M2XtP|3Way})Jl0@uYBuKmqypL`x; zY-VfJ8V$6`h2~7-F;NklX%uUZ$1XOuu+Q6BpZM#HE!;AfX#X_F7%w;}PDwv$98gCV zy=pXTO1u)aGqrNRQK=jkH@uZfuT+7zm$KAMcy~Y_Crip7GgaQcuuSlDNOy3)`m!fG zxSR($nq}5u-bQE=JUw-!cciit`(%TZq1D5H-%(iLU5%c5l6NOtXk-h$l=q|akm5d^ zAM{enjU~W0M3;Gsl{v)O8;~z1xr?&e^oHOLuwasavY|fwMpQG2ma*1pkoyJKzwD;KxY?n)sKJ#1h2)hxbof zUg<&a5@jbe{pw|LpUmu72eSbM}bfH?!0Gk`fqaNOYxi?x9bU-^o)XEGW8 zDCUf1csj!=3}*no=br^QNc)0w{e^Z(@bBIOTJNH&fUYhs$H^hXbya(W2Gb9F_Xr9JO!dgMkGqjUG!?>Xl1X>Vqp`VV$y8 z{UneKE>}LV1_al#o#jele1$ScrSV76?+#oTSj+fE=#L62dRf#C&S4+cGTa1ch|LVQ zF!bvaqSpuS)Q*YY7@P;VF*r{xuiEF`#P+u^+^i;w9t&<(7o;Bxu4en2RB8`nl!naS z;25Q@$6p}bn*K7l9o~-CHgS#={hNRt+=95{)XnO1X`FbgUj_6K*$IWVaDF#&S#4&x zh2bXkA9XdMOu$0+CUGDYP<%VLEuxYm`3C6CdUGK71Bs^CC@A?mCrnP ziIqIp98lBAX0?WG*07y4QXHfWV9q>^P7nt$f4TClzts1D^0D<%-WN)H#TR)8lymXC z?pMbI{{j5AUWV|i5A+Cx{p$UU(=Nxe%9`57@J#*Iq7mUEG%ERLm8bltg@Hw8IOmn*Xxit23`RwQN*#J8Q zVk9iwlfQs#VFA~|Qm%!S8qMY-)it#*g>A zIPcTcD^XT7ik?s~M&+|LwmC+f6B(y9!`c~vW;In(AezR3=_3a2+EO6qqmE(jB zKF=t4K<``GQm~mzZwuG;7Ov%eJe$wdZ;Gui*sIUM&hP>CGK{4(FW**>=F&@Z>7}{! z#wdf(o@v~;yC4sDMBV@#E#3hfEB*@DB)-6t@Qq@K>BC(!g1f|Uz^}wezyZp)kPcLS z06ap$KeH!>Dt^G>N*M4sB?>r7DFPg=lmU)assNjmI>6JFG~gtqFW?mD2gJF`p=MZI zt(=5!WG`370sa@m)qpB)nM{D@2IX|XTa{*L-ou&?u+GB_A7jlYSaTa|J`GKHtDFs( zr;YKPhszQeDPCkVJ~yuW6pcbQ54FHrjU-NP>w2@Q_Y-e z=2YVx=gi_p<_}~3Fy=QiXEJjpGiM%i<}+tLa~1%?Lxpm*fu*;w^cI$GW&SqiKgIl~ zn7@bldzt?p^WS5>P|1#>vd=2}tdh@o^9iX`GQXPnc;^WGM&=Atsca{4+{w)EV0=E~ z>lwd=@vV$M#rS&+6|Ggoi!{K^(Q1wC3}ZMPobkoYj8A4bjWy>nKA+(RhPN=>#_%bI z+gWpuMtRxGoc9>w%~EhQol>b_ypr*1hC`Xt$oMdw{Ap%**(4a~oV;YRS|McWuY#hks2D<0zOfGA1E8yPk;oX2nj!#y6dzri3sw=vwyQ1_CB z4lha9Gu+OcJzmljK7zWBbSfAh%5b=kJfFmP2g3!-Sz1Uf?G? z`+!d?76IZ{FdWKocz|>!G2Rhie;D7+{Cxq=X>f(;SF8uG5~GTngJfYI!wn3#G29cp zMU)opWn2l7P9?)ehRqD;F+E4%gvs)@FvU{x2@YpCg<*5S3hePV z7hENF6g8S8ox*Sf!;K90G8`JAa+}1kIYRAi9>Wa`w?;UX$O^HyXd83(FlR4w_A*CA zNk@s2juIsc70jt*d?@3Mj89^`gW-CH8<@Y9@okLnVSF#+BF2_uSK+>81>=>B4`sZO z@kxv~Gv2}Yycqegp79OL-^%zl=I>#AFZg-IBF^^X>@(w)j1OhJF-|&@7;k2N2jlY? zU(fgk*4fJVHs}zLBNd zV6CKh5A*jjUldX*6%2H2N7$#+}BS zhUGoOyTbdZSMyc+2Ki?BuJ>Vt4qO?yHn1x2X5jCELBWfHcL%ozi$V>di$blTCqr+9 z+VdXC`%~VoJTvUiSF!i2;XfUFyB@5X46K=acty-FO7SVip5RpBwYF;fEA6$ob=e1d zze6EC2HX*%25)Z&oa{Z4z}F`tzX7}}N%+cY!Y`@b3OK&*X~56wo&%g$^)lciY0}wS zM>@T$3BQx^dsu%t>rYFP{^K>I|6tt?z}A{S0lt^o4fs97c$)Afk#|X2_W|J3sgD3B zml6I!?Pq}7O1=U7uI`_JQ_`flBx2xnWNRVe6?GxtGS+~&3H%9@;ue>YoyD01I8!(k zwWb94$G)C`m5lqVDD^w*h<{Ht!ID~nZv_dS9VgBDYD&e+@ZBoH-|$s|e=kGLPq?4? zch*r__f!)+J5KzPTEgE75A;7NGp4Cjh??P!(5*69E^) z9(_@J4B(Zpp^B?uLlsN$W|Atd#`ls`-0YqRxJ;Y@c&#`SaFu9=o$CNqoSRPuz8X-) zy{xIgZva%qTKxL}3ZB!>0e%xsNa&{2G~nyuog(NtLKQc|J4M_AsEUp7P7${Ps^T`B zY$)P(KvmpOStMG2|mX_SqM zJJ44HeuwhFc{QLa{)rPFoHYTe;s>z;xKLIA_bC4bXeieKdX?3HK0M=~r71U})qJF+ z06$SOIEVTSc!l^IaJ-WQyb?Q&8N6Zk3)Iy@)WvG?BA#`3;jPy1k%Fpt6ra*V8Lc!a zS>+;Ssd63mKweP->d9)UR;L}Wjn^h=7io*No3#hEC$#T0Rrl+I^po`Q`d0lN{R90I zz20-E=Ty%$PpfBz=Vj0F#2KZ7TSP7mD{dMVUD@9?}Sc^Btx z$h#x&zP#;u&*#0CH#$5yJUhH7e0%u*@MGb3!ykr!o&W!|_pPy!9oczx`@z0!ialgg zqm{Dchg3o_yaHmn7_ zKx9M-40wMizy@n4MrJ{kLUTr=jg_}JaW_aeRz;rj)AAIA3) zeDNA^*rOp>qaoNLJf#ae_9(v7_&$d3<9L?$34EV~jeAOb-vwAH+=zfrNtyluwWdX)8T^)l`?!MhYxRqAKe&ppce zS@oMzKdUCCepdb966)NX2?+D3)YX5Pg~EYe%@kwC{eWj zL+TD-Z&<%?6|G-Mcdb86-?6@*KDPcc{SL~%4)}-aw}<{2(!+^|>?ac6wl60>v5VFp z+jEJBho82d7+y&Hv3e;xtEi_hVQ@2y_OtG_{rbC@auAZam-J?Hujz5kF{#OaQ#}jyf9y$n^iB| za69WY=b+j0JGF{`eJ?C1y3Oi}f84BGuL1_l>y>hOz2>*;&hc8^@%{PPGNEj0j;W3A z+ixFlIi1R(tJ@JM%a328OgUS4Qd#%?Qe&gy(glTbN%WM##g{oHy;vU+u%{ z<1|rDtL`aCjJxs8#%iRV4L^}4fHXL7U9>^594$ZdO7*K@0V zKyYHecF+a5I^vt<@4q9S~3`rAX1^6`y_}0w+U6_3plPX!WyU-nyp>8 zim6)Z7y#wRruNI_m1e7X+-PK)pGg1=d`b? z1GiJYQ`%S#8$s$!pGS<-1TMU}tXAr^gXXeYU%$3GyLRnrVPSrKZDsk&>c;x&#zx`F z(&EB;VPkpr+G1gSabtCLZEb0JVfkudb!B65d2WuUrM0U|*VunZ15#(w0Jslscl|Zk zOb!KrtyZ;Lcdx4#cD&j>r{iumFu@v7dq}0#Tz5N8t?pl!ah6&gr@pnS)AX>go(AND z4Pp`^5IIvotkOWEeRu;vVy@}=YFgiNj*w!bI<>esyRtsNxV&1JoxQSjbzyDk>gww3 z;>O0p!rc7QMxn5{wzRP{yS92|d3AMTc6niOv9MHF+L*frKCY~DY})Qk;vlvGdJ9(>(wgaE=sL} z3B*(wl1ageuir@|b|iwhPD4Wmjg5L04knmmnp8iptWLCR)TRm4Ifu?zkwJZ~FVF;iXSsnTr|3Er#Q#jnp89Sf)X6G$#kkH1jw?&nP=R83$2L&B8&1P_G7y zHn>U~1V>6VSVm7g2t~Z7J>dY$AgW#1j=?Gts11UnzRy7@(Yfz9$Mu#|{Y4*ZxPdM= z{j=dDohJQmwRQiDYNZYqhLu*c>JK()6g-$m16}HAac;3V#B)o{BpYay0nh3&?Cu~v z(%|QnF{(YIUg4gOh|egxDS_Y_1%YhOMcDL>=@_I_ChEYz%!+dmeMKzL8nm)ASV;b1 zb#!)__H5hDJI&hBVAVATyBgIM82K|wSKWhJQx;`dB80mR>XzdjxRF;c9l}~3wqSSZ z#*KLwA0c}Bat%PLU6k`hnFK}VnRr*7+U2}cH#?oSUJ|N?tPI6p?xLCpPY1hhl52LDBwmZJdq^%{%HghZRa6GfY(2q%O z3w2q-o&(kEhlz^~Kn;V=ZXM6X-pHZTMD^9y5q1J7LsdfL!k|vHKtes?h0c~M)D2Z~ z@Q^atQfS_*d95Zx-B%p1#^rx}=YUH}SUO+e=WG|Sne`UJ6Ibm*SrI9U_qgdas{j?@ zP1vUYN{b-_lBHS)$BrDyj@wQHKv~6sYPW<2L@H#H?AA2(=GCp*zRT8w2-I|DR1>=b z=tZJM%E{frFbRSu?aNbgVWD*20+wGvASU9V(Uk5bL4+-=+#Sh@u4_0vw=B}6j+x(5 z0)xukB}u<}h%FaYgnmm8Ok*C`u| z^NAHXad)w#=9#5|-iWZ{iiF6M;uHSvIG$8vtfi|NUnRp0(oo-R?@k*L4Cs`|TER+n z8(m1xRqO6v_u#xR9fw5+_7w!-I<5Z+Um4f}oe1wJL(!2p)8?fib+oXti}l zA$PnO=c3T5pNkbpS@5DB*4=#v`>J~HECTPk`$Yugo#S0BMEwXZTRjE680Acf710FL zf;hbH97#v9@?b1jWmLPBPSj?=bkWPDYf)sktWonirqb5-rcKiwGLDp`{LVPvEDs^yTd_m z)+=1LIHUo9#P7M>J6B#jgSD6d9dp(@$LfBq3QUURmfJi)(l{lP!@AvnwFWQ*=5=(E z>okP;>uwwHsvN^jgc)js@q9Bd2%UOO-SKLwi*MV9A;XjYwYFRbSUYqpcU5_hmp8b0 zfk_FkTm*(p;N==temYh6x*D}wm4HB17#0Zm+$V-9s$f^8E&yX4Lsx(WGD=$Q@(rvIs;vDqh!#7lBlUSV5>Cw3_MJ{SY34p$IrtJYM%*uvpV0Scij-A;>Ut-`IA zP_||-f~tWnAWSr~ybT)Ka+-)=7(vVg4YWX)>_lUz`YbC=dh~E198`zlGuIU2*BA7r zaO57j)g8+Sd$WP+38mohvc&UX5LUg$LLz zO+$P+sR`(V#IFY-k_q;<=qwdD%UF2LY2)QMZksA~#-&qLguzgb5Bn9((LhgbyPX*_ z5_Yk57r%;q_g*_FLhSce|EAGb%He}BTJcN%C_iU1SGuULHO(-?a6YZK6jKAZ0 z2v0C2!Nyyy`#9I-$T=CtE#OhJ0`H)(nB_@KXTJi3OEfGej2O&?T>?OIEGO5LV)@ub z!WeX>!kRM`Ic>GzVvsev{#(9U+4F_; z3~GIs4Rlp*=re_bzyqs4!%9TrvW=&B9YCkTp_qbTWKpTPET#cvb5T1gMu2LH5Ekgv z9=8!?V!*%8Co@8 zNtCIppR)WAiI#nE-9UlfFdedi;i9h?-J1!i-EO8ARQOmGm*FC^;-UE3ieAndm`6Go;{9YLAX@tl#87&B|>iepE z2)@P24VR;kO^>M13_1i`5`=_-2Yh!BgFr)wCmPTD%6tW$>jr|LL|O~t5y>Fv z7oDy+9i`(4>?d+9A8eG#x`|P{tAi1&H@KKk(fu|;*-9OLmRc1k6jzUsamT~H2!qiZ zZYS_>kTP27DBe-E+O1+dYEPfIr?=W=IH9m`kR{mm{yzACsxmuYb!!KQpp@p{r0;7` z+2^f+o6QZ}=Ku_ebHFZi#GCJ!VxSgdug{{!3TsD!agux7 z8-&wVg(HEngO0G!1W0sFVgS*W4wyy(kyEj;7~(bX`rAomhr# zGGfx#)dV+|Bz61Gikrl+8lI{>dVsL5wcI!TP zc_f$BWX}_8l>@nx<4AE|OT)Od(kX-l?hyhDcfWQdZRps=*H;)dqvF>bgL`XCxyXu( z0dvt&Du|H?0D3<)*hP)(wk%Jutrgtxq4TAdJ|momk84(r)n2Q5EQG-)=()!Pm5=cb z(|7TWEp8y4=)T8*XF4oGQDDPF`bIE19aYkK>?=t~haGPSSYL%KG}_4Z-@_vlE7=gRg_^!CT`DUSCOV^a^$7?bk_5Z?i@c!yk!prE0ffS+!FY}o?* zG|K{(kn!<4!Qd(5dl(+&Ta{s+7E8c}0)E!M4J@)8H;m!f05f#mh-qX8X}N{63ZCbB z6>auYyJRp{j1t^KO96J&&ee#m7xut3ikpo0@R*_Iib@{ip~7IiOdX=!2domLGDjMc zCYHim5AXm{@_D52^1sXs=&vf3egf3u(M3E3!}IIF#DhCYrf2XA<9IkH(~` znSG3nbU4Bbl_7l{v~(Y1Rm1m%N*Ad;q+HZdBTUthIso(#SRAuTGo>;-qSaycLN(L< z)lf_K%PFAKR8Y2iUpRV*)IKO^VH|3ht#^=C!<=X8owM~$>_q_kOsEjFZ_e5vD4;jIrsN2Y z7rMo=fKzp%>o~#Otjvi70d|EW@?(|FI!?STKaYNpCL6E0r=x+{y>ju zsnxLVi!^=(oDjaGt1QXzi1u-&HBiIMHw8kw4oWizfRJKx_AW~#MT%91Z4si+CPeUYS#CzqG-#BxYYhMol4 z_SEOq39<2pqtkd=+Uofk@c3tt@Jp0Q4Rqj24eDX74ca}?I_f3UXK^&7B_0HxWtIWgcJ-O;wR6e>vYMI|fZ5C7E?~8_#8{rz)byU9_+-b!jJ` zLr&M#e6lXA1GjQ%e zD+WK`#-Dm>4H5AU{z@okduzbavBnlsM~J^x@uG+Gu}Y{@hup{m&eJMdQOTEJF%J-1 zuJ_={2fz<-^X=Rn4#+lZh z(VX_glpMKQ1%xpJv2`n?#xvfJWs#R--N4$DV(mv}l=X5D{_FO1zodqGt?9x}v(V4h z*(!r}_WBHHih_B@c$e`+J5tfX(p%i zF*k&C{^URE(_h#Z^LAoFO<2jH35Bnf%~$~_b1~mLwkGg~3uRK7=>(CLDPeCmo+-~r z3pjv+Tw=l+#xH>9d%fJ$bdkU}iEj$ube`SyqnO4M=A1O= zlsTt!nZ6bi6QC)X!28uE(iqK{&?2MI$p7xS-K`5myrM)l)FerGg2ov`Dh#9beD53Mz2Abca#KlGNFtek zBWX`3rgF*g-Z#?HtG? z#I{HAk*21Q*3F}@F3KD1Nsjp)8w>#vY5XdA?{~mm(vroHpwPdk$A#mBWlt-%jo}Ir zsc9%VBuHVB?yQ`ow=6MhqGUswRDm=C2#FqsyvHWg7~AE9K>{rV!h{4y(PB6hG(#DK z(Rj@eM1gVP2vLuqtNck%S{@z445r?Q`ase)CNOkbX^nYP6Kao8vGmwS9~ny|FwF0Y zy2Z5ak~N$SaDa~zU4c9R|0Wb@4cG|U$W8LY64LC4p~ayRn^xsCl1W2j!E0zGRWF%@ z7Uq(umCNNWWit6gDGkKlrDhysnE5)UfKH~e%nx*6qZnOKl&EDURbm2oAw%?hN<5Yk zc3d3)S~53k+tAgMUyjeC92rD8NI8rXxEq^hWH@V3BgRhGh1hI#uB#4DC&4J%lm|4& zrNPlG8LN|VxZNiTBM};aOIB3F9?k}Y#p~vDU9uQTYZ!jIZVo@8fzsC^QIBLYQ#NJ> zKNDa+<|6C|=b0#Y?hBc0zLd-5Z}E>Ho{@#XnZ}DcAhq1XteJl!-zMktI~;0$2me&o z{2RHmbb+MNbc0fnE9*gh@^8@A-5SkgX|*8b4hR#m=Mt1Slp!{@1QZA+ZZZQfj79$B z>*FWi;0PwiPd=T(0R5HDPlpR0$R@gvDO=j9kjE$ds1imE3 zvalSZ>5K(J5<@xg0!C*lHzD-)ek9suD2Dr(%EA8UC>hw!w@~N17ob~kpHK#!r>7q8 zeTw&QkM~wab#8@jJt024_gQKP1!!vgt1xrpy>w>Q!cWFu!tXQ^1m2(ga5MvXQZ@4@ z-_PZS!xTJLPI&)k$y_!|v3-)rWhbVSG%XY4@1G?_&pBI>wgV(PI}F!3e)1zLKh0`? zg7dIUpC6$nriqjY6W&9vcah}^(+eqBSx|QJ9`RWdc{1w+x`)!76rO~ZEQc33YkP%M z44Qp>ESV&4FIkr&i==0UHV2my*(^)|EOIuN4co|0k0nsg%)-9O2aZH*K!x?0WDDaRhJBw+ojcYb$jAMj@gN7;_R9$B9BMKreh7Chm%yCLLu0a>3 zhrp;PikV@QhH?Kjs}Ez2%i|`0g8|_PoCS1iC|2fKDmvB_Dco7jnI_F;#j62W>CTwLO(|D585J_Y}THbw)+^(a4|=oMJSlD z7=9L$2jUKx@IK_&D_B%fh!E*bi_uw*^E;6n%j%OoNQVzzUNZkvw+qn6I_Bo1z&@rOoIKBa3jdpE@aZscsPn)1OHiCOv&tI9-Ip3 z(o-V8Zi5^#iysB&5G5pZ3Jyj%xeiv@_y(x(?}AN@Lnjau8Vm~I(h&_qrM?M2V)!f( z*t?6OFQ@?Tx1LRBd89X6mtZ5m{y4nkEolzSk(f}E0v!xuK0U|b@EF|k1O-Plk(S8U zW@Cup#O45#mPT@R7ST9o9tQD~@sq!TQ-H}w0{#*D@RRIp0)gfwHFn9$WI^&z#=ojv zg7ti6#D))nq3?}bF#NqyIgW$JgpJA>TqX{E4F&P;HHiRwk61aXGESmOFwk(Rp8>=a z6jtg_FgBKE#p&`2`840VY>45R)LqW^p2I|jnn0t>n52jt8}H5GKb%6EJn3K{2|QG=wS?&QK>fRz zFaI5bLgU~zwSp&T1(#v@ckjUH5`?MNdn{+`h~P1^cK?(pC!f-Qan@6akBNc!Ow{5t zxNpN~jNqy9cbypPF9)ob1X+JMV*O<}Wz!%v0mqG+&Y zH6~o=M`Fy7I&YESB+Q^#k#E0L{`=$qDf@TrKYC~4r=L9cqc7WY-&=m=TT8bW?SK9D z>#sa=<-hzxdqF<`<0V_k=TBd4qY6&>_SSnQ|{HOS&j+pvWys<4c z+m#pb@%Ian`w}W8hDH-Zlj)&Hznm`(L9lmV{f8t1v=c)KhPt9hgnc(Tlz$T6XHr8@ zR31KNxrAOO0c9D=!UzNoHcEaRCEw?2hc3s&0NPaya~d*`{75f`^6TvLpOB{4;3edg zzh%xlW5|QB$Wno>Qfg=_|7LOs=5Q!qB}KA}!Cu0}<*3Q%#ZF(&?#oG!T;~PtKn3zg zAd=xEVosBS_l?>|ycW-7$HSh3$SW#F#1+rPDxyh7#%T`!Cm8QIgG+MzQ$ju?2WaE~ z=?(KRB{;BK!a=c0pqaH0;sTjdm3G+#(X8?m4~slp4l7+o_T*PNEFFNbvyTY-4dQhT z8{kKvG|(N)3HU-ak>CMKW;K!GAaAcj$Y9q;Iw zfZYKYJB~yA4s8g=n}6~FSUY$MK@!L_qthwueFY#8h-o9oAC6+Ifk1Kz>@`Tdh4oW{ zl*0w#m7vqG@>+U;+7Bt?h5ErcGK~%n>H*~dk6~Jc@~o}WXhE!TB7a4=hF-F0-v+Z_ zrnzZjWx-9xd6PoKBpS%#I|{697NG1T*qp=>VKdGMnUI2o$^SwMD>-biSipl#HB%R! zH(39DB;3JX+4?YnkX?TLf$3ogPJh62{Ja{{kgVpB4T`3%(fkf7n2j*-9VwZtNWupM zNm&$PuLK0h78Dlua6MR=@Q6hogd}#HDePInL1lCF>5Ngr08Q^A7m)}vON3%dQ{FOT7B;b>2*89k|%qwE?=21(P5)ZwKn<;PO z*2CZlIot)0C&KaWTsjbZUbNK1Gq($+H9X~r$GN^_9(sN8-tx>W8pu5uK9g4tU^Z9Q~PY1dF^ABQpc(XaQZM|OK!X1IC zfmb?st}9RYvd&MRLL1V}*96YnOz zzl3@BAm@qsJeY)W!F$Dv(5hDuB<~`%iFd%?=8N`l+(4S2@239zf60{N?2(T?;|jKX zII*=6H2|E}@wLz@uRL$yN-3`bZ{o@+?`7iWNihe}4V%0JBDj7n2Dv?DmP7`Ohrf~&sZ0e Notifications*" + } + } + } + + Context "'Send to integration' is on" { + BeforeEach { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { @{ sendtoIntegration = $true } } + } + + It 'reports success and keeps the ticket text the extension returned' { + Mock -CommandName New-CippExtAlert -MockWith { 'Ticket created in HaloPSA: 1380' } + + $Result = Send-CIPPAlert @script:PsaParams + + $Result | Should -BeLike 'Sent PSA alert:*' + $Result | Should -BeLike '*Ticket created in HaloPSA: 1380*' + Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { $sev -eq 'info' -and $message -like 'Sent PSA alert*' } + } + + It 'still reports success when the extension returns nothing' { + $Result = Send-CIPPAlert @script:PsaParams + + $Result | Should -Be 'Sent PSA alert: User Offboarding - contoso.onmicrosoft.com - Offboard AdeleV' + } + + It 'returns an error when the extension reports a failed ticket' { + # New-HaloPSATicket returns this string instead of throwing, which is why the caller + # has to inspect it. + Mock -CommandName New-CippExtAlert -MockWith { 'Failed to send ticket to HaloPSA: Unauthorized' } + + $Result = Send-CIPPAlert @script:PsaParams + + $Result | Should -Be 'Error: Failed to send ticket to HaloPSA: Unauthorized' + } + + It 'logs the failed ticket at error severity and never claims it was sent' { + Mock -CommandName New-CippExtAlert -MockWith { 'Failed to send ticket to HaloPSA: Unauthorized' } + + $null = Send-CIPPAlert @script:PsaParams + + Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { + $sev -eq 'Error' -and $message -like '*PSA delivery failed for*Failed to send ticket to HaloPSA: Unauthorized*' + } + Should -Invoke Write-LogMessage -Times 0 -Exactly -ParameterFilter { $message -like 'Sent PSA alert*' } + } + + It 'treats an Error-prefixed result as a failure too' { + Mock -CommandName New-CippExtAlert -MockWith { 'Error: no HaloPSA token could be retrieved' } + + Send-CIPPAlert @script:PsaParams | Should -Be 'Error: Error: no HaloPSA token could be retrieved' + } + } +} diff --git a/Tests/Alerts/Send-CIPPScheduledTaskAlert.ResultEnvelope.Tests.ps1 b/Tests/Alerts/Send-CIPPScheduledTaskAlert.ResultEnvelope.Tests.ps1 new file mode 100644 index 0000000000000..7a4e08f767e7a --- /dev/null +++ b/Tests/Alerts/Send-CIPPScheduledTaskAlert.ResultEnvelope.Tests.ps1 @@ -0,0 +1,162 @@ +# Pester tests for how Send-CIPPScheduledTaskAlert renders a result envelope. +# Commands that also serve an HTTP caller return one row carrying the result lines plus the extras +# that caller needs: New-CIPPUserTask hands back Results alongside Username, Password, CopyFrom and +# the whole Graph user object. ConvertTo-Html turns that single row into a column per key, so the +# readable lines ended up squeezed into one cell beside a flattened 78-property Graph object - the +# notification email and the PSA ticket were a single unreadable row. +# +# The split is deliberately a rendering change only: $Results is not modified, so the webhook payload +# and the stored task results keep the shape they have always had, and nothing is dropped from the +# email or ticket either - the extras move below the table instead of beside it. + +BeforeAll { + Add-Type -AssemblyName System.Web -ErrorAction SilentlyContinue + + $BackendRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $BackendRoot 'Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1' + + function Get-CippTable { param([string]$TableName) } + function Get-CIPPAzDataTableEntity { param($TableName, $Filter, $Property, $First) } + function Get-Tenants { param($TenantFilter, [switch]$IncludeErrors) } + function Get-CIPPTextReplacement { param($Text, $TenantFilter, [switch]$EscapeForJson) } + function Send-CIPPAlert { + param($Type, $Title, $HTMLContent, $JSONContent, $TenantFilter, $altEmail, $altWebhook, + $APIName, $SchemaSource, $InvokingCommand, $Headers, $TableName, $RowKey, + $Attachments, $AffectedUser, [switch]$UseStandardizedSchema) + } + function Write-LogMessage { param($API, $tenant, $message, $sev, $LogData, $headers) } + + . (Join-Path $BackendRoot 'Modules/CIPPCore/Public/ConvertTo-PSAHtml.ps1') + . (Join-Path $BackendRoot 'Modules/CIPPCore/Public/GraphHelper/Get-AlertContentHash.ps1') + . $FunctionPath + + # The shape Push-ExecScheduledCommand hands over for a New-CIPPUserTask run: one row, because + # Select-Object * promotes the returned hashtable's keys to properties. + function New-EnvelopeResult { + $Lines = [System.Collections.Generic.List[string]]::new() + @( + 'Created New User.' + 'Username: starter@contoso.com' + 'Password: https://pwpush.example/p/abc' + 'Successfully added Starter to group Retail' + ) | ForEach-Object { $Lines.Add($_) } + + , @([pscustomobject]@{ + Results = $Lines + Username = 'starter@contoso.com' + Password = 'https://pwpush.example/p/abc' + CopyFrom = [pscustomobject]@{ Success = @('group A'); Error = @(); Skipped = @('group B') } + User = [pscustomobject]@{ + id = '00000000-0000-0000-0000-000000000001' + displayName = 'New Starter' + userPrincipalName = 'starter@contoso.com' + businessPhones = @('01603 888888') + } + }) + } + + function Get-TableColumns { + # ConvertTo-PSAHtml rewrites

with inline styles on the PSA path, so the header cells + # cannot be matched as a bare tag. + param([string]$Html, [int]$TableIndex = 0) + $Tables = [regex]::Matches($Html, '(?s)]*>.*?
') + if ($Tables.Count -le $TableIndex) { return @() } + @([regex]::Matches($Tables[$TableIndex].Value, ']*>(.*?)') | ForEach-Object { $_.Groups[1].Value }) + } + function Get-TableRowCount { + param([string]$Html, [int]$TableIndex = 0) + $Tables = [regex]::Matches($Html, '(?s)]*>.*?') + if ($Tables.Count -le $TableIndex) { return 0 } + ([regex]::Matches($Tables[$TableIndex].Value, '')).Count - 1 + } +} + +Describe 'Send-CIPPScheduledTaskAlert - result envelope rendering' { + BeforeEach { + $script:SentAlerts = [System.Collections.Generic.List[object]]::new() + $script:TaskInfo = [pscustomobject]@{ + RowKey = 'task-1' + Name = 'New user creation: starter@contoso.com' + Command = 'New-CIPPUserTask' + PostExecution = 'psa' + Parameters = '{}' + } + + Mock -CommandName Get-CippTable -MockWith { param([string]$TableName) @{ TableName = $TableName } } + Mock -CommandName Get-Tenants -MockWith { [pscustomobject]@{ customerId = 'customer-guid' } } + Mock -CommandName Get-CIPPTextReplacement -MockWith { param($Text, $TenantFilter) $Text } + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + param($TableName, $Filter) + if ($TableName -eq 'Extensionsconfig') { + return [pscustomobject]@{ config = (@{ HaloPSA = @{ Enabled = $false } } | ConvertTo-Json -Depth 5) } + } + $null + } + Mock -CommandName Send-CIPPAlert -MockWith { + param($Type, $Title, $HTMLContent) + $script:SentAlerts.Add([pscustomobject]@{ Type = $Type; HTMLContent = $HTMLContent }) + } + } + + Context 'a New-CIPPUserTask style envelope' { + BeforeEach { + Send-CIPPScheduledTaskAlert -Results (New-EnvelopeResult) -TaskInfo $script:TaskInfo -TenantFilter 'contoso.com' -TaskType 'Scheduled Task' + $script:Html = $script:SentAlerts[0].HTMLContent + } + + It 'renders the result lines as the table, one row each' { + Get-TableColumns -Html $script:Html -TableIndex 0 | Should -Be @('Results') + Get-TableRowCount -Html $script:Html -TableIndex 0 | Should -Be 4 + } + + It 'keeps the Graph user object rather than dropping it' { + # The whole point of rendering the extras below instead of unwrapping upstream. + $script:Html | Should -Match 'Additional detail' + $script:Html | Should -Match 'displayName: New Starter' + $script:Html | Should -Match '00000000-0000-0000-0000-000000000001' + } + + It 'keeps the other envelope fields too' { + foreach ($Field in 'Username', 'Password', 'CopyFrom', 'User') { + $script:Html | Should -Match ">$Field<" + } + } + + It 'puts the extras in their own table below the results' { + Get-TableColumns -Html $script:Html -TableIndex 1 | Should -Be @('Field', 'Value') + $script:Html.IndexOf('Additional detail') | Should -BeGreaterThan $script:Html.IndexOf('Created New User.') + } + } + + Context 'result shapes that are not envelopes' { + It 'renders an array of strings as before' { + Send-CIPPScheduledTaskAlert -Results @('First line', 'Second line') -TaskInfo $script:TaskInfo -TenantFilter 'contoso.com' -TaskType 'Scheduled Task' + + $Html = $script:SentAlerts[0].HTMLContent + Get-TableColumns -Html $Html -TableIndex 0 | Should -Be @('Text') + $Html | Should -Not -Match 'Additional detail' + } + + It 'renders a normal multi-row result set as before' { + $Rows = @( + [pscustomobject]@{ UserPrincipalName = 'a@contoso.com'; MFA = 'Disabled' } + [pscustomobject]@{ UserPrincipalName = 'b@contoso.com'; MFA = 'Disabled' } + ) + Send-CIPPScheduledTaskAlert -Results $Rows -TaskInfo $script:TaskInfo -TenantFilter 'contoso.com' -TaskType 'Scheduled Task' + + $Html = $script:SentAlerts[0].HTMLContent + Get-TableColumns -Html $Html -TableIndex 0 | Should -Be @('UserPrincipalName', 'MFA') + $Html | Should -Not -Match 'Additional detail' + } + + It 'leaves a single row that has no Results member alone' { + $Row = @([pscustomobject]@{ UserPrincipalName = 'a@contoso.com'; MFA = 'Disabled' }) + Send-CIPPScheduledTaskAlert -Results $Row -TaskInfo $script:TaskInfo -TenantFilter 'contoso.com' -TaskType 'Scheduled Task' + + $Html = $script:SentAlerts[0].HTMLContent + Get-TableColumns -Html $Html -TableIndex 0 | Should -Be @('UserPrincipalName', 'MFA') + $Html | Should -Not -Match 'Additional detail' + } + } +} diff --git a/Tests/Alerts/Send-CIPPScheduledTaskAlert.Tests.ps1 b/Tests/Alerts/Send-CIPPScheduledTaskAlert.Tests.ps1 index a54de402c6022..2308d7fbd2f6c 100644 --- a/Tests/Alerts/Send-CIPPScheduledTaskAlert.Tests.ps1 +++ b/Tests/Alerts/Send-CIPPScheduledTaskAlert.Tests.ps1 @@ -70,12 +70,14 @@ Describe 'Send-CIPPScheduledTaskAlert - PSA snooze links' { } Mock -CommandName Send-CIPPAlert -MockWith { - param($Type, $Title, $HTMLContent, $JSONContent, $TenantFilter, $AffectedUser) + param($Type, $Title, $HTMLContent, $JSONContent, $TenantFilter, $AffectedUser, $PSAReference, $PSATicketId) $script:SentAlerts.Add([pscustomobject]@{ Type = $Type Title = $Title HTMLContent = $HTMLContent AffectedUser = $AffectedUser + PSAReference = $PSAReference + PSATicketId = $PSATicketId }) } } @@ -151,4 +153,64 @@ Describe 'Send-CIPPScheduledTaskAlert - PSA snooze links' { $script:SentAlerts[0].HTMLContent | Should -Match 'Snooze Individual Alerts' } } + + # The task's reference is what lets a PSA add the result to the ticket the request came from + # rather than opening a second one, so every PSA call has to carry it - including the per-user + # split, or a split task's notes would land in new tickets while the consolidated one threads. + Context 'when the task carries a reference' { + BeforeEach { + $script:TaskInfo | Add-Member -NotePropertyName Reference -NotePropertyValue '[ID:1380] Starter Creation' -Force + } + + It 'passes it on the consolidated ticket' { + $script:LinkTicketsToUsers = $false + + Send-CIPPScheduledTaskAlert -Results $script:Results -TaskInfo $script:TaskInfo -TenantFilter 'contoso.com' -TaskType 'Alert' + + $script:SentAlerts.Count | Should -Be 1 + $script:SentAlerts[0].PSAReference | Should -Be '[ID:1380] Starter Creation' + } + + It 'passes it on every split ticket' { + Send-CIPPScheduledTaskAlert -Results $script:Results -TaskInfo $script:TaskInfo -TenantFilter 'contoso.com' -TaskType 'Alert' + + $script:SentAlerts.Count | Should -Be 2 + foreach ($Alert in $script:SentAlerts) { + $Alert.PSAReference | Should -Be '[ID:1380] Starter Creation' + } + } + + It 'passes an explicit PsaTicketId alongside it' { + $script:TaskInfo | Add-Member -NotePropertyName PsaTicketId -NotePropertyValue '1380' -Force + $script:LinkTicketsToUsers = $false + + Send-CIPPScheduledTaskAlert -Results $script:Results -TaskInfo $script:TaskInfo -TenantFilter 'contoso.com' -TaskType 'Alert' + + $script:SentAlerts[0].PSATicketId | Should -Be '1380' + } + + It 'keeps the ticket id out of the email subject entirely' { + # PsaTicketId drives the PSA note and nothing else. A mail-ingesting PSA threads on + # whatever the operator chose to put in Reference, so the subject must not be stamped + # with a ticket token nobody asked for. + $script:TaskInfo | Add-Member -NotePropertyName PsaTicketId -NotePropertyValue '1380' -Force + $script:TaskInfo | Add-Member -NotePropertyName Reference -NotePropertyValue 'Starter Creation' -Force + $script:TaskInfo.PostExecution = 'email' + + Send-CIPPScheduledTaskAlert -Results $script:Results -TaskInfo $script:TaskInfo -TenantFilter 'contoso.com' -TaskType 'Alert' + + $script:SentAlerts[0].Type | Should -Be 'email' + $script:SentAlerts[0].Title | Should -Not -Match '\[ID:' + $script:SentAlerts[0].Title | Should -BeLike '*Reference: Starter Creation*' + } + + It 'sends nothing extra when the task has no reference' { + $script:TaskInfo | Add-Member -NotePropertyName Reference -NotePropertyValue $null -Force + $script:LinkTicketsToUsers = $false + + Send-CIPPScheduledTaskAlert -Results $script:Results -TaskInfo $script:TaskInfo -TenantFilter 'contoso.com' -TaskType 'Alert' + + $script:SentAlerts[0].PSAReference | Should -BeNullOrEmpty + } + } } diff --git a/Tests/Baselines/BaselineDisableGuests.Tests.ps1 b/Tests/Baselines/BaselineDisableGuests.Tests.ps1 new file mode 100644 index 0000000000000..c1dd46cc61f56 --- /dev/null +++ b/Tests/Baselines/BaselineDisableGuests.Tests.ps1 @@ -0,0 +1,119 @@ +# Get-CIPPBaselineDisableGuestsState mirrors the DisableGuests standard: the same newest-attempt +# rule, the same IncludeNeverSignedIn switch (off by default and off for templates that predate +# it) and the same 7-day grace after an admin re-enables an account. Each test pins one of those, +# because drift between the standard and the baseline shows up as a guest one path disables and +# the other reports compliant. + +BeforeAll { + $script:RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $Baselines = Join-Path $script:RepoRoot 'Modules/CIPPCore/Public/Baselines' + + function New-GraphGetRequest { param($uri, $tenantid, $scope, $AsApp) } + function Write-LogMessage { param($API, $tenant, $message, $Sev, $LogData) } + + . (Join-Path $script:RepoRoot 'Modules/CIPPCore/Public/Get-CIPPLastSignInDateTime.ps1') + . (Join-Path $Baselines 'Get-CIPPBaselineDisableGuestsState.ps1') + + $script:Tenant = 'contoso.onmicrosoft.com' + $script:Now = (Get-Date).ToUniversalTime() + + # Guests go through ConvertFrom-Json, the shape New-GraphGetRequest hands the hook. Each + # *DaysAgo is how far back that signInActivity timestamp sits; leave all three out for a guest + # with no sign-in on record. + function script:New-Guest { + param( + [string]$Id, + [string]$Upn, + [int]$CreatedDaysAgo = 400, + [Nullable[int]]$InteractiveDaysAgo, + [Nullable[int]]$NonInteractiveDaysAgo, + [Nullable[int]]$SuccessfulDaysAgo + ) + $Stamp = { param($DaysAgo) if ($null -ne $DaysAgo) { $script:Now.AddDays(-$DaysAgo).ToString('o') } else { $null } } + $Guest = [ordered]@{ + id = $Id + userPrincipalName = $Upn + mail = $Upn + userType = 'Guest' + accountEnabled = $true + createdDateTime = $script:Now.AddDays(-$CreatedDaysAgo).ToString('o') + } + if ($null -ne $InteractiveDaysAgo -or $null -ne $NonInteractiveDaysAgo -or $null -ne $SuccessfulDaysAgo) { + $Guest.signInActivity = [ordered]@{ + lastSignInDateTime = & $Stamp $InteractiveDaysAgo + lastNonInteractiveSignInDateTime = & $Stamp $NonInteractiveDaysAgo + lastSuccessfulSignInDateTime = & $Stamp $SuccessfulDaysAgo + } + } + $Guest | ConvertTo-Json -Depth 5 | ConvertFrom-Json + } + + # A rendered template item. Omit -IncludeNeverSignedIn to model a template saved before the + # switch existed. + function script:New-DisableGuestsItem { + param([Nullable[int]]$Days, [Nullable[bool]]$IncludeNeverSignedIn) + $Variables = [PSCustomObject]@{} + if ($null -ne $Days) { $Variables | Add-Member -NotePropertyName days -NotePropertyValue $Days } + if ($null -ne $IncludeNeverSignedIn) { $Variables | Add-Member -NotePropertyName IncludeNeverSignedIn -NotePropertyValue $IncludeNeverSignedIn } + [PSCustomObject]@{ Variables = $Variables } + } +} + +Describe 'Get-CIPPBaselineDisableGuestsState' { + BeforeEach { + $script:guests = @() + $script:audits = @() + Mock New-GraphGetRequest { + param($uri) + if ($uri -like '*directoryAudits*') { return $script:audits } + return $script:guests + } + } + + It 'judges inactivity on the newest sign-in attempt, not the last successful sign-in' { + $script:guests = @( + New-Guest -Id 'g1' -Upn 'bas_example.com#EXT#@contoso.onmicrosoft.com' -SuccessfulDaysAgo 300 -InteractiveDaysAgo 154 -NonInteractiveDaysAgo 3 + New-Guest -Id 'stale' -Upn 'stale@example.com' -SuccessfulDaysAgo 250 -InteractiveDaysAgo 200 -NonInteractiveDaysAgo 190 + ) + + $Prepared = Get-CIPPBaselineDisableGuestsState -Item (New-DisableGuestsItem -Days 180) -TenantFilter $script:Tenant + + @($Prepared.Current.offenders) | Should -Be @('stale@example.com') + @($Prepared.Current.targets).id | Should -Be @('stale') + } + + It 'skips guests with no sign-in on record unless IncludeNeverSignedIn is on' { + $script:guests = @(New-Guest -Id 'pending' -Upn 'pending@example.com') + + $Legacy = Get-CIPPBaselineDisableGuestsState -Item (New-DisableGuestsItem -Days 90) -TenantFilter $script:Tenant + @($Legacy.Current.offenders) | Should -BeNullOrEmpty + + $Off = Get-CIPPBaselineDisableGuestsState -Item (New-DisableGuestsItem -Days 90 -IncludeNeverSignedIn $false) -TenantFilter $script:Tenant + @($Off.Current.offenders) | Should -BeNullOrEmpty + + $On = Get-CIPPBaselineDisableGuestsState -Item (New-DisableGuestsItem -Days 90 -IncludeNeverSignedIn $true) -TenantFilter $script:Tenant + @($On.Current.offenders) | Should -Be @('pending@example.com') + @($On.Current.targets).id | Should -Be @('pending') + } + + It 'leaves a guest an admin re-enabled in the last 7 days alone' { + $script:guests = @(New-Guest -Id 'stale' -Upn 'stale@example.com' -InteractiveDaysAgo 200) + $script:audits = @([pscustomobject]@{ targetResources = @([pscustomobject]@{ id = 'stale' }) }) + + $Prepared = Get-CIPPBaselineDisableGuestsState -Item (New-DisableGuestsItem -Days 90) -TenantFilter $script:Tenant + + @($Prepared.Current.offenders) | Should -BeNullOrEmpty + @($Prepared.Current.targets) | Should -BeNullOrEmpty + } + + It 'falls back to 90 days when the template carries no value' { + $script:guests = @( + New-Guest -Id 'over' -Upn 'over@example.com' -InteractiveDaysAgo 100 + New-Guest -Id 'under' -Upn 'under@example.com' -InteractiveDaysAgo 80 + ) + + $Prepared = Get-CIPPBaselineDisableGuestsState -Item (New-DisableGuestsItem) -TenantFilter $script:Tenant + + @($Prepared.Current.offenders) | Should -Be @('over@example.com') + } +} diff --git a/Tests/Baselines/BaselineEntraHeavies.Tests.ps1 b/Tests/Baselines/BaselineEntraHeavies.Tests.ps1 index 772f763b278c9..08fa17b073f0c 100644 --- a/Tests/Baselines/BaselineEntraHeavies.Tests.ps1 +++ b/Tests/Baselines/BaselineEntraHeavies.Tests.ps1 @@ -91,6 +91,24 @@ Describe 'Get-CIPPBaselineAuthenticationMethodsState' { (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 } + It "the 'notConfigured' state skips the method exactly like an absent variable" { + # The Enabled switches became three-state autoCompletes; Not Configured must never + # grade or write. SMS is enabled in the tenant, so treating it as $false would drift. + Mock New-CIPPDbRequest { @($script:AuthPolicy | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ MicrosoftAuthenticatorEnabled = $true; SMSEnabled = 'notConfigured' } } + $Prepared = Get-CIPPBaselineAuthenticationMethodsState -Item $Item -TenantFilter $script:Tenant + @($Prepared.Current.methodsOutOfPolicy).Count | Should -Be 0 + @($Prepared.Current.remediationSets).Count | Should -Be 0 + } + + It 'accepts an option wrapper the pipeline did not unwrap' { + Mock New-CIPPDbRequest { @($script:AuthPolicy | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ SMSEnabled = [PSCustomObject]@{ label = 'Disabled'; value = $false } } } + $Prepared = Get-CIPPBaselineAuthenticationMethodsState -Item $Item -TenantFilter $script:Tenant + $Prepared.Current.methodsOutOfPolicy | Should -Match 'SMS' + $Prepared.Current.remediationSets[0].Params.Enabled | Should -BeFalse + } + It 'a drifted method contributes named drifts AND a remediation parameter set' { Mock New-CIPPDbRequest { @($script:AuthPolicy | ConvertTo-Cached) } $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ SMSEnabled = $false } } @@ -180,6 +198,30 @@ Describe 'Get-CIPPBaselineFIDO2PasskeyProfilesState' { $type -eq 'PATCH' -and $AsApp -eq $true -and $body -match 'p-other' -and $body -match 'guid-1' } } + + It 'expects enforcement whenever AAGUIDs are supplied, even with the enforce switch off' { + # HubSpot 47469698699: Graph only retains a profile's AAGUID list while isEnforced = $true, + # so the expected state must enforce when AAGUIDs are present or the executor's write (which + # also enforces) would perpetually read back as drift. + Mock New-CIPPDbRequest { @($script:Fido2 | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ PasskeyTypes = 'deviceBound'; AttestationEnforcement = 'registrationOnly'; EnforceKeyRestrictions = $false; EnforcementType = 'allow'; AAGUIDs = 'de1e552d-db1d-4423-a619-566b625cdc84' } } + $Prepared = Get-CIPPBaselineFIDO2PasskeyProfilesState -Item $Item -TenantFilter $script:Tenant + $Prepared.Expected.keyRestrictionsEnforced | Should -BeTrue + @($Prepared.Expected.aaGuids) | Should -Be @('de1e552d-db1d-4423-a619-566b625cdc84') + } + + It 'enforces key restrictions in the PATCH when AAGUIDs are supplied without the enforce switch' { + Mock New-GraphPostRequest { $script:capturedBody = $body } + $Current = [PSCustomObject]@{ + defaultProfileId = 'p-default' + allProfiles = @($script:Fido2.passkeyProfiles | ConvertTo-Cached) + } + Invoke-CIPPBaselineFIDO2PasskeyProfiles -Remediate ([PSCustomObject]@{ passkeyTypes = 'deviceBound'; attestationEnforcement = 'registrationOnly'; enforceKeyRestrictions = $false; enforcementType = 'allow'; aaGuids = 'de1e552d-db1d-4423-a619-566b625cdc84' }) -TenantFilter $script:Tenant -Current $Current + $Body = $script:capturedBody | ConvertFrom-Json + $Default = @($Body.passkeyProfiles) | Where-Object { $_.id -eq 'p-default' } + $Default.keyRestrictions.isEnforced | Should -BeTrue + @($Default.keyRestrictions.aaGuids) | Should -Be @('de1e552d-db1d-4423-a619-566b625cdc84') + } } Describe 'Get-CIPPBaselineOauthConsentState' { diff --git a/Tests/Baselines/BaselineExchangeBatch.Tests.ps1 b/Tests/Baselines/BaselineExchangeBatch.Tests.ps1 index c3fabe6f1b671..56c544125edf3 100644 --- a/Tests/Baselines/BaselineExchangeBatch.Tests.ps1 +++ b/Tests/Baselines/BaselineExchangeBatch.Tests.ps1 @@ -127,6 +127,31 @@ Describe 'Get-CIPPBaselineUserSubmissionsState' { Invoke-CIPPBaselineUserSubmissions -Remediate ([PSCustomObject]@{ state = 'disable' }) -TenantFilter $script:Tenant -Current $Current Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { $cmdlet -eq 'Remove-ReportSubmissionRule' } } + + It 'grades reporting-to-Microsoft OFF as compliant for the mailbox-only destination, and as drift without it' { + # Issue #409: tenants using a third-party phishing service keep EnableReportToMicrosoft + # off - the mailbox-only destination must not read that as drift. + Mock New-CIPPDbRequest { + if ($Type -eq 'ReportSubmissionPolicy') { @(@{ EnableReportToMicrosoft = $false; ReportJunkToCustomizedAddress = $true; ReportJunkAddresses = @('soc@contoso.com'); ReportNotJunkToCustomizedAddress = $true; ReportNotJunkAddresses = @('soc@contoso.com'); ReportPhishToCustomizedAddress = $true; ReportPhishAddresses = @('soc@contoso.com') } | ConvertTo-Cached) } + else { @(@{ State = 'Enabled'; SentTo = @('soc@contoso.com') } | ConvertTo-Cached) } + } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ state = 'enable'; email = 'soc@contoso.com'; reportDestination = 'Mailbox' } } + $Prepared = Get-CIPPBaselineUserSubmissionsState -Item $Item -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + + $Legacy = [PSCustomObject]@{ Variables = [PSCustomObject]@{ state = 'enable'; email = 'soc@contoso.com' } } + $Prepared = Get-CIPPBaselineUserSubmissionsState -Item $Legacy -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -BeGreaterThan 0 + } + + It 'writes EnableReportToMicrosoft false when remediating the mailbox-only destination' { + Mock New-ExoRequest { } + $Current = [PSCustomObject]@{ policyExists = $true; ruleExists = $true; ruleEnabled = $true; resolvedEmail = 'soc@contoso.com'; reportDestination = 'Mailbox' } + Invoke-CIPPBaselineUserSubmissions -Remediate ([PSCustomObject]@{ state = 'enable' }) -TenantFilter $script:Tenant -Current $Current + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { + $cmdlet -eq 'Set-ReportSubmissionPolicy' -and $cmdParams.EnableReportToMicrosoft -eq $false -and $cmdParams.ReportPhishAddresses -eq 'soc@contoso.com' + } + } } Describe 'Get-CIPPBaselineRetentionPolicyTagState' { @@ -299,4 +324,18 @@ Describe 'Get-CIPPBaselineSpamFilterPolicyState block-list write params' { @($Prepared.Current.extraPolicyParams.RegionBlockList) | Should -BeExactly @('KP', 'RU') $Prepared.Expected.enableRegionBlockList | Should -BeTrue } + + It 'never grades or writes BulkMovesEnabled unless explicitly configured - the parameter is in Preview and not available in every organization' { + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ } } + $Prepared = Get-CIPPBaselineSpamFilterPolicyState -Item $Item -TenantFilter $script:Tenant + $Prepared.Expected.PSObject.Properties.Name | Should -Not -Contain 'bulkMovesEnabled' + $Prepared.Current.extraPolicyParams.PSObject.Properties.Name | Should -Not -Contain 'BulkMovesEnabled' + } + + It 'grades and writes BulkMovesEnabled when configured On, unwrapping an option wrapper if the picker saved one' { + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ BulkMovesEnabled = [PSCustomObject]@{ label = 'On'; value = 'On' } } } + $Prepared = Get-CIPPBaselineSpamFilterPolicyState -Item $Item -TenantFilter $script:Tenant + $Prepared.Expected.bulkMovesEnabled | Should -BeExactly 'On' + $Prepared.Current.extraPolicyParams.BulkMovesEnabled | Should -BeExactly 'On' + } } diff --git a/Tests/Baselines/BaselineOneOffStandards.Tests.ps1 b/Tests/Baselines/BaselineOneOffStandards.Tests.ps1 index af2a4702c8ec8..ef0b39145ca94 100644 --- a/Tests/Baselines/BaselineOneOffStandards.Tests.ps1 +++ b/Tests/Baselines/BaselineOneOffStandards.Tests.ps1 @@ -18,7 +18,7 @@ BeforeAll { . (Join-Path $script:RepoRoot 'Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1') . (Join-Path $Baselines 'Get-CIPPBaselineCacheRows.ps1') . (Join-Path $Baselines 'Test-CIPPBaselineCacheCollected.ps1') - foreach ($Name in @('ExternalMFATrusted', 'IntuneDeviceRetirementDays', 'AppManagementPolicy', 'EnableAppConsentRequests', 'TeamsFederationConfiguration', 'OMEBranding')) { + foreach ($Name in @('ExternalMFATrusted', 'ExternalComplianceTrusted', 'IntuneDeviceRetirementDays', 'AppManagementPolicy', 'EnableAppConsentRequests', 'TeamsFederationConfiguration', 'OMEBranding')) { . (Join-Path $Baselines "Get-CIPPBaseline${Name}State.ps1") . (Join-Path $Baselines "Invoke-CIPPBaseline${Name}.ps1") } @@ -58,6 +58,30 @@ Describe 'Get-CIPPBaselineExternalMFATrustedState' { } } +Describe 'Get-CIPPBaselineExternalComplianceTrustedState' { + It 'grades the compliance switch in BOTH directions' { + Mock New-CIPPDbRequest { @(@{ inboundTrust = @{ isCompliantDeviceAccepted = $true } } | ConvertTo-Cached) } + $Off = [PSCustomObject]@{ Variables = [PSCustomObject]@{ state = $false } } + $Prepared = Get-CIPPBaselineExternalComplianceTrustedState -Item $Off -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -BeGreaterThan 0 + $On = [PSCustomObject]@{ Variables = [PSCustomObject]@{ state = $true } } + $Prepared2 = Get-CIPPBaselineExternalComplianceTrustedState -Item $On -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared2.Expected -Current $Prepared2.Current).Count | Should -Be 0 + } + + It 'patches the merged inboundTrust, never the lone flag' { + Mock New-GraphGetRequest { [PSCustomObject]@{ inboundTrust = [PSCustomObject]@{ isMfaAccepted = $true; isCompliantDeviceAccepted = $false; isHybridAzureADJoinedDeviceAccepted = $true } } } + Mock New-GraphPostRequest { } + Invoke-CIPPBaselineExternalComplianceTrusted -Remediate ([PSCustomObject]@{ trusted = $true }) -TenantFilter $script:Tenant -Current $null + Should -Invoke New-GraphPostRequest -Times 1 -Exactly -ParameterFilter { + $type -eq 'PATCH' -and + $body -match '"isCompliantDeviceAccepted":\s*true' -and + $body -match 'isMfaAccepted' -and + $body -match 'isHybridAzureADJoinedDeviceAccepted' + } + } +} + Describe 'Get-CIPPBaselineIntuneDeviceRetirementDaysState' { BeforeAll { $script:DaysItem = [PSCustomObject]@{ Variables = [PSCustomObject]@{ days = 90 } } } BeforeEach { Mock Get-CIPPDbItem { [PSCustomObject]@{ RowKey = 'ManagedDeviceCleanupRules-Count'; DataCount = 1 } } } diff --git a/Tests/Baselines/BaselineSPGuestPeoplePicker.Tests.ps1 b/Tests/Baselines/BaselineSPGuestPeoplePicker.Tests.ps1 new file mode 100644 index 0000000000000..b7e6dadfb22e5 --- /dev/null +++ b/Tests/Baselines/BaselineSPGuestPeoplePicker.Tests.ps1 @@ -0,0 +1,118 @@ +# SPGuestPeoplePicker reads from cache: the prepare hook derives offenders/targets from +# Get-CIPPSPOTenant (the tenant default, 1h-cached) + the SPOSites reporting cache (New-CIPPDbRequest), +# and the executor writes then stops - the next daily cache read verifies. The write sweep is guarded to +# once per 24h per tenant by Test-CIPPRerun. Static counting mocks only. + +BeforeAll { + $script:RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $Baselines = Join-Path $script:RepoRoot 'Modules/CIPPCore/Public/Baselines' + + function Get-CIPPSPOTenant { param($TenantFilter, [switch]$UseCertificate, [switch]$SkipCache) } + function New-CIPPDbRequest { param($TenantFilter, $Type, $Fields) } + function Set-CIPPSPOTenant { [CmdletBinding()] param([Parameter(ValueFromPipeline)]$InputObject, $Properties, [switch]$UseCertificate) process {} } + function Set-CIPPSPOSiteBulk { param($TenantFilter, $Sites, $MaxConcurrency, $MaxRetries, [switch]$UseCertificate) } + function Test-CIPPRerun { param($TenantFilter, $API, [int64]$Interval) } + function Write-LogMessage { param($API, $tenant, $message, $Sev, $LogData) } + function Write-Information { param($MessageData) } + + . (Join-Path $Baselines 'Get-CIPPBaselineSPGuestPeoplePickerState.ps1') + . (Join-Path $Baselines 'Invoke-CIPPBaselineSPGuestPeoplePicker.ps1') + + $script:Tenant = 'contoso.onmicrosoft.com' + function script:New-Site { param([string]$Url, [bool]$Show) [PSCustomObject]@{ Url = $Url; ShowPeoplePickerSuggestionsForGuestUsers = $Show } } + function script:New-Item2 { param([bool]$ShowGuests) [PSCustomObject]@{ Variables = [PSCustomObject]@{ showGuests = $ShowGuests } } } +} + +Describe 'Get-CIPPBaselineSPGuestPeoplePickerState' { + It 'flags the tenant default and sites that differ (wanted = show)' { + Mock Get-CIPPSPOTenant { [PSCustomObject]@{ ShowPeoplePickerSuggestionsForGuestUsers = $false } } + Mock New-CIPPDbRequest { @((New-Site 'https://c.sharepoint.com/sites/A' $false), (New-Site 'https://c.sharepoint.com/sites/B' $true)) } + $p = Get-CIPPBaselineSPGuestPeoplePickerState -Item (New-Item2 -ShowGuests $true) -TenantFilter $script:Tenant + @($p.Current.offenders) | Should -Be @('Tenant default', 'https://c.sharepoint.com/sites/A') + @($p.Current.targets)[0].Scope | Should -Be 'tenant' + @($p.Current.targets)[1].SiteUrl | Should -Be 'https://c.sharepoint.com/sites/A' + } + + It 'flags only what differs (wanted = hide)' { + Mock Get-CIPPSPOTenant { [PSCustomObject]@{ ShowPeoplePickerSuggestionsForGuestUsers = $false } } + Mock New-CIPPDbRequest { @((New-Site 'https://c.sharepoint.com/sites/B' $true)) } + $p = Get-CIPPBaselineSPGuestPeoplePickerState -Item (New-Item2 -ShowGuests $false) -TenantFilter $script:Tenant + @($p.Current.offenders) | Should -Be @('https://c.sharepoint.com/sites/B') + } + + It 'is compliant when the tenant default and every cached site already match' { + Mock Get-CIPPSPOTenant { [PSCustomObject]@{ ShowPeoplePickerSuggestionsForGuestUsers = $true } } + Mock New-CIPPDbRequest { @((New-Site 'https://c.sharepoint.com/sites/A' $true)) } + $p = Get-CIPPBaselineSPGuestPeoplePickerState -Item (New-Item2 -ShowGuests $true) -TenantFilter $script:Tenant + @($p.Current.offenders) | Should -BeNullOrEmpty + } + + It 'returns null Current when the tenant read fails' { + Mock Get-CIPPSPOTenant { throw 'SharePoint admin access denied' } + Mock New-CIPPDbRequest { @() } + $p = Get-CIPPBaselineSPGuestPeoplePickerState -Item (New-Item2 -ShowGuests $true) -TenantFilter $script:Tenant + $p.Current | Should -BeNullOrEmpty + } +} + +Describe 'Invoke-CIPPBaselineSPGuestPeoplePicker' { + BeforeEach { + Mock Write-LogMessage {} + Mock Test-CIPPRerun { $false } + Mock Get-CIPPSPOTenant { [PSCustomObject]@{ _ObjectIdentity_ = 'id'; TenantFilter = $script:Tenant; ShowPeoplePickerSuggestionsForGuestUsers = $true } } + Mock Set-CIPPSPOTenant {} + $script:Remediate = [PSCustomObject]@{ executor = 'SPGuestPeoplePicker'; useCertificate = $true } + } + + It 'writes the tenant default and sites, without a verification re-read' { + Mock Set-CIPPSPOSiteBulk { @( + [PSCustomObject]@{ SiteUrl = 'https://c.sharepoint.com/sites/A'; Success = $true; Error = $null } + [PSCustomObject]@{ SiteUrl = 'https://c.sharepoint.com/sites/B'; Success = $true; Error = $null } + ) } + $Current = [PSCustomObject]@{ targets = @( + [PSCustomObject]@{ Scope = 'tenant'; SiteUrl = $null; Wanted = $true } + [PSCustomObject]@{ Scope = 'site'; SiteUrl = 'https://c.sharepoint.com/sites/A'; Wanted = $true } + [PSCustomObject]@{ Scope = 'site'; SiteUrl = 'https://c.sharepoint.com/sites/B'; Wanted = $true } + ) } + Invoke-CIPPBaselineSPGuestPeoplePicker -Remediate $script:Remediate -TenantFilter $script:Tenant -Current $Current + Should -Invoke Set-CIPPSPOTenant -Times 1 -Exactly + Should -Invoke Set-CIPPSPOSiteBulk -Times 1 -Exactly + } + + It 'skips the write sweep entirely inside the 24h rerun guard' { + Mock Test-CIPPRerun { $true } + Mock Set-CIPPSPOSiteBulk {} + $Current = [PSCustomObject]@{ targets = @( + [PSCustomObject]@{ Scope = 'tenant'; SiteUrl = $null; Wanted = $true } + [PSCustomObject]@{ Scope = 'site'; SiteUrl = 'https://c.sharepoint.com/sites/A'; Wanted = $true } + ) } + Invoke-CIPPBaselineSPGuestPeoplePicker -Remediate $script:Remediate -TenantFilter $script:Tenant -Current $Current + Should -Invoke Set-CIPPSPOTenant -Times 0 -Exactly + Should -Invoke Set-CIPPSPOSiteBulk -Times 0 -Exactly + } + + It 'tolerates a per-site failure without throwing' { + Mock Set-CIPPSPOSiteBulk { @( + [PSCustomObject]@{ SiteUrl = 'https://c.sharepoint.com/sites/A'; Success = $true; Error = $null } + [PSCustomObject]@{ SiteUrl = 'https://c.sharepoint.com/sites/B'; Success = $false; Error = 'denied' } + ) } + $Current = [PSCustomObject]@{ targets = @( + [PSCustomObject]@{ Scope = 'site'; SiteUrl = 'https://c.sharepoint.com/sites/A'; Wanted = $true } + [PSCustomObject]@{ Scope = 'site'; SiteUrl = 'https://c.sharepoint.com/sites/B'; Wanted = $true } + ) } + { Invoke-CIPPBaselineSPGuestPeoplePicker -Remediate $script:Remediate -TenantFilter $script:Tenant -Current $Current } | Should -Not -Throw + } + + It 'throws when the whole site write batch fails' { + Mock Set-CIPPSPOSiteBulk { throw 'denied' } + $Current = [PSCustomObject]@{ targets = @([PSCustomObject]@{ Scope = 'site'; SiteUrl = 'https://c.sharepoint.com/sites/A'; Wanted = $true }) } + { Invoke-CIPPBaselineSPGuestPeoplePicker -Remediate $script:Remediate -TenantFilter $script:Tenant -Current $Current } | Should -Throw + } + + It 'does nothing when there are no targets' { + Mock Set-CIPPSPOSiteBulk {} + Invoke-CIPPBaselineSPGuestPeoplePicker -Remediate $script:Remediate -TenantFilter $script:Tenant -Current ([PSCustomObject]@{ targets = @() }) + Should -Invoke Set-CIPPSPOSiteBulk -Times 0 -Exactly + Should -Invoke Test-CIPPRerun -Times 0 -Exactly + } +} diff --git a/Tests/Baselines/BaselineSharePointBatch.Tests.ps1 b/Tests/Baselines/BaselineSharePointBatch.Tests.ps1 index 2ba93cd8e13c9..dacd79f4f55b0 100644 --- a/Tests/Baselines/BaselineSharePointBatch.Tests.ps1 +++ b/Tests/Baselines/BaselineSharePointBatch.Tests.ps1 @@ -15,8 +15,9 @@ BeforeAll { function New-GraphGetRequest { param($uri, $tenantid, $AsApp) } function New-GraphBulkRequest { param($tenantid, $Requests) } function Get-CIPPSPOTenant { param($TenantFilter) } - function Set-CIPPSPOTenant { [CmdletBinding()] param([Parameter(ValueFromPipeline = $true)]$InputObject, $Properties, $MethodName, $MethodParameters) process { } } + function Set-CIPPSPOTenant { [CmdletBinding()] param([Parameter(ValueFromPipeline = $true)]$InputObject, $Properties, $MethodName, $MethodParameters, [switch]$UseCertificate) process { } } function Set-CIPPSPOSite { param($TenantFilter, $SiteUrl, $Properties) } + function Set-CIPPSPOSiteBulk { [CmdletBinding()] param($TenantFilter, $Sites, $MaxConcurrency, $MaxRetries, [switch]$UseCertificate) } function Get-CIPPTextReplacement { param($TenantFilter, $Text) $Text } function Get-NormalizedError { param($Message) "$Message" } @@ -160,10 +161,18 @@ Describe 'Get-CIPPBaselineSPOVersionControlState' { Mock Get-CIPPSPOTenant { [PSCustomObject]@{ _ObjectIdentity_ = 'fresh'; TenantFilter = $script:Tenant } } Mock Set-CIPPSPOTenant { } Mock New-GraphGetRequest { @([PSCustomObject]@{ webUrl = 'https://c.sharepoint.com/sites/bad' }, [PSCustomObject]@{ webUrl = 'https://c.sharepoint.com/sites/good' }) } - Mock Set-CIPPSPOSite { if ($SiteUrl -like '*bad') { throw 'site locked' } } - Invoke-CIPPBaselineSPOVersionControl -Remediate ([PSCustomObject]@{ enableAutoTrim = $true; applyToExistingSites = $true }) -TenantFilter $script:Tenant -Current $null - Should -Invoke Set-CIPPSPOSite -Times 2 -Exactly - Should -Invoke Set-CIPPSPOSite -Times 1 -Exactly -ParameterFilter { $SiteUrl -like '*good' -and $Properties.InheritVersionPolicyFromTenant -eq $true } + # The fan-out is now one concurrent Set-CIPPSPOSiteBulk call; a per-site failure comes back + # as Success=$false in its result rather than a thrown exception, and must not abort the run. + Mock Set-CIPPSPOSiteBulk { + @(foreach ($Site in $Sites) { + [PSCustomObject]@{ SiteUrl = $Site.SiteUrl; Success = ($Site.SiteUrl -notlike '*bad'); Error = if ($Site.SiteUrl -like '*bad') { 'site locked' } else { $null } } + }) + } + { Invoke-CIPPBaselineSPOVersionControl -Remediate ([PSCustomObject]@{ enableAutoTrim = $true; applyToExistingSites = $true }) -TenantFilter $script:Tenant -Current $null } | Should -Not -Throw + Should -Invoke Set-CIPPSPOSiteBulk -Times 1 -Exactly + Should -Invoke Set-CIPPSPOSiteBulk -Times 1 -Exactly -ParameterFilter { + @($Sites).Count -eq 2 -and @($Sites | Where-Object { $_.SiteUrl -like '*good' -and $_.Properties.InheritVersionPolicyFromTenant -eq $true }).Count -eq 1 + } } } diff --git a/Tests/DBCache/Clear-CIPPDbCache.Tests.ps1 b/Tests/DBCache/Clear-CIPPDbCache.Tests.ps1 new file mode 100644 index 0000000000000..fde375b2acfa1 --- /dev/null +++ b/Tests/DBCache/Clear-CIPPDbCache.Tests.ps1 @@ -0,0 +1,208 @@ +# Pester tests for Clear-CIPPDbCache and Remove-CIPPDbItem. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + + function Get-CippTable { param($tablename) @{ TableName = $tablename } } + + function Get-FakeTableList { + param([string]$TableName) + if ([string]::IsNullOrWhiteSpace($TableName)) { $TableName = 'CippReportingDB' } + if (-not $script:FakeTables.ContainsKey($TableName)) { + $script:FakeTables[$TableName] = [System.Collections.Generic.List[object]]::new() + } + $TableName + } + + function Invoke-FakeTableFilter { + param($Rows, [string]$Filter) + $Result = @($Rows) + if ($Filter -match "PartitionKey eq '([^']*)'") { + $Pk = $Matches[1] + $Result = @($Result | Where-Object { $_.PartitionKey -eq $Pk }) + } + if ($Filter -match "RowKey eq '([^']*)'") { + $Rk = $Matches[1] + $Result = @($Result | Where-Object { $_.RowKey -eq $Rk }) + } + if ($Filter -match "RowKey ge '([^']*)'") { + $Ge = $Matches[1] + $Result = @($Result | Where-Object { $_.RowKey -ge $Ge }) + } + if ($Filter -match "RowKey lt '([^']*)'") { + $Lt = $Matches[1] + $Result = @($Result | Where-Object { $_.RowKey -lt $Lt }) + } + if ($Filter -match 'DataCount ge 0') { + $Result = @($Result | Where-Object { $null -ne $_.DataCount }) + } + $Result + } + + function ConvertTo-FakeEntity { + param($Entity) + if ($Entity -is [hashtable]) { return [pscustomobject]$Entity } + $Clone = [ordered]@{} + foreach ($Property in $Entity.PSObject.Properties) { $Clone[$Property.Name] = $Property.Value } + [pscustomobject]$Clone + } + + function Get-CIPPAzDataTableEntity { + param($TableName, $Context, $Filter, $Property, $First, [switch]$Count) + $Name = Get-FakeTableList -TableName $(if ($TableName) { $TableName } else { 'CippReportingDB' }) + $Rows = $script:FakeTables[$Name] + $Matched = @(Invoke-FakeTableFilter -Rows $Rows -Filter $Filter | ForEach-Object { ConvertTo-FakeEntity -Entity $_ }) + if ($First -and $Matched.Count -gt $First) { + $Matched = $Matched[0..($First - 1)] + } + $Matched + } + + function Add-CIPPAzDataTableEntity { + [CmdletBinding()] + param($TableName, $Context, $Entity, [switch]$Force, [switch]$CreateTableIfNotExists) + $Name = Get-FakeTableList -TableName $(if ($TableName) { $TableName } else { 'CippReportingDB' }) + $Rows = $script:FakeTables[$Name] + foreach ($Item in @($Entity)) { + if ($null -eq $Item) { continue } + $New = ConvertTo-FakeEntity -Entity $Item + $Existing = $null + for ($i = 0; $i -lt $Rows.Count; $i++) { + if ($Rows[$i].PartitionKey -eq $New.PartitionKey -and $Rows[$i].RowKey -eq $New.RowKey) { + $Existing = $Rows[$i] + break + } + } + if ($Existing) { + if (-not $Force) { continue } + [void]$Rows.Remove($Existing) + } + [void]$Rows.Add($New) + } + } + + function Remove-CIPPAzDataTableEntity { + param($TableName, $Context, $Entity, [switch]$Force) + $Name = Get-FakeTableList -TableName $(if ($TableName) { $TableName } else { 'CippReportingDB' }) + $Rows = $script:FakeTables[$Name] + foreach ($Item in @($Entity)) { + if ($null -eq $Item) { continue } + $Existing = $null + for ($i = 0; $i -lt $Rows.Count; $i++) { + if ($Rows[$i].PartitionKey -eq $Item.PartitionKey -and $Rows[$i].RowKey -eq $Item.RowKey) { + $Existing = $Rows[$i] + break + } + } + if ($Existing) { [void]$Rows.Remove($Existing) } + } + } + + function Get-AzDataTableEntity { + param($TableName, $Context, $Filter, $Property, $First, [switch]$Count) + Get-CIPPAzDataTableEntity @PSBoundParameters + } + + function Remove-AzDataTableEntity { + param($TableName, $Context, $Entity, [switch]$Force) + Remove-CIPPAzDataTableEntity @PSBoundParameters + } + + function Write-LogMessage { param($headers, $API, $tenant, $message, $sev, $LogData) } + function Get-CippException { param($Exception) [pscustomobject]@{ NormalizedError = "$Exception" } } + function Get-Tenants { + param($TenantFilter, [switch]$IncludeErrors) + switch -Regex ($TenantFilter) { + '^(contoso\.com|contoso\.onmicrosoft\.com)$' { + return [pscustomobject]@{ customerId = 'tenant-guid-1'; defaultDomainName = 'contoso.com' } + } + '^(fabrikam\.com)$' { + return [pscustomobject]@{ customerId = 'tenant-guid-2'; defaultDomainName = 'fabrikam.com' } + } + default { return $null } + } + } + + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Get-CIPPDbItem.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Remove-CIPPDbItem.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Clear-CIPPDbCache.ps1') +} + +Describe 'Remove-CIPPDbItem' { + BeforeEach { + $script:FakeTables = @{} + Add-CIPPAzDataTableEntity -TableName 'CippReportingDB' -Entity @( + @{ PartitionKey = 'contoso.com'; RowKey = 'Users-user-1'; Data = '{"id":"user-1"}'; Type = 'Users'; ETag = 'etag-1' } + @{ PartitionKey = 'contoso.com'; RowKey = 'Users-Count'; DataCount = 2; ETag = 'etag-c' } + ) -Force + } + + It 'removes by RowKey and decrements DataCount' { + Remove-CIPPDbItem -TenantFilter 'contoso.com' -Type 'Users' -RowKey 'Users-user-1' -ETag 'etag-1' + + $Remaining = @($script:FakeTables['CippReportingDB']) + ($Remaining | Where-Object { $_.RowKey -eq 'Users-user-1' }).Count | Should -Be 0 + ($Remaining | Where-Object { $_.RowKey -eq 'Users-Count' }).DataCount | Should -Be 1 + } + + It 'removes by ItemId and decrements DataCount' { + Remove-CIPPDbItem -TenantFilter 'contoso.com' -Type 'Users' -ItemId 'user-1' + + $Remaining = @($script:FakeTables['CippReportingDB']) + ($Remaining | Where-Object { $_.RowKey -eq 'Users-user-1' }).Count | Should -Be 0 + ($Remaining | Where-Object { $_.RowKey -eq 'Users-Count' }).DataCount | Should -Be 1 + } + + It 'rejects RowKey that does not match Type' { + { Remove-CIPPDbItem -TenantFilter 'contoso.com' -Type 'Users' -RowKey 'Groups-g1' } | Should -Throw '*does not match type*' + } +} + +Describe 'Clear-CIPPDbCache' { + BeforeEach { + $script:FakeTables = @{} + Add-CIPPAzDataTableEntity -TableName 'CippReportingDB' -Entity @( + @{ PartitionKey = 'contoso.com'; RowKey = 'Users-user-1'; Data = '{"id":"user-1"}'; Type = 'Users' } + @{ PartitionKey = 'contoso.com'; RowKey = 'Users-user-2'; Data = '{"id":"user-2"}'; Type = 'Users' } + @{ PartitionKey = 'contoso.com'; RowKey = 'Users-Count'; DataCount = 2 } + @{ PartitionKey = 'contoso.com'; RowKey = 'Groups-g1'; Data = '{"id":"g1"}'; Type = 'Groups' } + @{ PartitionKey = 'contoso.com'; RowKey = 'Groups-Count'; DataCount = 1 } + @{ PartitionKey = 'fabrikam.com'; RowKey = 'Users-user-a'; Data = '{"id":"user-a"}'; Type = 'Users' } + @{ PartitionKey = 'fabrikam.com'; RowKey = 'Users-Count'; DataCount = 1 } + ) -Force + } + + It 'empties one tenant type and resets Count to 0' { + $Result = Clear-CIPPDbCache -TenantFilter 'contoso.com' -Type 'Users' + + # Data rows only (Count is upserted to 0, not counted as a delete). + $Result.RemovedCount | Should -Be 2 + $Result.Tenant | Should -Be 'contoso.com' + $Result.Type | Should -Be 'Users' + + $Remaining = @($script:FakeTables['CippReportingDB']) + ($Remaining | Where-Object { $_.PartitionKey -eq 'contoso.com' -and $_.RowKey -like 'Users-user*' }).Count | Should -Be 0 + ($Remaining | Where-Object { $_.PartitionKey -eq 'contoso.com' -and $_.RowKey -eq 'Users-Count' }).DataCount | Should -Be 0 + ($Remaining | Where-Object { $_.RowKey -eq 'Groups-g1' }).Count | Should -Be 1 + ($Remaining | Where-Object { $_.PartitionKey -eq 'fabrikam.com' -and $_.RowKey -eq 'Users-user-a' }).Count | Should -Be 1 + } + + It 'empties a type across AllTenants partitions' { + $Result = Clear-CIPPDbCache -TenantFilter 'AllTenants' -Type 'Users' + + $Result.RemovedCount | Should -Be 3 + $Result.Tenant | Should -Be 'AllTenants' + + $Remaining = @($script:FakeTables['CippReportingDB']) + ($Remaining | Where-Object { $_.RowKey -like 'Users-user*' }).Count | Should -Be 0 + ($Remaining | Where-Object { $_.RowKey -eq 'Users-Count' -and $_.DataCount -eq 0 }).Count | Should -Be 2 + ($Remaining | Where-Object { $_.RowKey -eq 'Groups-g1' }).Count | Should -Be 1 + } + + It 'returns RemovedCount 0 when nothing matches' { + $Result = Clear-CIPPDbCache -TenantFilter 'contoso.com' -Type 'Devices' + $Result.RemovedCount | Should -Be 0 + $Remaining = @($script:FakeTables['CippReportingDB']) + ($Remaining | Where-Object { $_.RowKey -eq 'Devices-Count' }).DataCount | Should -Be 0 + } +} diff --git a/Tests/DBCache/OneDriveLongPaths.Tests.ps1 b/Tests/DBCache/OneDriveLongPaths.Tests.ps1 new file mode 100644 index 0000000000000..73b7e5ba5a2cf --- /dev/null +++ b/Tests/DBCache/OneDriveLongPaths.Tests.ps1 @@ -0,0 +1,328 @@ +# Pester tests for OneDriveLongPaths fan-out, skip-no-UPN, and checkpoint resume. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + + function Get-CippTable { param($tablename) @{ TableName = $tablename } } + + function Get-FakeTableRows { + param([string]$TableName) + if (-not $script:FakeTables.ContainsKey($TableName)) { + $script:FakeTables[$TableName] = [System.Collections.Generic.List[object]]::new() + } + , $script:FakeTables[$TableName] + } + + function Invoke-FakeTableFilter { + param($Rows, [string]$Filter) + $Result = @($Rows) + if ($Filter -match "PartitionKey eq '([^']*)'") { + $Pk = $Matches[1] + $Result = @($Result | Where-Object { $_.PartitionKey -eq $Pk }) + } + if ($Filter -match "RowKey eq '([^']*)'") { + $Rk = $Matches[1] + $Result = @($Result | Where-Object { $_.RowKey -eq $Rk }) + } + $Result + } + + function ConvertTo-FakeEntity { + param($Entity) + if ($Entity -is [hashtable]) { return [pscustomobject]$Entity } + $Clone = [ordered]@{} + foreach ($Property in $Entity.PSObject.Properties) { $Clone[$Property.Name] = $Property.Value } + [pscustomobject]$Clone + } + + function Get-CIPPAzDataTableEntity { + param($TableName, $Filter, $Property, [switch]$Count) + $Rows = Get-FakeTableRows -TableName $TableName + foreach ($Row in (Invoke-FakeTableFilter -Rows $Rows -Filter $Filter)) { + ConvertTo-FakeEntity -Entity $Row + } + } + + function Add-CIPPAzDataTableEntity { + [CmdletBinding()] + param($TableName, $Entity, [switch]$Force, [switch]$CreateTableIfNotExists) + $Rows = Get-FakeTableRows -TableName $TableName + foreach ($Item in @($Entity)) { + if ($null -eq $Item) { continue } + $New = ConvertTo-FakeEntity -Entity $Item + $Existing = $Rows | Where-Object { $_.PartitionKey -eq $New.PartitionKey -and $_.RowKey -eq $New.RowKey } | Select-Object -First 1 + if ($Existing) { + if (-not $Force) { continue } + [void]$Rows.Remove($Existing) + } + $Rows.Add($New) + } + } + + function Remove-CIPPAzDataTableEntity { + param($TableName, $Entity, [switch]$Force) + $Rows = Get-FakeTableRows -TableName $TableName + foreach ($Item in @($Entity)) { + if ($null -eq $Item) { continue } + $Existing = $Rows | Where-Object { $_.PartitionKey -eq $Item.PartitionKey -and $_.RowKey -eq $Item.RowKey } | Select-Object -First 1 + if ($Existing) { [void]$Rows.Remove($Existing) } + } + } + + function Write-LogMessage { param($headers, $API, $tenant, $message, $sev, $LogData) } + function Get-CippException { param($Exception) [pscustomobject]@{ NormalizedError = "$Exception" } } + function Get-Tenants { param($TenantFilter, [switch]$IncludeErrors) [pscustomobject]@{ customerId = 'tenant-guid'; defaultDomainName = 'contoso.com' } } + function ConvertTo-CIPPODataFilterValue { param($Value, $Type) [string]$Value } + function Update-CippQueueEntry { param($RowKey, $Status, $Name, $TotalTasks, [switch]$IncrementTotalTasks) } + function Start-CIPPOrchestrator { + param($InputObject, $InputObjectGuid, [switch]$CallerIsQueueTrigger) + $script:Orchestrations.Add($InputObject) + } + function Get-CIPPDbItem { param($TenantFilter, $Type, [switch]$CountsOnly) @() } + function New-GraphGetRequest { + param($uri, $tenantid, $scope, $AsApp, [bool]$noPagination, $NoAuthCheck, [bool]$skipTokenCache, $Caller, [switch]$ComplexFilter, [switch]$CountOnly, [switch]$IncludeResponseHeaders, [hashtable]$extraHeaders, [switch]$ReturnRawResponse, [switch]$SkipValueExtraction, [switch]$Stream, [switch]$UseCertificate, $Headers) + & $script:GraphGetHandler $uri $SkipValueExtraction + } + + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Add-CIPPDbItem.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Get-CIPPDriveItemCloudPathLength.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOneDriveLongPaths.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/OneDrive Long Paths/Push-DBCacheOneDriveLongPaths.ps1') +} + +Describe 'Set-CIPPDBCacheOneDriveLongPaths' { + BeforeEach { + $script:FakeTables = @{} + $script:Orchestrations = [System.Collections.Generic.List[object]]::new() + $env:CIPP_ONEDRIVE_LONGPATHS_TIMEBOX_SECONDS = '99999' + } + + It 'fans out personal sites and passes UPN when usage map has it' { + $script:GraphGetHandler = { + param($Uri, $SkipValueExtraction) + if ($Uri -match '/organization') { + return @([pscustomobject]@{ displayName = 'Contoso' }) + } + if ($Uri -match 'getAllSites') { + return @( + [pscustomobject]@{ + id = 'contoso-my.sharepoint.com,aaaa,bbbb' + webUrl = 'https://contoso-my.sharepoint.com/personal/known_contoso_com' + sharepointIds = [pscustomobject]@{ siteId = 'site-known' } + } + [pscustomobject]@{ + id = 'contoso-my.sharepoint.com,cccc,dddd' + webUrl = 'https://contoso-my.sharepoint.com/personal/unknown_contoso_com' + sharepointIds = [pscustomobject]@{ siteId = 'site-unknown' } + } + ) + } + if ($Uri -match 'getOneDriveUsageAccountDetail') { + return @([pscustomobject]@{ siteId = 'site-known'; ownerPrincipalName = 'known@contoso.com' }) + } + @() + } + + Set-CIPPDBCacheOneDriveLongPaths -TenantFilter 'contoso.com' + + $script:Orchestrations.Count | Should -Be 1 + $Batch = @($script:Orchestrations[0].Batch) + $Batch.Count | Should -Be 2 + ($Batch | Where-Object { $_.OwnerPrincipalName -eq 'known@contoso.com' }).Count | Should -Be 1 + ($Batch | Where-Object { $_.SiteId -eq 'contoso-my.sharepoint.com,cccc,dddd' -and [string]::IsNullOrWhiteSpace($_.OwnerPrincipalName) }).Count | Should -Be 1 + $Batch[0].InferredLocalRootFixedLength | Should -Be (('C:\Users\').Length + ('\OneDrive - Contoso\').Length) + ((Get-FakeTableRows -TableName 'CippOneDriveLongPathsState') | Where-Object { $_.RowKey -eq 'scan' }).Count | Should -Be 1 + } +} + +Describe 'Push-DBCacheOneDriveLongPaths' { + BeforeEach { + $script:FakeTables = @{} + $script:Orchestrations = [System.Collections.Generic.List[object]]::new() + $env:CIPP_ONEDRIVE_LONGPATHS_TIMEBOX_SECONDS = '99999' + Add-CIPPAzDataTableEntity -TableName 'CippOneDriveLongPathsState' -Entity @{ + PartitionKey = 'contoso.com' + RowKey = 'scan' + ScanId = 'scan-1' + } -Force + } + + It 'writes allowlisted counts and resumes from checkpoint with running totals' { + $LongFolder = ('F' * 200) + $LongName = ('N' * 80) + '.docx' + # Cloud length ~281; with local root for known@contoso / Contoso this exceeds 260. + + $script:GraphGetHandler = { + param($Uri, $SkipValueExtraction) + if ($Uri -match '/sites/.+/drive\?') { + return [pscustomobject]@{ + id = 'b!drive1' + name = 'Documents' + driveType = 'documentLibrary' + owner = [pscustomobject]@{ user = [pscustomobject]@{ userPrincipalName = 'known@contoso.com' } } + } + } + if ($Uri -match '/root/delta' -and $Uri -notmatch 'token=page2') { + $Page = [pscustomobject]@{ + value = @( + [pscustomobject]@{ + id = 'item1' + name = $LongName + folder = $null + file = [pscustomobject]@{} + parentReference = [pscustomobject]@{ path = "/drives/b!drive1/root:/$LongFolder" } + } + ) + '@odata.nextLink' = 'https://graph.microsoft.com/beta/drives/b!drive1/root/delta?token=page2' + } + if ($SkipValueExtraction) { return $Page } + return $Page.value + } + if ($Uri -match 'token=page2') { + $Page = [pscustomobject]@{ + value = @( + [pscustomobject]@{ + id = 'item2' + name = 'short.txt' + folder = $null + file = [pscustomobject]@{} + parentReference = [pscustomobject]@{ path = '/drives/b!drive1/root:' } + } + ) + '@odata.deltaLink' = 'https://graph.microsoft.com/beta/drives/b!drive1/root/delta?token=done' + } + if ($SkipValueExtraction) { return $Page } + return $Page.value + } + @() + } + + $Item = [pscustomobject]@{ + FunctionName = 'DBCacheOneDriveLongPaths' + TenantFilter = 'contoso.com' + SiteId = 'contoso-my.sharepoint.com,aaaa,bbbb' + OwnerPrincipalName = 'known@contoso.com' + OrgDisplayName = 'Contoso' + InferredLocalRootFixedLength = ('C:\Users\').Length + ('\OneDrive - Contoso\').Length + ScanId = 'scan-1' + } + + # Force timebox after first page so resume carries counts. + $env:CIPP_ONEDRIVE_LONGPATHS_TIMEBOX_SECONDS = '0' + Push-DBCacheOneDriveLongPaths -Item $Item + $script:Orchestrations.Count | Should -Be 1 + + $Chk = (Get-FakeTableRows -TableName 'CippOneDriveLongPathsState') | Where-Object { $_.RowKey -like 'chk-*' } | Select-Object -First 1 + $Chk | Should -Not -BeNullOrEmpty + $Chk.StateJson | Should -Not -BeNullOrEmpty + $State = $Chk.StateJson | ConvertFrom-Json + $State.CountOver260 | Should -BeGreaterThan 0 + $State.PSObject.Properties.Name | Should -Not -Contain 'path' + $Carried260 = [int]$State.CountOver260 + + $env:CIPP_ONEDRIVE_LONGPATHS_TIMEBOX_SECONDS = '99999' + $Resume = $script:Orchestrations[0].Batch[0] + Push-DBCacheOneDriveLongPaths -Item $Resume + + $CacheRows = Get-FakeTableRows -TableName 'CippReportingDB' + $CacheRows.Count | Should -Be 1 + $Data = $CacheRows[0].Data | ConvertFrom-Json + $Data.ownerPrincipalName | Should -Be 'known@contoso.com' + $Data.countOver260 | Should -Be $Carried260 + $Data.countOver400 | Should -Be 0 + @($Data.PSObject.Properties.Name | Sort-Object) | Should -Be @('countOver260', 'countOver400', 'id', 'ownerPrincipalName') + $Data.PSObject.Properties.Name | Should -Not -Contain 'webUrl' + $Data.PSObject.Properties.Name | Should -Not -Contain 'name' + } + + It 'resolves owner from drive when UPN was not passed' { + Add-CIPPAzDataTableEntity -TableName 'CippOneDriveLongPathsState' -Entity @{ + PartitionKey = 'contoso.com' + RowKey = 'scan' + ScanId = 'scan-owner' + } -Force + $script:GraphGetHandler = { + param($Uri, $SkipValueExtraction) + if ($Uri -match '/sites/.+/drive\?') { + return [pscustomobject]@{ + id = 'b!drive1' + name = 'Documents' + owner = [pscustomobject]@{ user = [pscustomobject]@{ userPrincipalName = 'fromdrive@contoso.com' } } + } + } + if ($Uri -match '/root/delta') { + $Page = [pscustomobject]@{ + value = @() + '@odata.deltaLink' = 'https://graph.microsoft.com/beta/drives/b!drive1/root/delta?token=done' + } + if ($SkipValueExtraction) { return $Page } + return @() + } + @() + } + + Push-DBCacheOneDriveLongPaths -Item ([pscustomobject]@{ + TenantFilter = 'contoso.com' + SiteId = 'site1' + OwnerPrincipalName = '' + OrgDisplayName = 'Contoso' + InferredLocalRootFixedLength = ('C:\Users\').Length + ('\OneDrive - Contoso\').Length + ScanId = 'scan-owner' + }) + + $Data = (Get-FakeTableRows -TableName 'CippReportingDB')[0].Data | ConvertFrom-Json + $Data.ownerPrincipalName | Should -Be 'fromdrive@contoso.com' + } + + It 'does not $select webUrl on delta' { + Add-CIPPAzDataTableEntity -TableName 'CippOneDriveLongPathsState' -Entity @{ + PartitionKey = 'contoso.com' + RowKey = 'scan' + ScanId = 'scan-2' + } -Force + $script:SeenDelta = $null + $script:GraphGetHandler = { + param($Uri, $SkipValueExtraction) + if ($Uri -match '/sites/.+/drive\?') { + return [pscustomobject]@{ + id = 'b!drive1' + name = 'Documents' + owner = [pscustomobject]@{ user = [pscustomobject]@{ userPrincipalName = 'u@contoso.com' } } + } + } + if ($Uri -match '/root/delta') { + $script:SeenDelta = $Uri + $Page = [pscustomobject]@{ + value = @() + '@odata.deltaLink' = 'https://graph.microsoft.com/beta/drives/b!drive1/root/delta?token=done' + } + if ($SkipValueExtraction) { return $Page } + return @() + } + @() + } + + Push-DBCacheOneDriveLongPaths -Item ([pscustomobject]@{ + TenantFilter = 'contoso.com' + SiteId = 'site1' + OwnerPrincipalName = 'u@contoso.com' + OrgDisplayName = 'Contoso' + InferredLocalRootFixedLength = ('C:\Users\').Length + ('\OneDrive - Contoso\').Length + ScanId = 'scan-2' + }) + + $script:SeenDelta | Should -Match 'parentReference' + $script:SeenDelta | Should -Not -Match 'webUrl' + } + + It 'no-ops when ScanId is superseded' { + $script:GraphGetHandler = { param($Uri, $SkipValueExtraction) throw 'should not call graph' } + Push-DBCacheOneDriveLongPaths -Item ([pscustomobject]@{ + TenantFilter = 'contoso.com' + SiteId = 'site1' + OwnerPrincipalName = 'u@contoso.com' + ScanId = 'old-scan' + }) + (Get-FakeTableRows -TableName 'CippReportingDB').Count | Should -Be 0 + } +} diff --git a/Tests/DBCache/Push-GetCalendarPermissionsBatch.Tests.ps1 b/Tests/DBCache/Push-GetCalendarPermissionsBatch.Tests.ps1 new file mode 100644 index 0000000000000..c11ce40b02d6a --- /dev/null +++ b/Tests/DBCache/Push-GetCalendarPermissionsBatch.Tests.ps1 @@ -0,0 +1,134 @@ +# Pester tests for Push-GetCalendarPermissionsBatch +# +# Phase 1 caches each mailbox's calendar folder name forever; Phase 2 reads permissions from +# it. Get-MailboxFolderStatistics returns EVERY calendar folder flattened under one +# OperationGuid, so picking the wrong row is silent and permanent - it cached holiday +# calendars for over half a tenant, and those mailboxes then cached no permissions at all. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Push-GetCalendarPermissionsBatch.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Push-GetCalendarPermissionsBatch.ps1 under Modules/' } + + # Minimal stubs so Mock has commands to replace during tests. + function Write-LogMessage { param($API, $tenant, $message, $sev, $LogData) } + function Get-CippTable { param($tablename) } + function Get-CIPPAzDataTableEntity { param($Context, $Filter, $Property) } + function Add-CIPPAzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function New-ExoBulkRequest { param($tenantid, $cmdletArray, $useSystemMailbox, $Anchor, $NoAuthCheck, $Select, $ReturnWithCommand) } + + . $FunctionPath + + function New-WorkItem { + param($Mailboxes = @('user1@contoso.com')) + [PSCustomObject]@{ + TenantFilter = 'contoso.onmicrosoft.com' + Mailboxes = $Mailboxes + BatchNumber = 1 + TotalBatches = 1 + } + } + + function New-FolderStat { + param($UPN, $Name, $FolderType) + [PSCustomObject]@{ Name = $Name; FolderType = $FolderType; OperationGuid = $UPN } + } +} + +Describe 'Push-GetCalendarPermissionsBatch' { + BeforeEach { + $script:CacheEntries = @() + $script:FolderStats = @() + $script:PermResults = @() + $script:Written = [System.Collections.Generic.List[object]]::new() + $script:ExoCalls = [System.Collections.Generic.List[object]]::new() + + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Get-CippTable -MockWith { @{ Context = 'CalendarFolderCache' } } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { $script:CacheEntries } + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { foreach ($e in @($Entity)) { $script:Written.Add($e) } } + Mock -CommandName New-ExoBulkRequest -MockWith { + $Name = @($cmdletArray)[0].CmdletInput.CmdletName + $script:ExoCalls.Add([PSCustomObject]@{ Cmdlet = $Name; Select = $Select; Array = @($cmdletArray) }) + if ($Name -eq 'Get-MailboxFolderStatistics') { $script:FolderStats } else { $script:PermResults } + } + } + + It 'caches the root calendar even when a subfolder is the last folder returned' { + # The exact ordering that produced the bug: the root arrives first and a localised + # holiday calendar arrives last, so last-wins cached the holiday calendar. + $script:FolderStats = @( + New-FolderStat -UPN 'user1@contoso.com' -Name 'Calendar' -FolderType 'Calendar' + New-FolderStat -UPN 'user1@contoso.com' -Name 'Helligdage i Danmark' -FolderType 'User Created' + New-FolderStat -UPN 'user1@contoso.com' -Name 'Birthdays' -FolderType 'Birthday' + ) + + Push-GetCalendarPermissionsBatch -Item (New-WorkItem) + + $script:Written.Count | Should -Be 1 + $script:Written[0].FolderName | Should -Be 'Calendar' + $script:Written[0].FolderType | Should -Be 'Calendar' + $script:Written[0].RowKey | Should -Be 'user1@contoso.com' + + # ...and Phase 2 must then ask for that folder, not the holiday calendar. + $Phase2 = @($script:ExoCalls | Where-Object { $_.Cmdlet -eq 'Get-MailboxFolderPermission' }) + $Phase2.Count | Should -Be 1 + $Phase2[0].Array[0].CmdletInput.Parameters.Identity | Should -Be 'user1@contoso.com:\Calendar' + } + + It 'skips a mailbox with no root calendar rather than caching a guess' { + $script:FolderStats = @( + New-FolderStat -UPN 'user1@contoso.com' -Name 'United States holidays' -FolderType 'User Created' + ) + + Push-GetCalendarPermissionsBatch -Item (New-WorkItem) + + # Nothing cached, and no permission request built - a wrong name here would stick forever. + $script:Written.Count | Should -Be 0 + Should -Invoke Add-CIPPAzDataTableEntity -Times 0 -Exactly + @($script:ExoCalls | Where-Object { $_.Cmdlet -eq 'Get-MailboxFolderPermission' }).Count | Should -Be 0 + } + + It 'treats a cache entry with no FolderType as a miss so a poisoned cache self-heals' { + # Rows written before the fix carry a folder name but no FolderType, and the name alone + # cannot say whether it is the root or a subfolder. + $script:CacheEntries = @( + [PSCustomObject]@{ PartitionKey = 'contoso.onmicrosoft.com'; RowKey = 'user1@contoso.com'; FolderName = 'Helligdage i Danmark' } + ) + $script:FolderStats = @( + New-FolderStat -UPN 'user1@contoso.com' -Name 'Calendar' -FolderType 'Calendar' + ) + + Push-GetCalendarPermissionsBatch -Item (New-WorkItem) + + @($script:ExoCalls | Where-Object { $_.Cmdlet -eq 'Get-MailboxFolderStatistics' }).Count | Should -Be 1 + $script:Written[0].FolderName | Should -Be 'Calendar' + } + + It 'trusts a cache entry stamped as a root calendar and skips discovery' { + $script:CacheEntries = @( + [PSCustomObject]@{ PartitionKey = 'contoso.onmicrosoft.com'; RowKey = 'user1@contoso.com'; FolderName = 'Kalender'; FolderType = 'Calendar' } + ) + + Push-GetCalendarPermissionsBatch -Item (New-WorkItem) + + @($script:ExoCalls | Where-Object { $_.Cmdlet -eq 'Get-MailboxFolderStatistics' }).Count | Should -Be 0 + $Phase2 = @($script:ExoCalls | Where-Object { $_.Cmdlet -eq 'Get-MailboxFolderPermission' }) + $Phase2[0].Array[0].CmdletInput.Parameters.Identity | Should -Be 'user1@contoso.com:\Kalender' + } + + It 'returns the permissions it read under the Get-MailboxFolderPermission key' { + $script:CacheEntries = @( + [PSCustomObject]@{ PartitionKey = 'contoso.onmicrosoft.com'; RowKey = 'user1@contoso.com'; FolderName = 'Calendar'; FolderType = 'Calendar' } + ) + $script:PermResults = @( + [PSCustomObject]@{ Identity = 'user1@contoso.com:\Calendar'; User = 'Default'; AccessRights = @('Reviewer'); FolderName = 'Calendar'; OperationGuid = 'user1@contoso.com' } + ) + + $Result = Push-GetCalendarPermissionsBatch -Item (New-WorkItem) + + @($Result['Get-MailboxFolderPermission']).Count | Should -Be 1 + @($Result['Get-MailboxFolderPermission'])[0].User | Should -Be 'Default' + } +} diff --git a/Tests/DBCache/Set-CIPPDBCache.Memory.Tests.ps1 b/Tests/DBCache/Set-CIPPDBCache.Memory.Tests.ps1 index 5dce87099fa6f..dd1758ac09323 100644 --- a/Tests/DBCache/Set-CIPPDBCache.Memory.Tests.ps1 +++ b/Tests/DBCache/Set-CIPPDBCache.Memory.Tests.ps1 @@ -30,6 +30,9 @@ BeforeAll { function New-ExoRequest { param($cmdlet, $cmdParams, $Select, $Anchor, $useSystemMailbox, $tenantid, $NoAuthCheck, [switch]$Compliance, $ApiVersion, $ModuleVersion, [switch]$AsApp, [switch]$UseCertificate) } function Test-CIPPStandardLicense { param($StandardName, $TenantFilter, $Preset, [switch]$SkipLog) } function Get-Tenants { param($TenantFilter, [switch]$IncludeErrors) } + function Get-CIPPSPOSite { param($TenantFilter, $SiteUrl) @() } + function Get-SharePointAdminLink { param($Public, $tenantFilter) [PSCustomObject]@{ AdminUrl = 'https://contoso-admin.sharepoint.com'; SharePointUrl = 'https://contoso.sharepoint.com' } } + function Get-CIPPSPOAdminListData { param($TenantFilter, $AdminUrl, $Type) @() } function Update-CippQueueEntry { param($RowKey, $TotalTasks, [switch]$IncrementTotalTasks) } function Start-CIPPOrchestrator { param($InputObject, $InputObjectGuid, [switch]$CallerIsQueueTrigger) } function Get-ExoOnlineStringBytes { param($SizeString) } @@ -80,6 +83,8 @@ BeforeAll { # Real helper, not a stub: it is pure logic with no external calls, and the mailbox collector's # AutoExpandingArchive/AutoExpandingArchiveScope columns are part of the row shape under test. . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Get-CIPPAutoExpandingArchiveState.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/ConvertTo-SPOUsageRootWebTemplate.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Get-CIPPSharePointSiteUsageRows.ps1') . (Get-CollectorPath 'Set-CIPPDBCacheGroups') . (Get-CollectorPath 'Set-CIPPDBCacheTeams') @@ -119,9 +124,11 @@ Describe 'DBCache collectors reworked for bounded memory' { Set-CIPPDBCacheGroups -TenantFilter 'contoso.com' + Should -Invoke New-GraphGetRequest -Times 1 -Exactly -ParameterFilter { $Stream.IsPresent } $script:DbWrites.Count | Should -Be 1 $Rows = $script:DbWrites[0].Rows $Rows.Count | Should -Be 2 + $script:DbWrites[0].EndRan | Should -BeTrue # Members are matched by id, not by position - the bulk response above is deliberately # in a different order than the group list. ($Rows | Where-Object id -EQ 'g1').members.id | Should -Be @('u1a', 'u1b') @@ -130,23 +137,46 @@ Describe 'DBCache collectors reworked for bounded memory' { It 'emits the computed properties in the documented order' { Mock -CommandName New-GraphGetRequest -MockWith { - @([pscustomobject]@{ id = 'g1'; displayName = 'One'; mail = 'one@contoso.com'; groupTypes = @('Unified'); mailEnabled = $true; securityEnabled = $false; resourceProvisioningOptions = @('Team') }) + @([pscustomobject]@{ id = 'g1'; displayName = 'One'; mail = 'one@contoso.com'; groupTypes = @('Unified'); mailEnabled = $true; securityEnabled = $false; resourceProvisioningOptions = @('Team'); owners = @([pscustomobject]@{ id = 'o1'; userPrincipalName = 'owner1@contoso.com' }) }) } Mock -CommandName New-GraphBulkRequest -MockWith { - @([pscustomobject]@{ id = 'g1'; body = [pscustomobject]@{ value = @() } }) + @([pscustomobject]@{ id = 'g1'; body = [pscustomobject]@{ value = @([pscustomobject]@{ id = 'u1'; userPrincipalName = 'user1@contoso.com' }) } }) } Set-CIPPDBCacheGroups -TenantFilter 'contoso.com' + Should -Invoke New-GraphGetRequest -Times 1 -Exactly -ParameterFilter { $Stream.IsPresent } $Row = $script:DbWrites[0].Rows[0] - $Added = @($Row.PSObject.Properties.Name) | Select-Object -Last 6 - $Added | Should -Be @('members', 'primDomain', 'teamsEnabled', 'dynamicGroupBool', 'groupType', 'calculatedGroupType') + # membersCsv/ownersCsv are precomputed so the paged list read can stream the blob + # verbatim; both sit next to their source arrays in the emitted order. hasOwner is + # set unconditionally right after, so the AsRawJson paged read has a stable owner + # flag even for a genuinely owner-less group (where ownersCsv itself never gets set). + $Added = @($Row.PSObject.Properties.Name) | Select-Object -Last 9 + $Added | Should -Be @('members', 'membersCsv', 'ownersCsv', 'hasOwner', 'primDomain', 'teamsEnabled', 'dynamicGroupBool', 'groupType', 'calculatedGroupType') + $Row.membersCsv | Should -Be 'user1@contoso.com' + $Row.ownersCsv | Should -Be 'owner1@contoso.com' + $Row.hasOwner | Should -BeTrue $Row.groupType | Should -Be 'Microsoft 365' $Row.calculatedGroupType | Should -Be 'm365' $Row.primDomain | Should -Be 'contoso.com' $Row.teamsEnabled | Should -BeTrue } + It 'sets hasOwner to false for a genuinely owner-less group' { + Mock -CommandName New-GraphGetRequest -MockWith { + @([pscustomobject]@{ id = 'g1'; displayName = 'One'; mail = 'one@contoso.com'; groupTypes = @('Unified'); mailEnabled = $true; securityEnabled = $false; resourceProvisioningOptions = @('Team'); owners = @() }) + } + Mock -CommandName New-GraphBulkRequest -MockWith { + @([pscustomobject]@{ id = 'g1'; body = [pscustomobject]@{ value = @() } }) + } + + Set-CIPPDBCacheGroups -TenantFilter 'contoso.com' + + $Row = $script:DbWrites[0].Rows[0] + $Row.hasOwner | Should -BeFalse + $Row.PSObject.Properties.Name | Should -Not -Contain 'ownersCsv' + } + It 'omits the members property entirely when no member lookup ran' { # No group carries an id, so no member requests are built - the pre-existing no-members shape. Mock -CommandName New-GraphGetRequest -MockWith { @@ -156,6 +186,7 @@ Describe 'DBCache collectors reworked for bounded memory' { Set-CIPPDBCacheGroups -TenantFilter 'contoso.com' + Should -Invoke New-GraphGetRequest -Times 1 -Exactly -ParameterFilter { $Stream.IsPresent } $Row = $script:DbWrites[0].Rows[0] $Row.PSObject.Properties.Name | Should -Not -Contain 'members' $Row.groupType | Should -Be 'Mail-Enabled Security' @@ -169,8 +200,74 @@ Describe 'DBCache collectors reworked for bounded memory' { Set-CIPPDBCacheGroups -TenantFilter 'contoso.com' + Should -Invoke New-GraphGetRequest -Times 1 -Exactly -ParameterFilter { $Stream.IsPresent } $script:DbWrites[0].Rows[0].PSObject.Properties.Name | Should -Contain 'members' } + + It 'fetches members in batches of 50 and uses a single writer end block' { + $Groups = 1..105 | ForEach-Object { + [pscustomobject]@{ + id = ('g{0:D3}' -f $_) + displayName = "Group $_" + mail = "g$_@contoso.com" + groupTypes = @() + mailEnabled = $true + securityEnabled = $true + resourceProvisioningOptions = @() + } + } + Mock -CommandName New-GraphGetRequest -MockWith { $Groups } + $script:BulkCallCount = 0 + Mock -CommandName New-GraphBulkRequest -MockWith { + $script:BulkCallCount++ + foreach ($Request in $Requests) { + [pscustomobject]@{ id = $Request.id; body = [pscustomobject]@{ value = @() } } + } + } + + Set-CIPPDBCacheGroups -TenantFilter 'contoso.com' + + Should -Invoke New-GraphGetRequest -Times 1 -Exactly -ParameterFilter { $Stream.IsPresent } + $script:BulkCallCount | Should -Be 3 + $script:DbWrites.Count | Should -Be 1 + $script:DbWrites[0].Rows.Count | Should -Be 105 + $script:DbWrites[0].EndRan | Should -BeTrue + } + + It 'writes nothing when the stream is empty, preserving the previous cache' { + Mock -CommandName New-GraphGetRequest -MockWith { @() } + Mock -CommandName New-GraphBulkRequest -MockWith { throw 'should not be called' } + + Set-CIPPDBCacheGroups -TenantFilter 'contoso.com' + + Should -Invoke New-GraphGetRequest -Times 1 -Exactly -ParameterFilter { $Stream.IsPresent } + Should -Invoke New-GraphBulkRequest -Times 0 -Exactly + $script:DbWrites.Count | Should -Be 1 + $script:DbWrites[0].Rows.Count | Should -Be 0 + $script:DbWrites[0].EndRan | Should -BeFalse + } + + It 'skips member lookup for dynamic groups and omits the members property' { + Mock -CommandName New-GraphGetRequest -MockWith { + @( + [pscustomobject]@{ id = 'g-static'; displayName = 'Static'; mail = 'static@contoso.com'; groupTypes = @(); mailEnabled = $true; securityEnabled = $true; resourceProvisioningOptions = @() } + [pscustomobject]@{ id = 'g-dynamic'; displayName = 'Dynamic'; mail = 'dynamic@contoso.com'; groupTypes = @('DynamicMembership'); mailEnabled = $false; securityEnabled = $true; resourceProvisioningOptions = @(); membershipRule = '(user.department -eq "Sales")' } + ) + } + Mock -CommandName New-GraphBulkRequest -MockWith { + param($Requests) + $Requests.id | Should -Be @('g-static') + @([pscustomobject]@{ id = 'g-static'; body = [pscustomobject]@{ value = @([pscustomobject]@{ id = 'u1'; userPrincipalName = 'u1@contoso.com' }) } }) + } + + Set-CIPPDBCacheGroups -TenantFilter 'contoso.com' + + Should -Invoke New-GraphGetRequest -Times 1 -Exactly -ParameterFilter { $Stream.IsPresent } + $Rows = $script:DbWrites[0].Rows + ($Rows | Where-Object id -EQ 'g-static').members.userPrincipalName | Should -Be @('u1@contoso.com') + ($Rows | Where-Object id -EQ 'g-dynamic').PSObject.Properties.Name | Should -Not -Contain 'members' + ($Rows | Where-Object id -EQ 'g-dynamic').dynamicGroupBool | Should -BeTrue + } } Context 'Set-CIPPDBCacheTeams' { @@ -193,15 +290,46 @@ Describe 'DBCache collectors reworked for bounded memory' { Context 'Set-CIPPDBCacheSharePointSiteUsage' { BeforeEach { + Mock -CommandName Get-CIPPSPOSite -MockWith { @() } + Mock -CommandName Get-CIPPSPOAdminListData -MockWith { + @( + [pscustomobject]@{ + Title = 'Site One' + SiteUrl = 'https://c/s1' + SiteId = '{site-1}' + StorageUsed = [int64]1073741824 + StorageQuota = [int64]1024 + StorageQuotaBytes = [int64](1024 * 1MB) + NumOfFiles = [int64]10 + TemplateName = 'STS#3' + SiteOwnerEmail = 'owner1@contoso.com' + SiteOwnerName = 'Owner One' + LastActivityOn = '2026-01-01' + TimeCreated = '2020-01-01' + } + [pscustomobject]@{ + Title = 'Site Two' + SiteUrl = 'https://c/s2' + SiteId = '{site-2}' + StorageUsed = [int64]2048 + StorageQuota = [int64]2048 + StorageQuotaBytes = [int64](2048 * 1MB) + NumOfFiles = [int64]2 + TemplateName = 'GROUP#0' + SiteOwnerEmail = 'owner2@contoso.com' + SiteOwnerName = 'Owner Two' + LastActivityOn = '2026-02-01' + TimeCreated = '2021-01-01' + } + ) + } Mock -CommandName New-GraphBulkRequest -MockWith { - # First call: the site listing + usage report. Second call: the per-site lists. if ($Requests[0].id -eq 'listAllSites') { return @( [pscustomobject]@{ id = 'listAllSites'; body = [pscustomobject]@{ value = @( [pscustomobject]@{ id = 's1'; displayName = 'Site One'; webUrl = 'https://c/s1'; isPersonalSite = $false; sharepointIds = [pscustomobject]@{ siteId = 'site-1'; webId = 'web-1' } } [pscustomobject]@{ id = 's2'; displayName = 'Site Two'; webUrl = 'https://c/s2'; isPersonalSite = $false; sharepointIds = [pscustomobject]@{ siteId = 'site-2'; webId = 'web-2' } } ) } } - [pscustomobject]@{ id = 'usage'; status = 200; body = [pscustomobject]@{ value = @([pscustomobject]@{ siteId = 'site-1' }) } } ) } return @( @@ -233,8 +361,39 @@ Describe 'DBCache collectors reworked for bounded memory' { Set-CIPPDBCacheSharePointSiteUsage -TenantFilter 'contoso.com' ($script:DbWrites | Where-Object Type -EQ 'SharePointSiteListing').Rows.Count | Should -Be 2 - ($script:DbWrites | Where-Object Type -EQ 'SharePointSiteUsage').Rows.Count | Should -Be 1 - ($script:DbWrites | Where-Object Type -EQ 'SharePointSiteUsage').Rows[0].id | Should -Be 'site-1' + ($script:DbWrites | Where-Object Type -EQ 'SharePointSiteUsage').Rows.Count | Should -Be 2 + ($script:DbWrites | Where-Object Type -EQ 'SharePointSiteUsage').Rows[0].siteId | Should -Be 'site-1' + ($script:DbWrites | Where-Object Type -EQ 'SharePointSiteUsage').Rows[0].storageUsedInBytes | Should -Be 1073741824 + ($script:DbWrites | Where-Object Type -EQ 'SharePointSiteUsage').Rows[0].storageAllocatedInBytes | Should -Be (1024 * 1MB) + ($script:DbWrites | Where-Object Type -EQ 'SharePointSiteUsage').Rows[0].ownerPrincipalName | Should -Be 'owner1@contoso.com' + ($script:DbWrites | Where-Object Type -EQ 'SharePointSiteUsage').Rows[0].rootWebTemplate | Should -Be 'STS' + ($script:DbWrites | Where-Object Type -EQ 'SharePointSiteUsage').Rows[1].rootWebTemplate | Should -Be 'Group' + ($script:DbWrites | Where-Object Type -EQ 'SharePointSiteUsage').Rows[0].reportRefreshDate | Should -Not -BeNullOrEmpty + } + + It 'merges file-level archive fields from Get-CIPPSPOSite into the site listing' { + Mock -CommandName Get-CIPPSPOSite -MockWith { + @( + [pscustomobject]@{ + Url = 'https://c/s1' + ArchivedFileDiskUsed = 1073741824 + AllowFileArchive = $true + } + [pscustomobject]@{ + Url = 'https://c/s2/' + ArchivedFileDiskUsed = 0 + AllowFileArchive = $false + } + ) + } + + Set-CIPPDBCacheSharePointSiteUsage -TenantFilter 'contoso.com' + + $Listing = ($script:DbWrites | Where-Object Type -EQ 'SharePointSiteListing').Rows + ($Listing | Where-Object id -EQ 's1').archivedFileDiskUsedBytes | Should -Be 1073741824 + ($Listing | Where-Object id -EQ 's1').allowFileArchive | Should -BeTrue + ($Listing | Where-Object id -EQ 's2').archivedFileDiskUsedBytes | Should -Be 0 + ($Listing | Where-Object id -EQ 's2').allowFileArchive | Should -BeFalse } } diff --git a/Tests/DBCache/Set-CIPPDBCacheDefenderCVEs.Flush.Tests.ps1 b/Tests/DBCache/Set-CIPPDBCacheDefenderCVEs.Flush.Tests.ps1 index 137fd91f0444d..c48b56ee80dca 100644 --- a/Tests/DBCache/Set-CIPPDBCacheDefenderCVEs.Flush.Tests.ps1 +++ b/Tests/DBCache/Set-CIPPDBCacheDefenderCVEs.Flush.Tests.ps1 @@ -122,7 +122,8 @@ Describe 'Set-CIPPDBCacheDefenderCVEs flush semantics' { $Batch.Count | Should -Be 1 $Batch[0].PartitionKey | Should -Be $script:Tenant $Batch[0].Type | Should -Be 'DefenderCVEs' - $Batch[0].RowKey | Should -BeLike 'DefenderCVEs-*' + # Stable, idempotent RowKey derived from the CVE id (was a random GUID per run). + $Batch[0].RowKey | Should -Be 'DefenderCVEs-CVE-2024-0001' # Get-CIPPCVEReport reads these fields off the deserialised Data blob. $Payload = $Batch[0].Data | ConvertFrom-Json diff --git a/Tests/DBCache/Set-CIPPDBCacheDefenderCVEs.Tests.ps1 b/Tests/DBCache/Set-CIPPDBCacheDefenderCVEs.Tests.ps1 index d04d4025a0667..8c3b4ab1e7900 100644 --- a/Tests/DBCache/Set-CIPPDBCacheDefenderCVEs.Tests.ps1 +++ b/Tests/DBCache/Set-CIPPDBCacheDefenderCVEs.Tests.ps1 @@ -14,6 +14,9 @@ BeforeAll { function Get-DefenderTvmRaw { param($TenantId, [int]$MaxPages, [switch]$Stream) } function Get-CippException { param($Exception) } function Write-LogMessage { param($API, $tenant, $message, $sev, $LogData) } + # Not a capability (licence) error by default, so the outer catch takes the normal + # 'CVE Cache Refresh failed' path rather than the skip-and-return branch. + function Test-CIPPCacheCapabilityError { param($Message) $false } function Add-CIPPDbItem { [CmdletBinding()] param( @@ -82,38 +85,31 @@ Describe 'Set-CIPPDBCacheDefenderCVEs' { $Row.PartitionKey | Should -Be 'CVE-2024-0001' $Row.RowKey | Should -Be $script:Tenant + # Stable RowKey source: Add-CIPPDbItem derives "DefenderCVEs-". + $Row.id | Should -Be 'CVE-2024-0001' $Row.customerId | Should -Be $script:Tenant $Row.cveId | Should -Be 'CVE-2024-0001' $Row.softwareVendor | Should -Be 'microsoft' $Row.softwareName | Should -Be 'edge' + $Row.softwareVersion | Should -Be '120.0.0' $Row.vulnerabilitySeverityLevel | Should -Be 'High' - $Row.recommendedSecurityUpdate | Should -Be 'KB5034123' - $Row.recommendedSecurityUpdateUrl | Should -Be 'https://support.microsoft.com/kb/5034123' $Row.exploitabilityLevel | Should -Be 'ExploitIsPublic' $Row.deviceCount | Should -Be 1 # PowerShell 7's -UFormat drops the literal '+' prefix, so the stored stamp is # a bare ISO-8601 UTC string truncated to whole seconds. $Row.lastUpdated | Should -Match '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.000Z$' + # Dropped fields are no longer stored. + $Row.PSObject.Properties.Name | Should -Not -Contain 'recommendedSecurityUpdate' + $Row.PSObject.Properties.Name | Should -Not -Contain 'recommendedSecurityUpdateUrl' + + # Minimal per-device payload: id and name only. $Devices = $Row.deviceDetailsJson | ConvertFrom-Json $Devices.deviceId | Should -Be 'd1' $Devices.deviceName | Should -Be 'PC-1' - $Devices.osVersion | Should -Be '10.0.19045' - $Devices.softwareVersion | Should -Be '120.0.0' - $Devices.diskPaths | Should -Be '' - $Devices.registryPaths | Should -Be '' - } - - It 'joins disk and registry path arrays with semicolons' { - Mock -CommandName Get-DefenderTvmRaw -MockWith { - New-TvmRecord -diskPaths @('C:\a\edge.exe', 'C:\b\edge.exe') -registryPaths @('HKLM\SOFTWARE\X') - } - - Set-CIPPDBCacheDefenderCVEs -TenantFilter $script:Tenant - - $Devices = $script:Rows[0].deviceDetailsJson | ConvertFrom-Json - $Devices.diskPaths | Should -Be 'C:\a\edge.exe;C:\b\edge.exe' - $Devices.registryPaths | Should -Be 'HKLM\SOFTWARE\X' + $Devices.PSObject.Properties.Name | Should -Not -Contain 'osVersion' + $Devices.PSObject.Properties.Name | Should -Not -Contain 'diskPaths' + $Devices.PSObject.Properties.Name | Should -Not -Contain 'registryPaths' } It 'serialises a single device as a JSON object and multiple devices as a JSON array' { @@ -157,11 +153,37 @@ Describe 'Set-CIPPDBCacheDefenderCVEs' { $Row = $script:Rows[0] $Row.softwareVendor | Should -Be '' $Row.softwareName | Should -Be '' + $Row.softwareVersion | Should -Be '' $Row.vulnerabilitySeverityLevel | Should -Be '' - $Row.recommendedSecurityUpdate | Should -Be '' - $Row.recommendedSecurityUpdateUrl | Should -Be '' $Row.exploitabilityLevel | Should -Be '' } + + It 'counts a device once and stores it once when the same device reports the CVE across several software packages' { + Mock -CommandName Get-DefenderTvmRaw -MockWith { + New-TvmRecord -cveId 'CVE-DEDUP' -deviceId 'd1' -deviceName 'PC-1' -softwareName 'edge' + New-TvmRecord -cveId 'CVE-DEDUP' -deviceId 'd1' -deviceName 'PC-1' -softwareName 'chrome' + New-TvmRecord -cveId 'CVE-DEDUP' -deviceId 'd2' -deviceName 'PC-2' -softwareName 'edge' + } + + Set-CIPPDBCacheDefenderCVEs -TenantFilter $script:Tenant + + $script:Rows.Count | Should -Be 1 + $script:Rows[0].deviceCount | Should -Be 2 + (($script:Rows[0].deviceDetailsJson | ConvertFrom-Json).deviceId | Sort-Object) | Should -Be @('d1', 'd2') + } + + It 'skips software-inventory rows with no CVE without throwing or logging an error' { + Mock -CommandName Get-DefenderTvmRaw -MockWith { + [pscustomobject]@{ cveId = $null; deviceId = 'd0'; deviceName = 'PC-0' } + New-TvmRecord -cveId 'CVE-2024-0009' -deviceId 'd1' + } + + Set-CIPPDBCacheDefenderCVEs -TenantFilter $script:Tenant + + $script:Rows.Count | Should -Be 1 + $script:Rows[0].cveId | Should -Be 'CVE-2024-0009' + Should -Invoke Write-LogMessage -Times 0 -Exactly -ParameterFilter { $sev -eq 'Error' } + } } Context 'CVE bucketing' { diff --git a/Tests/DBCache/SharePointSharingLinks.Resume.Tests.ps1 b/Tests/DBCache/SharePointSharingLinks.Resume.Tests.ps1 index 3ab76fd014d1f..3e608cc6d309e 100644 --- a/Tests/DBCache/SharePointSharingLinks.Resume.Tests.ps1 +++ b/Tests/DBCache/SharePointSharingLinks.Resume.Tests.ps1 @@ -392,11 +392,15 @@ Describe 'Per-drive sharing-links scan' { Context 'incremental scan from a stored delta token' { BeforeEach { $script:ScanId = 'scan-incr-1' + # LastFullScanUtc must sit inside the incremental window (Push-...'s $FullScanDays, + # default 14) or the drive falls back to a full scan. Anchor it to "now" so the + # fixture never drifts out of the window as the calendar advances past a fixed date. + $script:LastFullScanUtc = [string]([DateTimeOffset]::UtcNow.AddDays(-1).ToString('o')) Initialize-TestScan -ScanId $script:ScanId -TotalSites 1 Add-CIPPAzDataTableEntity -TableName 'CippSharingLinksState' -Entity @{ PartitionKey = 'contoso.com'; RowKey = 'delta-b!driveone'; DriveId = 'b!driveone'; SiteId = 'contoso.sharepoint.com,site1,web1' DeltaLink = 'https://graph.microsoft.com/beta/drives/b!driveone/root/delta?token=stored' - LastScanId = 'previous-scan'; LastScanUtc = '2026-08-10T00:00:00Z'; LastFullScanUtc = '2026-08-10T00:00:00Z' + LastScanId = 'previous-scan'; LastScanUtc = $script:LastFullScanUtc; LastFullScanUtc = $script:LastFullScanUtc } Add-CacheRow -RowKey 'SharePointSharingLinks-b!driveone_01ITEMX_permOld' Add-CacheRow -RowKey 'SharePointSharingLinks-b!driveone_01ITEMY_permKeep' @@ -427,7 +431,7 @@ Describe 'Per-drive sharing-links scan' { $DriveState = Get-CIPPSharingLinksDriveState -TenantFilter 'contoso.com' -DriveId 'b!driveone' $DriveState.DeltaLink | Should -BeLike '*token=newer' # Incremental completion must not claim a full scan happened. - $DriveState.LastFullScanUtc | Should -Be '2026-08-10T00:00:00Z' + $DriveState.LastFullScanUtc | Should -Be $script:LastFullScanUtc } It 'falls back to a classic full scan when the stored token is rejected' { @@ -452,7 +456,7 @@ Describe 'Per-drive sharing-links scan' { $DriveState = Get-CIPPSharingLinksDriveState -TenantFilter 'contoso.com' -DriveId 'b!driveone' $DriveState.DeltaLink | Should -BeLike '*token=rebuilt' - $DriveState.LastFullScanUtc | Should -Not -Be '2026-08-10T00:00:00Z' + $DriveState.LastFullScanUtc | Should -Not -Be $script:LastFullScanUtc } } diff --git a/Tests/Endpoint/Invoke-AddAppTemplate.Tests.ps1 b/Tests/Endpoint/Invoke-AddAppTemplate.Tests.ps1 new file mode 100644 index 0000000000000..e7c8b9721f342 --- /dev/null +++ b/Tests/Endpoint/Invoke-AddAppTemplate.Tests.ps1 @@ -0,0 +1,171 @@ +# Pester tests for Invoke-AddAppTemplate +# Covers the guard that keeps applications CIPP cannot rebuild at deploy time out of +# application templates: a config carrying an IntuneBody read straight off Graph (it has an id) +# describes an app whose installer content lives inside Intune. Office and Edge are the exception +# because their body builders replay the stored body after stripping the read-only properties. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + + # ContentType is unused by this endpoint but must be here: the endpoint is loaded through + # [ScriptBlock]::Create, which resolves HttpResponseContext against the runspace type table + # shared by every test file loaded the same way. The first such definition wins for the whole + # run, and this file sorts first, so a narrower shape would make the endpoints that do set + # ContentType fail their cast and fall into the wrong branch. + class HttpResponseContext { + [int]$StatusCode + [object]$Body + [object]$ContentType + } + + # Only the helpers these tests actually reach are stubbed. Get-CIPPAzDataTableEntity is not, + # because the endpoint only calls it on the GUID upsert path, which no test here exercises. + function Get-CippTable { param($tablename) @{} } + function Add-CIPPAzDataTableEntity { param([switch]$Force, $Entity) $script:LastEntity = $Entity } + function Write-LogMessage { param($headers, $API, $message, $Sev, $LogData) } + function Get-CippException { + param($Exception) + [pscustomobject]@{ NormalizedError = "$Exception" } + } + + $EndpointPath = Join-Path $RepoRoot 'Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddAppTemplate.ps1' + $EndpointScript = [ScriptBlock]::Create("using namespace System.Net`n" + (Get-Content -LiteralPath $EndpointPath -Raw)) + . $EndpointScript + + # A row as the Applications list hands it to Save as Template: the Graph object, id and all. + function New-GraphAppRow { + param([string]$OdataType, [string]$DisplayName) + [pscustomobject]@{ + '@odata.type' = $OdataType + id = '11111111-2222-3333-4444-555555555555' + displayName = $DisplayName + publishingState = 'published' + createdDateTime = '2026-01-01T00:00:00Z' + } + } + + function New-TemplateRequest { + param([object[]]$Apps, [string]$DisplayName = 'Template A') + [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'AddAppTemplate' } + Headers = @{ Authorization = 'Bearer token' } + Body = [pscustomobject]@{ + displayName = $DisplayName + description = '' + apps = $Apps + } + } + } +} + +Describe 'Invoke-AddAppTemplate' { + BeforeEach { + $script:LastEntity = $null + } + + It 'rejects an app whose config holds a Graph body with an id' { + $Row = New-GraphAppRow -OdataType '#microsoft.graph.win32LobApp' -DisplayName 'FortiClient' + $Request = New-TemplateRequest -Apps @( + [pscustomobject]@{ + appType = 'chocolateyApp' + appName = 'FortiClient' + config = (@{ ApplicationName = 'FortiClient'; IntuneBody = $Row; AssignTo = 'On' } | ConvertTo-Json -Depth 15 -Compress) + } + ) + + $Response = Invoke-AddAppTemplate -Request $Request -TriggerMetadata $null + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::InternalServerError) + $Response.Body.Results | Should -Match "'FortiClient' is an existing Intune application with uploaded installer content" + $Response.Body.Results | Should -Match 'rebuilt from a package or script at deployment' + $script:LastEntity | Should -BeNullOrEmpty + } + + It 'rejects the same body when the config is supplied as an object rather than a string' { + $Row = New-GraphAppRow -OdataType '#microsoft.graph.win32LobApp' -DisplayName 'FortiClient' + $Request = New-TemplateRequest -Apps @( + [pscustomobject]@{ + appType = 'win32ScriptApp' + appName = 'FortiClient' + config = [pscustomobject]@{ ApplicationName = 'FortiClient'; IntuneBody = $Row; AssignTo = 'On' } + } + ) + + $Response = Invoke-AddAppTemplate -Request $Request -TriggerMetadata $null + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::InternalServerError) + $Response.Body.Results | Should -Match 'uploaded installer content' + $script:LastEntity | Should -BeNullOrEmpty + } + + It 'rejects the whole template when only one of its apps carries a Graph body' { + $Row = New-GraphAppRow -OdataType '#microsoft.graph.winGetApp' -DisplayName 'Notepad++' + $Request = New-TemplateRequest -Apps @( + [pscustomobject]@{ + appType = 'chocolateyApp' + appName = 'Firefox' + config = (@{ ApplicationName = 'Firefox'; packagename = 'firefox'; AssignTo = 'On' } | ConvertTo-Json -Depth 15 -Compress) + } + [pscustomobject]@{ + appType = 'StoreApp' + appName = 'Notepad++' + config = (@{ ApplicationName = 'Notepad++'; IntuneBody = $Row; AssignTo = 'On' } | ConvertTo-Json -Depth 15 -Compress) + } + ) + + $Response = Invoke-AddAppTemplate -Request $Request -TriggerMetadata $null + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::InternalServerError) + $Response.Body.Results | Should -Match "'Notepad\+\+' is an existing Intune application" + $script:LastEntity | Should -BeNullOrEmpty + } + + It 'still accepts an Office template saved from an existing deployment' { + $Row = New-GraphAppRow -OdataType '#microsoft.graph.officeSuiteApp' -DisplayName 'Microsoft 365 Apps' + $Request = New-TemplateRequest -Apps @( + [pscustomobject]@{ + appType = 'officeApp' + appName = 'Microsoft 365 Apps' + config = (@{ ApplicationName = 'Microsoft 365 Apps'; IntuneBody = $Row; AssignTo = 'On' } | ConvertTo-Json -Depth 15 -Compress) + } + ) + + $Response = Invoke-AddAppTemplate -Request $Request -TriggerMetadata $null + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $Response.Body.Results | Should -Match 'Successfully saved app template' + $script:LastEntity.PartitionKey | Should -Be 'AppTemplate' + $script:LastEntity.JSON | Should -Match '"officeApp"' + } + + It 'still accepts an Edge template saved from an existing deployment' { + $Row = New-GraphAppRow -OdataType '#microsoft.graph.windowsMicrosoftEdgeApp' -DisplayName 'Microsoft Edge' + $Request = New-TemplateRequest -Apps @( + [pscustomobject]@{ + appType = 'edgeApp' + appName = 'Microsoft Edge' + config = (@{ ApplicationName = 'Microsoft Edge'; IntuneBody = $Row; AssignTo = 'On' } | ConvertTo-Json -Depth 15 -Compress) + } + ) + + $Response = Invoke-AddAppTemplate -Request $Request -TriggerMetadata $null + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $script:LastEntity.JSON | Should -Match '"edgeApp"' + } + + It 'still accepts a wizard built template with no stored Graph body' { + $Request = New-TemplateRequest -Apps @( + [pscustomobject]@{ + appType = 'chocolateyApp' + appName = 'Firefox' + config = (@{ applicationName = 'Firefox'; packagename = 'firefox'; AssignTo = 'On' } | ConvertTo-Json -Depth 15 -Compress) + } + ) + + $Response = Invoke-AddAppTemplate -Request $Request -TriggerMetadata $null + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $script:LastEntity.JSON | Should -Match '"firefox"' + } +} diff --git a/Tests/Endpoint/Invoke-AddPIMRoleSettingsTemplate.Tests.ps1 b/Tests/Endpoint/Invoke-AddPIMRoleSettingsTemplate.Tests.ps1 new file mode 100644 index 0000000000000..71a238604586b --- /dev/null +++ b/Tests/Endpoint/Invoke-AddPIMRoleSettingsTemplate.Tests.ps1 @@ -0,0 +1,215 @@ +# Pester tests for Invoke-AddPIMRoleSettingsTemplate. +# +# A PIM role settings template that weakens a tenant below the secure floor must be rejected at +# save time with the reasons, and nothing may be written. A template above the recommended +# activation (8h) but inside the hard cap (24h) saves with a warning that reaches the logbook. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Roles/Invoke-AddPIMRoleSettingsTemplate.ps1' + if (-not (Test-Path $FunctionPath)) { throw "Could not locate Invoke-AddPIMRoleSettingsTemplate.ps1 at $FunctionPath" } + + class HttpResponseContext { + [object]$StatusCode + [object]$Body + } + $Accelerators = [PSObject].Assembly.GetType('System.Management.Automation.TypeAccelerators') + if (-not ('HttpStatusCode' -as [type])) { + $Accelerators::Add('HttpStatusCode', [System.Net.HttpStatusCode]) + } + + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/PIM/ConvertTo-CIPPPIMRoleSettings.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/PIM/Test-CIPPPIMRoleSettingsFloor.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/PIM/ConvertFrom-CIPPPIMPolicyRules.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/PIM/Repair-CIPPPIMRoleSettingsFloor.ps1') + + function Get-CIPPPIMRolePolicies { param($TenantFilter) } + function Get-CippTable { param($tablename) @{} } + function Get-CIPPAzDataTableEntity { param($Filter) } + function Add-CIPPAzDataTableEntity { param($Entity, $Force) } + function ConvertTo-CIPPODataFilterValue { param($Value, $Type) $Value } + function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev, $LogData) } + function Get-CippException { param($Exception) @{ NormalizedError = "$Exception" } } + + . $FunctionPath + + function New-TemplateRequest { + param([hashtable]$Settings = @{}, [hashtable]$Body = @{}) + $DefaultSettings = @{ + activationMaxDuration = 'PT8H' + activationRequires = @{ label = 'MFA'; value = 'MFA' } + activationRequiresJustification = $true + eligibilityMaxDuration = 'P365D' + activeAssignmentMaxDuration = 'P180D' + activeAssignmentRequiresJustification = $true + } + foreach ($Key in $Settings.Keys) { $DefaultSettings[$Key] = $Settings[$Key] } + $RequestBody = [pscustomobject]@{ + templateName = 'Secure PIM' + description = 'test' + roleScope = @{ label = 'Privileged roles'; value = 'PrivilegedRoles' } + roles = @() + settings = [pscustomobject]$DefaultSettings + } + foreach ($Key in $Body.Keys) { $RequestBody | Add-Member -NotePropertyName $Key -NotePropertyValue $Body[$Key] -Force } + [pscustomobject]@{ + Body = $RequestBody + Headers = @{ 'x-ms-client-principal' = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes('{"userDetails":"tester@cipp"}')) } + Params = @{ CIPPEndpoint = 'AddPIMRoleSettingsTemplate' } + } + } +} + +Describe 'Invoke-AddPIMRoleSettingsTemplate' { + BeforeEach { + $script:Saved = $null + Mock Get-CippTable { @{} } + Mock Get-CIPPAzDataTableEntity { $null } + Mock Add-CIPPAzDataTableEntity { $script:Saved = $Entity } + Mock Write-LogMessage {} + } + + It 'saves a template that meets the floor' { + $Response = Invoke-AddPIMRoleSettingsTemplate -Request (New-TemplateRequest) + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + Should -Invoke Add-CIPPAzDataTableEntity -Times 1 -Exactly + $script:Saved.PartitionKey | Should -Be 'PIMRoleSettingsTemplate' + $Stored = $script:Saved.JSON | ConvertFrom-Json + $Stored.templateName | Should -Be 'Secure PIM' + $Stored.roleScope | Should -Be 'PrivilegedRoles' + $Stored.settings.activationRequires | Should -Be 'MFA' + $Stored.settings.activationMaxDuration | Should -Be 'PT8H' + $Stored.createdBy | Should -Be 'tester@cipp' + } + + It 'rejects a template below the floor and writes nothing' { + $Response = Invoke-AddPIMRoleSettingsTemplate -Request (New-TemplateRequest @{ activationRequires = 'None'; activationMaxDuration = 'PT48H' }) + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + $Text = ($Response.Body.Results | ForEach-Object { if ($_ -is [string]) { $_ } else { $_.resultText } }) -join ' ' + $Text | Should -Match 'below the secure floor' + $Text | Should -Match 'exceeds the maximum of PT24H' + $Text | Should -Match 'must require MFA or an authentication context' + Should -Invoke Add-CIPPAzDataTableEntity -Times 0 -Exactly + } + + It 'rejects a template that allows permanent eligibility' { + $Response = Invoke-AddPIMRoleSettingsTemplate -Request (New-TemplateRequest @{ eligibilityMaxDuration = '' }) + # An empty duration is normalised to the secure default, so permanence can only be + # expressed by exceeding the cap - which is refused. + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $Response = Invoke-AddPIMRoleSettingsTemplate -Request (New-TemplateRequest @{ eligibilityMaxDuration = 'P10Y' }) + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + } + + It 'saves with a warning when activation exceeds the recommended 8h but not the 24h cap' { + $Response = Invoke-AddPIMRoleSettingsTemplate -Request (New-TemplateRequest @{ activationMaxDuration = 'PT12H' }) + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $Warnings = @($Response.Body.Results | Where-Object { $_ -isnot [string] -and $_.state -eq 'warning' }) + $Warnings.Count | Should -Be 1 + $Warnings[0].resultText | Should -Match 'exceeds the recommended PT8H' + Should -Invoke Write-LogMessage -Times 1 -ParameterFilter { $Sev -eq 'Warning' -and $message -match 'exceeds the recommended' } + } + + It 'requires roles when the scope is Custom' { + $Response = Invoke-AddPIMRoleSettingsTemplate -Request (New-TemplateRequest -Body @{ roleScope = 'Custom'; roles = @() }) + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + Should -Invoke Add-CIPPAzDataTableEntity -Times 0 -Exactly + } + + It 'updates in place when a GUID is supplied and keeps the original creator' { + $script:Existing = [pscustomobject]@{ RowKey = 'abc'; GUID = 'abc'; JSON = (@{ templateName = 'Old'; createdBy = 'first@cipp'; createdDate = '2026-01-01T00:00:00Z' } | ConvertTo-Json -Compress) } + Mock Get-CIPPAzDataTableEntity { $script:Existing } + $Response = Invoke-AddPIMRoleSettingsTemplate -Request (New-TemplateRequest -Body @{ GUID = 'abc' }) + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $script:Saved.RowKey | Should -Be 'abc' + ($script:Saved.JSON | ConvertFrom-Json).createdBy | Should -Be 'first@cipp' + ($script:Saved.JSON | ConvertFrom-Json).updatedBy | Should -Be 'tester@cipp' + } + + Context 'capture from a role' { + BeforeEach { + # Roles & PIM page action: no settings, no roleScope, just the role to capture. + $script:CaptureBody = @{ + captureRoleId = '644ef478-e28f-4e28-b9dc-3fdde9aa0b1f' + captureRoleName = 'Printer Administrator' + tenantFilter = 'tenant.example.com' + roleScope = $null + roles = @() + settings = $null + description = '' + } + } + + It 'captures a compliant role exactly, with no adjustments' { + Mock Get-CIPPPIMRolePolicies { + @([pscustomobject]@{ + RoleDefinitionId = '644ef478-e28f-4e28-b9dc-3fdde9aa0b1f' + PolicyId = 'DirectoryRole_p1' + Rules = @( + @{ id = 'Expiration_EndUser_Assignment'; isExpirationRequired = $true; maximumDuration = 'PT4H' } + @{ id = 'Enablement_EndUser_Assignment'; enabledRules = @('MultiFactorAuthentication', 'Justification') } + @{ id = 'Expiration_Admin_Eligibility'; isExpirationRequired = $true; maximumDuration = 'P180D' } + @{ id = 'Expiration_Admin_Assignment'; isExpirationRequired = $true; maximumDuration = 'P90D' } + @{ id = 'Enablement_Admin_Assignment'; enabledRules = @('MultiFactorAuthentication', 'Justification') } + ) + }) + } + $Response = Invoke-AddPIMRoleSettingsTemplate -Request (New-TemplateRequest -Body $script:CaptureBody) + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $Stored = $script:Saved.JSON | ConvertFrom-Json + $Stored.settings.activationMaxDuration | Should -Be 'PT4H' + $Stored.settings.activationRequires | Should -Be 'MFA' + $Stored.settings.eligibilityMaxDuration | Should -Be 'P180D' + $Stored.settings.activeAssignmentMaxDuration | Should -Be 'P90D' + $Stored.roleScope | Should -Be 'Custom' + @($Stored.roles).value | Should -Be '644ef478-e28f-4e28-b9dc-3fdde9aa0b1f' + $Stored.description | Should -Match 'Captured from the Printer Administrator role' + @($Response.Body.Results | Where-Object { $_ -isnot [string] -and $_.resultText -match 'Raised to the secure floor' }).Count | Should -Be 0 + } + + It 'raises a below-floor role to the floor and reports every raise' { + Mock Get-CIPPPIMRolePolicies { + # Entra defaults: no MFA on activation, permanent eligibility and active allowed. + @([pscustomobject]@{ + RoleDefinitionId = '644ef478-e28f-4e28-b9dc-3fdde9aa0b1f' + PolicyId = 'DirectoryRole_p1' + Rules = @( + @{ id = 'Expiration_EndUser_Assignment'; isExpirationRequired = $true; maximumDuration = 'PT8H' } + @{ id = 'Enablement_EndUser_Assignment'; enabledRules = @('Justification') } + @{ id = 'Expiration_Admin_Eligibility'; isExpirationRequired = $false; maximumDuration = 'P365D' } + @{ id = 'Expiration_Admin_Assignment'; isExpirationRequired = $false; maximumDuration = 'P180D' } + @{ id = 'Enablement_Admin_Assignment'; enabledRules = @('Justification') } + ) + }) + } + $Response = Invoke-AddPIMRoleSettingsTemplate -Request (New-TemplateRequest -Body $script:CaptureBody) + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $Stored = $script:Saved.JSON | ConvertFrom-Json + $Stored.settings.activationRequires | Should -Be 'MFA' + $Stored.settings.eligibilityMaxDuration | Should -Be 'P365D' + $Stored.settings.activeAssignmentMaxDuration | Should -Be 'P365D' + $Raises = @($Response.Body.Results | Where-Object { $_ -isnot [string] -and $_.resultText -match 'Raised to the secure floor' }) + $Raises.Count | Should -Be 3 + ($Raises.resultText -join ' ') | Should -Match 'MFA' + ($Raises.resultText -join ' ') | Should -Match 'Eligible assignments' + ($Raises.resultText -join ' ') | Should -Match 'Active assignments' + Should -Invoke Write-LogMessage -Times 3 -ParameterFilter { $Sev -eq 'Warning' -and $message -match 'raised to the secure floor' } + } + + It 'returns 400 when the role has no PIM policy in the tenant' { + Mock Get-CIPPPIMRolePolicies { @() } + $Response = Invoke-AddPIMRoleSettingsTemplate -Request (New-TemplateRequest -Body $script:CaptureBody) + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + ($Response.Body.Results -join ' ') | Should -Match 'No PIM role management policy' + Should -Invoke Add-CIPPAzDataTableEntity -Times 0 -Exactly + } + + It 'requires a single tenant to capture from' { + $script:CaptureBody.tenantFilter = 'AllTenants' + $Response = Invoke-AddPIMRoleSettingsTemplate -Request (New-TemplateRequest -Body $script:CaptureBody) + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + ($Response.Body.Results -join ' ') | Should -Match 'single tenantFilter' + Should -Invoke Add-CIPPAzDataTableEntity -Times 0 -Exactly + } + } +} diff --git a/Tests/Endpoint/Invoke-EditTenant.Tests.ps1 b/Tests/Endpoint/Invoke-EditTenant.Tests.ps1 new file mode 100644 index 0000000000000..3655260a282b3 --- /dev/null +++ b/Tests/Endpoint/Invoke-EditTenant.Tests.ps1 @@ -0,0 +1,140 @@ +# Pester tests for Invoke-EditTenant +# +# Tenant group membership changes are gated on the group being static. Groups created +# before dynamic groups shipped have no GroupType property at all, and the table service +# skips property-missing entities in comparison filters - so the static/dynamic split has +# to happen client-side or those groups can never be added or removed from this endpoint +# (CyberDrain/CIPP#389). + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-EditTenant.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-EditTenant.ps1 under Modules/' } + + class HttpResponseContext { + [object]$StatusCode + [object]$Body + } + + $Accelerators = [psobject].Assembly.GetType('System.Management.Automation.TypeAccelerators') + if (-not $Accelerators::Get.ContainsKey('HttpStatusCode')) { + $Accelerators::Add('HttpStatusCode', [System.Net.HttpStatusCode]) + } + + function Get-CippTable { param($TableName) } + function Get-CIPPAzDataTableEntity { param($Context, $Filter) } + function Add-CIPPAzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function Remove-CIPPAzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function Get-Tenants { param($TenantFilter, [switch]$TriggerRefresh) } + function Get-TenantGroups { param([switch]$SkipCache) } + function Write-LogMessage { param($headers, $API, $tenant, $TenantId, $message, $Sev) } + + . $FunctionPath + + $script:CustomerId = 'f0e1d2c3-0000-0000-0000-000000000001' + $script:StaticGroupId = '11111111-1111-1111-1111-111111111111' + $script:LegacyGroupId = '22222222-2222-2222-2222-222222222222' + $script:DynamicGroupId = '33333333-3333-3333-3333-333333333333' + + function New-EditRequest { + param($TenantGroups) + [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'EditTenant' } + Headers = @{ } + Body = [pscustomobject]@{ + customerId = $script:CustomerId + tenantAlias = $null + tenantGroups = $TenantGroups + } + Query = [pscustomobject]@{ } + } + } +} + +Describe 'Invoke-EditTenant tenant groups' { + BeforeEach { + $script:GroupEntities = @( + [pscustomobject]@{ PartitionKey = 'TenantGroup'; RowKey = $script:StaticGroupId; Name = 'Whatever - Do this'; GroupType = 'static' } + # Legacy group: created before dynamic groups existed, no GroupType property at all + [pscustomobject]@{ PartitionKey = 'TenantGroup'; RowKey = $script:LegacyGroupId; Name = 'Whatever - Exclude that' } + [pscustomobject]@{ PartitionKey = 'TenantGroup'; RowKey = $script:DynamicGroupId; Name = 'Whatever - Dynamic'; GroupType = 'dynamic' } + ) + $script:MemberEntities = @() + + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Get-CippTable -MockWith { @{ Context = $TableName } } + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { } + Mock -CommandName Remove-CIPPAzDataTableEntity -MockWith { } + Mock -CommandName Get-TenantGroups -MockWith { } + Mock -CommandName Get-Tenants -MockWith { + [pscustomobject]@{ customerId = $script:CustomerId; defaultDomainName = 'contoso.onmicrosoft.com'; displayName = 'Contoso' } + } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + switch ($Context) { + 'TenantGroups' { + if ($Filter -like '*GroupType*') { + # Emulate table-service semantics: a comparison filter on GroupType + # skips entities that do not carry the property at all + $script:GroupEntities | Where-Object { $null -ne $_.PSObject.Properties['GroupType'] -and $_.GroupType -ne 'dynamic' } + } else { + $script:GroupEntities + } + } + 'TenantGroupMembers' { + if ($Filter -like "*'$($script:CustomerId)'*") { $script:MemberEntities } else { @() } + } + default { @() } + } + } + } + + It 'adds membership for a legacy group that has no GroupType property' { + $Request = New-EditRequest -TenantGroups @( + [pscustomobject]@{ groupId = $script:StaticGroupId; groupName = 'Whatever - Do this' } + [pscustomobject]@{ groupId = $script:LegacyGroupId; groupName = 'Whatever - Exclude that' } + ) + + $Response = Invoke-EditTenant -Request $Request -TriggerMetadata $null + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + Should -Invoke Add-CIPPAzDataTableEntity -Times 1 -Exactly -ParameterFilter { + $Entity.GroupId -eq $script:StaticGroupId -and $Entity.customerId -eq $script:CustomerId + } + Should -Invoke Add-CIPPAzDataTableEntity -Times 1 -Exactly -ParameterFilter { + $Entity.GroupId -eq $script:LegacyGroupId -and $Entity.customerId -eq $script:CustomerId + } + } + + It 'does not add membership for a dynamic group' { + $Request = New-EditRequest -TenantGroups @( + [pscustomobject]@{ groupId = $script:DynamicGroupId; groupName = 'Whatever - Dynamic' } + ) + + $Response = Invoke-EditTenant -Request $Request -TriggerMetadata $null + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + Should -Invoke Add-CIPPAzDataTableEntity -Times 0 -Exactly + } + + It 'removes a deselected legacy group that has no GroupType property' { + $script:MemberEntities = @( + [pscustomobject]@{ + PartitionKey = 'Member' + RowKey = '{0}-{1}' -f $script:LegacyGroupId, $script:CustomerId + GroupId = $script:LegacyGroupId + customerId = $script:CustomerId + } + ) + $Request = New-EditRequest -TenantGroups @( + [pscustomobject]@{ groupId = $script:StaticGroupId; groupName = 'Whatever - Do this' } + ) + + $Response = Invoke-EditTenant -Request $Request -TriggerMetadata $null + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + Should -Invoke Remove-CIPPAzDataTableEntity -Times 1 -Exactly -ParameterFilter { + $Entity.GroupId -eq $script:LegacyGroupId + } + } +} diff --git a/Tests/Endpoint/Invoke-ExecAppServiceDomains.Tests.ps1 b/Tests/Endpoint/Invoke-ExecAppServiceDomains.Tests.ps1 new file mode 100644 index 0000000000000..931edc72ea4b1 --- /dev/null +++ b/Tests/Endpoint/Invoke-ExecAppServiceDomains.Tests.ps1 @@ -0,0 +1,74 @@ +# Pester tests for the AddBinding action of Invoke-ExecAppServiceDomains. +# ARM only validates ownership through the alias record when customHostNameDnsRecordType is set +# explicitly; without it the bind demands an asuid TXT record and fails on a perfectly good CNAME. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecAppServiceDomains.ps1' + if (-not (Test-Path $FunctionPath)) { throw "Could not locate Invoke-ExecAppServiceDomains.ps1 at $FunctionPath" } + + class HttpResponseContext { + [object]$StatusCode + [object]$Body + } + $Accelerators = [PSObject].Assembly.GetType('System.Management.Automation.TypeAccelerators') + if (-not ('HttpStatusCode' -as [type])) { + $Accelerators::Add('HttpStatusCode', [System.Net.HttpStatusCode]) + } + + function Get-CIPPAppServiceSite { param($ApiVersion) } + function New-CIPPAzRestRequest { param($Uri, $Method, $Body, $ContentType) } + function Write-LogMessage { param($API, $headers, $message, $sev, $LogData) } + function Get-CippException { param($Exception) @{ NormalizedError = "$Exception" } } + + . $FunctionPath + + function New-DomainRequest { + param([string]$Action, [string]$Hostname) + [pscustomobject]@{ + Body = [pscustomobject]@{ Action = $Action; Hostname = $Hostname } + Query = [pscustomobject]@{} + Headers = @{} + Params = @{ CIPPEndpoint = 'ExecAppServiceDomains' } + } + } +} + +Describe 'Invoke-ExecAppServiceDomains AddBinding' { + BeforeEach { + Mock -CommandName Get-CIPPAppServiceSite -MockWith { + [pscustomobject]@{ + SiteName = 'cippxyz' + ArmBase = 'https://management.azure.com/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/sites/cippxyz' + ApiVersion = '2024-11-01' + Site = [pscustomobject]@{ properties = [pscustomobject]@{ defaultHostName = 'cippxyz.azurewebsites.net'; inboundIpAddress = '1.2.3.4' } } + } + } + Mock -CommandName New-CIPPAzRestRequest -MockWith { [pscustomobject]@{} } + Mock -CommandName Write-LogMessage + } + + It 'binds a subdomain with CNAME validation' { + $Response = Invoke-ExecAppServiceDomains -Request (New-DomainRequest -Action 'AddBinding' -Hostname 'Portal.Contoso.com ') + + $Response.StatusCode | Should -Be ([HttpStatusCode]::OK) + Should -Invoke New-CIPPAzRestRequest -Times 1 -Exactly -ParameterFilter { + $Method -eq 'PUT' -and $Uri -like '*/hostNameBindings/portal.contoso.com?*' -and $Body.properties.customHostNameDnsRecordType -eq 'CName' + } + } + + It 'binds an apex domain with A record validation' { + Invoke-ExecAppServiceDomains -Request (New-DomainRequest -Action 'AddBinding' -Hostname 'contoso.com') | Out-Null + + Should -Invoke New-CIPPAzRestRequest -Times 1 -Exactly -ParameterFilter { + $Body.properties.customHostNameDnsRecordType -eq 'A' + } + } + + It 'refuses the platform hostname' { + $Response = Invoke-ExecAppServiceDomains -Request (New-DomainRequest -Action 'AddBinding' -Hostname 'cippxyz.azurewebsites.net') + + $Response.StatusCode | Should -Be ([HttpStatusCode]::BadRequest) + Should -Invoke New-CIPPAzRestRequest -Times 0 + } +} diff --git a/Tests/Endpoint/Invoke-ExecCippReplacemap.Tests.ps1 b/Tests/Endpoint/Invoke-ExecCippReplacemap.Tests.ps1 index ee56f7fe95b96..35517c9315279 100644 --- a/Tests/Endpoint/Invoke-ExecCippReplacemap.Tests.ps1 +++ b/Tests/Endpoint/Invoke-ExecCippReplacemap.Tests.ps1 @@ -35,6 +35,7 @@ BeforeAll { function Add-CIPPAzDataTableEntity { param($Entity, [switch]$Force) $script:SavedEntity = $Entity } function Remove-CIPPAzDataTableEntity { param($Entity, [switch]$Force) $script:RemovedEntity = $Entity } function Get-Tenants { param($TenantFilter, [switch]$IncludeErrors) } + function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev, $LogData) } . $FunctionPath diff --git a/Tests/Endpoint/Invoke-ExecEmptySiteRecycleBin.Tests.ps1 b/Tests/Endpoint/Invoke-ExecEmptySiteRecycleBin.Tests.ps1 new file mode 100644 index 0000000000000..7cf4917211e4a --- /dev/null +++ b/Tests/Endpoint/Invoke-ExecEmptySiteRecycleBin.Tests.ps1 @@ -0,0 +1,148 @@ +# Pester tests for Invoke-ExecEmptySiteRecycleBin and Invoke-ListSiteRecycleBinSummary + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $ExecPath = Join-Path $RepoRoot 'Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecEmptySiteRecycleBin.ps1' + $SummaryPath = Join-Path $RepoRoot 'Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteRecycleBinSummary.ps1' + if (-not (Test-Path $ExecPath)) { throw "Could not locate $ExecPath" } + if (-not (Test-Path $SummaryPath)) { throw "Could not locate $SummaryPath" } + + class HttpResponseContext { + [object]$StatusCode + [object]$Body + } + $Accelerators = [PSObject].Assembly.GetType('System.Management.Automation.TypeAccelerators') + if (-not ('HttpStatusCode' -as [type])) { + $Accelerators::Add('HttpStatusCode', [System.Net.HttpStatusCode]) + } + + function Get-SharePointAdminLink { param($Public, $tenantFilter) [PSCustomObject]@{ SharePointUrl = 'https://contoso.sharepoint.com'; AdminUrl = 'https://contoso-admin.sharepoint.com' } } + function Resolve-CIPPSharePointRestContext { + param($TenantFilter, $SiteUrl) + $BaseUri = 'https://contoso.sharepoint.com/sites/a/_api' + [PSCustomObject]@{ + Scope = 'https://contoso.sharepoint.com/.default' + Headers = @{ Accept = 'application/json;odata=nometadata' } + BaseUri = $BaseUri + WebUri = "$BaseUri/web" + } + } + function Write-LogMessage { param($Headers, $API, $tenant, $message, $sev, $LogData) } + function Get-CippException { param($Exception) [PSCustomObject]@{ NormalizedError = $Exception.Message } } + + . $ExecPath + . $SummaryPath +} + +Describe 'Invoke-ExecEmptySiteRecycleBin' { + BeforeEach { + $script:GraphPostCalls = 0 + function global:New-GraphPostRequest { + param( + $uri, $tenantid, $scope, $type, $body, $contentType, $AddedHeaders, + [switch]$UseCertificate, + $AsApp + ) + $script:GraphPostCalls++ + } + function global:New-GraphGetRequest { + param( + $uri, $tenantid, $scope, $AsApp, $extraHeaders, + [switch]$UseCertificate, + [bool]$noPagination, + [switch]$SkipValueExtraction + ) + } + } + + It 'returns BadRequest when SiteUrl is missing' { + $Response = Invoke-ExecEmptySiteRecycleBin -Request ([pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecEmptySiteRecycleBin' } + Headers = @{} + Body = [pscustomobject]@{ tenantFilter = 'contoso.onmicrosoft.com'; Stage = 'Both' } + }) + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + } + + It 'returns BadRequest for invalid Stage' { + $Response = Invoke-ExecEmptySiteRecycleBin -Request ([pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecEmptySiteRecycleBin' } + Headers = @{} + Body = [pscustomobject]@{ + tenantFilter = 'contoso.onmicrosoft.com' + SiteUrl = 'https://contoso.sharepoint.com/sites/a' + Stage = 'Nope' + } + }) + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + $Response.Body.Results | Should -Match 'Invalid Stage' + } + + It 'calls deleteAll endpoints for Both' { + $Response = Invoke-ExecEmptySiteRecycleBin -Request ([pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecEmptySiteRecycleBin' } + Headers = @{} + Body = [pscustomobject]@{ + tenantFilter = 'contoso.onmicrosoft.com' + SiteUrl = 'https://contoso.sharepoint.com/sites/a' + Stage = 'Both' + } + }) + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $script:GraphPostCalls | Should -Be 2 + $Response.Body.Results | Should -Match 'Emptied recycle bin' + } +} + +Describe 'Invoke-ListSiteRecycleBinSummary' { + BeforeEach { + function global:New-GraphGetRequest { + param( + $uri, $tenantid, $scope, $AsApp, $extraHeaders, + [switch]$UseCertificate, + [bool]$noPagination, + [switch]$SkipValueExtraction + ) + [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ Id = '1'; Size = 100; ItemState = 1 } + [PSCustomObject]@{ Id = '2'; Size = 50; ItemState = 2 } + ) + '@odata.nextLink' = $null + } + } + } + + It 'returns BadRequest when SiteUrl is missing' { + $Response = Invoke-ListSiteRecycleBinSummary -Request ([pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ListSiteRecycleBinSummary' } + Headers = @{} + Query = @{ tenantFilter = 'contoso.onmicrosoft.com' } + Body = @{} + }) + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + } + + It 'aggregates sizes without returning names' { + $Response = Invoke-ListSiteRecycleBinSummary -Request ([pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ListSiteRecycleBinSummary' } + Headers = @{} + Query = @{ + tenantFilter = 'contoso.onmicrosoft.com' + SiteUrl = 'https://contoso.sharepoint.com/sites/a' + } + Body = @{} + }) + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $Response.Body.Results.totalBytes | Should -Be 150 + $Response.Body.Results.firstStageBytes | Should -Be 100 + $Response.Body.Results.secondStageBytes | Should -Be 50 + ($Response.Body.Results.PSObject.Properties.Name -contains 'Title') | Should -BeFalse + ($Response.Body.Results.PSObject.Properties.Name -contains 'LeafName') | Should -BeFalse + } +} diff --git a/Tests/Endpoint/Invoke-ExecOffboardUser.Tests.ps1 b/Tests/Endpoint/Invoke-ExecOffboardUser.Tests.ps1 index 29c067ff56ee7..a29f313a3b3fd 100644 --- a/Tests/Endpoint/Invoke-ExecOffboardUser.Tests.ps1 +++ b/Tests/Endpoint/Invoke-ExecOffboardUser.Tests.ps1 @@ -23,7 +23,10 @@ BeforeAll { } function Test-CIPPOffboardingRequest { param($Body) } - function Add-CIPPScheduledTask { param($Task, $hidden, $Headers, $RunNow, $DisallowDuplicateName) } + function Add-CIPPScheduledTask { param($Task, $hidden, $Headers, [switch]$RunNow, $DisallowDuplicateName, $RowKey) } + function Get-CIPPTable { param($TableName) } + function Get-CIPPAzDataTableEntity { param($Context, $Filter) } + function New-CIPPAsyncDeployment { param($JobId, $Names, $StepTitles, $Source) } function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev, $LogData) } . $FunctionPath @@ -42,6 +45,32 @@ BeforeAll { Body = $RequestBody Headers = @{} Params = @{ CIPPEndpoint = 'ExecOffboardUser' } + Query = @{} + } + } + + function New-RerunRequest { + param([string]$Action, [hashtable]$Body = @{}) + $RequestBody = [pscustomobject]@{ TaskId = 'task-1'; tenantFilter = 'contoso.com' } + foreach ($Key in $Body.Keys) { + $RequestBody | Add-Member -NotePropertyName $Key -NotePropertyValue $Body[$Key] -Force + } + [pscustomobject]@{ + Body = $RequestBody + Headers = @{} + Params = @{ CIPPEndpoint = 'ExecOffboardUser' } + Query = @{ Action = $Action } + } + } + + function New-StoredOffboardingTask { + param([string]$Command = 'Invoke-CIPPOffboardingJob', [string]$Tenant = 'contoso.com') + [pscustomobject]@{ + RowKey = 'task-1' + Command = $Command + Tenant = $Tenant + Reference = 'ticket-42' + Parameters = (@{ Username = 'pat@contoso.com'; options = @{ RevokeSessions = $true; DisableSignIn = $true }; DeploymentId = 'job-1' } | ConvertTo-Json -Compress) } } } @@ -50,6 +79,7 @@ Describe 'Invoke-ExecOffboardUser' { BeforeEach { Mock -CommandName Write-LogMessage -MockWith { } Mock -CommandName Add-CIPPScheduledTask -MockWith { 'Successfully added task' } + Mock -CommandName New-CIPPAsyncDeployment -MockWith { 'job-1' } Mock -CommandName Test-CIPPOffboardingRequest -MockWith { [pscustomobject]@{ IsValid = $true @@ -156,6 +186,92 @@ Describe 'Invoke-ExecOffboardUser' { } } + Context 'Live progress' { + It 'creates one queued progress row per user and hands the job id to every offboarding job' { + $Request = New-OffboardRequest -Users @('one@contoso.com', 'two@contoso.com') + + $Response = Invoke-ExecOffboardUser -Request $Request + + Should -Invoke New-CIPPAsyncDeployment -Times 1 -Exactly -ParameterFilter { + (@($Names) -join ',') -eq 'one@contoso.com,two@contoso.com' -and $Source -eq 'Offboarding' + } + Should -Invoke Add-CIPPScheduledTask -Times 2 -Exactly -ParameterFilter { $Task.Parameters.DeploymentId -eq 'job-1' } + $Response.Body.DeploymentId | Should -Be 'job-1' + } + + It 'does not hand back a job id for a deferred offboarding, which is watched from its task page' { + $Request = New-OffboardRequest -Body @{ Scheduled = [pscustomobject]@{ enabled = $true; date = 1785000000 } } + + $Response = Invoke-ExecOffboardUser -Request $Request + + Should -Invoke Add-CIPPScheduledTask -Times 1 -Exactly -ParameterFilter { $Task.Parameters.DeploymentId -eq 'job-1' } + $Response.Body.PSObject.Properties['DeploymentId'] | Should -BeNullOrEmpty + } + + It 'still queues the offboarding when the progress rows cannot be created' { + Mock -CommandName New-CIPPAsyncDeployment -MockWith { throw 'table unavailable' } + + $Response = Invoke-ExecOffboardUser -Request (New-OffboardRequest) + + $Response.StatusCode | Should -Be ([HttpStatusCode]::OK) + Should -Invoke Add-CIPPScheduledTask -Times 1 -Exactly -ParameterFilter { $null -eq $Task.Parameters.DeploymentId } + $Response.Body.PSObject.Properties['DeploymentId'] | Should -BeNullOrEmpty + } + } + + Context 'Re-running' { + BeforeEach { + Mock -CommandName Get-CIPPTable -MockWith { @{ Context = 'ctx' } } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { New-StoredOffboardingTask } + } + + It 'queues the whole task again through Run Now' { + $Response = Invoke-ExecOffboardUser -Request (New-RerunRequest -Action 'Rerun') + + $Response.StatusCode | Should -Be ([HttpStatusCode]::OK) + Should -Invoke Add-CIPPScheduledTask -Times 1 -Exactly -ParameterFilter { $RunNow -eq $true -and $RowKey -eq 'task-1' } + } + + It 'queues a single step as its own task under the same progress job' { + $Response = Invoke-ExecOffboardUser -Request (New-RerunRequest -Action 'RerunStep' -Body @{ StepIndex = 1; StepTitle = 'Disable sign in' }) + + $Response.StatusCode | Should -Be ([HttpStatusCode]::OK) + Should -Invoke Add-CIPPScheduledTask -Times 1 -Exactly -ParameterFilter { + $RunNow -eq $true -and + $Task.Command.value -eq 'Invoke-CIPPOffboardingJob' -and + $Task.TenantFilter -eq 'contoso.com' -and + $Task.Name -like '*Disable sign in*' -and + $Task.Reference -eq 'ticket-42' -and + $Task.Parameters.Username -eq 'pat@contoso.com' -and + $Task.Parameters.DeploymentId -eq 'job-1' -and + $Task.Parameters.options.DisableSignIn -eq $true -and + (@($Task.Parameters.StepIndexes) -join ',') -eq '1' + } + } + + It 'accepts step zero' { + $null = Invoke-ExecOffboardUser -Request (New-RerunRequest -Action 'RerunStep' -Body @{ StepIndex = 0 }) + + Should -Invoke Add-CIPPScheduledTask -Times 1 -Exactly -ParameterFilter { (@($Task.Parameters.StepIndexes) -join ',') -eq '0' } + } + + It 'refuses a task that belongs to another tenant' { + $Response = Invoke-ExecOffboardUser -Request (New-RerunRequest -Action 'Rerun' -Body @{ tenantFilter = 'other.com' }) + + $Response.StatusCode | Should -Be ([HttpStatusCode]::BadRequest) + Should -Invoke Add-CIPPScheduledTask -Times 0 -Exactly + } + + It 'refuses a task that is not an offboarding job' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { New-StoredOffboardingTask -Command 'Invoke-Something' } + + $Response = Invoke-ExecOffboardUser -Request (New-RerunRequest -Action 'RerunStep' -Body @{ StepIndex = 1 }) + + $Response.StatusCode | Should -Be ([HttpStatusCode]::BadRequest) + Should -Invoke Add-CIPPScheduledTask -Times 0 -Exactly + } + } + Context 'Guard rails' { It 'rejects an invalid request without queueing anything' { Mock -CommandName Test-CIPPOffboardingRequest -MockWith { diff --git a/Tests/Endpoint/Invoke-ExecPIMRoleAssignment.Tests.ps1 b/Tests/Endpoint/Invoke-ExecPIMRoleAssignment.Tests.ps1 new file mode 100644 index 0000000000000..73108f933a445 --- /dev/null +++ b/Tests/Endpoint/Invoke-ExecPIMRoleAssignment.Tests.ps1 @@ -0,0 +1,163 @@ +# Pester tests for Invoke-ExecPIMRoleAssignment. +# +# The endpoint is the API/MCP-facing gate for PIM assignment changes. These tests pin its input +# contract: every change needs a justification, any schedule-creating action needs an expiration, +# and a request that spells out permanence ('noExpiration', 'permanent', ...) is refused before +# Invoke-CIPPPIMAssignmentAction is ever called. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Roles/Invoke-ExecPIMRoleAssignment.ps1' + if (-not (Test-Path $FunctionPath)) { throw "Could not locate Invoke-ExecPIMRoleAssignment.ps1 at $FunctionPath" } + + class HttpResponseContext { + [object]$StatusCode + [object]$Body + } + $Accelerators = [PSObject].Assembly.GetType('System.Management.Automation.TypeAccelerators') + if (-not ('HttpStatusCode' -as [type])) { + $Accelerators::Add('HttpStatusCode', [System.Net.HttpStatusCode]) + } + + function Invoke-CIPPPIMAssignmentAction { param($TenantFilter, $Action, $PrincipalId, $RoleDefinitionId, $DirectoryScopeId, $AssignmentType, $Duration, $EndDateTime, $Justification, $TimeZone, $Headers, $APIName) } + function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev, $LogData) } + function Get-CippException { param($Exception) @{ NormalizedError = "$Exception" } } + + . $FunctionPath + + function New-PimRequest { + param([hashtable]$Body = @{}) + $RequestBody = [pscustomobject]@{ + tenantFilter = 'contoso.onmicrosoft.com' + Action = 'GrantActive' + PrincipalId = 'user-guid' + RoleDefinitionId = '62e90394-69f5-4237-9190-012177145e10' + DirectoryScopeId = '/' + AssignmentType = 'Eligible' + Duration = 'PT4H' + Justification = 'Ticket 42' + } + foreach ($Key in $Body.Keys) { + if ($null -eq $Body[$Key]) { $RequestBody.PSObject.Properties.Remove($Key) } + else { $RequestBody | Add-Member -NotePropertyName $Key -NotePropertyValue $Body[$Key] -Force } + } + [pscustomobject]@{ + Body = $RequestBody + Headers = @{} + Params = @{ CIPPEndpoint = 'ExecPIMRoleAssignment' } + } + } +} + +Describe 'Invoke-ExecPIMRoleAssignment' { + BeforeEach { + Mock Invoke-CIPPPIMAssignmentAction { [pscustomobject]@{ resultText = 'done'; state = 'success' } } + Mock Write-LogMessage {} + } + + Context 'time zone for the result wording' { + It 'passes the browser time zone through, and leaves it out when the request has none' { + $null = Invoke-ExecPIMRoleAssignment -Request (New-PimRequest -Body @{ TimeZone = 'Australia/Perth' }) -TriggerMetadata $null + Should -Invoke Invoke-CIPPPIMAssignmentAction -Times 1 -Exactly -ParameterFilter { $TimeZone -eq 'Australia/Perth' -and $Duration -eq 'PT4H' } + $null = Invoke-ExecPIMRoleAssignment -Request (New-PimRequest) -TriggerMetadata $null + Should -Invoke Invoke-CIPPPIMAssignmentAction -Times 1 -Exactly -ParameterFilter { [string]::IsNullOrEmpty($TimeZone) } + } + } + + Context 'input validation (nothing reaches PIM)' { + It 'returns 400 when required fields are missing' { + $Response = Invoke-ExecPIMRoleAssignment -Request (New-PimRequest @{ PrincipalId = $null }) + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + $Response.Body.Results[0].resultText | Should -Match 'required' + Should -Invoke Invoke-CIPPPIMAssignmentAction -Times 0 -Exactly + } + + It 'returns 400 for an unknown action' { + $Response = Invoke-ExecPIMRoleAssignment -Request (New-PimRequest @{ Action = 'MakePermanent' }) + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + $Response.Body.Results[0].resultText | Should -Match 'not supported' + Should -Invoke Invoke-CIPPPIMAssignmentAction -Times 0 -Exactly + } + + It 'returns 400 without a justification' { + $Response = Invoke-ExecPIMRoleAssignment -Request (New-PimRequest @{ Justification = '' }) + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + $Response.Body.Results[0].resultText | Should -Match 'justification' + Should -Invoke Invoke-CIPPPIMAssignmentAction -Times 0 -Exactly + } + + It 'refuses a duration that asks for permanence: <_>' -ForEach @('noExpiration', 'permanent', 'never', 'unlimited') { + $Response = Invoke-ExecPIMRoleAssignment -Request (New-PimRequest @{ Duration = $_ }) + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + $Response.Body.Results[0].resultText | Should -Match 'Permanent \(no-expiration\) assignments cannot be created' + Should -Invoke Invoke-CIPPPIMAssignmentAction -Times 0 -Exactly + } + + It 'refuses an end date that asks for permanence' { + $Response = Invoke-ExecPIMRoleAssignment -Request (New-PimRequest @{ Duration = $null; EndDateTime = 'noExpiration' }) + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + Should -Invoke Invoke-CIPPPIMAssignmentAction -Times 0 -Exactly + } + + It 'requires an expiration for <_>' -ForEach @('GrantActive', 'Extend', 'Renew') { + $Response = Invoke-ExecPIMRoleAssignment -Request (New-PimRequest @{ Action = $_; Duration = $null }) + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + $Response.Body.Results[0].resultText | Should -Match 'requires a Duration or an EndDateTime' + Should -Invoke Invoke-CIPPPIMAssignmentAction -Times 0 -Exactly + } + + It 'rejects Duration and EndDateTime together' { + $Response = Invoke-ExecPIMRoleAssignment -Request (New-PimRequest @{ EndDateTime = '2099-01-01T00:00:00Z' }) + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + $Response.Body.Results[0].resultText | Should -Match 'not both' + } + + It 'rejects an unparseable end date' { + $Response = Invoke-ExecPIMRoleAssignment -Request (New-PimRequest @{ Duration = $null; EndDateTime = 'next tuesday' }) + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + $Response.Body.Results[0].resultText | Should -Match 'not a valid date' + } + } + + Context 'forwarding to Invoke-CIPPPIMAssignmentAction' { + It 'passes the duration, scope, type and justification through' { + $Response = Invoke-ExecPIMRoleAssignment -Request (New-PimRequest) + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $Response.Body.Results[0].state | Should -Be 'success' + Should -Invoke Invoke-CIPPPIMAssignmentAction -Times 1 -Exactly -ParameterFilter { + $Action -eq 'GrantActive' -and $Duration -eq 'PT4H' -and $DirectoryScopeId -eq '/' -and $AssignmentType -eq 'Eligible' -and $Justification -eq 'Ticket 42' -and $null -eq $EndDateTime + } + } + + It 'converts a unix-seconds end date to a UTC datetime' { + $Unix = [System.DateTimeOffset]::UtcNow.AddHours(6).ToUnixTimeSeconds() + $null = Invoke-ExecPIMRoleAssignment -Request (New-PimRequest @{ Duration = $null; EndDateTime = $Unix }) + Should -Invoke Invoke-CIPPPIMAssignmentAction -Times 1 -Exactly -ParameterFilter { + $EndDateTime -is [datetime] -and [math]::Abs(($EndDateTime - [datetime]::UtcNow).TotalHours - 6) -lt 0.1 + } + } + + It 'unwraps label/value objects from the dialog' { + $Request = New-PimRequest @{ + Action = [pscustomobject]@{ label = 'Convert to eligible'; value = 'ConvertToEligible' } + Duration = [pscustomobject]@{ label = '1 year'; value = 'P365D' } + } + $null = Invoke-ExecPIMRoleAssignment -Request $Request + Should -Invoke Invoke-CIPPPIMAssignmentAction -Times 1 -Exactly -ParameterFilter { $Action -eq 'ConvertToEligible' -and $Duration -eq 'P365D' } + } + + It 'lets Remove through without an expiration' { + $Response = Invoke-ExecPIMRoleAssignment -Request (New-PimRequest @{ Action = 'Remove'; Duration = $null }) + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + Should -Invoke Invoke-CIPPPIMAssignmentAction -Times 1 -Exactly -ParameterFilter { $Action -eq 'Remove' } + } + + It 'returns 400 with the refusal message when the action throws' { + Mock Invoke-CIPPPIMAssignmentAction { throw 'Refusing: last active Global Administrator' } + $Response = Invoke-ExecPIMRoleAssignment -Request (New-PimRequest @{ Action = 'Remove'; Duration = $null }) + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + $Response.Body.Results[0].state | Should -Be 'error' + $Response.Body.Results[0].resultText | Should -Match 'last active Global Administrator' + } + } +} diff --git a/Tests/Endpoint/Invoke-ExecRefreshMyAccess.Tests.ps1 b/Tests/Endpoint/Invoke-ExecRefreshMyAccess.Tests.ps1 index 9e66df0c79f21..8854b86d5c5cb 100644 --- a/Tests/Endpoint/Invoke-ExecRefreshMyAccess.Tests.ps1 +++ b/Tests/Endpoint/Invoke-ExecRefreshMyAccess.Tests.ps1 @@ -128,6 +128,10 @@ Describe 'Invoke-ExecRefreshMyAccess' { $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::TooManyRequests) Should -Invoke Test-CIPPAccessUserRole -Times 0 -Exactly Should -Invoke Start-UserSyncTimer -Times 0 -Exactly + # A throttle the operator can't see is a throttle nobody can diagnose — the 429 must be logged. + Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { + $API -eq 'RefreshMyAccess' -and $sev -eq 'Info' -and $message -match 'cooldown' + } } It 'allows a refresh once the cooldown has elapsed' { diff --git a/Tests/Endpoint/Invoke-ExecSiteBrowserLibraryCopy.Tests.ps1 b/Tests/Endpoint/Invoke-ExecSiteBrowserLibraryCopy.Tests.ps1 new file mode 100644 index 0000000000000..0c855f76b7053 --- /dev/null +++ b/Tests/Endpoint/Invoke-ExecSiteBrowserLibraryCopy.Tests.ps1 @@ -0,0 +1,105 @@ +# Pester tests for Invoke-ExecSiteBrowserLibraryCopy and Invoke-ListSiteBrowserLibraryCopy + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $ExecPath = Join-Path $RepoRoot 'Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSiteBrowserLibraryCopy.ps1' + $ListPath = Join-Path $RepoRoot 'Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteBrowserLibraryCopy.ps1' + if (-not (Test-Path $ExecPath)) { throw "Could not locate $ExecPath" } + if (-not (Test-Path $ListPath)) { throw "Could not locate $ListPath" } + + class HttpResponseContext { + [object]$StatusCode + [object]$Body + } + $Accelerators = [PSObject].Assembly.GetType('System.Management.Automation.TypeAccelerators') + if (-not ('HttpStatusCode' -as [type])) { + $Accelerators::Add('HttpStatusCode', [System.Net.HttpStatusCode]) + } + + function Start-CIPPSharePointLibraryCopy { + param( + [string]$Mode, + [string]$TenantFilter, + [int]$NameConflictBehavior + ) + } + function Update-CIPPSharePointLibraryCopyStatus { + param([string]$TenantFilter, [string]$OperationId) + } + function Write-LogMessage { param($Headers, $API, $tenant, $message, $sev) } + function Get-CippException { param($Exception) [PSCustomObject]@{ NormalizedError = $Exception.Message } } + + . $ExecPath + . $ListPath +} + +Describe 'Invoke-ExecSiteBrowserLibraryCopy' { + BeforeEach { + Mock Start-CIPPSharePointLibraryCopy { [PSCustomObject]@{ EligibleRootCount = 3; WarnLevel = 'none'; Message = 'ok' } } + } + + It 'returns BadRequest when tenantFilter is missing' { + $Response = Invoke-ExecSiteBrowserLibraryCopy -Request ([pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecSiteBrowserLibraryCopy' } + Headers = @{} + Body = [pscustomobject]@{ Action = 'PreflightLibraryCopy' } + }) + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + Should -Invoke Start-CIPPSharePointLibraryCopy -Times 0 -Exactly + } + + It 'calls PreflightLibraryCopy with conflict behavior mapping' { + $Response = Invoke-ExecSiteBrowserLibraryCopy -Request ([pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecSiteBrowserLibraryCopy' } + Headers = @{ 'x-ms-client-principal-name' = 'admin@contoso.com' } + Body = [pscustomobject]@{ + Action = 'PreflightLibraryCopy' + tenantFilter = 'contoso.com' + SourceSiteId = 'site-a' + SourceListId = 'list-a' + DestSiteId = 'site-b' + DestListId = 'list-b' + NameConflictBehavior = 'Fail' + } + }) + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + Should -Invoke Start-CIPPSharePointLibraryCopy -Times 1 -Exactly + } +} + +Describe 'Invoke-ListSiteBrowserLibraryCopy' { + BeforeEach { + Mock Update-CIPPSharePointLibraryCopyStatus { + [PSCustomObject]@{ + OperationId = 'op-1' + Status = 'Processing' + JobsComplete = 1 + JobsTotal = 2 + } + } + } + + It 'returns BadRequest when OperationId is missing' { + $Response = Invoke-ListSiteBrowserLibraryCopy -Request ([pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ListSiteBrowserLibraryCopy' } + Headers = @{} + Query = @{ tenantFilter = 'contoso.com' } + }) + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + } + + It 'returns sanitized status in Results' { + $Response = Invoke-ListSiteBrowserLibraryCopy -Request ([pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ListSiteBrowserLibraryCopy' } + Headers = @{} + Query = @{ tenantFilter = 'contoso.com'; OperationId = 'op-1' } + }) + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $Response.Body.Results.Status | Should -Be 'Processing' + $Response.Body.Results.JobsTotal | Should -Be 2 + } +} diff --git a/Tests/Endpoint/Invoke-ListConditionalAccessPolicies.Tests.ps1 b/Tests/Endpoint/Invoke-ListConditionalAccessPolicies.Tests.ps1 new file mode 100644 index 0000000000000..990d546056cc6 --- /dev/null +++ b/Tests/Endpoint/Invoke-ListConditionalAccessPolicies.Tests.ps1 @@ -0,0 +1,154 @@ +# Pester tests for the AllTenants branch of Invoke-ListConditionalAccessPolicies +# Validates the manualPagination contract over the cacheCAPolicies table, the queue +# fallback on a cold cache (and its suppression mid-walk), and the legacy unpaged path. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + + class HttpResponseContext { + [int]$StatusCode + [object]$Body + [object]$ContentType + } + + # Rows-exist path returns a raw-JSON string Body; queue/cold paths return an object. + function ConvertFrom-ResponseBody { + param($Response) + if ($Response.Body -is [string]) { return ($Response.Body | ConvertFrom-Json) } + return $Response.Body + } + + # Stub every CIPP helper the exercised paths call so Pester's Mock has a command to replace. + function Get-CIPPTable { param($TableName) } + function Get-CIPPAzDataTableEntity { param($Context, $Filter, $Property, $First) } + function Get-CIPPPagedTableRows { param($Table, $PartitionKeys, $RowKeyGe, $RowKeyLt, $ExtraFilterClauses, $PageSize, $MaxQueries, $ContinuationToken) } + function Get-CIPPQueueData { param($Reference) } + function New-CippQueueEntry { param($Name, $Link, $Reference, $TotalTasks) } + function Start-CIPPOrchestrator { param($InputObject) } + function Get-Tenants { param($TenantFilter, [switch]$IncludeErrors) } + function New-GraphBulkRequest { param($Requests, $tenantid, $asapp) } + function Get-NormalizedError { param($Message) $Message } + # Real passthrough, not a Mock: the endpoint pipes rows into it, and pipeline binding + # inside Pester mock bodies is unreliable. + function Select-CippAllowedTenantData { param([Parameter(ValueFromPipeline)]$Row, $TenantProperty) process { $Row } } + + $EndpointPath = Join-Path $RepoRoot 'Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ListConditionalAccessPolicies.ps1' + $EndpointScript = [ScriptBlock]::Create("using namespace System.Net`n" + (Get-Content -LiteralPath $EndpointPath -Raw)) + . $EndpointScript + + function New-CaRequest { + param([hashtable]$Query = @{}) + $Merged = @{ tenantFilter = 'AllTenants' } + foreach ($Key in $Query.Keys) { $Merged[$Key] = $Query[$Key] } + [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ListConditionalAccessPolicies' } + Headers = @{ Authorization = 'token' } + Query = [pscustomobject]$Merged + } + } + + function New-CacheRow { + param([string]$Tenant, [string]$Id) + [PSCustomObject]@{ + PartitionKey = 'CAPolicy' + RowKey = [guid]::NewGuid().ToString() + Tenant = $Tenant + Timestamp = (Get-Date) + Policy = (@{ id = $Id; displayName = "Policy $Id"; Tenant = $Tenant } | ConvertTo-Json -Compress) + } + } +} + +Describe 'Invoke-ListConditionalAccessPolicies AllTenants' { + BeforeEach { + Mock -CommandName Get-CIPPTable -MockWith { @{ Context = 'fake' } } + Mock -CommandName Get-CIPPQueueData -MockWith { $null } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { @() } + Mock -CommandName Get-CIPPPagedTableRows -MockWith { [PSCustomObject]@{ Rows = @(); NextToken = $null } } + Mock -CommandName New-CippQueueEntry -MockWith { @{ RowKey = 'queue-1' } } + Mock -CommandName Start-CIPPOrchestrator -MockWith { 'instance-1' } + Mock -CommandName Get-Tenants -MockWith { @([PSCustomObject]@{ defaultDomainName = 'a.com' }) } + } + + It 'serves a paged { Results, Metadata } page with nextLink and clamps PageSize' { + Mock -CommandName Get-CIPPPagedTableRows -MockWith { + [PSCustomObject]@{ + Rows = @((New-CacheRow 'a.com' 'p1'), (New-CacheRow 'b.com' 'p2')) + NextToken = 'CAPolicy|some-guid' + } + } + + $response = Invoke-ListConditionalAccessPolicies -Request (New-CaRequest -Query @{ manualPagination = 'true'; PageSize = '10' }) -TriggerMetadata $null + + $response.StatusCode | Should -Be 200 + $response.ContentType | Should -Be 'application/json' + $response.Body | Should -BeOfType [string] + $body = ConvertFrom-ResponseBody $response + $body.Results | Should -HaveCount 2 + $body.Results.id | Should -Contain 'p1' + $body.Metadata.nextLink | Should -Be 'CAPolicy|some-guid' + Should -Invoke Get-CIPPPagedTableRows -Times 1 -ParameterFilter { + ($PartitionKeys -join ',') -eq 'CAPolicy' -and $PageSize -eq 250 -and + ($ExtraFilterClauses -join '') -like 'Timestamp ge datetime*' + } + Should -Invoke Start-CIPPOrchestrator -Times 0 + } + + It 'stitches the cached Policy blob verbatim without a parse round-trip' { + $rowA = New-CacheRow 'a.com' 'p1' + Mock -CommandName Get-CIPPPagedTableRows -MockWith { + [PSCustomObject]@{ Rows = @($rowA); NextToken = $null } + }.GetNewClosure() + + $response = Invoke-ListConditionalAccessPolicies -Request (New-CaRequest -Query @{ manualPagination = 'true' }) -TriggerMetadata $null + + # Exact stored blob bytes must appear verbatim; a parse + re-serialize would restyle them. + $response.Body | Should -BeLike ('*' + $rowA.Policy + '*') + $body = ConvertFrom-ResponseBody $response + $body.Results.id | Should -Be 'p1' + } + + It 'omits nextLink on the final page' { + Mock -CommandName Get-CIPPPagedTableRows -MockWith { + [PSCustomObject]@{ Rows = @((New-CacheRow 'a.com' 'p1')); NextToken = $null } + } + + $response = Invoke-ListConditionalAccessPolicies -Request (New-CaRequest -Query @{ manualPagination = 'true' }) -TriggerMetadata $null + + $body = ConvertFrom-ResponseBody $response + $body.Results | Should -HaveCount 1 + $body.Metadata.nextLink | Should -BeNullOrEmpty + Should -Invoke Get-CIPPPagedTableRows -Times 1 -ParameterFilter { $PageSize -eq 5000 } + } + + It 'queues the fan-out on a cold cache first page' { + $response = Invoke-ListConditionalAccessPolicies -Request (New-CaRequest -Query @{ manualPagination = 'true' }) -TriggerMetadata $null + + $response.Body.Metadata.QueueMessage | Should -Match 'Loading data' + @($response.Body.Results) | Should -HaveCount 0 + Should -Invoke Start-CIPPOrchestrator -Times 1 + } + + It 'does not re-queue when an empty page arrives mid-walk' { + $response = Invoke-ListConditionalAccessPolicies -Request (New-CaRequest -Query @{ manualPagination = 'true'; nextLink = 'CAPolicy|stale' }) -TriggerMetadata $null + + $body = ConvertFrom-ResponseBody $response + @($body.Results) | Should -HaveCount 0 + Should -Invoke Start-CIPPOrchestrator -Times 0 + Should -Invoke New-CippQueueEntry -Times 0 + } + + It 'keeps the legacy unpaged full fetch without manualPagination' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @((New-CacheRow 'a.com' 'p1'), (New-CacheRow 'b.com' 'p2'), (New-CacheRow 'c.com' 'p3')) + } + + $response = Invoke-ListConditionalAccessPolicies -Request (New-CaRequest) -TriggerMetadata $null + + $body = ConvertFrom-ResponseBody $response + $body.Results | Should -HaveCount 3 + $body.Metadata.nextLink | Should -BeNullOrEmpty + Should -Invoke Get-CIPPPagedTableRows -Times 0 + Should -Invoke Get-CIPPAzDataTableEntity -Times 1 + } +} diff --git a/Tests/Endpoint/Invoke-ListGroups.Tests.ps1 b/Tests/Endpoint/Invoke-ListGroups.Tests.ps1 new file mode 100644 index 0000000000000..0d422788b606e --- /dev/null +++ b/Tests/Endpoint/Invoke-ListGroups.Tests.ps1 @@ -0,0 +1,133 @@ +# Pester tests for the reporting-database branch of Invoke-ListGroups +# Validates the legacy bare-array shape and the manualPagination contract +# ({ Results, Metadata } pages chained via Metadata.nextLink). + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + + class HttpResponseContext { + [int]$StatusCode + [object]$Body + [object]$ContentType + } + + # Paged path returns a raw-JSON string Body; the legacy path returns an object. + function ConvertFrom-ResponseBody { + param($Response) + if ($Response.Body -is [string]) { return ($Response.Body | ConvertFrom-Json) } + return $Response.Body + } + + # Stub every CIPP helper the exercised paths call so Pester's Mock has a command to replace. + function Get-CIPPGroupsReport { param($TenantFilter, $PageSize, $ContinuationToken, [switch]$AsRawJson) } + function New-GraphGetRequest { param($uri, $tenantid) } + function New-GraphBulkRequest { param($Requests, $tenantid) } + function Get-GraphBulkResultByID { param($Results, $ID) } + function Convert-AzureAdObjectIdToSid { param($ObjectID) } + function Get-NormalizedError { param($Message) $Message } + + $EndpointPath = Join-Path $RepoRoot 'Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-ListGroups.ps1' + $EndpointScript = [ScriptBlock]::Create("using namespace System.Net`n" + (Get-Content -LiteralPath $EndpointPath -Raw)) + . $EndpointScript + + function New-GroupsRequest { + param([hashtable]$Query = @{}) + $Merged = @{ tenantFilter = 'AllTenants' } + foreach ($Key in $Query.Keys) { $Merged[$Key] = $Query[$Key] } + [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ListGroups' } + Headers = @{ Authorization = 'token' } + Query = [pscustomobject]$Merged + } + } + + function New-GroupRow { + param([string]$Id) + [pscustomobject]@{ + id = $Id + displayName = "Group $Id" + members = @([pscustomobject]@{ id = 'u1'; userPrincipalName = 'u1@contoso.com' }) + membersCsv = 'u1@contoso.com' + Tenant = 'contoso.onmicrosoft.com' + } + } + + # Mirror what Get-CIPPGroupsReport -AsRawJson returns: the group blobs pre-stitched + # into a JSON array string, with a continuation token. + function New-GroupsPageJson { + param([object[]]$Rows, [string]$NextToken) + $parts = foreach ($row in $Rows) { $row | ConvertTo-Json -Depth 10 -Compress } + [PSCustomObject]@{ + CippPagedJson = '[' + ($parts -join ',') + ']' + NextToken = $NextToken + } + } +} + +Describe 'Invoke-ListGroups report database branch' { + BeforeEach { + Mock -CommandName New-GraphGetRequest -MockWith { throw 'live Graph should not be called' } + Mock -CommandName New-GraphBulkRequest -MockWith { throw 'live Graph should not be called' } + } + + It 'returns the legacy bare array without manualPagination' { + Mock -CommandName Get-CIPPGroupsReport -MockWith { @((New-GroupRow 'g1'), (New-GroupRow 'g2')) } + + $response = Invoke-ListGroups -Request (New-GroupsRequest) -TriggerMetadata $null + + $response.StatusCode | Should -Be 200 + $response.Body | Should -HaveCount 2 + $response.Body[0].members | Should -HaveCount 1 + Should -Invoke Get-CIPPGroupsReport -Times 1 -ParameterFilter { + $TenantFilter -eq 'AllTenants' -and -not $PageSize + } + } + + It 'returns { Results, Metadata } pages with members intact when manualPagination is set' { + Mock -CommandName Get-CIPPGroupsReport -MockWith { + New-GroupsPageJson -Rows @((New-GroupRow 'g1')) -NextToken 'contoso.onmicrosoft.com|Groups-abc' + } + + $response = Invoke-ListGroups -Request (New-GroupsRequest -Query @{ + manualPagination = 'true'; PageSize = '7'; nextLink = 'prev|token' + }) -TriggerMetadata $null + + $response.StatusCode | Should -Be 200 + $response.ContentType | Should -Be 'application/json' + $response.Body | Should -BeOfType [string] + $body = ConvertFrom-ResponseBody $response + $body.Results | Should -HaveCount 1 + $body.Results[0].members[0].userPrincipalName | Should -Be 'u1@contoso.com' + $body.Metadata.nextLink | Should -Be 'contoso.onmicrosoft.com|Groups-abc' + # PageSize 7 is below the 100 floor and must be clamped; the incoming token is + # forwarded verbatim, and the read runs in raw-JSON mode. + Should -Invoke Get-CIPPGroupsReport -Times 1 -ParameterFilter { + $TenantFilter -eq 'AllTenants' -and $PageSize -eq 100 -and $ContinuationToken -eq 'prev|token' -and $AsRawJson + } + } + + It 'omits nextLink on the final page but keeps the paged shape and default size' { + Mock -CommandName Get-CIPPGroupsReport -MockWith { + New-GroupsPageJson -Rows @((New-GroupRow 'g1')) -NextToken $null + } + + $response = Invoke-ListGroups -Request (New-GroupsRequest -Query @{ + UseReportDB = 'true'; manualPagination = 'true' + }) -TriggerMetadata $null + + $body = ConvertFrom-ResponseBody $response + $body.Results | Should -HaveCount 1 + $body.Metadata.nextLink | Should -BeNullOrEmpty + $body.PSObject.Properties.Name | Should -Contain 'Metadata' + Should -Invoke Get-CIPPGroupsReport -Times 1 -ParameterFilter { $PageSize -eq 750 } + } + + It 'returns InternalServerError when the paged report read fails' { + Mock -CommandName Get-CIPPGroupsReport -MockWith { throw 'No groups data found in reporting database for AllTenants. Sync the report data first.' } + + $response = Invoke-ListGroups -Request (New-GroupsRequest -Query @{ manualPagination = 'true' }) -TriggerMetadata $null + + $response.StatusCode | Should -Be 500 + "$($response.Body)" | Should -Match 'Sync the report data first' + } +} diff --git a/Tests/Endpoint/Invoke-ListGuestUsers.Tests.ps1 b/Tests/Endpoint/Invoke-ListGuestUsers.Tests.ps1 index 7c2346f68df16..816577e6e06a2 100644 --- a/Tests/Endpoint/Invoke-ListGuestUsers.Tests.ps1 +++ b/Tests/Endpoint/Invoke-ListGuestUsers.Tests.ps1 @@ -20,7 +20,7 @@ BeforeAll { function Get-CippException { param($Exception) @{ NormalizedError = $Exception } } function Test-CIPPStandardLicense { param($StandardName, $TenantFilter, $RequiredCapabilities, $Preset, [switch]$SkipLog) } function New-GraphGetRequest { param($uri, $tenantid, [switch]$ComplexFilter) } - function Get-CIPPGuestUsersReport { param($TenantFilter) } + function Get-CIPPGuestUsersReport { param($TenantFilter, $PageSize, $ContinuationToken) } function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev, $LogData) } . $FunctionPath @@ -277,4 +277,80 @@ Describe 'Invoke-ListGuestUsers' { Should -Invoke Get-CIPPGuestUsersReport -Times 1 -ParameterFilter { $TenantFilter -eq 'AllTenants' } Should -Invoke New-GraphGetRequest -Times 0 } + + Context 'manualPagination' { + BeforeEach { + Mock -CommandName New-GraphGetRequest -MockWith { throw 'live Graph should not be called' } + } + + It 'returns a { Results, Metadata } page with nextLink and clamps PageSize' { + Mock -CommandName Get-CIPPGuestUsersReport -MockWith { + [PSCustomObject]@{ + Items = @( + [pscustomobject]@{ + id = 'g-1'; displayName = 'Guest'; mail = 'g@partner.com' + userPrincipalName = 'g_partner.com#EXT#@contoso.onmicrosoft.com' + createdDateTime = (Get-Date).AddDays(-10).ToString('o'); accountEnabled = $true + externalUserState = 'PendingAcceptance'; externalUserStateChangeDateTime = $null + signInLogsCapable = $true + CacheTimestamp = '2026-08-18T10:00:00Z'; Tenant = 'contoso.onmicrosoft.com' + } + ) + NextToken = 'contoso.onmicrosoft.com|Guests-abc' + } + } + + $response = Invoke-ListGuestUsers -Request (New-GuestRequest -Query @{ + tenantFilter = 'AllTenants'; manualPagination = 'true'; PageSize = '10'; nextLink = 'prev|token' + }) -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $response.Body.Results | Should -HaveCount 1 + $response.Body.Results[0].status | Should -Be 'Pending Acceptance' + $response.Body.Results[0].Tenant | Should -Be 'contoso.onmicrosoft.com' + $response.Body.Metadata.nextLink | Should -Be 'contoso.onmicrosoft.com|Guests-abc' + # PageSize 10 is below the floor and must be clamped to 250; the incoming + # continuation token is forwarded verbatim. + Should -Invoke Get-CIPPGuestUsersReport -Times 1 -ParameterFilter { + $TenantFilter -eq 'AllTenants' -and $PageSize -eq 250 -and $ContinuationToken -eq 'prev|token' + } + } + + It 'omits nextLink on the final page but keeps the paged shape' { + Mock -CommandName Get-CIPPGuestUsersReport -MockWith { + [PSCustomObject]@{ Items = @(); NextToken = $null } + } + + $response = Invoke-ListGuestUsers -Request (New-GuestRequest -Query @{ + UseReportDB = 'true'; manualPagination = 'true' + }) -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + @($response.Body.Results) | Should -HaveCount 0 + $response.Body.Metadata.nextLink | Should -BeNullOrEmpty + $response.Body.PSObject.Properties.Name | Should -Contain 'Metadata' + } + + It 'keeps the legacy bare array for live reads even when manualPagination is set' { + Mock -CommandName Get-CIPPGuestUsersReport -MockWith { throw 'report cache should not be called' } + Mock -CommandName New-GraphGetRequest -MockWith { + @( + [pscustomobject]@{ + id = 'g-1'; displayName = 'Guest'; mail = 'g@partner.com' + userPrincipalName = 'g_partner.com#EXT#@contoso.onmicrosoft.com' + createdDateTime = (Get-Date).AddDays(-10).ToString('o'); accountEnabled = $true + externalUserState = 'PendingAcceptance'; externalUserStateChangeDateTime = $null + } + ) + } + + $response = Invoke-ListGuestUsers -Request (New-GuestRequest -Query @{ manualPagination = 'true' }) -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + # No Results wrapper: the body is the plain row array. + $response.Body | Should -HaveCount 1 + $response.Body[0].status | Should -Be 'Pending Acceptance' + Should -Invoke Get-CIPPGuestUsersReport -Times 0 + } + } } diff --git a/Tests/Endpoint/Invoke-ListLicenseOptimization.Tests.ps1 b/Tests/Endpoint/Invoke-ListLicenseOptimization.Tests.ps1 new file mode 100644 index 0000000000000..41ec4aba925a4 --- /dev/null +++ b/Tests/Endpoint/Invoke-ListLicenseOptimization.Tests.ps1 @@ -0,0 +1,96 @@ +# Pester tests for Invoke-ListLicenseOptimization — single-tenant report, AllTenants money map, +# the required-tenant guard, and error handling. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-ListLicenseOptimization.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-ListLicenseOptimization.ps1 under Modules/' } + + class HttpResponseContext { + [int]$StatusCode + [object]$Body + } + + function Get-CippException { param($Exception) @{ NormalizedError = $Exception } } + function Get-CIPPLicenseOptimization { param($TenantFilter, $InactiveDays) } + function Get-Tenants { param([switch]$IncludeErrors) } + function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev, $LogData) } + + . $FunctionPath + + function New-OptRequest { + param([hashtable]$Query = @{}) + [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ListLicenseOptimization' } + Headers = @{ Authorization = 'token' } + Query = [pscustomobject]$Query + Body = [pscustomobject]@{} + } + } +} + +Describe 'Invoke-ListLicenseOptimization' { + BeforeEach { + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Get-CippException -MockWith { param($Exception) @{ NormalizedError = "$Exception" } } + } + + It 'returns the full report for a single tenant' { + Mock -CommandName Get-CIPPLicenseOptimization -MockWith { + [pscustomobject]@{ + Summary = [pscustomobject]@{ Tenant = $TenantFilter; ReclaimableMonthly = 100; DataAvailable = $true } + Opportunities = @([pscustomobject]@{ Tier = 'UnassignedSeats'; MonthlySaving = 100 }) + } + } + + $Response = Invoke-ListLicenseOptimization -Request (New-OptRequest @{ tenantFilter = 'contoso.com' }) + + $Response.StatusCode | Should -Be 200 + $Response.Body.Results.Summary.ReclaimableMonthly | Should -Be 100 + $Response.Body.Results.Opportunities.Count | Should -Be 1 + Should -Invoke Get-CIPPLicenseOptimization -Times 1 -Exactly + } + + It 'passes the inactiveDays override through' { + Mock -CommandName Get-CIPPLicenseOptimization -MockWith { + [pscustomobject]@{ Summary = [pscustomobject]@{ DataAvailable = $true }; Opportunities = @() } + } + + $null = Invoke-ListLicenseOptimization -Request (New-OptRequest @{ tenantFilter = 'contoso.com'; inactiveDays = '30' }) + + Should -Invoke Get-CIPPLicenseOptimization -Times 1 -Exactly -ParameterFilter { $InactiveDays -eq 30 } + } + + It 'returns a ranked per-tenant summary money map for AllTenants' { + Mock -CommandName Get-Tenants -MockWith { + @( + [pscustomobject]@{ defaultDomainName = 'a.com' } + [pscustomobject]@{ defaultDomainName = 'b.com' } + [pscustomobject]@{ defaultDomainName = 'empty.com' } + ) + } + Mock -CommandName Get-CIPPLicenseOptimization -MockWith { + $Map = @{ 'a.com' = 50; 'b.com' = 200; 'empty.com' = 0 } + [pscustomobject]@{ + Summary = [pscustomobject]@{ Tenant = $TenantFilter; ReclaimableMonthly = $Map[$TenantFilter]; DataAvailable = ($TenantFilter -ne 'empty.com') } + Opportunities = @() + } + } + + $Response = Invoke-ListLicenseOptimization -Request (New-OptRequest @{ tenantFilter = 'AllTenants' }) + + $Response.StatusCode | Should -Be 200 + # empty.com has no cached data and is dropped; the rest are ranked by reclaimable spend + $Response.Body.Results.Count | Should -Be 2 + $Response.Body.Results[0].Tenant | Should -Be 'b.com' + $Response.Body.Results[1].Tenant | Should -Be 'a.com' + } + + It 'fails when no tenant is supplied and it is not AllTenants' { + $Response = Invoke-ListLicenseOptimization -Request (New-OptRequest @{}) + + $Response.StatusCode | Should -Be 500 + $Response.Body.Results | Should -Match 'tenantFilter is required' + } +} diff --git a/Tests/Endpoint/Invoke-ListLogs.Tests.ps1 b/Tests/Endpoint/Invoke-ListLogs.Tests.ps1 new file mode 100644 index 0000000000000..fa90116aa1d1d --- /dev/null +++ b/Tests/Endpoint/Invoke-ListLogs.Tests.ps1 @@ -0,0 +1,297 @@ +# Pester tests for Invoke-ListLogs +# Validates the manualPagination contract (page shape, keyset continuation, split-row guard, +# cross-day walk, query budget), the client-side severity/user filters, the legacy +# unpaginated shape, and single-entry lookup including tick-derived partitions. + +BeforeAll { + # Resolve by name under Modules/ so the test survives the function moving between modules. + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-ListLogs.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-ListLogs.ps1 under Modules/' } + $ConverterPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'ConvertTo-CIPPODataFilterValue.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $ConverterPath) { throw 'Could not locate ConvertTo-CIPPODataFilterValue.ps1 under Modules/' } + + # Azure Functions binding types do not exist outside the Functions host - fake them. + class HttpResponseContext { + [int]$StatusCode + [object]$Body + } + + $Accelerators = [psobject].Assembly.GetType('System.Management.Automation.TypeAccelerators') + if (-not $Accelerators::Get.ContainsKey('HttpStatusCode')) { + $Accelerators::Add('HttpStatusCode', [System.Net.HttpStatusCode]) + } + + # Stub every CIPP helper the function calls so Pester's Mock has a command to replace. + function Get-CIPPTable { param($tablename) @{ Context = ($tablename ?? 'CippLogs') } } + function Get-CIPPAzDataTableEntity { param($Context, $Filter, $Property, $First, $Skip, $Sort, [switch]$Count, $MaxRetries) } + function Test-CIPPAccess { param($Request, [switch]$TenantList) } + function Get-Tenants { param([switch]$IncludeErrors) } + + . $ConverterPath + . $FunctionPath + + # Deterministic partitioning: pin the timezone the endpoint reads. + $script:OldTz = $env:CIPP_TIMEZONE + $env:CIPP_TIMEZONE = 'UTC' + + function New-FakeRowKey([datetime]$InstantUtc, [string]$Suffix = 'aaaaaaaaaaaa') { + '{0:D19}-{1}' -f ([DateTime]::MaxValue.Ticks - $InstantUtc.Ticks), $Suffix + } + + # Rows land in the partition of their instant's UTC date, mirroring Write-LogMessage. + # $RowKey deliberately untyped: [string]$null coerces to '' and would defeat the ?? fallback. + function New-FakeLogRow([datetime]$InstantUtc, [int]$Seq, [string]$Severity = 'Info', [string]$Username = 'user@contoso.com', $RowKey = $null) { + [pscustomobject]@{ + PartitionKey = $InstantUtc.ToString('yyyyMMdd') + RowKey = $RowKey ?? (New-FakeRowKey $InstantUtc) + Timestamp = [System.DateTimeOffset]::new($InstantUtc) + Tenant = 'contoso.onmicrosoft.com' + API = 'FakeApi' + Message = "seq $Seq" + Username = $Username + Severity = $Severity + LogData = '' + } + } + + # Static store the table mock reads; ordinal (PartitionKey, RowKey) order like the service. + function Select-FakeRows { + param($Filter, $First) + $Rows = @($script:FakeLogRows) + if ($Filter -match "PartitionKey eq '([^']+)'") { $PK = $Matches[1]; $Rows = @($Rows | Where-Object { $_.PartitionKey -eq $PK }) } + if ($Filter -match "PartitionKey ge '([^']+)'") { $PK = $Matches[1]; $Rows = @($Rows | Where-Object { [string]::CompareOrdinal($_.PartitionKey, $PK) -ge 0 }) } + if ($Filter -match "PartitionKey le '([^']+)'") { $PK = $Matches[1]; $Rows = @($Rows | Where-Object { [string]::CompareOrdinal($_.PartitionKey, $PK) -le 0 }) } + if ($Filter -match "RowKey gt '([^']+)'") { $RK = $Matches[1]; $Rows = @($Rows | Where-Object { [string]::CompareOrdinal($_.RowKey, $RK) -gt 0 }) } + if ($Filter -match "RowKey eq '([^']+)'") { $RK = $Matches[1]; $Rows = @($Rows | Where-Object { $_.RowKey -eq $RK }) } + $Sorted = [System.Collections.Generic.List[object]]::new() + $Sorted.AddRange($Rows) + $Sorted.Sort([System.Comparison[object]] { param($a, $b) [string]::CompareOrdinal("$($a.PartitionKey)|$($a.RowKey)", "$($b.PartitionKey)|$($b.RowKey)") }) + if ($First) { @($Sorted | Select-Object -First $First) } else { @($Sorted) } + } + + function New-LogsRequest { + param([hashtable]$Query = @{}) + [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ListLogs' } + Headers = @{ Authorization = 'token' } + Query = [pscustomobject]$Query + } + } +} + +AfterAll { + $env:CIPP_TIMEZONE = $script:OldTz +} + +Describe 'Invoke-ListLogs pagination' { + BeforeEach { + Mock -CommandName Test-CIPPAccess -MockWith { @('AllTenants') } + Mock -CommandName Get-Tenants -MockWith { @() } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { Select-FakeRows -Filter $Filter -First $First } + } + + It 'returns a full page with a continuation token, newest entries first' { + $Base = [datetime]::new(2025, 6, 10, 12, 0, 0, [System.DateTimeKind]::Utc) + $script:FakeLogRows = foreach ($i in 1..120) { New-FakeLogRow $Base.AddSeconds($i) $i } + + $response = Invoke-ListLogs -Request (New-LogsRequest -Query @{ + Filter = 'true'; StartDate = '20250610'; EndDate = '20250610' + manualPagination = 'true'; PageSize = '50' + }) -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $response.Body.Results | Should -HaveCount 50 + $response.Body.Results[0].Message | Should -Be 'seq 120' + $response.Body.Results[49].Message | Should -Be 'seq 71' + # Pin the record shape: the paged and legacy paths build this literal separately and + # the frontend treats their rows interchangeably. + $response.Body.Results[0].PSObject.Properties.Name | Should -Be @( + 'DateTime', 'Tenant', 'API', 'Message', 'User', 'Severity', 'LogData', + 'TenantID', 'AppId', 'IP', 'RowKey', 'StandardInfo', 'DateFilter') + $response.Body.Metadata.nextLink | Should -Be ('20250610|{0}' -f $response.Body.Results[49].RowKey) + # One chunk fills the page: exactly one CippLogs read. + Should -Invoke Get-CIPPAzDataTableEntity -Times 1 -Exactly + } + + It 'continues after the token without duplicates and skips -partN split rows' { + $Base = [datetime]::new(2025, 6, 10, 12, 0, 0, [System.DateTimeKind]::Utc) + $Rows = [System.Collections.Generic.List[object]]::new() + foreach ($i in 1..120) { $Rows.Add((New-FakeLogRow $Base.AddSeconds($i) $i)) } + $script:FakeLogRows = $Rows + + $PageOne = Invoke-ListLogs -Request (New-LogsRequest -Query @{ + Filter = 'true'; StartDate = '20250610'; EndDate = '20250610' + manualPagination = 'true'; PageSize = '50' + }) -TriggerMetadata $null + $BoundaryKey = $PageOne.Body.Results[49].RowKey + # A split-entity continuation row for the boundary entity sorts right after it and + # must not surface as an entity of its own on the next page. + $PartRow = New-FakeLogRow $Base.AddSeconds(71) 71 -RowKey "$BoundaryKey-part2" + $Rows.Add($PartRow) + $script:FakeLogRows = $Rows + + $PageTwo = Invoke-ListLogs -Request (New-LogsRequest -Query @{ + Filter = 'true'; StartDate = '20250610'; EndDate = '20250610' + manualPagination = 'true'; PageSize = '50' + nextLink = $PageOne.Body.Metadata.nextLink + }) -TriggerMetadata $null + + $PageTwo.Body.Results | Should -HaveCount 50 + $PageTwo.Body.Results[0].Message | Should -Be 'seq 70' + $PageTwo.Body.Results.RowKey | Should -Not -Contain "$BoundaryKey-part2" + $Overlap = @($PageTwo.Body.Results.RowKey | Where-Object { $PageOne.Body.Results.RowKey -contains $_ }) + $Overlap | Should -HaveCount 0 + } + + It 'walks the date range newest day first, skipping empty days, and omits nextLink when exhausted' { + $DayNew = [datetime]::new(2025, 6, 10, 12, 0, 0, [System.DateTimeKind]::Utc) + $DayOld = [datetime]::new(2025, 6, 7, 12, 0, 0, [System.DateTimeKind]::Utc) + $script:FakeLogRows = @( + foreach ($i in 1..10) { New-FakeLogRow $DayNew.AddSeconds($i) $i } + foreach ($i in 301..305) { New-FakeLogRow $DayOld.AddSeconds($i) $i } + ) + + $response = Invoke-ListLogs -Request (New-LogsRequest -Query @{ + Filter = 'true'; StartDate = '20250607'; EndDate = '20250610' + manualPagination = 'true'; PageSize = '50' + }) -TriggerMetadata $null + + $response.Body.Results | Should -HaveCount 15 + $response.Body.Results[0].Message | Should -Be 'seq 10' + $response.Body.Results[9].Message | Should -Be 'seq 1' + $response.Body.Results[10].Message | Should -Be 'seq 305' + $response.Body.Results[14].Message | Should -Be 'seq 301' + $response.Body.Metadata.nextLink | Should -BeNullOrEmpty + } + + It 'bounds table reads per request and resumes via nextLink over an empty range' { + $script:FakeLogRows = @() + + $PageOne = Invoke-ListLogs -Request (New-LogsRequest -Query @{ + Filter = 'true'; StartDate = '20250527'; EndDate = '20250610' + manualPagination = 'true'; PageSize = '50' + }) -TriggerMetadata $null + + $PageOne.Body.Results | Should -HaveCount 0 + # 15-day range, 10-query budget: page one stops mid-walk with a resumable token. + $PageOne.Body.Metadata.nextLink | Should -Not -BeNullOrEmpty + Should -Invoke Get-CIPPAzDataTableEntity -Times 10 -Exactly + + $PageTwo = Invoke-ListLogs -Request (New-LogsRequest -Query @{ + Filter = 'true'; StartDate = '20250527'; EndDate = '20250610' + manualPagination = 'true'; PageSize = '50' + nextLink = $PageOne.Body.Metadata.nextLink + }) -TriggerMetadata $null + + $PageTwo.Body.Results | Should -HaveCount 0 + $PageTwo.Body.Metadata.nextLink | Should -BeNullOrEmpty + } + + It 'applies severity and username filters client-side within pages' { + $Base = [datetime]::new(2025, 6, 10, 12, 0, 0, [System.DateTimeKind]::Utc) + $script:FakeLogRows = @( + New-FakeLogRow $Base.AddSeconds(1) 1 'Info' 'alice@contoso.com' + New-FakeLogRow $Base.AddSeconds(2) 2 'Error' 'alice@contoso.com' + New-FakeLogRow $Base.AddSeconds(3) 3 'Error' 'bob@contoso.com' + New-FakeLogRow $Base.AddSeconds(4) 4 'Debug' 'alice@contoso.com' + ) + + $response = Invoke-ListLogs -Request (New-LogsRequest -Query @{ + Filter = 'true'; StartDate = '20250610'; EndDate = '20250610' + Severity = 'Error'; User = 'alice*' + manualPagination = 'true'; PageSize = '50' + }) -TriggerMetadata $null + + $response.Body.Results | Should -HaveCount 1 + $response.Body.Results[0].Message | Should -Be 'seq 2' + } + + It 'keeps the legacy unpaginated bare-array shape when manualPagination is absent' { + $Base = [datetime]::new(2025, 6, 10, 12, 0, 0, [System.DateTimeKind]::Utc) + $script:FakeLogRows = foreach ($i in 1..5) { New-FakeLogRow $Base.AddSeconds($i) $i } + + $response = Invoke-ListLogs -Request (New-LogsRequest -Query @{ + Filter = 'true'; StartDate = '20250610'; EndDate = '20250610' + }) -TriggerMetadata $null + + # Bare array of entries - no Results/Metadata wrapper - with the same record shape + # as the paged path. + $response.Body | Should -HaveCount 5 + $response.Body[0].PSObject.Properties.Name | Should -Be @( + 'DateTime', 'Tenant', 'API', 'Message', 'User', 'Severity', 'LogData', + 'TenantID', 'AppId', 'IP', 'RowKey', 'StandardInfo', 'DateFilter') + Should -Invoke Get-CIPPAzDataTableEntity -Times 1 -Exactly -ParameterFilter { + $Filter -match "PartitionKey ge '20250610' and PartitionKey le '20250610'" + } + } + + It 'widens a filtered query without dates to the last N days via Days, on both paths' { + # The scoped log drawers pass Days=N so a run that finished last night is still + # visible early the next day. Timezone is pinned to UTC by the harness. + $script:FakeLogRows = @() + $Today = [DateTime]::UtcNow.Date + $From = $Today.AddDays(-6).ToString('yyyyMMdd') + $To = $Today.ToString('yyyyMMdd') + + $null = Invoke-ListLogs -Request (New-LogsRequest -Query @{ Filter = 'true'; Days = '7' }) -TriggerMetadata $null + Should -Invoke Get-CIPPAzDataTableEntity -Times 1 -Exactly -ParameterFilter { + $Filter -match "PartitionKey ge '$From' and PartitionKey le '$To'" + } + + # Paged: the day walk covers the same seven partitions (all empty here) and completes. + $response = Invoke-ListLogs -Request (New-LogsRequest -Query @{ Filter = 'true'; Days = '7'; manualPagination = 'true' }) -TriggerMetadata $null + $response.Body.Metadata.nextLink | Should -BeNullOrEmpty + Should -Invoke Get-CIPPAzDataTableEntity -Times 7 -Exactly -ParameterFilter { $Filter -match "PartitionKey eq '" } + Should -Invoke Get-CIPPAzDataTableEntity -Times 1 -Exactly -ParameterFilter { $Filter -match "PartitionKey eq '$From'" } + Should -Invoke Get-CIPPAzDataTableEntity -Times 1 -Exactly -ParameterFilter { $Filter -match "PartitionKey eq '$To'" } + } +} + +Describe 'Invoke-ListLogs single entry' { + BeforeEach { + Mock -CommandName Test-CIPPAccess -MockWith { @('AllTenants') } + Mock -CommandName Get-Tenants -MockWith { @() } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { Select-FakeRows -Filter $Filter -First $First } + } + + It 'derives the day partition from an inverted-ticks RowKey when no DateFilter is given' { + $Instant = [datetime]::new(2025, 6, 8, 12, 0, 0, [System.DateTimeKind]::Utc) + $Row = New-FakeLogRow $Instant 42 + $script:FakeLogRows = @($Row) + + $response = Invoke-ListLogs -Request (New-LogsRequest -Query @{ logentryid = $Row.RowKey }) -TriggerMetadata $null + + $response.Body | Should -HaveCount 1 + $response.Body[0].RowKey | Should -Be $Row.RowKey + $response.Body[0].DateFilter | Should -Be '20250608' + Should -Invoke Get-CIPPAzDataTableEntity -Times 1 -Exactly -ParameterFilter { + $Filter -match "PartitionKey eq '20250608'" + } + } + + It 'honours an explicit DateFilter and still resolves legacy GUID RowKeys' { + $Instant = [datetime]::new(2025, 6, 8, 12, 0, 0, [System.DateTimeKind]::Utc) + $Row = New-FakeLogRow $Instant 7 -RowKey 'd6b31653-b3a4-4a54-9761-d5a2b746a349' + $script:FakeLogRows = @($Row) + + $response = Invoke-ListLogs -Request (New-LogsRequest -Query @{ + logentryid = $Row.RowKey; DateFilter = '20250608' + }) -TriggerMetadata $null + + $response.Body | Should -HaveCount 1 + $response.Body[0].RowKey | Should -Be $Row.RowKey + # The single-entry shape exposes Standard, not StandardInfo. + $response.Body[0].PSObject.Properties.Name | Should -Contain 'Standard' + $response.Body[0].PSObject.Properties.Name | Should -Not -Contain 'StandardInfo' + } + + It 'rejects log entry ids that are not plain hex-and-hyphen strings' { + $script:FakeLogRows = @() + { + Invoke-ListLogs -Request (New-LogsRequest -Query @{ logentryid = "x' or PartitionKey gt '" }) -TriggerMetadata $null + } | Should -Throw '*Invalid log entry id*' + } +} diff --git a/Tests/Endpoint/Invoke-ListMailboxes.Tests.ps1 b/Tests/Endpoint/Invoke-ListMailboxes.Tests.ps1 new file mode 100644 index 0000000000000..8fadadafa5564 --- /dev/null +++ b/Tests/Endpoint/Invoke-ListMailboxes.Tests.ps1 @@ -0,0 +1,113 @@ +# Pester tests for Invoke-ListMailboxes +# Validates the reporting-database branch: the legacy bare-array shape, and the +# manualPagination contract ({ Results, Metadata } pages chained via Metadata.nextLink). + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + + class HttpResponseContext { + [int]$StatusCode + [object]$Body + } + + # Stub every CIPP helper the function calls so Pester's Mock has a command to replace. + function Get-CIPPMailboxesReport { param($TenantFilter, $PageSize, $ContinuationToken) } + function New-ExoRequest { param($tenantid, $cmdlet, $cmdParams, $Select) } + function Get-CIPPAutoExpandingArchiveState { param($MailboxAutoExpandingArchiveEnabled, $OrgAutoExpandingArchiveEnabled) } + function Get-NormalizedError { param($Message) $Message } + + $EndpointPath = Join-Path $RepoRoot 'Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListMailboxes.ps1' + $EndpointScript = [ScriptBlock]::Create("using namespace System.Net`n" + (Get-Content -LiteralPath $EndpointPath -Raw)) + . $EndpointScript + + function New-MailboxRequest { + param([hashtable]$Query = @{}) + $Merged = @{ tenantFilter = 'contoso.onmicrosoft.com' } + foreach ($Key in $Query.Keys) { $Merged[$Key] = $Query[$Key] } + [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ListMailboxes' } + Headers = @{ Authorization = 'token' } + Query = [pscustomobject]$Merged + } + } +} + +Describe 'Invoke-ListMailboxes report database branch' { + BeforeEach { + Mock -CommandName New-ExoRequest -MockWith { throw 'live EXO should not be called' } + } + + It 'returns the legacy bare array without manualPagination' { + Mock -CommandName Get-CIPPMailboxesReport -MockWith { + @( + [pscustomobject]@{ displayName = 'Box One'; UPN = 'one@contoso.com'; CacheTimestamp = '2026-08-18T10:00:00Z' } + [pscustomobject]@{ displayName = 'Box Two'; UPN = 'two@contoso.com'; CacheTimestamp = '2026-08-18T10:00:00Z' } + ) + } + + $response = Invoke-ListMailboxes -Request (New-MailboxRequest -Query @{ UseReportDB = 'true' }) -TriggerMetadata $null + + $response.StatusCode | Should -Be 200 + $response.Body | Should -HaveCount 2 + $response.Body[0].displayName | Should -Be 'Box One' + Should -Invoke Get-CIPPMailboxesReport -Times 1 -ParameterFilter { + $TenantFilter -eq 'contoso.onmicrosoft.com' -and -not $PageSize + } + } + + It 'returns { Results, Metadata } pages when manualPagination is set' { + Mock -CommandName Get-CIPPMailboxesReport -MockWith { + [PSCustomObject]@{ + Items = @( + [pscustomobject]@{ displayName = 'Box One'; UPN = 'one@contoso.com'; Tenant = 'contoso.onmicrosoft.com' } + ) + NextToken = 'contoso.onmicrosoft.com|Mailboxes-abc' + } + } + + $response = Invoke-ListMailboxes -Request (New-MailboxRequest -Query @{ + tenantFilter = 'AllTenants'; UseReportDB = 'true'; manualPagination = 'true'; PageSize = '9999'; nextLink = 'prev|token' + }) -TriggerMetadata $null + + $response.StatusCode | Should -Be 200 + $response.Body.Results | Should -HaveCount 1 + $response.Body.Results[0].displayName | Should -Be 'Box One' + $response.Body.Metadata.nextLink | Should -Be 'contoso.onmicrosoft.com|Mailboxes-abc' + # PageSize 9999 is inside the 250-10000 clamp and passes through; the incoming + # continuation token is forwarded verbatim. + Should -Invoke Get-CIPPMailboxesReport -Times 1 -ParameterFilter { + $TenantFilter -eq 'AllTenants' -and $PageSize -eq 9999 -and $ContinuationToken -eq 'prev|token' + } + } + + It 'omits nextLink on the final page but keeps the paged shape' { + Mock -CommandName Get-CIPPMailboxesReport -MockWith { + [PSCustomObject]@{ + Items = @([pscustomobject]@{ displayName = 'Box One' }) + NextToken = $null + } + } + + $response = Invoke-ListMailboxes -Request (New-MailboxRequest -Query @{ + UseReportDB = 'true'; manualPagination = 'true' + }) -TriggerMetadata $null + + $response.StatusCode | Should -Be 200 + $response.Body.Results | Should -HaveCount 1 + $response.Body.Metadata.nextLink | Should -BeNullOrEmpty + $response.Body.PSObject.Properties.Name | Should -Contain 'Metadata' + # No PageSize in the request: the default lands between the clamp bounds. + Should -Invoke Get-CIPPMailboxesReport -Times 1 -ParameterFilter { $PageSize -eq 5000 } + } + + It 'returns InternalServerError when the paged report read fails' { + Mock -CommandName Get-CIPPMailboxesReport -MockWith { throw 'No mailbox data found in reporting database. Sync the report data first.' } + + $response = Invoke-ListMailboxes -Request (New-MailboxRequest -Query @{ + UseReportDB = 'true'; manualPagination = 'true' + }) -TriggerMetadata $null + + $response.StatusCode | Should -Be 500 + "$($response.Body)" | Should -Match 'Sync the report data first' + } +} diff --git a/Tests/Endpoint/Invoke-ListMessageTrace.Tests.ps1 b/Tests/Endpoint/Invoke-ListMessageTrace.Tests.ps1 new file mode 100644 index 0000000000000..8a64d94f69f6d --- /dev/null +++ b/Tests/Endpoint/Invoke-ListMessageTrace.Tests.ps1 @@ -0,0 +1,146 @@ +# Pester tests for Invoke-ListMessageTrace +# Regression coverage for the "days" window off-by-epsilon: two separate Get-Date/UtcNow +# calls for Start/End made a "last 10 days" search span slightly over 10 days and trip the +# 10-day-window guard. Also covers that an explicit range genuinely over 10 days is still rejected. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + + class HttpResponseContext { + [int]$StatusCode + [object]$Body + } + + # Stub every CIPP helper the function calls so Pester's Mock has a command to replace. + function New-GraphGetRequest { param($uri, $tenantid, $AsApp, $NoAuthCheck) } + function New-GraphPostRequest { param($Uri, $tenantid, $type, $body, $NoAuthCheck) } + function New-ExoRequest { param($TenantId, $Cmdlet, $CmdParams) } + function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev) } + function Get-NormalizedError { param($message) $message } + + function Get-WindowFromUri { + param([string]$Uri) + $Decoded = [uri]::UnescapeDataString($Uri) + if ($Decoded -match 'receivedDateTime ge (\S+) and receivedDateTime le (\S+)') { + [pscustomobject]@{ + Start = [DateTime]::Parse($Matches[1], [cultureinfo]::InvariantCulture, 'AdjustToUniversal') + End = [DateTime]::Parse($Matches[2], [cultureinfo]::InvariantCulture, 'AdjustToUniversal') + } + } + } + + $EndpointPath = Join-Path $RepoRoot 'Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Tools/Invoke-ListMessageTrace.ps1' + $EndpointScript = [ScriptBlock]::Create("using namespace System.Net`n" + (Get-Content -LiteralPath $EndpointPath -Raw)) + . $EndpointScript + + function New-MessageTraceRequest { + param([hashtable]$Body = @{}) + $Merged = @{ tenantFilter = 'contoso.onmicrosoft.com' } + foreach ($Key in $Body.Keys) { $Merged[$Key] = $Body[$Key] } + [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ListMessageTrace' } + Headers = @{ Authorization = 'token' } + Body = [pscustomobject]$Merged + } + } +} + +Describe 'Invoke-ListMessageTrace date window validation' { + BeforeEach { + Mock -CommandName New-GraphGetRequest -MockWith { + @([pscustomobject]@{ id = 'trace1'; messageId = 'msg1'; status = 'delivered'; subject = 'hi'; recipientAddress = 'to@contoso.com'; senderAddress = 'from@contoso.com'; receivedDateTime = (Get-Date).ToUniversalTime().ToString('o'); size = 100; fromIP = '1.1.1.1'; toIP = '2.2.2.2' }) + } + Mock -CommandName New-GraphPostRequest -MockWith { } + Mock -CommandName New-ExoRequest -MockWith { throw 'live EXO should not be called' } + Mock -CommandName Write-LogMessage -MockWith { } + } + + It 'does not reject a "last 10 days" relative search' { + $response = Invoke-ListMessageTrace -Request (New-MessageTraceRequest -Body @{ days = 10 }) -TriggerMetadata $null + + $response.StatusCode | Should -Be 200 + $response.Body.Metadata.Error | Should -BeNullOrEmpty + $response.Body.Results | Should -HaveCount 1 + } + + It 'rejects an explicit range spanning 10 days and 60 seconds' { + $End = [DateTimeOffset]::UtcNow + $Start = $End.AddDays(-10).AddSeconds(-60) + + $response = Invoke-ListMessageTrace -Request (New-MessageTraceRequest -Body @{ + startDate = $Start.ToUnixTimeSeconds().ToString() + endDate = $End.ToUnixTimeSeconds().ToString() + }) -TriggerMetadata $null + + $response.StatusCode | Should -Be 500 + $response.Body.Metadata.Error | Should -Match '10 day window' + } +} + +# Regression coverage for the messageId-with-no-window case: Graph applies a silent ~48h +# default when no receivedDateTime filter is present, so an explicit backward sweep in +# 10-day pages (Graph's per-request window cap) is required to find older messages. +Describe 'Invoke-ListMessageTrace messageId sweep' { + BeforeEach { + $script:CallUris = [System.Collections.Generic.List[string]]::new() + $script:CallCount = 0 + Mock -CommandName New-GraphPostRequest -MockWith { } + Mock -CommandName New-ExoRequest -MockWith { throw 'live EXO should not be called' } + Mock -CommandName Write-LogMessage -MockWith { } + } + + It 'sweeps backwards in 10-day windows and stops at the first hit' { + Mock -CommandName New-GraphGetRequest -MockWith { + $script:CallCount++ + $script:CallUris.Add($uri) + if ($script:CallCount -lt 3) { return @() } + @([pscustomobject]@{ id = 'trace1'; messageId = 'msg1'; status = 'delivered'; subject = 'hi'; recipientAddress = 'to@contoso.com'; senderAddress = 'from@contoso.com'; receivedDateTime = (Get-Date).ToUniversalTime().ToString('o'); size = 100; fromIP = '1.1.1.1'; toIP = '2.2.2.2' }) + } + + $response = Invoke-ListMessageTrace -Request (New-MessageTraceRequest -Body @{ messageId = 'msg1' }) -TriggerMetadata $null + + $response.StatusCode | Should -Be 200 + $response.Body.Results | Should -HaveCount 1 + Should -Invoke -CommandName New-GraphGetRequest -Times 3 -Exactly + $script:CallUris.Count | Should -Be 3 + + $Windows = @($script:CallUris | ForEach-Object { Get-WindowFromUri $_ }) + $Windows.Count | Should -Be 3 + foreach ($Window in $Windows) { + ($Window.End - $Window.Start).TotalDays | Should -BeLessOrEqual 10 + } + # Contiguous, moving backwards: each window's End equals the previous window's Start. + ($Windows[0].End - [DateTime]::UtcNow).TotalMinutes | Should -BeLessThan 1 + $Windows[1].End | Should -Be $Windows[0].Start + $Windows[2].End | Should -Be $Windows[1].Start + } + + It 'returns an empty, non-error result when no window contains a match' { + Mock -CommandName New-GraphGetRequest -MockWith { + $script:CallCount++ + $script:CallUris.Add($uri) + @() + } + + $response = Invoke-ListMessageTrace -Request (New-MessageTraceRequest -Body @{ messageId = 'msg-not-found' }) -TriggerMetadata $null + + Should -Invoke -CommandName New-GraphGetRequest -Times 9 -Exactly + $response.StatusCode | Should -Be 200 + $response.Body.Results | Should -HaveCount 0 + $response.Body.Metadata.Error | Should -BeNullOrEmpty + } + + It 'does not sweep when an explicit window is supplied alongside messageId' { + Mock -CommandName New-GraphGetRequest -MockWith { + $script:CallCount++ + $script:CallUris.Add($uri) + @([pscustomobject]@{ id = 'trace1'; messageId = 'msg1'; status = 'delivered'; subject = 'hi'; recipientAddress = 'to@contoso.com'; senderAddress = 'from@contoso.com'; receivedDateTime = (Get-Date).ToUniversalTime().ToString('o'); size = 100; fromIP = '1.1.1.1'; toIP = '2.2.2.2' }) + } + + $response = Invoke-ListMessageTrace -Request (New-MessageTraceRequest -Body @{ messageId = 'msg1'; days = 3 }) -TriggerMetadata $null + + $response.StatusCode | Should -Be 200 + $response.Body.Results | Should -HaveCount 1 + Should -Invoke -CommandName New-GraphGetRequest -Times 1 -Exactly + } +} diff --git a/Tests/Endpoint/Invoke-ListScheduledItems.TenantDomains.Tests.ps1 b/Tests/Endpoint/Invoke-ListScheduledItems.TenantDomains.Tests.ps1 new file mode 100644 index 0000000000000..1d3d6645a38a6 --- /dev/null +++ b/Tests/Endpoint/Invoke-ListScheduledItems.TenantDomains.Tests.ps1 @@ -0,0 +1,96 @@ +# Regression tests for CyberDrain/CIPP#491 - scheduler tenant-selector filtering. +# +# A scheduled task is stored against whichever tenant identifier the caller supplied when it was +# created (customerId, default domain, or the initial .onmicrosoft.com domain). The list endpoint +# used to resolve the selected tenant to only its default domain + customerId, so a task created via +# the API against the initial domain was filtered out of the tenant view and only reappeared under +# "*AllTenants". Both the storage query filter and the allowed-tenant access check must accept all +# three identifiers. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-ListScheduledItems.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-ListScheduledItems.ps1 under Modules/' } + + class HttpResponseContext { + [object]$StatusCode + [object]$Body + } + + $Accelerators = [psobject].Assembly.GetType('System.Management.Automation.TypeAccelerators') + if (-not $Accelerators::Get.ContainsKey('HttpStatusCode')) { + $Accelerators::Add('HttpStatusCode', [System.Net.HttpStatusCode]) + } + + # Stubs so Mock has commands to replace. + function Get-CIPPTable { param($TableName) } + function Get-CIPPAzDataTableEntity { param($Context, $Filter, $Property) } + function Test-CIPPAccess { param($Request, [switch]$TenantList) } + function Get-Tenants { param($TenantFilter, [switch]$IncludeErrors, [switch]$SkipList, [switch]$IncludeAll, [switch]$TriggerRefresh, [switch]$SkipDomains, [switch]$CleanOld) } + # Return the raw value so filter assertions are predictable (the real helper quotes/escapes). + function ConvertTo-CIPPODataFilterValue { param($Value, $Type) $Value } + + . $FunctionPath + + $script:Contoso = [pscustomobject]@{ + customerId = 'aaaaaaaa-1111-2222-3333-444444444444' + defaultDomainName = 'contoso.com' # custom domain made default in M365 + initialDomainName = 'contoso.onmicrosoft.com' # differs from default - the bug's trigger + } + $script:Fabrikam = [pscustomobject]@{ + customerId = 'bbbbbbbb-1111-2222-3333-444444444444' + defaultDomainName = 'fabrikam.com' + initialDomainName = 'fabrikam.onmicrosoft.com' + } + + function New-ListRequest { + param($TenantFilter) + [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ListScheduledItems' } + Headers = @{} + Query = [pscustomobject]@{} + Body = [pscustomobject]@{ tenantFilter = $TenantFilter } + } + } +} + +Describe 'Invoke-ListScheduledItems tenant identifier resolution (#491)' { + BeforeEach { + $script:CapturedFilter = $null + Mock -CommandName Get-CIPPTable -MockWith { @{ Context = $TableName } } + # -TenantFilter call resolves the selected tenant; -IncludeErrors call builds the display/access lookup. + Mock -CommandName Get-Tenants -ParameterFilter { $TenantFilter } -MockWith { $script:Contoso } + Mock -CommandName Get-Tenants -ParameterFilter { $IncludeErrors } -MockWith { @($script:Contoso, $script:Fabrikam) } + } + + It 'builds a storage filter that matches default domain, initial domain, and customerId' { + Mock -CommandName Test-CIPPAccess -MockWith { 'AllTenants' } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { $script:CapturedFilter = $Filter; @() } + + $null = Invoke-ListScheduledItems -Request (New-ListRequest -TenantFilter 'contoso.com') + + $script:CapturedFilter | Should -Match "Tenant eq 'contoso\.com'" + $script:CapturedFilter | Should -Match "Tenant eq 'contoso\.onmicrosoft\.com'" + $script:CapturedFilter | Should -Match "Tenant eq 'aaaaaaaa-1111-2222-3333-444444444444'" + } + + It 'returns a task stored under the initial domain to a tenant-scoped caller' { + # Scoped (non-AllTenants) caller: the access check must accept the initial domain too. + Mock -CommandName Test-CIPPAccess -MockWith { @('aaaaaaaa-1111-2222-3333-444444444444') } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @( + [pscustomobject]@{ RowKey = '1'; Name = 'Task-Default'; Command = 'Invoke-CIPPOffboardingJob'; Tenant = 'contoso.com' } + [pscustomobject]@{ RowKey = '2'; Name = 'Task-Initial'; Command = 'Invoke-CIPPOffboardingJob'; Tenant = 'contoso.onmicrosoft.com' } + [pscustomobject]@{ RowKey = '3'; Name = 'Task-Other'; Command = 'Invoke-CIPPOffboardingJob'; Tenant = 'fabrikam.onmicrosoft.com' } + ) + } + + $Response = Invoke-ListScheduledItems -Request (New-ListRequest -TenantFilter 'contoso.com') + $Names = @($Response.Body.Name) + + $Names | Should -Contain 'Task-Default' + $Names | Should -Contain 'Task-Initial' # regressed before the fix: dropped by the access check + $Names | Should -Not -Contain 'Task-Other' + } +} diff --git a/Tests/Extensions/New-CippExtAlert.TicketReference.Tests.ps1 b/Tests/Extensions/New-CippExtAlert.TicketReference.Tests.ps1 new file mode 100644 index 0000000000000..461194db5ef2e --- /dev/null +++ b/Tests/Extensions/New-CippExtAlert.TicketReference.Tests.ps1 @@ -0,0 +1,193 @@ +# Pester tests for the ticket reference New-CippExtAlert hands to HaloPSA. +# A scheduled task's reference travels with the alert untouched; reading it is this extension's job +# because [ID:nnnn] is HaloPSA's own token - the same one it uses to thread emailed replies onto a +# ticket. When one is present the alert becomes a note on that ticket, and the affected-user lookup +# (plus the Graph call under it) is skipped, since the ticket already carries its end user. + +BeforeAll { + $BackendRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $BackendRoot 'Modules/CippExtensions/Public/New-CippExtAlert.ps1' + + function Get-CIPPTable { param([string]$TableName) } + function Get-CIPPAzDataTableEntity { param($TableName, $Filter, $Property, $First) } + function Get-Tenants { param($TenantFilter, [switch]$IncludeErrors) } + function New-HaloPSATicket { param($Title, $Description, $Client, $UserUPN, $AzureOID, $DisplayName, $TicketId) } + function New-GradientAlert { param($Title, $Description, $Client) } + function New-GraphGetRequest { param($uri, $tenantid, $AsApp) } + function Write-LogMessage { param($API, $tenant, $message, $sev, $LogData, $headers) } + + . $FunctionPath + + function New-Alert { + param($Reference, $AffectedUser, $PsaTicketId) + $Alert = [pscustomobject]@{ + TenantId = 'contoso.onmicrosoft.com' + AlertTitle = '[CIPP] Scheduled Task - contoso - New user creation' + AlertText = '

body

' + } + if ($Reference) { $Alert | Add-Member -NotePropertyName Reference -NotePropertyValue $Reference } + if ($PsaTicketId) { $Alert | Add-Member -NotePropertyName PsaTicketId -NotePropertyValue $PsaTicketId } + if ($AffectedUser) { $Alert | Add-Member -NotePropertyName AffectedUser -NotePropertyValue $AffectedUser } + $Alert + } + + $script:NewStarter = [pscustomobject]@{ UPN = 'new.starter@contoso.com'; DisplayName = 'New Starter' } +} + +Describe 'New-CippExtAlert - HaloPSA ticket reference' { + BeforeEach { + $script:TicketArgs = $null + Mock -CommandName Get-CIPPTable -MockWith { param([string]$TableName) @{ TableName = $TableName } } + Mock -CommandName Get-Tenants -MockWith { [pscustomobject]@{ customerId = 'customer-guid' } } + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName New-GraphGetRequest -MockWith { [pscustomobject]@{ id = 'oid-guid'; displayName = 'New Starter' } } + # An explicit param block is required: a Pester mock body without one leaves + # $PSBoundParameters empty, which silently passes every "was not passed" assertion. + Mock -CommandName New-HaloPSATicket -MockWith { + param($Title, $Description, $Client, $UserUPN, $AzureOID, $DisplayName, $TicketId) + $script:TicketArgs = [pscustomobject]@{ + Title = $Title; Client = $Client; UserUPN = $UserUPN + AzureOID = $AzureOID; DisplayName = $DisplayName; TicketId = $TicketId + } + } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + param($TableName, $Filter) + if ($Filter -like '*HaloMapping*') { return [pscustomobject]@{ RowKey = 'customer-guid'; IntegrationId = 19 } } + [pscustomobject]@{ + config = (@{ HaloPSA = @{ enabled = $true; LinkTicketsToUsers = $true } } | ConvertTo-Json -Depth 5) + } + } + } + + Context 'The task carries an explicit ticket id' { + It 'uses PsaTicketId when it is set' { + # Set from the ticket box on the user / offboarding / scheduler forms. + New-CippExtAlert -Alert (New-Alert -PsaTicketId 1380) + + $script:TicketArgs.TicketId | Should -Be 1380 + } + + It 'accepts it as a string, which is how the task row stores it' { + New-CippExtAlert -Alert (New-Alert -PsaTicketId '1380') + + $script:TicketArgs.TicketId | Should -Be 1380 + } + + It 'wins over an [ID:] token in the reference' { + New-CippExtAlert -Alert (New-Alert -PsaTicketId 1380 -Reference '[ID:99] older reference') + + $script:TicketArgs.TicketId | Should -Be 1380 + } + + It 'warns when the reference names a different ticket' { + # The reference is what the notification title shows, so a mismatch puts the note on a + # ticket the title never mentions - which reads as the feature not working at all. + New-CippExtAlert -Alert (New-Alert -PsaTicketId 1380 -Reference '[ID:1376] Starter Creation') + + $script:TicketArgs.TicketId | Should -Be 1380 + Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { + $sev -eq 'Warning' -and $message -like '*targets HaloPSA ticket 1380*' -and $message -like '*names ticket 1376*' + } + } + + It 'stays quiet when both name the same ticket' { + New-CippExtAlert -Alert (New-Alert -PsaTicketId 1380 -Reference '[ID:1380] Starter Creation') + + $script:TicketArgs.TicketId | Should -Be 1380 + Should -Invoke Write-LogMessage -Times 0 -Exactly + } + + It 'skips the contact lookup like any targeted ticket' { + New-CippExtAlert -Alert (New-Alert -PsaTicketId 1380 -AffectedUser $script:NewStarter) + + Should -Invoke New-GraphGetRequest -Times 0 -Exactly + $script:TicketArgs.UserUPN | Should -BeNullOrEmpty + } + + It 'warns and raises a new ticket when the value is not a usable id' { + New-CippExtAlert -Alert (New-Alert -PsaTicketId 'not-a-ticket') + + $script:TicketArgs.TicketId | Should -BeNullOrEmpty + Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { $sev -eq 'Warning' -and $message -like '*not a usable HaloPSA ticket id*' } + } + } + + Context 'The reference names a ticket' { + It 'passes the ticket id through to HaloPSA' { + New-CippExtAlert -Alert (New-Alert -Reference '[ID:1380] Starter Creation of FirstName LastName') + + $script:TicketArgs.TicketId | Should -Be 1380 + } + + It 'finds the token wherever it sits in the reference' { + New-CippExtAlert -Alert (New-Alert -Reference 'Starter Creation - see [ID:42] for detail') + + $script:TicketArgs.TicketId | Should -Be 42 + } + + It 'refuses a digit run too large to be a ticket id, and warns' { + New-CippExtAlert -Alert (New-Alert -Reference '[ID:99999999999999999999]') + + $script:TicketArgs.TicketId | Should -BeNullOrEmpty + Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { $sev -eq 'Warning' -and $message -like '*not a usable HaloPSA ticket id*' } + } + + It 'skips the contact lookup and its Graph call' { + New-CippExtAlert -Alert (New-Alert -Reference '[ID:1380] Starter' -AffectedUser $script:NewStarter) + + Should -Invoke New-GraphGetRequest -Times 0 -Exactly + $script:TicketArgs.UserUPN | Should -BeNullOrEmpty + $script:TicketArgs.AzureOID | Should -BeNullOrEmpty + } + } + + Context 'The reference names no ticket' { + It 'sends no ticket id for a free-text reference' { + New-CippExtAlert -Alert (New-Alert -Reference 'Starter Creation of FirstName LastName') + + $script:TicketArgs.TicketId | Should -BeNullOrEmpty + } + + It 'sends no ticket id when there is no reference at all' { + New-CippExtAlert -Alert (New-Alert) + + $script:TicketArgs.TicketId | Should -BeNullOrEmpty + } + + It 'still resolves the affected user' { + New-CippExtAlert -Alert (New-Alert -AffectedUser $script:NewStarter) + + Should -Invoke New-GraphGetRequest -Times 1 -Exactly + $script:TicketArgs.UserUPN | Should -Be 'new.starter@contoso.com' + $script:TicketArgs.AzureOID | Should -Be 'oid-guid' + } + + It 'ignores a reference that merely contains digits' { + # 'PO 1380' or 'INV-2024-1389' are free text, not a ticket number. + New-CippExtAlert -Alert (New-Alert -Reference 'PO 1380') + + $script:TicketArgs.TicketId | Should -BeNullOrEmpty + } + + It 'ignores a reference with digits embedded in an identifier' { + New-CippExtAlert -Alert (New-Alert -Reference 'INV-2024-1389') + + $script:TicketArgs.TicketId | Should -BeNullOrEmpty + } + + It 'ignores a bare number - a reference is free text, not a ticket id' { + # Deliberate: order numbers, asset tags and change ids all live in this field, and + # treating one as a ticket id would append a starter's password to an unrelated ticket. + # [ID:nnnn] is required, which is also what Halo's own email threading matches on. + New-CippExtAlert -Alert (New-Alert -Reference '1389') + + $script:TicketArgs.TicketId | Should -BeNullOrEmpty + } + + It 'ignores a bare number with surrounding whitespace' { + New-CippExtAlert -Alert (New-Alert -Reference ' 1389 ') + + $script:TicketArgs.TicketId | Should -BeNullOrEmpty + } + } +} diff --git a/Tests/Extensions/New-HaloPSATicket.Priority.Tests.ps1 b/Tests/Extensions/New-HaloPSATicket.Priority.Tests.ps1 new file mode 100644 index 0000000000000..9e1b859ecb9c1 --- /dev/null +++ b/Tests/Extensions/New-HaloPSATicket.Priority.Tests.ps1 @@ -0,0 +1,159 @@ +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + + function Get-CIPPTable { param($TableName) @{} } + function Get-CIPPAzDataTableEntity { param($Filter, $Property) } + function Add-CIPPAzDataTableEntity { param($Entity, [switch]$Force) } + function Get-HaloToken { param($configuration) } + function Get-HaloTicketTypeSlaId { param($TicketType, $Configuration, $Token) } + function Get-HaloUser { param($AzureOID, $Email, $ClientId, $Configuration, $Token) } + function Get-StringHash { param($String) } + function Get-NormalizedError { param($Message) } + function Get-CippException { param($Exception) } + function Write-LogMessage { param($API, $tenant, $message, $sev, $LogData) } + + . (Join-Path $RepoRoot 'Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1') + + # Rebuilds the Extensionsconfig row the function reads on every call. DefaultPriority is the + # integration-wide setting; the per-alert override arrives as the -TicketPriority parameter. + function New-HaloConfigRow { + param($DefaultPriority, [switch]$ConsolidateTickets) + $Halo = @{ + ResourceURL = 'https://halo.example.com/api' + TicketType = 1 + ConsolidateTickets = [bool]$ConsolidateTickets + } + if ($PSBoundParameters.ContainsKey('DefaultPriority')) { $Halo.DefaultPriority = $DefaultPriority } + [pscustomobject]@{ config = (@{ HaloPSA = $Halo } | ConvertTo-Json -Depth 5 -Compress) } + } + + # The ticket payload is only observable as the JSON body handed to Invoke-RestMethod. + function Get-SentTicket { + param($Body) + @($Body | ConvertFrom-Json)[0] + } +} + +Describe 'New-HaloPSATicket priority resolution' { + BeforeEach { + $script:SentBody = $null + + Mock Get-CIPPTable { @{} } + Mock Get-HaloToken { @{ access_token = 'token' } } + # Ticket type has an SLA unless a test says otherwise - priority is only sent when one is + # attached, because a priority id is meaningless outside the SLA that defines it. + Mock Get-HaloTicketTypeSlaId { 1 } + Mock Get-StringHash { 'hash' } + Mock Add-CIPPAzDataTableEntity {} + Mock Write-LogMessage {} + Mock Invoke-RestMethod { + $script:SentBody = $Body + @{ id = 42 } + } + } + + Context 'when creating a new ticket' { + BeforeEach { + Mock Get-CIPPAzDataTableEntity { New-HaloConfigRow -DefaultPriority 3 } + } + + It 'uses the per-alert priority over the integration default' { + $null = New-HaloPSATicket -title 'Alert' -description 'Body' -client 1 -TicketPriority 5 + + (Get-SentTicket -Body $script:SentBody).priority_id | Should -Be 5 + } + + It 'unwraps the {label, value} shape saved by the alert form' { + $null = New-HaloPSATicket -title 'Alert' -description 'Body' -client 1 -TicketPriority @{ label = 'Critical'; value = 5 } + + (Get-SentTicket -Body $script:SentBody).priority_id | Should -Be 5 + } + + It 'falls back to the integration default when no per-alert priority is set' { + $null = New-HaloPSATicket -title 'Alert' -description 'Body' -client 1 + + (Get-SentTicket -Body $script:SentBody).priority_id | Should -Be 3 + } + + It 'falls back to the integration default when the per-alert value is empty' { + $null = New-HaloPSATicket -title 'Alert' -description 'Body' -client 1 -TicketPriority '' + + (Get-SentTicket -Body $script:SentBody).priority_id | Should -Be 3 + } + + It 'falls back and warns when the per-alert value is a hint row' { + # -1 is the id Get-HaloPriority uses for its explanatory rows. It casts to a truthy + # int, so only the -gt 0 guard keeps it out of the payload. + $null = New-HaloPSATicket -title 'Alert' -description 'Body' -client 1 -TicketPriority -1 + + (Get-SentTicket -Body $script:SentBody).priority_id | Should -Be 3 + Should -Invoke Write-LogMessage -Times 1 -ParameterFilter { + $sev -eq 'Warning' -and $message -like "*from alert is not a valid priority id*" + } + } + } + + Context 'when the ticket type has no SLA' { + BeforeEach { + Mock Get-CIPPAzDataTableEntity { New-HaloConfigRow -DefaultPriority 3 } + Mock Get-HaloTicketTypeSlaId { $null } + } + + It 'omits priority_id even when the alert asks for one' { + # A priority id resolves against an SLA, so with none attached there is nothing for it + # to mean. Halo applies its own priority instead of us gambling on the SLA it picks. + $null = New-HaloPSATicket -title 'Alert' -description 'Body' -client 1 -TicketPriority 5 + + (Get-SentTicket -Body $script:SentBody).PSObject.Properties.Name | Should -Not -Contain 'priority_id' + } + + It 'omits priority_id when only the integration default is set' { + $null = New-HaloPSATicket -title 'Alert' -description 'Body' -client 1 + + (Get-SentTicket -Body $script:SentBody).PSObject.Properties.Name | Should -Not -Contain 'priority_id' + } + + It 'does not look up the SLA when there is no priority to send' { + Mock Get-CIPPAzDataTableEntity { New-HaloConfigRow } + + $null = New-HaloPSATicket -title 'Alert' -description 'Body' -client 1 + + Should -Invoke Get-HaloTicketTypeSlaId -Times 0 + } + } + + Context 'when neither priority is configured' { + BeforeEach { + Mock Get-CIPPAzDataTableEntity { New-HaloConfigRow } + } + + It 'omits priority_id entirely and logs nothing' { + $null = New-HaloPSATicket -title 'Alert' -description 'Body' -client 1 + + $Ticket = Get-SentTicket -Body $script:SentBody + $Ticket.PSObject.Properties.Name | Should -Not -Contain 'priority_id' + Should -Invoke Write-LogMessage -Times 0 + } + } + + Context 'when consolidating onto an existing open ticket' { + BeforeEach { + Mock Get-CIPPAzDataTableEntity -ParameterFilter { $Filter } { [pscustomobject]@{ TicketID = 99 } } + Mock Get-CIPPAzDataTableEntity -ParameterFilter { -not $Filter } { New-HaloConfigRow -DefaultPriority 3 -ConsolidateTickets } + Mock Invoke-RestMethod -ParameterFilter { $Method -eq 'Get' } { @{ id = 99; hasbeenclosed = $false } } + Mock Invoke-RestMethod -ParameterFilter { $Method -eq 'Post' } { + $script:SentBody = $Body + @{ id = 100 } + } + } + + It 'leaves the existing ticket priority alone' { + # Priority is deliberately create-path only - appending a note must not overwrite a + # priority a technician has since changed on the ticket. + $Result = New-HaloPSATicket -title 'Alert' -description 'Body' -client 1 -TicketPriority 5 + + $Result | Should -BeLike 'Note added to ticket in HaloPSA*' + (Get-SentTicket -Body $script:SentBody).PSObject.Properties.Name | Should -Not -Contain 'priority_id' + } + } +} diff --git a/Tests/Extensions/New-HaloPSATicket.TicketTarget.Tests.ps1 b/Tests/Extensions/New-HaloPSATicket.TicketTarget.Tests.ps1 new file mode 100644 index 0000000000000..ccd844a9304be --- /dev/null +++ b/Tests/Extensions/New-HaloPSATicket.TicketTarget.Tests.ps1 @@ -0,0 +1,169 @@ +# Pester tests for targeting an existing HaloPSA ticket from New-HaloPSATicket. +# A scheduled task raised from a Halo request carries that request's ticket in its reference, so the +# result belongs on that ticket rather than in a second one. The emailed copy already threads onto it +# via the [ID:nnnn] token in the subject; this is the PSA side of the same behaviour. It also sidesteps +# contact matching: an onboarding task creates the user seconds before the ticket is raised, so +# Get-HaloUser can never match and every ticket landed on the client's General User. + +BeforeAll { + $BackendRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $BackendRoot 'Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1' + + function Get-CIPPTable { param([string]$TableName) } + function Get-CIPPAzDataTableEntity { param($TableName, $Filter, $Property, $First) } + function Add-CIPPAzDataTableEntity { param($TableName, $Entity, [switch]$Force) } + function Get-HaloToken { param($configuration) } + function Get-HaloUser { param($AzureOID, $Email, $ClientId, $Configuration, $Token) } + function Get-StringHash { param($String) } + function Get-NormalizedError { param($Message) } + function Get-CippException { param($Exception) } + function Write-LogMessage { param($API, $tenant, $message, $sev, $LogData, $headers) } + + . $FunctionPath + + function New-HaloConfigRow { + param([bool]$ConsolidateTickets = $false, [bool]$LinkTicketsToUsers = $true) + [pscustomobject]@{ + config = (@{ + HaloPSA = @{ + Enabled = $true + ResourceURL = 'https://halo.example.com/api' + TicketType = 21 + ConsolidateTickets = $ConsolidateTickets + LinkTicketsToUsers = $LinkTicketsToUsers + Outcome = @{ label = 'CIPP Update'; value = 155 } + } + } | ConvertTo-Json -Depth 5) + } + } + + # Records every call so a test can tell an /actions note from a /Tickets create. The mock body + # runs in Pester's own scope, not this function's, so the switches have to travel as script-scoped + # variables rather than closure captures. + function Set-RestMock { + param([bool]$TicketExists = $true, [bool]$Closed = $false, [switch]$NoteFails) + $script:Calls = [System.Collections.Generic.List[object]]::new() + $script:MockTicketExists = $TicketExists + $script:MockClosed = $Closed + $script:MockNoteFails = [bool]$NoteFails + Mock -CommandName Invoke-RestMethod -MockWith { + param($Uri, $ContentType, $Method, $Body, $Headers, [switch]$SkipHttpErrorCheck) + $script:Calls.Add([pscustomobject]@{ Uri = $Uri; Method = $Method; Body = $Body }) + if ($Method -eq 'Get') { + if (-not $script:MockTicketExists) { return @{} } + return @{ id = 1380; hasbeenclosed = $script:MockClosed } + } + if ($Uri -like '*/actions') { + if ($script:MockNoteFails) { throw 'Access denied to this action' } + return @{ id = 5555 } + } + @{ id = 1382 } + } + } + + function Get-NoteCall { $script:Calls | Where-Object { $_.Uri -like '*/actions' } | Select-Object -First 1 } + function Get-CreateCall { $script:Calls | Where-Object { $_.Uri -like '*/Tickets' -and $_.Method -eq 'Post' } | Select-Object -First 1 } +} + +Describe 'New-HaloPSATicket - targeting a referenced ticket' { + BeforeEach { + Mock -CommandName Get-CIPPTable -MockWith { param([string]$TableName) @{ TableName = $TableName } } + Mock -CommandName Get-HaloToken -MockWith { @{ access_token = 'token' } } + Mock -CommandName Get-StringHash -MockWith { 'hash' } + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { } + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Get-NormalizedError -MockWith { param($Message) $Message } + Mock -CommandName Get-CippException -MockWith { @{} } + Mock -CommandName Get-HaloUser -MockWith { $null } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { New-HaloConfigRow } + } + + Context 'The referenced ticket is open' { + BeforeEach { Set-RestMock } + + It 'adds a note to that ticket instead of creating one' { + $Result = New-HaloPSATicket -title 'Test alert' -description '

body

' -client 19 -TicketId 1380 + + $Result | Should -Be 'Note added to ticket in HaloPSA: 1380' + Get-CreateCall | Should -BeNullOrEmpty + $Note = @((Get-NoteCall).Body | ConvertFrom-Json)[0] + $Note.ticket_id | Should -Be 1380 + $Note.note_html | Should -Be '

body

' + } + + It 'uses the configured outcome for the note' { + $null = New-HaloPSATicket -title 'Test alert' -description '

body

' -client 19 -TicketId 1380 + + $Note = @((Get-NoteCall).Body | ConvertFrom-Json)[0] + $Note.outcome_id | Should -Be 155 + } + + It 'does not try to match a HaloPSA contact for the affected user' { + # The point of the feature: the ticket already has its user, and a just-created starter + # would never match anyway. + $null = New-HaloPSATicket -title 'Test alert' -description '

body

' -client 19 -TicketId 1380 -UserUPN 'new.starter@contoso.com' + + Should -Invoke Get-HaloUser -Times 0 -Exactly + } + + It 'ignores the consolidation table when a ticket is named' { + # Consolidation is keyed on a hash of the title; an explicit ticket must win over it. + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + param($TableName, $Filter) + if ($Filter) { return [pscustomobject]@{ TicketID = 999 } } + New-HaloConfigRow -ConsolidateTickets $true + } + + $null = New-HaloPSATicket -title 'Test alert' -description '

body

' -client 19 -TicketId 1380 + + $Note = @((Get-NoteCall).Body | ConvertFrom-Json)[0] + $Note.ticket_id | Should -Be 1380 + } + } + + Context 'The referenced ticket cannot take the note' { + It 'creates a new ticket and warns when the ticket is closed' { + Set-RestMock -Closed $true + + $null = New-HaloPSATicket -title 'Test alert' -description '

body

' -client 19 -TicketId 1380 + + Get-NoteCall | Should -BeNullOrEmpty + Get-CreateCall | Should -Not -BeNullOrEmpty + Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { $sev -eq 'Warning' -and $message -like '*1380 is closed*' } + } + + It 'creates a new ticket and warns when the ticket does not exist' { + Set-RestMock -TicketExists $false + + $null = New-HaloPSATicket -title 'Test alert' -description '

body

' -client 19 -TicketId 1380 + + Get-CreateCall | Should -Not -BeNullOrEmpty + Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { $sev -eq 'Warning' -and $message -like '*could not be found*' } + } + + It 'creates a new ticket when the note is rejected' { + Set-RestMock -NoteFails + + $null = New-HaloPSATicket -title 'Test alert' -description '

body

' -client 19 -TicketId 1380 + + Get-CreateCall | Should -Not -BeNullOrEmpty + } + } + + Context 'No ticket is referenced' { + BeforeEach { Set-RestMock } + + It 'creates a ticket exactly as before' { + $Result = New-HaloPSATicket -title 'Test alert' -description '

body

' -client 19 + + $Result | Should -Be 'Ticket created in HaloPSA: 1382' + Get-NoteCall | Should -BeNullOrEmpty + } + + It 'still resolves the affected user' { + $null = New-HaloPSATicket -title 'Test alert' -description '

body

' -client 19 -UserUPN 'existing@contoso.com' + + Should -Invoke Get-HaloUser -Times 1 -Exactly + } + } +} diff --git a/Tests/GraphHelper/Get-ClassicAPIToken.CertificateAuth.Tests.ps1 b/Tests/GraphHelper/Get-ClassicAPIToken.CertificateAuth.Tests.ps1 new file mode 100644 index 0000000000000..bc0a58a21fa3d --- /dev/null +++ b/Tests/GraphHelper/Get-ClassicAPIToken.CertificateAuth.Tests.ps1 @@ -0,0 +1,72 @@ +# Get-ClassicAPIToken is the second SAM-app token path (the classic v1 /oauth2/token endpoint used +# by New-ClassicAPIGetRequest). It must honour certificate-exclusive auth too, otherwise the flag +# would be enabled while classic API calls still sent the client secret. The v1 endpoint also needs +# the assertion audience to be the v1 token endpoint, not the default v2 one - pinned here. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + + function Get-CIPPSAMCertificate { param([switch]$SkipCache) } + function New-CIPPCertificateAssertion { param($TenantId, $AppId, $Certificate, $Audience) } + function Invoke-CIPPRestMethod { param($Uri, $Body, $ContentType, $Method) } + function Get-CippTable { param($tablename) } + function Get-CIPPAzDataTableEntity { param($Context, $Filter) } + function Update-AzDataTableEntity { param($Context, $Entity, [switch]$Force) } + + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/GraphHelper/Get-ClassicAPIToken.ps1') +} + +Describe 'Get-ClassicAPIToken certificate-exclusive gating' { + BeforeEach { + $script:classictoken = $null # clear the per-key token cache between cases + $script:SavedEnv = @{} + foreach ($Name in 'CertificateAuthMode', 'ApplicationID', 'ApplicationSecret', 'TenantID', 'RefreshToken') { + $script:SavedEnv[$Name] = [Environment]::GetEnvironmentVariable($Name) + } + $env:ApplicationID = 'sam-app-id' + $env:ApplicationSecret = 'sam-secret' + $env:RefreshToken = 'sam-refresh-token' + $env:TenantID = '11111111-2222-3333-4444-555555555555' + + Mock Get-CIPPSAMCertificate { [pscustomobject]@{ Certificate = 'CERT-OBJECT' } } + Mock New-CIPPCertificateAssertion { 'signed.jwt.assertion' } + Mock Invoke-CIPPRestMethod { @{ access_token = 't'; expires_on = ([int](Get-Date -UFormat %s) + 3600) } } + Mock Get-CippTable { @{} } + Mock Get-CIPPAzDataTableEntity { $null } + Mock Update-AzDataTableEntity {} + } + + AfterEach { + foreach ($Name in $script:SavedEnv.Keys) { + if ($null -eq $script:SavedEnv[$Name]) { Remove-Item "env:$Name" -ErrorAction SilentlyContinue } + else { Set-Item "env:$Name" -Value $script:SavedEnv[$Name] } + } + } + + It 'signs a certificate assertion (not the client secret) for the classic v1 endpoint when the flag is on' { + $env:CertificateAuthMode = $true + + $null = Get-ClassicAPIToken -tenantID 'contoso.onmicrosoft.com' -Resource 'https://api.example.com' + + # The assertion audience must be the v1 token endpoint for the target tenant. + Should -Invoke New-CIPPCertificateAssertion -Times 1 -Exactly -ParameterFilter { + $Audience -eq 'https://login.microsoftonline.com/contoso.onmicrosoft.com/oauth2/token' + } + Should -Invoke Invoke-CIPPRestMethod -Times 1 -Exactly -ParameterFilter { + $Body.ContainsKey('client_assertion') -and + $Body['client_assertion_type'] -eq 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' -and + -not $Body.ContainsKey('client_secret') + } + } + + It 'uses the client secret when the flag is off' { + Remove-Item env:CertificateAuthMode -ErrorAction SilentlyContinue + + $null = Get-ClassicAPIToken -tenantID 'contoso.onmicrosoft.com' -Resource 'https://api.example.com' + + Should -Invoke New-CIPPCertificateAssertion -Times 0 -Exactly + Should -Invoke Invoke-CIPPRestMethod -Times 1 -Exactly -ParameterFilter { + $Body['client_secret'] -eq 'sam-secret' -and -not $Body.ContainsKey('client_assertion') + } + } +} diff --git a/Tests/GraphHelper/Get-GraphToken.CertificateAuth.Tests.ps1 b/Tests/GraphHelper/Get-GraphToken.CertificateAuth.Tests.ps1 new file mode 100644 index 0000000000000..ffb8f08f31f84 --- /dev/null +++ b/Tests/GraphHelper/Get-GraphToken.CertificateAuth.Tests.ps1 @@ -0,0 +1,110 @@ +# The certificate-exclusive auth flag lets CIPP authenticate its SAM application with the SAM +# certificate instead of the client secret. Two invariants must never regress: +# 1. When the flag is on, the SAM app's own tokens (app-only AND delegated) use the certificate. +# 2. It is scoped to the SAM app only - callers passing an explicit $AppID/$AppSecret authenticate +# a different application whose registration does not carry the SAM certificate, so they must +# keep using the secret even while the flag is on. +# A regression in either direction is a broad authentication outage, so both are pinned here. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + + function Get-CIPPAuthentication { $true } + function Get-CippTable { param($tablename) } + function Get-CIPPAzDataTableEntity { param($Context, $Filter, $Property) } + function Add-CIPPAzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function Update-AzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function Get-CIPPSAMCertificate { param([switch]$SkipCache) } + function Get-GraphTokenFromCert { param($TenantId, $AppId, $Scope, $Certificate, [switch]$SkipCache) } + function New-CIPPCertificateAssertion { param($TenantId, $AppId, $Certificate) } + function Invoke-CIPPRestMethod { param($Method, $Uri, $Body, $ContentType) } + function Get-CippKeyVaultName {} + function Get-CippKeyVaultSecret { param($VaultName, $Name, [switch]$AsPlainText) } + + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/GraphHelper/Get-GraphToken.ps1') +} + +Describe 'Get-GraphToken certificate-exclusive gating' { + BeforeEach { + # A clean, non-direct-tenant environment: the tenant we ask for equals $env:TenantID so the + # direct-tenant refresh-token branch is never taken. + $script:SavedEnv = @{} + foreach ($Name in 'CertificateAuthMode', 'ApplicationID', 'ApplicationSecret', 'TenantID', 'RefreshToken', 'SetFromProfile') { + $script:SavedEnv[$Name] = [Environment]::GetEnvironmentVariable($Name) + } + $env:ApplicationID = 'sam-app-id' + $env:ApplicationSecret = 'sam-secret' + $env:TenantID = '11111111-2222-3333-4444-555555555555' + $env:RefreshToken = 'sam-refresh-token' + $env:SetFromProfile = 'true' # skip the Get-CIPPAuthentication reload inside the function + + Mock Get-CippTable { @{} } + Mock Get-CIPPAzDataTableEntity { $null } + Mock Add-CIPPAzDataTableEntity {} + Mock Update-AzDataTableEntity {} + Mock Get-CIPPSAMCertificate { [pscustomobject]@{ Certificate = 'CERT-OBJECT'; Thumbprint = 'ABC' } } + Mock Get-GraphTokenFromCert { @{ access_token = 'cert-token'; expires_in = 3600 } } + Mock New-CIPPCertificateAssertion { 'signed.jwt.assertion' } + Mock Invoke-CIPPRestMethod { @{ access_token = 'secret-token'; expires_in = 3600 } } + } + + AfterEach { + foreach ($Name in $script:SavedEnv.Keys) { + if ($null -eq $script:SavedEnv[$Name]) { + Remove-Item "env:$Name" -ErrorAction SilentlyContinue + } else { + Set-Item "env:$Name" -Value $script:SavedEnv[$Name] + } + } + } + + It 'uses the certificate for an app-only SAM token when the flag is on' { + $env:CertificateAuthMode = $true + + $null = Get-GraphToken -AsApp $true + + Should -Invoke Get-GraphTokenFromCert -Times 1 -Exactly + Should -Invoke Invoke-CIPPRestMethod -Times 0 -Exactly + } + + It 'uses a certificate assertion (not the client secret) for a delegated SAM token when the flag is on' { + $env:CertificateAuthMode = $true + + $null = Get-GraphToken + + Should -Invoke New-CIPPCertificateAssertion -Times 1 -Exactly + Should -Invoke Get-GraphTokenFromCert -Times 0 -Exactly + Should -Invoke Invoke-CIPPRestMethod -Times 1 -Exactly -ParameterFilter { + $Body.ContainsKey('client_assertion') -and + $Body['client_assertion_type'] -eq 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' -and + -not $Body.ContainsKey('client_secret') + } + } + + It 'still uses the client secret when the flag is off' { + Remove-Item env:CertificateAuthMode -ErrorAction SilentlyContinue + + $null = Get-GraphToken -AsApp $true + + Should -Invoke Get-GraphTokenFromCert -Times 0 -Exactly + Should -Invoke Invoke-CIPPRestMethod -Times 1 -Exactly -ParameterFilter { + $Body['client_secret'] -eq 'sam-secret' -and -not $Body.ContainsKey('client_assertion') + } + } + + It 'does NOT use the SAM certificate for a different app passed with an explicit AppID/AppSecret, even when the flag is on' { + # This is the guard: the SAM certificate is not registered on an arbitrary application, so + # forcing it here would break every extension/other-app token. The explicit secret must win. + $env:CertificateAuthMode = $true + + $null = Get-GraphToken -AppID 'other-app-id' -AppSecret 'other-app-secret' + + Should -Invoke Get-GraphTokenFromCert -Times 0 -Exactly + Should -Invoke New-CIPPCertificateAssertion -Times 0 -Exactly + Should -Invoke Invoke-CIPPRestMethod -Times 1 -Exactly -ParameterFilter { + $Body['client_id'] -eq 'other-app-id' -and + $Body['client_secret'] -eq 'other-app-secret' -and + -not $Body.ContainsKey('client_assertion') + } + } +} diff --git a/Tests/GraphHelper/Get-Tenants.RefreshLoop.Tests.ps1 b/Tests/GraphHelper/Get-Tenants.RefreshLoop.Tests.ps1 new file mode 100644 index 0000000000000..916947d3e2fbe --- /dev/null +++ b/Tests/GraphHelper/Get-Tenants.RefreshLoop.Tests.ps1 @@ -0,0 +1,225 @@ +# Get-Tenants is the only writer of the Tenants cache, and its refresh loop decides when a cached +# row is trusted as-is and when its domains are re-read from Graph. These pin the rules that were +# found broken in the field: a healthy row's default domain was never re-derived (a custom domain +# made default after onboarding stayed .onmicrosoft.com for months), a refresh scoped to one +# tenant was defeated by a matching Alias, the by-domain form of that refresh matched no +# relationships at all, and a transient failure of the domains read could overwrite a good custom +# default with the fallback's initial domain. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + + function Get-CippTable { param($tablename) } + function Get-CIPPAzDataTableEntity { param($Context, $Filter, $Property) } + function Get-AzDataTableEntity { param($Context, $Filter) } + function Add-CIPPAzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function Add-AzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function Remove-CIPPAzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function New-GraphGetRequest { param($uri, $tenantid, $NoAuthCheck) } + function Write-LogMessage { param($API, $tenant, $message, $Sev, $LogData, $headers, $level) } + function Get-CippException { param($Exception) } + function ConvertTo-CIPPODataFilterValue { param($Value, $Type) } + + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/GraphHelper/Get-Tenants.ps1') + + $script:OrigRefreshToken = $env:RefreshToken + $script:OrigTenantID = $env:TenantID + $env:RefreshToken = 'pester' + $env:TenantID = 'ffffffff-ffff-ffff-ffff-ffffffffffff' + + $script:GuidA = '11111111-1111-1111-1111-111111111111' + $script:GuidB = '22222222-2222-2222-2222-222222222222' + + # LastRefresh is a [DateTimeOffset] here on purpose - that is what the table hands back, and + # the staleness check must cope with it (a [datetime] cast of it throws). + function New-CachedRow { + param($Guid, $DisplayName, $Default, $Initial, $LastRefresh) + [PSCustomObject]@{ + PartitionKey = 'Tenants' + RowKey = $Guid + customerId = $Guid + displayName = $DisplayName + defaultDomainName = $Default + initialDomainName = $Initial + delegatedPrivilegeStatus = 'granularDelegatedAdminPrivileges' + Excluded = $false + GraphErrorCount = 0 + LastGraphError = '' + RequiresRefresh = $false + LastRefresh = $LastRefresh + } + } + function New-Relationship { + param($Guid, $DisplayName) + [PSCustomObject]@{ + displayName = "GDAP-$DisplayName" + customer = [PSCustomObject]@{ tenantId = $Guid; displayName = $DisplayName } + autoExtendDuration = 'P180D' + endDateTime = (Get-Date).AddYears(1).ToString('o') + } + } + function New-Domain { + param($Id, [bool]$IsDefault, [bool]$IsInitial) + [PSCustomObject]@{ id = $Id; isDefault = $IsDefault; isInitial = $IsInitial } + } +} + +AfterAll { + $env:RefreshToken = $script:OrigRefreshToken + $env:TenantID = $script:OrigTenantID +} + +Describe 'Get-Tenants refresh loop' { + BeforeEach { + $script:RowsByKey = @{} + $script:Relationships = @() + $script:Aliases = @{} + $script:DomainsByTenant = @{} + $script:ThrowDomainsFor = @() + $script:FallbackDomain = 'fallback.onmicrosoft.com' + + Mock Get-CippTable { @{} } + Mock ConvertTo-CIPPODataFilterValue { $Value } + Mock Write-LogMessage {} + Mock Get-CippException { @{} } + Mock Add-CIPPAzDataTableEntity {} + Mock Get-AzDataTableEntity { + if ($Filter -match "PartitionKey eq '([^']+)'" -and $script:Aliases.ContainsKey($Matches[1])) { + return [PSCustomObject]@{ Value = $script:Aliases[$Matches[1]] } + } + $null + } + Mock Get-CIPPAzDataTableEntity { + if ([string]::IsNullOrEmpty($Filter)) { return [PSCustomObject]@{ state = 'gdap' } } # tenantMode + if ($Filter -like '*Excluded eq true*') { return $null } # skip list + if ($Filter -match "RowKey eq '([^']+)'") { return $script:RowsByKey[$Matches[1]] } # one tenant + return @($script:RowsByKey.Values) # cache read + } + Mock New-GraphGetRequest { + if ($uri -like '*delegatedAdminRelationships*') { return $script:Relationships } + if ($uri -like '*beta/domains*') { + if ($script:ThrowDomainsFor -contains $tenantid) { throw 'domains read failed' } + return $script:DomainsByTenant[$tenantid] + } + if ($uri -like '*findTenantInformationByTenantId*') { return [PSCustomObject]@{ defaultDomainName = $script:FallbackDomain } } + throw "unexpected Graph call: $uri" + } + } + + Context 'bulk refresh (no TenantFilter)' { + BeforeEach { + $script:Relationships = @(New-Relationship -Guid $script:GuidA -DisplayName 'Contoso') + $script:DomainsByTenant[$script:GuidA] = @( + (New-Domain -Id 'contoso.com' -IsDefault $true -IsInitial $false), + (New-Domain -Id 'contoso.onmicrosoft.com' -IsDefault $false -IsInitial $true) + ) + } + + It 'trusts a fresh healthy row and does not re-read its domains' { + $script:RowsByKey[$script:GuidA] = New-CachedRow -Guid $script:GuidA -DisplayName 'Contoso' -Default 'contoso.com' -Initial 'contoso.onmicrosoft.com' -LastRefresh ([DateTimeOffset]::UtcNow.AddDays(-2)) + + $Result = Get-Tenants -IncludeAll -TriggerRefresh + + Should -Invoke New-GraphGetRequest -ParameterFilter { $uri -like '*beta/domains*' } -Times 0 -Exactly + @($Result).Count | Should -Be 1 + $Result.defaultDomainName | Should -Be 'contoso.com' + } + + It 're-reads domains for a row last derived over 7 days ago and picks up the new default' { + # Cached while .onmicrosoft.com was still the default; a custom domain has since been made default in M365. + $script:RowsByKey[$script:GuidA] = New-CachedRow -Guid $script:GuidA -DisplayName 'Contoso' -Default 'contoso.onmicrosoft.com' -Initial 'contoso.onmicrosoft.com' -LastRefresh ([DateTimeOffset]::UtcNow.AddDays(-30)) + + $Result = Get-Tenants -IncludeAll -TriggerRefresh + + Should -Invoke New-GraphGetRequest -ParameterFilter { $uri -like '*beta/domains*' } -Times 1 -Exactly + $Result.defaultDomainName | Should -Be 'contoso.com' + $Result.RequiresRefresh | Should -BeFalse + $Result.LastRefresh | Should -BeGreaterThan (Get-Date).ToUniversalTime().AddMinutes(-1) + } + + It 'treats a row with no LastRefresh as stale' { + $script:RowsByKey[$script:GuidA] = New-CachedRow -Guid $script:GuidA -DisplayName 'Contoso' -Default 'contoso.onmicrosoft.com' -Initial 'contoso.onmicrosoft.com' -LastRefresh $null + + $null = Get-Tenants -IncludeAll -TriggerRefresh + + Should -Invoke New-GraphGetRequest -ParameterFilter { $uri -like '*beta/domains*' } -Times 1 -Exactly + } + + It "does not let one tenant's fallback flag the next tenant for refresh" { + $script:Relationships = @( + (New-Relationship -Guid $script:GuidA -DisplayName 'Contoso'), + (New-Relationship -Guid $script:GuidB -DisplayName 'Fabrikam') + ) + $Stale = [DateTimeOffset]::UtcNow.AddDays(-30) + $script:RowsByKey[$script:GuidA] = New-CachedRow -Guid $script:GuidA -DisplayName 'Contoso' -Default 'contoso.onmicrosoft.com' -Initial 'contoso.onmicrosoft.com' -LastRefresh $Stale + $script:RowsByKey[$script:GuidB] = New-CachedRow -Guid $script:GuidB -DisplayName 'Fabrikam' -Default 'fabrikam.onmicrosoft.com' -Initial 'fabrikam.onmicrosoft.com' -LastRefresh $Stale + $script:DomainsByTenant[$script:GuidB] = @( + (New-Domain -Id 'fabrikam.com' -IsDefault $true -IsInitial $false), + (New-Domain -Id 'fabrikam.onmicrosoft.com' -IsDefault $false -IsInitial $true) + ) + $script:ThrowDomainsFor = @($script:GuidA) + + $null = Get-Tenants -IncludeAll -TriggerRefresh + + # A fell back and is flagged; B read fine and must not inherit the flag. + Should -Invoke Add-CIPPAzDataTableEntity -ParameterFilter { $Entity.customerId -eq $script:GuidA -and $Entity.RequiresRefresh -eq $true } -Times 1 -Exactly + Should -Invoke Add-CIPPAzDataTableEntity -ParameterFilter { $Entity.customerId -eq $script:GuidB -and $Entity.defaultDomainName -eq 'fabrikam.com' -and $Entity.RequiresRefresh -eq $false } -Times 1 -Exactly + } + } + + Context 'refresh scoped to one tenant' { + BeforeEach { + $script:Relationships = @(New-Relationship -Guid $script:GuidA -DisplayName 'Contoso') + $script:DomainsByTenant[$script:GuidA] = @( + (New-Domain -Id 'contoso.com' -IsDefault $true -IsInitial $false), + (New-Domain -Id 'contoso.onmicrosoft.com' -IsDefault $false -IsInitial $true) + ) + # Fresh and healthy: only the scoping should force the re-read. + $script:RowsByKey[$script:GuidA] = New-CachedRow -Guid $script:GuidA -DisplayName 'Contoso' -Default 'contoso.onmicrosoft.com' -Initial 'contoso.onmicrosoft.com' -LastRefresh ([DateTimeOffset]::UtcNow.AddDays(-2)) + } + + It 're-reads domains even when the tenant alias matches its display name' { + $script:Aliases[$script:GuidA] = 'Contoso' + + $Result = Get-Tenants -TriggerRefresh -TenantFilter $script:GuidA + + Should -Invoke New-GraphGetRequest -ParameterFilter { $uri -like '*beta/domains*' } -Times 1 -Exactly + $Result.defaultDomainName | Should -Be 'contoso.com' + } + + It 'resolves a domain filter to the customerId and scopes the relationship pull to it' { + $Result = Get-Tenants -TriggerRefresh -TenantFilter 'contoso.onmicrosoft.com' + + Should -Invoke New-GraphGetRequest -ParameterFilter { $uri -like '*delegatedAdminRelationships*' -and $uri -like "*customer/tenantId eq '$($script:GuidA)'*" } -Times 1 -Exactly + Should -Invoke New-GraphGetRequest -ParameterFilter { $uri -like '*beta/domains*' } -Times 1 -Exactly + @($Result).Count | Should -Be 1 + $Result.defaultDomainName | Should -Be 'contoso.com' + } + } + + Context 'when the domains read fails' { + BeforeEach { + $script:Relationships = @(New-Relationship -Guid $script:GuidA -DisplayName 'Contoso') + $script:ThrowDomainsFor = @($script:GuidA) + $script:FallbackDomain = 'contoso.onmicrosoft.com' + } + + It "keeps a cached custom default over the fallback's initial domain and flags the row for retry" { + $script:RowsByKey[$script:GuidA] = New-CachedRow -Guid $script:GuidA -DisplayName 'Contoso' -Default 'contoso.com' -Initial 'contoso.onmicrosoft.com' -LastRefresh ([DateTimeOffset]::UtcNow.AddDays(-30)) + + $Result = Get-Tenants -IncludeAll -TriggerRefresh + + Should -Invoke Add-CIPPAzDataTableEntity -ParameterFilter { $Entity.RequiresRefresh -eq $true -and $Entity.defaultDomainName -eq 'contoso.com' -and $Entity.initialDomainName -eq 'contoso.onmicrosoft.com' } -Times 1 -Exactly + $Result.defaultDomainName | Should -Be 'contoso.com' + } + + It 'takes the fallback value when there is no custom default to protect' { + $script:RowsByKey[$script:GuidA] = New-CachedRow -Guid $script:GuidA -DisplayName 'Contoso' -Default 'contoso.onmicrosoft.com' -Initial 'contoso.onmicrosoft.com' -LastRefresh ([DateTimeOffset]::UtcNow.AddDays(-30)) + + $Result = Get-Tenants -IncludeAll -TriggerRefresh + + Should -Invoke Add-CIPPAzDataTableEntity -ParameterFilter { $Entity.RequiresRefresh -eq $true -and $Entity.defaultDomainName -eq 'contoso.onmicrosoft.com' } -Times 1 -Exactly + $Result.defaultDomainName | Should -Be 'contoso.onmicrosoft.com' + } + } +} diff --git a/Tests/GraphHelper/New-CIPPMFAConnectorToken.Tests.ps1 b/Tests/GraphHelper/New-CIPPMFAConnectorToken.Tests.ps1 new file mode 100644 index 0000000000000..bcb4237ecb99d --- /dev/null +++ b/Tests/GraphHelper/New-CIPPMFAConnectorToken.Tests.ps1 @@ -0,0 +1,63 @@ +# New-CIPPMFAConnectorToken caches a long-lived connector secret per tenant so the expensive provisioning +# (adding a credential to the MFA client SP) only runs when no usable cached secret exists. These tests pin +# that a cached secret is reused without provisioning, and that a cache miss provisions and stores one. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + + function New-GraphGetRequest { param($uri, $tenantid, $AsApp) } + function New-GraphPostRequest { param($uri, $tenantid, $type, $body, $AsApp) } + function Update-AppManagementPolicy { param($TenantFilter, $ApplicationId, [switch]$ServicePrincipal) } + function Get-CIPPTable { param($tablename) } + function Get-CIPPAzDataTableEntity { param($Filter) } + function Add-CIPPAzDataTableEntity { param($Entity, [switch]$Force) } + function Get-Tenants { param($TenantFilter) } + + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/GraphHelper/New-CIPPMFAConnectorToken.ps1') + + $script:TenantGuid = '11111111-1111-1111-1111-111111111111' + $script:MFAAppID = '981f26a1-7f43-403b-a875-f8b09b8cd720' +} + +Describe 'New-CIPPMFAConnectorToken secret caching' { + BeforeEach { + # Force the dev (DevSecrets table) storage path so the assertions are deterministic. + $env:NonLocalHostAzurite = 'true' + Mock Get-CIPPTable { @{ Context = 'stub' } } + Mock Add-CIPPAzDataTableEntity {} + Mock Update-AppManagementPolicy {} + # Token exchange + Mock Invoke-RestMethod { [pscustomobject]@{ access_token = 'TOKEN123' } } + # SP lookup returns the MFA client SP so provisioning finds it (no SP create) + Mock New-GraphGetRequest { @([pscustomobject]@{ id = 'mfa-sp-id'; appId = $script:MFAAppID }) } + # addPassword returns a fresh secret + Mock New-GraphPostRequest { [pscustomobject]@{ secretText = 'NEWSECRET' } } + } + + AfterEach { + Remove-Item env:NonLocalHostAzurite -ErrorAction SilentlyContinue + } + + It 'reuses a cached secret without provisioning' { + Mock Get-CIPPAzDataTableEntity { [pscustomobject]@{ SecretValue = 'CACHEDSECRET' } } + + $result = New-CIPPMFAConnectorToken -TenantFilter $script:TenantGuid + + $result.AccessToken | Should -Be 'TOKEN123' + # No provisioning: neither the SP lookup nor addPassword should run on a cache hit. + Should -Not -Invoke New-GraphGetRequest + Should -Not -Invoke New-GraphPostRequest + Should -Not -Invoke Add-CIPPAzDataTableEntity + } + + It 'provisions and stores a new secret on a cache miss' { + Mock Get-CIPPAzDataTableEntity { $null } + + $result = New-CIPPMFAConnectorToken -TenantFilter $script:TenantGuid + + $result.AccessToken | Should -Be 'TOKEN123' + # addPassword is the only New-GraphPostRequest here (the SP already exists), and the new secret is cached. + Should -Invoke New-GraphPostRequest -Times 1 -Exactly + Should -Invoke Add-CIPPAzDataTableEntity -Times 1 -Exactly + } +} diff --git a/Tests/GraphHelper/New-GraphBulkRequest.Paging.Tests.ps1 b/Tests/GraphHelper/New-GraphBulkRequest.Paging.Tests.ps1 new file mode 100644 index 0000000000000..293b0d8dbee2a --- /dev/null +++ b/Tests/GraphHelper/New-GraphBulkRequest.Paging.Tests.ps1 @@ -0,0 +1,115 @@ +# New-GraphBulkRequest follows @odata.nextLink for every $batch item by re-batching the +# continuation pages. A continuation page that fails used to vanish without a trace: the parent +# item kept status 200 and only its first page, so a caller that treats "status < 400" as "the +# collection is complete" (the drift engine's stale-row prune does) worked from a partial list. +# On tenants with more than one page of settings-catalog policies a throttled page 2 therefore +# looked like "those policies are gone", their accepted drift rows were pruned, and they came +# back as New on the next run. These tests pin the retry and the incomplete marker. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + + function Get-AuthorisedRequest { param($Uri, $TenantID) } + function Get-GraphToken { param($tenantid, $scope, $AsApp) } + function Get-CippTable { param($tablename) } + function Get-CIPPAzDataTableEntity { param($Filter, $TableName) } + function Update-AzDataTableEntity { param($Entity, [switch]$Force, $TableName) } + function Invoke-CIPPRestMethod { param($Uri, $Method, $Headers, $ContentType, $Body) } + + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/GraphHelper/New-GraphBulkRequest.ps1') + + function New-BatchReply { + param($Responses) + [pscustomobject]@{ responses = @($Responses) } + } + function New-PageItem { + param($Id, $Status = 200, $Values = @(), $NextLink, $Headers = $null, $Error = $null) + $Body = [pscustomobject]@{ value = @($Values) } + if ($NextLink) { $Body | Add-Member -NotePropertyName '@odata.nextLink' -NotePropertyValue $NextLink } + if ($Error) { $Body | Add-Member -NotePropertyName 'error' -NotePropertyValue ([pscustomobject]@{ message = $Error }) } + [pscustomobject]@{ id = $Id; status = $Status; headers = $Headers; body = $Body } + } +} + +Describe 'New-GraphBulkRequest continuation paging' { + BeforeEach { + $script:Calls = [System.Collections.Generic.List[string]]::new() + $script:Replies = [System.Collections.Generic.Queue[object]]::new() + Mock Get-AuthorisedRequest { $true } + Mock Get-GraphToken { @{} } + Mock Get-CippTable { @{} } + Mock Get-CIPPAzDataTableEntity { $null } + Mock Update-AzDataTableEntity {} + Mock Start-Sleep {} + Mock Invoke-CIPPRestMethod { + $script:Calls.Add($Body) + $script:Replies.Dequeue() + } + $script:Requests = @(@{ id = 'cp'; url = 'deviceManagement/configurationPolicies?$top=999'; method = 'GET' }) + $script:Next = 'https://graph.microsoft.com/beta/deviceManagement/configurationPolicies?$top=999&$skiptoken=page2' + } + + It 'merges every continuation page into the parent item' { + $script:Replies.Enqueue((New-BatchReply (New-PageItem -Id 'cp' -Values @(@{ id = 'p1' }, @{ id = 'p2' }) -NextLink $script:Next))) + $script:Replies.Enqueue((New-BatchReply (New-PageItem -Id 'cp' -Values @(@{ id = 'p3' })))) + + $Result = @(New-GraphBulkRequest -Requests $script:Requests -tenantid 'contoso.onmicrosoft.com' -asapp $true) + + $Result.Count | Should -Be 1 + @($Result[0].body.value).id | Should -Be @('p1', 'p2', 'p3') + $Result[0].PSObject.Properties['PagingIncomplete'] | Should -BeNullOrEmpty + $script:Calls.Count | Should -Be 2 + } + + It 'retries a throttled continuation page once, honouring Retry-After' { + $script:Replies.Enqueue((New-BatchReply (New-PageItem -Id 'cp' -Values @(@{ id = 'p1' }) -NextLink $script:Next))) + $script:Replies.Enqueue((New-BatchReply (New-PageItem -Id 'cp' -Status 429 -Headers ([pscustomobject]@{ 'Retry-After' = '3' }) -Error 'throttled'))) + $script:Replies.Enqueue((New-BatchReply (New-PageItem -Id 'cp' -Values @(@{ id = 'p2' })))) + + $Result = @(New-GraphBulkRequest -Requests $script:Requests -tenantid 'contoso.onmicrosoft.com' -asapp $true -WarningAction SilentlyContinue) + + @($Result[0].body.value).id | Should -Be @('p1', 'p2') + $Result[0].PSObject.Properties['PagingIncomplete'] | Should -BeNullOrEmpty + $script:Calls.Count | Should -Be 3 + $script:Calls[2] | Should -Match 'skiptoken=page2' + Should -Invoke Start-Sleep -Times 1 -Exactly -ParameterFilter { $Seconds -eq 3 } + } + + It 'marks the parent incomplete when the continuation page keeps failing' { + $script:Replies.Enqueue((New-BatchReply (New-PageItem -Id 'cp' -Values @(@{ id = 'p1' }) -NextLink $script:Next))) + $script:Replies.Enqueue((New-BatchReply (New-PageItem -Id 'cp' -Status 429 -Error 'throttled'))) + $script:Replies.Enqueue((New-BatchReply (New-PageItem -Id 'cp' -Status 503 -Error 'busy'))) + + $Result = @(New-GraphBulkRequest -Requests $script:Requests -tenantid 'contoso.onmicrosoft.com' -asapp $true -WarningVariable Warnings -WarningAction SilentlyContinue) + + @($Result[0].body.value).id | Should -Be @('p1') + $Result[0].status | Should -Be 200 + $Result[0].PagingIncomplete | Should -BeTrue + $Result[0].PagingError | Should -Match '503' + $Result[0].PagingError | Should -Match 'busy' + @($Warnings) | Where-Object { $_ -match 'incomplete' } | Should -Not -BeNullOrEmpty + $script:Calls.Count | Should -Be 3 + } + + It 'marks the parent incomplete when the batch reply omits the continuation page' { + $script:Replies.Enqueue((New-BatchReply (New-PageItem -Id 'cp' -Values @(@{ id = 'p1' }) -NextLink $script:Next))) + $script:Replies.Enqueue((New-BatchReply @())) + + $Result = @(New-GraphBulkRequest -Requests $script:Requests -tenantid 'contoso.onmicrosoft.com' -asapp $true -WarningAction SilentlyContinue) + + @($Result[0].body.value).id | Should -Be @('p1') + $Result[0].PagingIncomplete | Should -BeTrue + $Result[0].PagingError | Should -Match 'missing' + } + + It 'leaves items that never paged untouched' { + $script:Replies.Enqueue((New-BatchReply @((New-PageItem -Id 'cp' -Values @(@{ id = 'p1' })), (New-PageItem -Id 'other' -Status 429 -Error 'throttled')))) + + $Result = @(New-GraphBulkRequest -Requests ($script:Requests + @(@{ id = 'other'; url = 'x'; method = 'GET' })) -tenantid 'contoso.onmicrosoft.com' -asapp $true) + + $Result.Count | Should -Be 2 + ($Result | Where-Object id -eq 'cp').PSObject.Properties['PagingIncomplete'] | Should -BeNullOrEmpty + ($Result | Where-Object id -eq 'other').status | Should -Be 429 + $script:Calls.Count | Should -Be 1 + } +} diff --git a/Tests/GraphHelper/Update-AppManagementPolicy.CertificateOnly.Tests.ps1 b/Tests/GraphHelper/Update-AppManagementPolicy.CertificateOnly.Tests.ps1 new file mode 100644 index 0000000000000..85a4bf8ecc4d5 --- /dev/null +++ b/Tests/GraphHelper/Update-AppManagementPolicy.CertificateOnly.Tests.ps1 @@ -0,0 +1,91 @@ +# In certificate-only mode CIPP adds no client secret, so the app management policy exemption must +# NOT disable the password-addition block - doing so would re-permit secrets on the CIPP app and +# defeat the tenant's "Block password addition" (Secure Future Initiative) policy. It must still +# disable the key-credential restrictions so the SAM certificate can be registered. Both are pinned. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + + function New-GraphBulkRequest { param($Requests, $NoAuthCheck, $asapp, $tenantid, $headers) } + function New-GraphPostRequest { param($uri, $type, $body, $asapp, $NoAuthCheck, $tenantid, $headers) } + + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/GraphHelper/Update-AppManagementPolicy.ps1') +} + +Describe 'Update-AppManagementPolicy certificate-only exemption' { + BeforeEach { + $script:CreatedPolicyBody = $null + $script:SavedEnv = @{} + foreach ($Name in 'CertificateAuthMode', 'ApplicationID') { $script:SavedEnv[$Name] = [Environment]::GetEnvironmentVariable($Name) } + Remove-Item env:CertificateAuthMode -ErrorAction SilentlyContinue + $env:ApplicationID = 'sam-app-id' + + # Default tenant policy blocks BOTH password addition and asymmetric key lifetime. + Mock New-GraphBulkRequest { + @( + [pscustomobject]@{ id = 'defaultPolicy'; body = [pscustomobject]@{ + applicationRestrictions = [pscustomobject]@{ + passwordCredentials = @([pscustomobject]@{ restrictionType = 'passwordAddition'; state = 'enabled' }) + keyCredentials = @([pscustomobject]@{ restrictionType = 'asymmetricKeyLifetime'; state = 'enabled' }) + } + } } + [pscustomobject]@{ id = 'appPolicies'; body = [pscustomobject]@{ value = @() } } + [pscustomobject]@{ id = 'appRegistration'; body = [pscustomobject]@{ id = 'cipp-obj-id'; appId = 'cipp-app-id'; displayName = 'CIPP-SAM' } } + ) + } + + # Capture the created exemption policy body; the assignment call just needs to succeed. + Mock New-GraphPostRequest -ParameterFilter { $uri -eq 'https://graph.microsoft.com/v1.0/policies/appManagementPolicies' } { + $script:CreatedPolicyBody = $body | ConvertFrom-Json + [pscustomobject]@{ id = 'new-policy-id' } + } + Mock New-GraphPostRequest { [pscustomobject]@{ id = 'new-policy-id' } } + } + + AfterEach { + foreach ($Name in $script:SavedEnv.Keys) { + if ($null -eq $script:SavedEnv[$Name]) { Remove-Item "env:$Name" -ErrorAction SilentlyContinue } + else { Set-Item "env:$Name" -Value $script:SavedEnv[$Name] } + } + } + + It 'omits the password-credential exemption in certificate-only mode (key exemption still applied)' { + $null = Update-AppManagementPolicy -TenantFilter 'contoso' -ApplicationId 'cipp-app-id' -CertificateOnly $true + + $script:CreatedPolicyBody | Should -Not -BeNullOrEmpty + # Password block left in force - no passwordCredentials exemption. + $script:CreatedPolicyBody.restrictions.PSObject.Properties.Name | Should -Not -Contain 'passwordCredentials' + # Key restrictions still disabled so the SAM certificate can be registered. + $KeyTypes = $script:CreatedPolicyBody.restrictions.keyCredentials.restrictionType + $KeyTypes | Should -Contain 'asymmetricKeyLifetime' + $KeyTypes | Should -Contain 'trustedCertificateAuthority' + } + + It 'includes the password-credential exemption in secret mode' { + $null = Update-AppManagementPolicy -TenantFilter 'contoso' -ApplicationId 'cipp-app-id' -CertificateOnly $false + + $script:CreatedPolicyBody | Should -Not -BeNullOrEmpty + $PwdTypes = $script:CreatedPolicyBody.restrictions.passwordCredentials.restrictionType + $PwdTypes | Should -Contain 'passwordAddition' + $PwdTypes | Should -Contain 'symmetricKeyAddition' + $script:CreatedPolicyBody.restrictions.keyCredentials.restrictionType | Should -Contain 'asymmetricKeyLifetime' + } + + It 'defaults to skipping the password exemption for the SAM app when the flag is on' { + $env:CertificateAuthMode = $true + + $null = Update-AppManagementPolicy -TenantFilter 'contoso' -ApplicationId 'sam-app-id' + + $script:CreatedPolicyBody.restrictions.PSObject.Properties.Name | Should -Not -Contain 'passwordCredentials' + } + + It 'still exempts the password for a NON-SAM app even when the flag is on' { + # The global flag must not strip the password exemption from other app registrations, which + # legitimately use a secret - only the SAM app authenticates with the certificate. + $env:CertificateAuthMode = $true + + $null = Update-AppManagementPolicy -TenantFilter 'contoso' -ApplicationId 'some-other-app' + + $script:CreatedPolicyBody.restrictions.passwordCredentials.restrictionType | Should -Contain 'passwordAddition' + } +} diff --git a/Tests/GraphHelper/Update-AppManagementPolicy.ExemptionBody.Tests.ps1 b/Tests/GraphHelper/Update-AppManagementPolicy.ExemptionBody.Tests.ps1 new file mode 100644 index 0000000000000..9d234e4af7ca0 --- /dev/null +++ b/Tests/GraphHelper/Update-AppManagementPolicy.ExemptionBody.Tests.ps1 @@ -0,0 +1,136 @@ +# Update-AppManagementPolicy builds the "CIPP Exemption Policy" body that Graph must accept. Graph +# rejects the whole appManagementPolicy create when it carries a restriction type it does not need, or +# a lifetime-type restriction (asymmetricKeyLifetime) without a valid maxLifetime. These tests pin the +# emitted body so a hardened tenant's default policy no longer blocks the exemption. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + + function New-GraphBulkRequest { param($Requests, $NoAuthCheck, $asapp, $tenantid, $headers) } + function New-GraphPostRequest { param($uri, $type, $body, $asapp, $NoAuthCheck, $tenantid, $headers) } + + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/GraphHelper/Update-AppManagementPolicy.ps1') + + $script:AppId = '981f26a1-7f43-403b-a875-f8b09b8cd720' + + # Builds the three-item bulk response the function expects, with the caller-supplied default policy + # restrictions and an empty app-policy list (no existing exemption, app not yet targeted). + function New-BulkResponse { + param($PasswordRestrictions = @(), $KeyRestrictions = @()) + @( + [PSCustomObject]@{ id = 'defaultPolicy'; body = [PSCustomObject]@{ + applicationRestrictions = [PSCustomObject]@{ + passwordCredentials = $PasswordRestrictions + keyCredentials = $KeyRestrictions + } + } + } + [PSCustomObject]@{ id = 'appPolicies'; body = [PSCustomObject]@{ value = @() } } + [PSCustomObject]@{ id = 'appRegistration'; body = [PSCustomObject]@{ id = 'mfa-app-object-id'; appId = $script:AppId; displayName = 'Azure Multi-Factor Auth Client' } } + ) + } +} + +Describe 'Update-AppManagementPolicy exemption body' { + BeforeEach { + $script:CreateBody = $null + $script:AssignUri = $null + # Certificate-only mode is exercised in the sibling CertificateOnly suite; pin it off here so the + # body-shape assertions do not depend on the host's CertificateAuthMode environment variable. + $script:SavedCertMode = [Environment]::GetEnvironmentVariable('CertificateAuthMode') + Remove-Item env:CertificateAuthMode -ErrorAction SilentlyContinue + + Mock New-GraphPostRequest { + if ($uri -match 'policies/appManagementPolicies$') { + $script:CreateBody = $body | ConvertFrom-Json + } + if ($uri -match 'appManagementPolicies/\$ref$') { + $script:AssignUri = $uri + } + [PSCustomObject]@{ id = 'created-policy-id' } + } + } + + AfterEach { + if ($null -eq $script:SavedCertMode) { Remove-Item env:CertificateAuthMode -ErrorAction SilentlyContinue } + else { $env:CertificateAuthMode = $script:SavedCertMode } + } + + It 'emits only the passwordCredentials block when the default policy blocks password addition' { + Mock New-GraphBulkRequest { + New-BulkResponse -PasswordRestrictions @([PSCustomObject]@{ restrictionType = 'passwordAddition'; state = 'enabled' }) + } + + $null = Update-AppManagementPolicy -TenantFilter 'contoso.onmicrosoft.com' -ApplicationId $script:AppId -CertificateOnly $false + + $script:CreateBody | Should -Not -BeNullOrEmpty + $script:CreateBody.restrictions.passwordCredentials | Should -Not -BeNullOrEmpty + # No key credentials are blocked, so the body must not drag in a keyCredentials restriction Graph would reject. + $script:CreateBody.restrictions.PSObject.Properties.Name | Should -Not -Contain 'keyCredentials' + } + + It 'emits an asymmetricKeyLifetime restriction with a non-null maxLifetime when key credentials are blocked' { + Mock New-GraphBulkRequest { + New-BulkResponse -KeyRestrictions @([PSCustomObject]@{ restrictionType = 'asymmetricKeyLifetime'; state = 'enabled'; maxLifetime = 'P90D' }) + } + + $null = Update-AppManagementPolicy -TenantFilter 'contoso.onmicrosoft.com' -ApplicationId $script:AppId -CertificateOnly $false + + $script:CreateBody | Should -Not -BeNullOrEmpty + $Lifetime = $script:CreateBody.restrictions.keyCredentials | Where-Object { $_.restrictionType -eq 'asymmetricKeyLifetime' } + $Lifetime | Should -Not -BeNullOrEmpty + $Lifetime.maxLifetime | Should -Not -BeNullOrEmpty + # It echoes the tenant default's value when the default exposes one. + $Lifetime.maxLifetime | Should -Be 'P90D' + } + + It 'falls back to a conservative maxLifetime when the default policy does not expose one' { + Mock New-GraphBulkRequest { + New-BulkResponse -KeyRestrictions @([PSCustomObject]@{ restrictionType = 'asymmetricKeyLifetime'; state = 'enabled' }) + } + + $null = Update-AppManagementPolicy -TenantFilter 'contoso.onmicrosoft.com' -ApplicationId $script:AppId -CertificateOnly $false + + $Lifetime = $script:CreateBody.restrictions.keyCredentials | Where-Object { $_.restrictionType -eq 'asymmetricKeyLifetime' } + $Lifetime.maxLifetime | Should -Be 'P730D' + } + + It 'assigns the exemption to the application registration by default' { + Mock New-GraphBulkRequest { + New-BulkResponse -PasswordRestrictions @([PSCustomObject]@{ restrictionType = 'passwordAddition'; state = 'enabled' }) + } + + $null = Update-AppManagementPolicy -TenantFilter 'contoso.onmicrosoft.com' -ApplicationId $script:AppId -CertificateOnly $false + + $script:AssignUri | Should -Not -BeNullOrEmpty + $script:AssignUri | Should -Match '/applications/' + $script:AssignUri | Should -Not -Match '/servicePrincipals/' + } + + It 'assigns the exemption to the service principal when -ServicePrincipal is set' { + Mock New-GraphBulkRequest { + New-BulkResponse -PasswordRestrictions @([PSCustomObject]@{ restrictionType = 'passwordAddition'; state = 'enabled' }) + } + + $null = Update-AppManagementPolicy -TenantFilter 'contoso.onmicrosoft.com' -ApplicationId $script:AppId -CertificateOnly $false -ServicePrincipal + + $script:AssignUri | Should -Not -BeNullOrEmpty + $script:AssignUri | Should -Match '/servicePrincipals/' + $script:AssignUri | Should -Not -Match '/applications/' + } + + It 'treats an already-assigned policy reference as success, not a failure' { + Mock New-GraphBulkRequest { + New-BulkResponse -PasswordRestrictions @([PSCustomObject]@{ restrictionType = 'passwordAddition'; state = 'enabled' }) + } + # The target already has the policy assigned, so the $ref POST returns a duplicate-reference error. + Mock New-GraphPostRequest -ParameterFilter { $uri -match 'appManagementPolicies/\$ref$' } { + throw "One or more added object references already exist for the following modified properties: 'appManagementPolicies'." + } + + # A throw here would surface as a test error, which is the failure we are guarding against. + $result = Update-AppManagementPolicy -TenantFilter 'contoso.onmicrosoft.com' -ApplicationId $script:AppId -CertificateOnly $false -ServicePrincipal + $result.PolicyAction | Should -Not -Match 'Failed' + $result.PolicyAction | Should -Match 'assigned' + } +} diff --git a/Tests/Private/Add-CIPPDbItem.Tests.ps1 b/Tests/Private/Add-CIPPDbItem.Tests.ps1 index 1a982fdbf16f0..2fd4f8fa94cf0 100644 --- a/Tests/Private/Add-CIPPDbItem.Tests.ps1 +++ b/Tests/Private/Add-CIPPDbItem.Tests.ps1 @@ -85,6 +85,27 @@ Describe 'Add-CIPPDbItem authoritative empty collections' { } } + It 'projects every row-level split marker so the cleanup reassembles split orphans instead of dropping them' { + # Regression for #462. Get-AzDataTableLargeEntity only reassembles a split entity when the + # projection carries all of OriginalEntityId, PartIndex and PartCount. Selecting a subset + # (OriginalEntityId alone) made the module recognise a split row but fail to reassemble it, + # throw IncompleteEntity, and drop the whole entity - so the sweep never saw stale split + # rows from earlier runs and one uncollectable generation accumulated per run. The sweep + # relies on reassembly (each logical entity carries its RunId), so all three must be present. + Mock Get-CIPPAzDataTableEntity { @() } + + Add-CIPPDbItem -TenantFilter 'contoso.onmicrosoft.com' -Type 'IntuneIntents' -Data @( + [PSCustomObject]@{ id = 'new-policy' } + ) + + Should -Invoke Get-CIPPAzDataTableEntity -Times 1 -Exactly -ParameterFilter { + $Property -contains 'OriginalEntityId' -and + $Property -contains 'PartIndex' -and + $Property -contains 'PartCount' -and + $Property -contains 'RunId' + } + } + It 'stamps every written row with the run id the cleanup keys on' { $script:Flushed = [System.Collections.Generic.List[object]]::new() Mock Add-CIPPAzDataTableEntity { $script:Flushed.AddRange(@($Entity)) } diff --git a/Tests/Private/Format-CIPPCAPolicy.Tests.ps1 b/Tests/Private/Format-CIPPCAPolicy.Tests.ps1 index 0401056ac55fd..14490235e8b28 100644 --- a/Tests/Private/Format-CIPPCAPolicy.Tests.ps1 +++ b/Tests/Private/Format-CIPPCAPolicy.Tests.ps1 @@ -326,6 +326,60 @@ Describe 'Format-CIPPCAPolicy' { } } + Context 'sessionControls.signInFrequency canonicalization' { + It 'casts a numeric string value to an int' { + $Policy = Convert-Policy '{ + "displayName": "CA201", + "conditions": { "users": { "includeUsers": ["All"] } }, + "sessionControls": { "signInFrequency": { "isEnabled": true, "frequencyInterval": "timeBased", "type": "hours", "value": "12" } } + }' + $Policy.sessionControls.signInFrequency.value | Should -Be 12 + $Policy.sessionControls.signInFrequency.value | Should -BeOfType [int] + } + + It 'sets value and type to explicit null when frequencyInterval is everyTime' { + $Policy = Convert-Policy '{ + "displayName": "CA201", + "conditions": { "users": { "includeUsers": ["All"] } }, + "sessionControls": { "signInFrequency": { "isEnabled": true, "frequencyInterval": "everyTime" } } + }' + $Policy.sessionControls.signInFrequency.PSObject.Properties.Name | Should -Contain 'value' + $Policy.sessionControls.signInFrequency.PSObject.Properties.Name | Should -Contain 'type' + $Policy.sessionControls.signInFrequency.value | Should -BeNullOrEmpty + $Policy.sessionControls.signInFrequency.type | Should -BeNullOrEmpty + } + + It 'overrides a stale value/type with null even when everyTime carries leftovers' { + $Policy = Convert-Policy '{ + "displayName": "CA201", + "conditions": { "users": { "includeUsers": ["All"] } }, + "sessionControls": { "signInFrequency": { "isEnabled": true, "frequencyInterval": "everyTime", "type": "hours", "value": "12" } } + }' + $Policy.sessionControls.signInFrequency.value | Should -BeNullOrEmpty + $Policy.sessionControls.signInFrequency.type | Should -BeNullOrEmpty + } + + It 'leaves an int value under timeBased unchanged' { + $Policy = Convert-Policy '{ + "displayName": "CA201", + "conditions": { "users": { "includeUsers": ["All"] } }, + "sessionControls": { "signInFrequency": { "isEnabled": true, "frequencyInterval": "timeBased", "type": "days", "value": 4 } } + }' + $Policy.sessionControls.signInFrequency.value | Should -Be 4 + $Policy.sessionControls.signInFrequency.type | Should -Be 'days' + } + + It 'casts a disabled signInFrequency with a string value too - Graph validates disabled sub-objects' { + $Policy = Convert-Policy '{ + "displayName": "CA201", + "conditions": { "users": { "includeUsers": ["All"] } }, + "sessionControls": { "signInFrequency": { "isEnabled": false, "frequencyInterval": "timeBased", "type": "hours", "value": "12" } } + }' + $Policy.sessionControls.signInFrequency.value | Should -Be 12 + $Policy.sessionControls.signInFrequency.value | Should -BeOfType [int] + } + } + Context 'edge cases' { It 'does nothing to a policy with no conditions at all' { { Convert-Policy '{"displayName":"CA201"}' } | Should -Not -Throw diff --git a/Tests/Private/Get-CIPPCVEReport.Tests.ps1 b/Tests/Private/Get-CIPPCVEReport.Tests.ps1 index a17aa0c425012..77903bebc2fa3 100644 --- a/Tests/Private/Get-CIPPCVEReport.Tests.ps1 +++ b/Tests/Private/Get-CIPPCVEReport.Tests.ps1 @@ -26,29 +26,34 @@ BeforeAll { param( $CveId = 'CVE-2024-0001', $Tenant = 'contoso.onmicrosoft.com', - $Devices = @(@{ deviceId = 'd1'; deviceName = 'PC-1'; osVersion = '10.0.19045'; softwareVersion = '120.0.0'; diskPaths = ''; registryPaths = '' }), - $LastUpdated = '2026-08-12T00:00:00.000Z' + $Devices = @(@{ deviceId = 'd1'; deviceName = 'PC-1' }), + $LastUpdated = '2026-08-12T00:00:00.000Z', + # The collector writes a unique-device count the reader trusts; default it to the + # number of stored device fragments, but allow tests to force a mismatch. + $DeviceCount ) + # Only the id and name are stored per device, whatever richer objects a caller passes. + $StoredDevices = @($Devices | ForEach-Object { @{ deviceId = $_.deviceId; deviceName = $_.deviceName } }) $Payload = @{ - PartitionKey = $CveId - RowKey = $Tenant - customerId = $Tenant - cveId = $CveId - softwareVendor = 'microsoft' - softwareName = 'edge' - vulnerabilitySeverityLevel = 'High' - recommendedSecurityUpdate = 'KB5034123' - recommendedSecurityUpdateUrl = 'https://support.microsoft.com/kb/5034123' - exploitabilityLevel = 'ExploitIsPublic' - deviceCount = @($Devices).Count + PartitionKey = $CveId + RowKey = $Tenant + id = $CveId + customerId = $Tenant + cveId = $CveId + softwareVendor = 'microsoft' + softwareName = 'edge' + softwareVersion = '120.0.0' + vulnerabilitySeverityLevel = 'High' + exploitabilityLevel = 'ExploitIsPublic' + deviceCount = if ($PSBoundParameters.ContainsKey('DeviceCount')) { $DeviceCount } else { $StoredDevices.Count } # Piped, not -InputObject: one device stays a bare object, several become an # array - the exact shape the collector writes. - deviceDetailsJson = [string]($Devices | ConvertTo-Json -Compress) - lastUpdated = $LastUpdated + deviceDetailsJson = [string]($StoredDevices | ConvertTo-Json -Compress) + lastUpdated = $LastUpdated } [pscustomobject]@{ PartitionKey = $Tenant - RowKey = "DefenderCVEs-$([guid]::NewGuid())" + RowKey = "DefenderCVEs-$CveId" Data = [string]($Payload | ConvertTo-Json -Depth 100 -Compress) Type = 'DefenderCVEs' } @@ -81,8 +86,8 @@ Describe 'Get-CIPPCVEReport' { It 'returns one aggregated entry per CVE with every field the frontend reads' { Mock -CommandName Get-CIPPDbItem -MockWith { New-CveRow -CveId 'CVE-B' -Devices @( - @{ deviceId = 'd1'; deviceName = 'PC-1'; osVersion = ''; softwareVersion = ''; diskPaths = 'C:\a\edge.exe'; registryPaths = 'HKLM\SOFTWARE\X' } - @{ deviceId = 'd2'; deviceName = 'PC-2'; osVersion = ''; softwareVersion = ''; diskPaths = ''; registryPaths = '' } + @{ deviceId = 'd1'; deviceName = 'PC-1' } + @{ deviceId = 'd2'; deviceName = 'PC-2' } ) New-CveRow -CveId 'CVE-A' New-CountRow @@ -100,13 +105,15 @@ Describe 'Get-CIPPCVEReport' { $B.exploitabilityLevel | Should -Be 'ExploitIsPublic' $B.softwareName | Should -Be 'edge' $B.softwareVendor | Should -Be 'microsoft' + $B.softwareVersion | Should -Be '120.0.0' $B.deviceCount | Should -Be 2 $B.tenantCount | Should -Be 1 @($B.affectedTenants).customerId | Should -Be @($script:Tenant) (@($B.affectedDevices).deviceName | Sort-Object) | Should -Be @('PC-1', 'PC-2') - @($B.diskPaths).Count | Should -Be 1 - @($B.diskPaths)[0].diskPaths | Should -Be 'C:\a\edge.exe' - @($B.registryPaths)[0].registryPaths | Should -Be 'HKLM\SOFTWARE\X' + (@($B.affectedDevices).deviceId | Sort-Object) | Should -Be @('d1', 'd2') + # registryPaths / diskPaths are no longer part of the response. + $B.PSObject.Properties.Name | Should -Not -Contain 'registryPaths' + $B.PSObject.Properties.Name | Should -Not -Contain 'diskPaths' $B.exceptionStatus | Should -Be 'None' $B.hasException | Should -BeFalse # ConvertFrom-Json turns the ISO stamp in the Data blob into a DateTime, so the @@ -114,18 +121,20 @@ Describe 'Get-CIPPCVEReport' { ([datetime]$B.cacheTimeStamp).ToUniversalTime().Ticks | Should -Be ([datetime]::Parse('2026-08-12T00:00:00Z', [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AdjustToUniversal)).Ticks } - It 'deduplicates devices by name within a row' { + It 'trusts the stored unique-device count rather than recounting fragments' { + # Dedupe now happens at write time, so the reader takes deviceCount as authoritative + # even if it differs from the number of device fragments present. Mock -CommandName Get-CIPPDbItem -MockWith { - New-CveRow -CveId 'CVE-A' -Devices @( - @{ deviceId = 'd1'; deviceName = 'PC-1'; osVersion = ''; softwareVersion = '1.0'; diskPaths = ''; registryPaths = '' } - @{ deviceId = 'd1'; deviceName = 'PC-1'; osVersion = ''; softwareVersion = '2.0'; diskPaths = ''; registryPaths = '' } + New-CveRow -CveId 'CVE-A' -DeviceCount 5 -Devices @( + @{ deviceId = 'd1'; deviceName = 'PC-1' } + @{ deviceId = 'd2'; deviceName = 'PC-2' } ) } $Result = @(Get-CIPPCVEReport -TenantFilter $script:Tenant) - $Result[0].deviceCount | Should -Be 1 - @($Result[0].affectedDevices).Count | Should -Be 1 + $Result[0].deviceCount | Should -Be 5 + @($Result[0].affectedDevices).Count | Should -Be 2 } It 'returns a bare empty array when the cache only holds the count row' { diff --git a/Tests/Private/Get-CIPPDbItemPage.Tests.ps1 b/Tests/Private/Get-CIPPDbItemPage.Tests.ps1 new file mode 100644 index 0000000000000..f69ce7e8ff37f --- /dev/null +++ b/Tests/Private/Get-CIPPDbItemPage.Tests.ps1 @@ -0,0 +1,84 @@ +# Pester tests for Get-CIPPDbItemPage +# Validates the reporting-database partition plan (count-row enumeration intersected with +# managed tenants, alphabetical), the single-tenant plan, the RowKey range handed to the +# walker, and count-marker removal. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + + # Stub every helper the function calls so Pester's Mock has a command to replace. + function Get-CippTable { param($tablename) } + function Get-CIPPDbItem { param($TenantFilter, $Type, [switch]$CountsOnly) } + function Get-Tenants { param($TenantFilter, [switch]$IncludeErrors) } + function Get-CIPPPagedTableRows { + param($Table, $PartitionKeys, $RowKeyGe, $RowKeyLt, $ExtraFilterClauses, $PageSize, $MaxQueries, $ContinuationToken) + } + + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Get-CIPPDbItemPage.ps1') +} + +Describe 'Get-CIPPDbItemPage' { + BeforeEach { + Mock -CommandName Get-CippTable -MockWith { @{ Context = 'fake' } } + Mock -CommandName Get-CIPPDbItem -MockWith { @() } + Mock -CommandName Get-CIPPPagedTableRows -MockWith { + [PSCustomObject]@{ + Rows = [System.Collections.Generic.List[object]]@( + [PSCustomObject]@{ PartitionKey = 'alpha.onmicrosoft.com'; RowKey = 'Guests-1'; Data = '{}' } + [PSCustomObject]@{ PartitionKey = 'alpha.onmicrosoft.com'; RowKey = 'Guests-Count'; DataCount = 1 } + ) + NextToken = 'alpha.onmicrosoft.com|Guests-Count' + } + } + } + + It 'builds the AllTenants plan from count rows, managed tenants only, alphabetical' { + Mock -CommandName Get-CIPPDbItem -MockWith { + @( + [PSCustomObject]@{ PartitionKey = 'zeta.onmicrosoft.com'; RowKey = 'Guests-Count'; DataCount = 5 } + [PSCustomObject]@{ PartitionKey = 'alpha.onmicrosoft.com'; RowKey = 'Guests-Count'; DataCount = 2 } + # Has cached data but is no longer managed - must be excluded from the walk. + [PSCustomObject]@{ PartitionKey = 'gone.onmicrosoft.com'; RowKey = 'Guests-Count'; DataCount = 9 } + ) + } + Mock -CommandName Get-Tenants -MockWith { + @( + [PSCustomObject]@{ defaultDomainName = 'alpha.onmicrosoft.com' } + [PSCustomObject]@{ defaultDomainName = 'zeta.onmicrosoft.com' } + ) + } + + $Result = Get-CIPPDbItemPage -TenantFilter 'AllTenants' -Type 'Guests' -PageSize 500 -ContinuationToken 'tok' + + Should -Invoke Get-CIPPDbItem -Times 1 -ParameterFilter { $TenantFilter -eq 'allTenants' -and $Type -eq 'Guests' -and $CountsOnly } + Should -Invoke Get-CIPPPagedTableRows -Times 1 -ParameterFilter { + ($PartitionKeys -join ',') -eq 'alpha.onmicrosoft.com,zeta.onmicrosoft.com' -and + $RowKeyGe -eq 'Guests-' -and $RowKeyLt -eq 'Guests.' -and + $PageSize -eq 500 -and $ContinuationToken -eq 'tok' + } + # The count marker row is stripped; the data row and token pass through. + $Result.Items | Should -HaveCount 1 + $Result.Items[0].RowKey | Should -Be 'Guests-1' + $Result.NextToken | Should -Be 'alpha.onmicrosoft.com|Guests-Count' + } + + It 'walks a single tenant partition after normalizing the tenant filter' { + Mock -CommandName Get-Tenants -MockWith { + [PSCustomObject]@{ defaultDomainName = 'alpha.onmicrosoft.com' } + } + + $null = Get-CIPPDbItemPage -TenantFilter 'alpha.onmicrosoft.com' -Type 'Mailboxes' + + Should -Invoke Get-Tenants -Times 1 -ParameterFilter { $TenantFilter -eq 'alpha.onmicrosoft.com' } + Should -Invoke Get-CIPPDbItem -Times 0 + Should -Invoke Get-CIPPPagedTableRows -Times 1 -ParameterFilter { + ($PartitionKeys -join ',') -eq 'alpha.onmicrosoft.com' -and $RowKeyGe -eq 'Mailboxes-' -and $RowKeyLt -eq 'Mailboxes.' + } + } + + It 'throws when the single tenant cannot be resolved' { + Mock -CommandName Get-Tenants -MockWith { $null } + + { Get-CIPPDbItemPage -TenantFilter 'missing.example.com' -Type 'Guests' } | Should -Throw "*not found*" + } +} diff --git a/Tests/Private/Get-CIPPDriveItemCloudPathLength.Tests.ps1 b/Tests/Private/Get-CIPPDriveItemCloudPathLength.Tests.ps1 new file mode 100644 index 0000000000000..f517a9b9b8528 --- /dev/null +++ b/Tests/Private/Get-CIPPDriveItemCloudPathLength.Tests.ps1 @@ -0,0 +1,36 @@ +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Get-CIPPDriveItemCloudPathLength.ps1') +} + +Describe 'Get-CIPPDriveItemCloudPathLength' { + It 'returns name length for root children' { + Get-CIPPDriveItemCloudPathLength -ParentPath '/drives/b!abc/root:' -Name 'file.docx' | Should -Be 9 + } + + It 'includes nested folders after root:' { + # Folder/Sub/file.docx = 6+1+3+1+9 = 20 + Get-CIPPDriveItemCloudPathLength -ParentPath '/drives/b!abc/root:/Folder/Sub' -Name 'file.docx' | Should -Be 20 + } + + It 'URL-decodes before measuring' { + # "My Folder"/a.txt -> My Folder/a.txt = 9+1+5 = 15 + Get-CIPPDriveItemCloudPathLength -ParentPath '/drive/root:/My%20Folder' -Name 'a.txt' | Should -Be 15 + } + + It 'returns 0 for empty name' { + Get-CIPPDriveItemCloudPathLength -ParentPath '/drive/root:/X' -Name '' | Should -Be 0 + } +} + +Describe 'inferred local + cloud threshold' { + It 'combines tenant-fixed root length with UPN local-part and cloud path' { + $Org = 'Contoso' + $Fixed = ('C:\Users\').Length + ("\OneDrive - $Org\").Length + $LocalPart = 'user' + $Cloud = 250 + $Inferred = $Fixed + $LocalPart.Length + $Cloud + ($Inferred -gt 260) | Should -Be $true + ($Cloud -gt 400) | Should -Be $false + } +} diff --git a/Tests/Private/Get-CIPPGroupsReport.Tests.ps1 b/Tests/Private/Get-CIPPGroupsReport.Tests.ps1 new file mode 100644 index 0000000000000..f5183e523cb62 --- /dev/null +++ b/Tests/Private/Get-CIPPGroupsReport.Tests.ps1 @@ -0,0 +1,99 @@ +# Pester tests for Get-CIPPGroupsReport -AsRawJson +# Validates that a page is stitched from the stored blobs verbatim (no member array is +# ever deserialized), with per-row CacheTimestamp and (AllTenants only) Tenant spliced in. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + + # Stub the helpers the exercised path touches so Pester's Mock has a command to replace. + function Get-CIPPDbItemPage { param($TenantFilter, $Type, $PageSize, $ContinuationToken) } + function Get-CIPPDbItem { param($TenantFilter, $Type) } + function Get-Tenants { param($TenantFilter, [switch]$IncludeErrors) } + function Write-LogMessage { param($API, $tenant, $message, $sev) } + + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Get-CIPPGroupsReport.ps1') + + function New-GroupBlob { + param([string]$Id, [string[]]$Upns) + $members = @(foreach ($u in $Upns) { [PSCustomObject]@{ id = $u; userPrincipalName = $u } }) + [PSCustomObject]@{ + id = $Id + displayName = "Group $Id" + members = $members + membersCsv = ($Upns -join ',') + } | ConvertTo-Json -Depth 10 -Compress + } + + function New-PageItem { + param([string]$Tenant, [string]$Blob, $Timestamp) + [PSCustomObject]@{ PartitionKey = $Tenant; Timestamp = $Timestamp; Data = $Blob } + } +} + +Describe 'Get-CIPPGroupsReport -AsRawJson' { + It 'stitches blobs verbatim and splices Tenant + CacheTimestamp for AllTenants' { + $ts = [datetimeoffset]'2024-05-01T10:00:00Z' + $blobA = New-GroupBlob -Id 'g1' -Upns @('a@contoso.com', 'b@contoso.com') + $blobB = New-GroupBlob -Id 'g2' -Upns @('c@fabrikam.com') + $page = [PSCustomObject]@{ + Items = @( + (New-PageItem -Tenant 'contoso.onmicrosoft.com' -Blob $blobA -Timestamp $ts), + (New-PageItem -Tenant 'fabrikam.onmicrosoft.com' -Blob $blobB -Timestamp $ts) + ) + NextToken = 'fabrikam.onmicrosoft.com|Groups-xyz' + } + Mock -CommandName Get-CIPPDbItemPage -MockWith { $page }.GetNewClosure() + + $result = Get-CIPPGroupsReport -TenantFilter 'AllTenants' -PageSize 100 -AsRawJson + + $result.NextToken | Should -Be 'fabrikam.onmicrosoft.com|Groups-xyz' + # The stored blob (minus its closing brace) must appear byte-for-byte — proof the + # member array was streamed, not parsed and rebuilt. Literal Contains, not -BeLike: + # the members array's [ ] are wildcard metacharacters. + $result.CippPagedJson.Contains($blobA.Substring(0, $blobA.Length - 1)) | Should -BeTrue + + $parsed = $result.CippPagedJson | ConvertFrom-Json + $parsed | Should -HaveCount 2 + $parsed[0].members | Should -HaveCount 2 + $parsed[0].members[0].userPrincipalName | Should -Be 'a@contoso.com' + $parsed[0].membersCsv | Should -Be 'a@contoso.com,b@contoso.com' + $parsed[0].Tenant | Should -Be 'contoso.onmicrosoft.com' + $parsed[1].Tenant | Should -Be 'fabrikam.onmicrosoft.com' + $parsed[0].CacheTimestamp | Should -Not -BeNullOrEmpty + } + + It 'does not splice a Tenant field for a single-tenant read' { + $blob = New-GroupBlob -Id 'g1' -Upns @('a@contoso.com') + $page = [PSCustomObject]@{ + Items = @((New-PageItem -Tenant 'contoso.onmicrosoft.com' -Blob $blob -Timestamp ([datetimeoffset]::UtcNow))) + NextToken = $null + } + Mock -CommandName Get-CIPPDbItemPage -MockWith { $page }.GetNewClosure() + + $result = Get-CIPPGroupsReport -TenantFilter 'contoso.onmicrosoft.com' -PageSize 100 -AsRawJson + $parsed = $result.CippPagedJson | ConvertFrom-Json + + $parsed.PSObject.Properties.Name | Should -Not -Contain 'Tenant' + $parsed.CacheTimestamp | Should -Not -BeNullOrEmpty + $result.NextToken | Should -BeNullOrEmpty + } + + It 'skips empty or malformed blobs' { + $good = New-GroupBlob -Id 'g1' -Upns @('a@contoso.com') + $page = [PSCustomObject]@{ + Items = @( + (New-PageItem -Tenant 'contoso.onmicrosoft.com' -Blob '' -Timestamp ([datetimeoffset]::UtcNow)), + (New-PageItem -Tenant 'contoso.onmicrosoft.com' -Blob ' ' -Timestamp ([datetimeoffset]::UtcNow)), + (New-PageItem -Tenant 'contoso.onmicrosoft.com' -Blob $good -Timestamp ([datetimeoffset]::UtcNow)) + ) + NextToken = $null + } + Mock -CommandName Get-CIPPDbItemPage -MockWith { $page }.GetNewClosure() + + $result = Get-CIPPGroupsReport -TenantFilter 'AllTenants' -PageSize 100 -AsRawJson + $parsed = @($result.CippPagedJson | ConvertFrom-Json) + + $parsed | Should -HaveCount 1 + $parsed[0].id | Should -Be 'g1' + } +} diff --git a/Tests/Private/Get-CIPPLicenseOptimization.Tests.ps1 b/Tests/Private/Get-CIPPLicenseOptimization.Tests.ps1 new file mode 100644 index 0000000000000..40c1879f2da09 --- /dev/null +++ b/Tests/Private/Get-CIPPLicenseOptimization.Tests.ps1 @@ -0,0 +1,158 @@ +# Pester tests for Get-CIPPLicenseOptimization — the five-tier waste join and summary math. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Get-CIPPLicenseOptimization.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Get-CIPPLicenseOptimization.ps1 under Modules/' } + + function Get-CIPPLicensePrice { param($SkuId) } + function New-CIPPDbRequest { param($TenantFilter, $Type, $Fields) } + + . $FunctionPath + + # SKU GUIDs + $script:E5 = 'c7df2760-2c81-4ef7-b578-5b5392b571df' + $script:E3 = '6fd2c87f-b296-42f0-b197-1e91e994b900' + $script:ExP1 = '4b9405b0-7788-4568-add1-99614e613b69' + $script:ExP2 = '19ec0d23-8335-4cbd-94ac-6050e30712fa' + + function New-Lic { param($SkuId, $Name, $Total, $Used, $PlanIds) + [pscustomobject]@{ + skuId = $SkuId + License = $Name + TotalLicenses = "$Total" + CountUsed = "$Used" + ServicePlans = @($PlanIds | ForEach-Object { [pscustomobject]@{ servicePlanId = $_ } }) + } + } + function New-User { param($Upn, $Enabled, $LastSignIn, $Skus, $Type = 'Member', $Resource = $false) + [pscustomobject]@{ + userPrincipalName = $Upn + accountEnabled = $Enabled + userType = $Type + isResourceAccount = $Resource + signInActivity = if ($LastSignIn) { [pscustomobject]@{ lastSignInDateTime = $LastSignIn; lastNonInteractiveSignInDateTime = $null } } else { $null } + assignedLicenses = @($Skus | ForEach-Object { [pscustomobject]@{ skuId = $_; disabledPlans = @() } }) + } + } +} + +Describe 'Get-CIPPLicenseOptimization' { + BeforeEach { + Mock -CommandName Get-CIPPLicensePrice -MockWith { + @( + [pscustomobject]@{ skuId = $script:E5; Product_Display_Name = 'Office 365 E5'; MonthlyPrice = 38.0; Currency = 'USD'; Source = 'Estimate' } + [pscustomobject]@{ skuId = $script:E3; Product_Display_Name = 'Office 365 E3'; MonthlyPrice = 23.0; Currency = 'USD'; Source = 'Estimate' } + [pscustomobject]@{ skuId = $script:ExP1; Product_Display_Name = 'Exchange Online (Plan 1)'; MonthlyPrice = 4.0; Currency = 'USD'; Source = 'Estimate' } + [pscustomobject]@{ skuId = $script:ExP2; Product_Display_Name = 'Exchange Online (Plan 2)'; MonthlyPrice = 8.0; Currency = 'USD'; Source = 'Estimate' } + ) + } + + $script:Recent = (Get-Date).AddDays(-5).ToString('o') + $script:Old = (Get-Date).AddDays(-200).ToString('o') + + # E3 plan set is a strict superset of Exchange P1's -> P1 is redundant when held together. + $script:Licenses = @( + New-Lic $script:E5 'Office 365 E5' 10 8 @('EXCH1', 'SPO', 'TEAMS') + New-Lic $script:E3 'Office 365 E3' 5 5 @('EXCH1', 'SPO', 'TEAMS') + New-Lic $script:ExP1 'Exchange Online (Plan 1)' 3 2 @('EXCH1') + ) + $script:Users = @( + New-User 'u1@contoso.com' $true $script:Recent @($script:E5) # tier4: exchange-only on E5 + New-User 'u2@contoso.com' $false $script:Recent @($script:E5) # tier2: disabled + New-User 'u3@contoso.com' $true $script:Old @($script:E3) # tier3: inactive + New-User 'u4@contoso.com' $true $script:Recent @($script:E3, $script:ExP1) # tier5: overlap + New-User 'guest@ext.com' $true $script:Recent @($script:E5) 'Guest' # excluded + New-User 'room@contoso.com' $true $script:Recent @($script:E5) 'Member' $true # excluded (resource) + ) + # u1 mailbox-only; u4 uses collaboration too (so not a downgrade candidate) + $script:Activity = @( + [pscustomobject]@{ userPrincipalName = 'u1@contoso.com'; exchangeLastActivityDate = $script:Recent; oneDriveLastActivityDate = ''; sharePointLastActivityDate = ''; teamsLastActivityDate = ''; yammerLastActivityDate = '' } + [pscustomobject]@{ userPrincipalName = 'u4@contoso.com'; exchangeLastActivityDate = $script:Recent; oneDriveLastActivityDate = $script:Recent; sharePointLastActivityDate = ''; teamsLastActivityDate = $script:Recent; yammerLastActivityDate = '' } + ) + } + + It 'computes the monthly spend from assigned seats x price' { + $Report = Get-CIPPLicenseOptimization -TenantFilter 'contoso.com' -Licenses $script:Licenses -Users $script:Users -ActivityDetail $script:Activity + # 8*38 + 5*23 + 2*4 = 427 + $Report.Summary.MonthlySpend | Should -Be 427.0 + $Report.Summary.PriceCoverage | Should -Be 1 + } + + It 'flags unassigned seats (tier 1) with per-seat pricing' { + $Report = Get-CIPPLicenseOptimization -TenantFilter 'contoso.com' -Licenses $script:Licenses -Users $script:Users -ActivityDetail $script:Activity + $Opp = $Report.Opportunities | Where-Object { $_.Tier -eq 'UnassignedSeats' -and $_.skuId -eq $script:E5 } + $Opp.Seats | Should -Be 2 + $Opp.MonthlySaving | Should -Be 76.0 + } + + It 'flags a disabled account (tier 2)' { + $Report = Get-CIPPLicenseOptimization -TenantFilter 'contoso.com' -Licenses $script:Licenses -Users $script:Users -ActivityDetail $script:Activity + $Opp = $Report.Opportunities | Where-Object { $_.Tier -eq 'DisabledAccount' } + $Opp.Seats | Should -Be 1 + $Opp.MonthlySaving | Should -Be 38.0 + $Opp.Users | Should -Contain 'u2@contoso.com' + } + + It 'flags an inactive account (tier 3)' { + $Report = Get-CIPPLicenseOptimization -TenantFilter 'contoso.com' -Licenses $script:Licenses -Users $script:Users -ActivityDetail $script:Activity + $Opp = $Report.Opportunities | Where-Object { $_.Tier -eq 'Inactive' } + $Opp.Seats | Should -Be 1 + $Opp.MonthlySaving | Should -Be 23.0 + $Opp.Users | Should -Contain 'u3@contoso.com' + } + + It 'flags a mailbox-only premium user for review (tier 4) without claiming a saving' { + $Report = Get-CIPPLicenseOptimization -TenantFilter 'contoso.com' -Licenses $script:Licenses -Users $script:Users -ActivityDetail $script:Activity + $Opp = $Report.Opportunities | Where-Object { $_.Tier -eq 'Downgrade' } + $Opp.Seats | Should -Be 1 + # Review only: no monetary saving is claimed, but the SKU itself is priced (E5). + $Opp.MonthlySaving | Should -Be 0 + $Opp.UnitCost | Should -Be 38.0 + $Opp.PriceKnown | Should -BeTrue + $Opp.Users | Should -Contain 'u1@contoso.com' + } + + It 'flags a redundant overlapping SKU (tier 5)' { + $Report = Get-CIPPLicenseOptimization -TenantFilter 'contoso.com' -Licenses $script:Licenses -Users $script:Users -ActivityDetail $script:Activity + $Opp = $Report.Opportunities | Where-Object { $_.Tier -eq 'Overlap' } + $Opp.skuId | Should -Be $script:ExP1 + $Opp.Seats | Should -Be 1 + $Opp.MonthlySaving | Should -Be 4.0 + } + + It 'totals reclaimable spend and reclaimable seats' { + $Report = Get-CIPPLicenseOptimization -TenantFilter 'contoso.com' -Licenses $script:Licenses -Users $script:Users -ActivityDetail $script:Activity + # 76 (t1 E5) + 4 (t1 ExP1) + 38 (t2) + 23 (t3) + 0 (t4 review) + 4 (t5) = 145 + $Report.Summary.ReclaimableMonthly | Should -Be 145.0 + # tiers 1-3 seats only: (2+1) + 1 + 1 = 5 + $Report.Summary.ReclaimableSeats | Should -Be 5 + $Report.Summary.AnonymizedReports | Should -BeFalse + } + + It 'excludes guests and resource accounts from per-user tiers' { + $Report = Get-CIPPLicenseOptimization -TenantFilter 'contoso.com' -Licenses $script:Licenses -Users $script:Users -ActivityDetail $script:Activity + $AllUsers = @($Report.Opportunities.Users) + $AllUsers | Should -Not -Contain 'guest@ext.com' + $AllUsers | Should -Not -Contain 'room@contoso.com' + } + + It 'marks unpriced SKUs as PriceKnown false with zero saving' { + $NoPriceLic = @(New-Lic '00000000-0000-0000-0000-000000000000' 'Mystery SKU' 4 2 @('X')) + $Report = Get-CIPPLicenseOptimization -TenantFilter 'contoso.com' -Licenses $NoPriceLic -Users @() -ActivityDetail @() + $Opp = $Report.Opportunities | Where-Object { $_.Tier -eq 'UnassignedSeats' } + $Opp.PriceKnown | Should -BeFalse + $Opp.UnitCost | Should -BeNullOrEmpty + $Opp.MonthlySaving | Should -Be 0 + $Opp.Seats | Should -Be 2 + } + + It 'detects anonymized usage reports when activity UPNs do not match users' { + $AnonActivity = @( + [pscustomobject]@{ userPrincipalName = 'AB6E27EA1F9A4C00'; exchangeLastActivityDate = $script:Recent; oneDriveLastActivityDate = ''; sharePointLastActivityDate = ''; teamsLastActivityDate = ''; yammerLastActivityDate = '' } + ) + $Report = Get-CIPPLicenseOptimization -TenantFilter 'contoso.com' -Licenses $script:Licenses -Users $script:Users -ActivityDetail $AnonActivity + $Report.Summary.AnonymizedReports | Should -BeTrue + } +} diff --git a/Tests/Private/Get-CIPPLicensePrice.Tests.ps1 b/Tests/Private/Get-CIPPLicensePrice.Tests.ps1 new file mode 100644 index 0000000000000..5f768c16f6c9a --- /dev/null +++ b/Tests/Private/Get-CIPPLicensePrice.Tests.ps1 @@ -0,0 +1,151 @@ +# Pester tests for Get-CIPPLicensePrice — estimates from CSV, overrides win, unknown SKUs. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Get-CIPPLicensePrice.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Get-CIPPLicensePrice.ps1 under Modules/' } + + function Get-CIPPTable { param($TableName) } + function Get-CIPPAzDataTableEntity { param($Filter, $Property) } + + . $FunctionPath + + # The function joins $env:CIPPRootPath to locate the defaults CSV; the CSV read itself is + # mocked, so any non-empty base path is enough to get past Join-Path. + $script:SavedRoot = $env:CIPPRootPath + $env:CIPPRootPath = "$TestDrive" + + # SKU GUIDs used across the cases + $script:E5 = '06ebc4ee-1bb5-47dd-8120-11324bc54e06' + $script:E3 = '6fd2c87f-b296-42f0-b197-1e91e994b900' +} + +AfterAll { + $env:CIPPRootPath = $script:SavedRoot +} + +Describe 'Get-CIPPLicensePrice' { + BeforeEach { + Mock -CommandName Test-Path -MockWith { $true } + Mock -CommandName Import-Csv -MockWith { + @( + [pscustomobject]@{ skuId = $script:E5; skuPartNumber = 'ENTERPRISEPREMIUM'; Product_Display_Name = 'Office 365 E5'; MonthlyPrice = '38.00'; Currency = 'USD' } + [pscustomobject]@{ skuId = $script:E3; skuPartNumber = 'ENTERPRISEPACK'; Product_Display_Name = 'Office 365 E3'; MonthlyPrice = '23.00'; Currency = 'USD' } + ) + } + Mock -CommandName Get-CIPPTable -MockWith { @{ Context = 'fake' } } + # Default: no overrides + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { @() } + } + + It 'returns the shipped estimate when no override exists' { + $Result = Get-CIPPLicensePrice -SkuId $script:E5 + + $Result.MonthlyPrice | Should -Be 38.00 + $Result.Source | Should -Be 'Estimate' + $Result.Currency | Should -Be 'USD' + } + + It 'lets an override win over the estimate' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ PartitionKey = 'Price'; RowKey = $script:E5; skuId = $script:E5; skuPartNumber = 'ENTERPRISEPREMIUM'; Product_Display_Name = 'Office 365 E5'; MonthlyPrice = 30.0; Currency = 'USD' }) + } + + $Result = Get-CIPPLicensePrice -SkuId $script:E5 + + $Result.MonthlyPrice | Should -Be 30.0 + $Result.Source | Should -Be 'Override' + } + + It 'returns Source Unknown with a null price for an unknown SKU' { + $Result = Get-CIPPLicensePrice -SkuId '00000000-0000-0000-0000-000000000000' + + $Result.Source | Should -Be 'Unknown' + $Result.MonthlyPrice | Should -BeNullOrEmpty + } + + It 'is case-insensitive on the requested SKU GUID' { + $Result = Get-CIPPLicensePrice -SkuId $script:E5.ToUpper() + + $Result.Source | Should -Be 'Estimate' + $Result.MonthlyPrice | Should -Be 38.00 + } + + It 'returns every known SKU when no SkuId is given' { + $Result = @(Get-CIPPLicensePrice) + + $Result.Count | Should -Be 2 + ($Result.skuId | Sort-Object) | Should -Be (@($script:E3, $script:E5) | Sort-Object) + } + + It 'merges an override-only SKU into the full list' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ PartitionKey = 'Price'; RowKey = 'aaaa1111-2222-3333-4444-555566667777'; skuId = 'aaaa1111-2222-3333-4444-555566667777'; skuPartNumber = 'CUSTOM'; Product_Display_Name = 'Custom SKU'; MonthlyPrice = 5.0; Currency = 'USD' }) + } + + $Result = @(Get-CIPPLicensePrice) + + $Result.Count | Should -Be 3 + ($Result | Where-Object { $_.skuId -eq 'aaaa1111-2222-3333-4444-555566667777' }).Source | Should -Be 'Override' + } +} + +Describe 'Get-CIPPLicensePrice - multi-currency' { + BeforeEach { + Mock -CommandName Test-Path -MockWith { $true } + # E5 priced in USD and AUD; E3 in USD only + Mock -CommandName Import-Csv -MockWith { + @( + [pscustomobject]@{ skuId = $script:E5; skuPartNumber = 'ENTERPRISEPREMIUM'; Product_Display_Name = 'Office 365 E5'; MonthlyPrice = '38.00'; Currency = 'USD' } + [pscustomobject]@{ skuId = $script:E5; skuPartNumber = 'ENTERPRISEPREMIUM'; Product_Display_Name = 'Office 365 E5'; MonthlyPrice = '60.00'; Currency = 'AUD' } + [pscustomobject]@{ skuId = $script:E3; skuPartNumber = 'ENTERPRISEPACK'; Product_Display_Name = 'Office 365 E3'; MonthlyPrice = '23.00'; Currency = 'USD' } + ) + } + Mock -CommandName Get-CIPPTable -MockWith { @{ Context = 'fake' } } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { @() } + } + + It 'resolves the price in the requested currency' { + $Result = Get-CIPPLicensePrice -SkuId $script:E5 -Currency 'AUD' + + $Result.MonthlyPrice | Should -Be 60.00 + $Result.Currency | Should -Be 'AUD' + $Result.Source | Should -Be 'Estimate' + } + + It 'reports Unknown (no cross-currency fallback) when the SKU lacks the requested currency' { + $Result = Get-CIPPLicensePrice -SkuId $script:E3 -Currency 'AUD' + + $Result.Source | Should -Be 'Unknown' + $Result.MonthlyPrice | Should -BeNullOrEmpty + # SKU metadata is still surfaced so the row remains identifiable + $Result.skuPartNumber | Should -Be 'ENTERPRISEPACK' + } + + It 'omits SKUs with no price in the requested currency from the full list' { + $Result = @(Get-CIPPLicensePrice -Currency 'AUD') + + $Result.Count | Should -Be 1 + ($Result | Where-Object { $_.skuId -eq $script:E3 }) | Should -BeNullOrEmpty + ($Result | Where-Object { $_.skuId -eq $script:E5 }).MonthlyPrice | Should -Be 60.00 + } + + It 'lists the distinct currencies present' { + $Result = @(Get-CIPPLicensePrice -ListCurrencies) + + $Result | Should -Be @('AUD', 'USD') + } + + It 'scopes an override to its currency' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ PartitionKey = 'Price'; RowKey = "$($script:E5)-aud"; skuId = $script:E5; skuPartNumber = 'ENTERPRISEPREMIUM'; Product_Display_Name = 'Office 365 E5'; MonthlyPrice = 55.0; Currency = 'AUD' }) + } + + (Get-CIPPLicensePrice -SkuId $script:E5 -Currency 'AUD').MonthlyPrice | Should -Be 55.0 + (Get-CIPPLicensePrice -SkuId $script:E5 -Currency 'AUD').Source | Should -Be 'Override' + # USD is untouched by the AUD override + (Get-CIPPLicensePrice -SkuId $script:E5 -Currency 'USD').MonthlyPrice | Should -Be 38.00 + (Get-CIPPLicensePrice -SkuId $script:E5 -Currency 'USD').Source | Should -Be 'Estimate' + } +} diff --git a/Tests/Private/Get-CIPPPagedTableRows.Tests.ps1 b/Tests/Private/Get-CIPPPagedTableRows.Tests.ps1 new file mode 100644 index 0000000000000..c458192a23f83 --- /dev/null +++ b/Tests/Private/Get-CIPPPagedTableRows.Tests.ps1 @@ -0,0 +1,200 @@ +# Pester tests for Get-CIPPPagedTableRows +# Validates the cross-partition range-scan pager: row-count paging independent of how many +# partitions the data spans, continuation token round-trips (including escaping), plan +# membership filtering, and the query-count economy that motivated the range design. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + + # Stub so Pester's Mock has a command to replace. + function Get-CIPPAzDataTableEntity { param($Context, $Filter, $Property, $First) } + + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/ConvertTo-CIPPODataFilterValue.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Get-CIPPPagedTableRows.ps1') + + # In-memory table emulation for the filter shapes the pager emits (PK/RK ranges, the + # OR resume clause, ordinal ordering, -First). Test keys never contain quotes. + function Select-FakeRows { + param([object[]]$Rows, [string]$Filter, $First) + $PkGe = if ($Filter -match "PartitionKey ge '([^']*)'") { $Matches[1] } else { $null } + $PkLe = if ($Filter -match "PartitionKey le '([^']*)'") { $Matches[1] } else { $null } + $RkGe = if ($Filter -match "RowKey ge '([^']*)'") { $Matches[1] } else { $null } + $RkLt = if ($Filter -match "RowKey lt '([^']*)'") { $Matches[1] } else { $null } + $Resume = if ($Filter -match "\(\(PartitionKey gt '([^']*)'\) or \(PartitionKey eq '[^']*' and RowKey gt '([^']*)'\)\)") { + @{ Pk = $Matches[1]; Rk = $Matches[2] } + } else { $null } + $Out = @($Rows | Where-Object { + $Row = $_ + $Keep = (-not $PkGe -or [string]::CompareOrdinal($Row.PartitionKey, $PkGe) -ge 0) -and + (-not $PkLe -or [string]::CompareOrdinal($Row.PartitionKey, $PkLe) -le 0) -and + (-not $RkGe -or [string]::CompareOrdinal($Row.RowKey, $RkGe) -ge 0) -and + (-not $RkLt -or [string]::CompareOrdinal($Row.RowKey, $RkLt) -lt 0) + if ($Keep -and $Resume) { + $Keep = ([string]::CompareOrdinal($Row.PartitionKey, $Resume.Pk) -gt 0) -or + ($Row.PartitionKey -ceq $Resume.Pk -and [string]::CompareOrdinal($Row.RowKey, $Resume.Rk) -gt 0) + } + $Keep + } | Sort-Object -Property @{ Expression = { $_.PartitionKey }; Ascending = $true }, @{ Expression = { $_.RowKey }; Ascending = $true }) + if ($First) { $Out = @($Out | Select-Object -First ([int]$First)) } + $Out + } + + function New-FakeRow { + param([string]$Pk, [string]$Rk) + [PSCustomObject]@{ PartitionKey = $Pk; RowKey = $Rk; Data = "$Pk/$Rk" } + } +} + +Describe 'Get-CIPPPagedTableRows' { + BeforeEach { + $script:FakeRows = @() + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + Select-FakeRows -Rows $script:FakeRows -Filter $Filter -First $First + } + } + + It 'walks partitions in ordinal order and completes with a null token when everything fits' { + $script:FakeRows = @( + New-FakeRow 'tenantB' 'Guests-2' + New-FakeRow 'tenantA' 'Guests-1' + New-FakeRow 'tenantB' 'Guests-1' + ) + + $Page = Get-CIPPPagedTableRows -Table @{ Context = 'fake' } -PartitionKeys @('tenantA', 'tenantB') -PageSize 100 + + $Page.Rows | Should -HaveCount 3 + $Page.Rows[0].Data | Should -Be 'tenantA/Guests-1' + $Page.Rows[1].Data | Should -Be 'tenantB/Guests-1' + $Page.Rows[2].Data | Should -Be 'tenantB/Guests-2' + $Page.NextToken | Should -BeNullOrEmpty + } + + It 'pages many small partitions in one page with a bounded query count' { + # The complaint this design answers: row-count paging must not degrade with the + # partition count. 60 one-row tenants fit one 100-row page in a couple of queries. + $script:FakeRows = foreach ($i in 1..60) { + New-FakeRow ('tenant{0:D3}' -f $i) 'Users-1' + } + $Plan = @(1..60 | ForEach-Object { 'tenant{0:D3}' -f $_ }) + + $Page = Get-CIPPPagedTableRows -Table @{ Context = 'fake' } -PartitionKeys $Plan -PageSize 100 + + $Page.Rows | Should -HaveCount 60 + $Page.NextToken | Should -BeNullOrEmpty + Should -Invoke Get-CIPPAzDataTableEntity -Times 2 -Exactly + } + + It 'returns full coverage without duplicates when paging with continuation tokens' { + $script:FakeRows = foreach ($Tenant in 'tenantA', 'tenantB') { + foreach ($i in 1..5) { New-FakeRow $Tenant ('Users-{0:D2}' -f $i) } + } + + $Collected = [System.Collections.Generic.List[string]]::new() + $Token = $null + $Pages = 0 + do { + $Page = Get-CIPPPagedTableRows -Table @{ Context = 'fake' } -PartitionKeys @('tenantA', 'tenantB') -PageSize 3 -ContinuationToken $Token + foreach ($Row in $Page.Rows) { $Collected.Add($Row.Data) } + $Token = $Page.NextToken + $Pages++ + } while ($Token -and $Pages -lt 20) + + $Pages | Should -BeLessThan 20 + $Collected | Should -HaveCount 10 + ($Collected | Sort-Object -Unique) | Should -HaveCount 10 + $Collected[0] | Should -Be 'tenantA/Users-01' + $Collected[-1] | Should -Be 'tenantB/Users-05' + } + + It 'applies the RowKey range bounds' { + $script:FakeRows = @( + New-FakeRow 'tenantA' 'Groups-1' + New-FakeRow 'tenantA' 'Guests-1' + New-FakeRow 'tenantA' 'Guests-Count' + New-FakeRow 'tenantA' 'Mailboxes-1' + ) + + $Page = Get-CIPPPagedTableRows -Table @{ Context = 'fake' } -PartitionKeys @('tenantA') -RowKeyGe 'Guests-' -RowKeyLt 'Guests.' -PageSize 100 + + # Only the Guests-* range: no Groups, no Mailboxes. The count marker is inside the + # range by design; callers remove it. + $Page.Rows.RowKey | Should -Be @('Guests-1', 'Guests-Count') + } + + It 'drops rows from partitions outside the plan but keeps advancing the cursor' { + # gone.example sits ordinally between the plan tenants and floods the range with + # rows; they must be filtered out without stalling or re-reading. + $script:FakeRows = @(New-FakeRow 'aaa.example' 'Users-1') + + @(foreach ($i in 1..6) { New-FakeRow 'gone.example' ('Users-{0}' -f $i) }) + + @(New-FakeRow 'zzz.example' 'Users-1') + + $Collected = [System.Collections.Generic.List[string]]::new() + $Token = $null + $Pages = 0 + do { + $Page = Get-CIPPPagedTableRows -Table @{ Context = 'fake' } -PartitionKeys @('aaa.example', 'zzz.example') -PageSize 3 -ContinuationToken $Token + foreach ($Row in $Page.Rows) { $Collected.Add($Row.Data) } + $Token = $Page.NextToken + $Pages++ + } while ($Token -and $Pages -lt 10) + + $Collected | Should -Be @('aaa.example/Users-1', 'zzz.example/Users-1') + } + + It 'ends a short page with a token when MaxQueries runs out before PageSize' { + # Every chunk is full of non-plan rows, so kept rows stay short of PageSize and + # the safety bound has to end the page with resumable progress. + $script:FakeRows = @(foreach ($i in 1..9) { New-FakeRow 'gone.example' ('Users-{0}' -f $i) }) + + @(New-FakeRow 'zzz.example' 'Users-1') + + $Page1 = Get-CIPPPagedTableRows -Table @{ Context = 'fake' } -PartitionKeys @('aaa.example', 'zzz.example') -PageSize 3 -MaxQueries 2 + $Page1.Rows | Should -HaveCount 0 + $Page1.NextToken | Should -Not -BeNullOrEmpty + + # Chaining the short pages still reaches everything exactly once. + $Collected = [System.Collections.Generic.List[string]]::new() + $Token = $Page1.NextToken + $Pages = 1 + do { + $Page = Get-CIPPPagedTableRows -Table @{ Context = 'fake' } -PartitionKeys @('aaa.example', 'zzz.example') -PageSize 3 -MaxQueries 2 -ContinuationToken $Token + foreach ($Row in $Page.Rows) { $Collected.Add($Row.Data) } + $Token = $Page.NextToken + $Pages++ + } while ($Token -and $Pages -lt 10) + + $Pages | Should -BeLessThan 10 + $Collected | Should -Be @('zzz.example/Users-1') + } + + It 'round-trips tokens whose keys contain the separator and non-ASCII characters' { + $script:FakeRows = @( + New-FakeRow 'tenant|pipe' 'Users-aä' + New-FakeRow 'tenant|pipe' 'Users-b' + ) + + $Page1 = Get-CIPPPagedTableRows -Table @{ Context = 'fake' } -PartitionKeys @('tenant|pipe') -PageSize 1 + $Page1.Rows | Should -HaveCount 1 + $Page1.Rows[0].RowKey | Should -Be 'Users-aä' + $Page1.NextToken | Should -Not -BeNullOrEmpty + + $Page2 = Get-CIPPPagedTableRows -Table @{ Context = 'fake' } -PartitionKeys @('tenant|pipe') -PageSize 1 -ContinuationToken $Page1.NextToken + $Page2.Rows | Should -HaveCount 1 + $Page2.Rows[0].RowKey | Should -Be 'Users-b' + } + + It 'ANDs extra filter clauses onto every query' { + $script:FakeRows = @(New-FakeRow 'tenantA' 'Users-1') + + $null = Get-CIPPPagedTableRows -Table @{ Context = 'fake' } -PartitionKeys @('tenantA') -PageSize 10 -ExtraFilterClauses @("Severity eq 'Error'") + + Should -Invoke Get-CIPPAzDataTableEntity -ParameterFilter { $Filter -like "*and Severity eq 'Error'" } + } + + It 'returns an empty completed page for an empty partition plan' { + $Page = Get-CIPPPagedTableRows -Table @{ Context = 'fake' } -PartitionKeys @() -PageSize 10 + + $Page.Rows | Should -HaveCount 0 + $Page.NextToken | Should -BeNullOrEmpty + Should -Invoke Get-CIPPAzDataTableEntity -Times 0 + } +} diff --git a/Tests/Private/Get-CIPPSharePointCopyJobProgress.Tests.ps1 b/Tests/Private/Get-CIPPSharePointCopyJobProgress.Tests.ps1 new file mode 100644 index 0000000000000..1a3bdb55aca66 --- /dev/null +++ b/Tests/Private/Get-CIPPSharePointCopyJobProgress.Tests.ps1 @@ -0,0 +1,205 @@ +# Pester tests for Get-CIPPSharePointCopyJobProgress sanitization + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Get-CIPPSharePointCopyJobProgress.ps1' + $QueuePath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Get-CIPPSharePointCopyJobQueueLogs.ps1' + if (-not (Test-Path $FunctionPath)) { throw "Could not locate $FunctionPath" } + if (-not (Test-Path $QueuePath)) { throw "Could not locate $QueuePath" } + + function Get-SharePointAdminLink { param($Public, $tenantFilter) [PSCustomObject]@{ SharePointUrl = 'https://contoso.sharepoint.com' } } + function New-GraphPOSTRequest { param($uri, $tenantid, $body) } + + . $QueuePath + . $FunctionPath +} + +Describe 'Get-CIPPSharePointCopyJobProgress' { + It 'aggregates errors without returning raw log paths' { + Mock New-GraphPOSTRequest { + [PSCustomObject]@{ + d = [PSCustomObject]@{ + GetCopyJobProgress = [PSCustomObject]@{ + JobState = 0 + Logs = @( + '{"Event":"JobError","Url":"/sites/x/secret/file.docx","Message":"failed"}' + '{"Event":"JobProgress","ObjectsProcessed":10,"TotalExpectedSPObjects":20,"TotalErrors":1}' + ) + } + } + } + } + Mock Get-CIPPSharePointCopyJobQueueLogs { @() } + + $Result = Get-CIPPSharePointCopyJobProgress -TenantFilter 'contoso.com' -SourceSiteUrl 'https://contoso.sharepoint.com/sites/a' -CopyJobInfo @{ + JobId = 'job-1'; JobQueueUri = 'https://queue'; EncryptionKey = 'key' + } + + $Result.TotalErrors | Should -BeGreaterThan 0 + $Result.ErrorMessages.Count | Should -BeGreaterThan 0 + $Result | Get-Member -Name Url | Should -BeNullOrEmpty + ($Result | ConvertTo-Json) | Should -Not -Match 'secret/file' + ($Result.ErrorMessages -join ' ') | Should -Not -Match 'secret/file' + } + + It 'reads OData verbose Logs.results and Event-based job errors' { + Mock New-GraphPOSTRequest { + [PSCustomObject]@{ + d = [PSCustomObject]@{ + GetCopyJobProgress = [PSCustomObject]@{ + JobState = 0 + Logs = [PSCustomObject]@{ + results = @( + '{"Event":"JobEnd","TotalErrors":1}' + '{"Event":"JobError","ObjectType":"File","ErrorType":"Microsoft.SharePoint.SPException","ErrorCode":"-2147024816","Message":"Access denied."}' + ) + } + } + } + } + } + Mock Get-CIPPSharePointCopyJobQueueLogs { @() } + + $Result = Get-CIPPSharePointCopyJobProgress -TenantFilter 'contoso.com' -SourceSiteUrl 'https://contoso.sharepoint.com/sites/a' -CopyJobInfo @{ + JobId = 'job-2'; JobQueueUri = 'https://queue'; EncryptionKey = 'key' + } + + $Result.TotalErrors | Should -BeGreaterThan 0 + ($Result.ErrorMessages -join ' ') | Should -Match 'Access denied' + ($Result.ErrorMessages -join ' ') | Should -Match 'File' + } + + It 'falls back to Azure queue logs when REST logs omit JobError detail' { + Mock New-GraphPOSTRequest { + [PSCustomObject]@{ + d = [PSCustomObject]@{ + GetCopyJobProgress = [PSCustomObject]@{ + JobState = 0 + Logs = @('{"Event":"JobEnd","TotalErrors":1}') + } + } + } + } + Mock Get-CIPPSharePointCopyJobQueueLogs { + @([PSCustomObject]@{ + Event = 'JobError' + ObjectType = 'File' + Message = 'Access denied.' + ErrorCode = '-2147024816' + }) + } + + $Result = Get-CIPPSharePointCopyJobProgress -TenantFilter 'contoso.com' -SourceSiteUrl 'https://contoso.sharepoint.com/sites/a' -CopyJobInfo @{ + JobId = 'job-3'; JobQueueUri = 'https://queue.example/messages?sas=1'; EncryptionKey = 'key' + } + + ($Result.ErrorMessages -join ' ') | Should -Match 'Access denied' + } + + It 'sanitizes path-like content from error messages' { + Mock New-GraphPOSTRequest { + [PSCustomObject]@{ + d = [PSCustomObject]@{ + GetCopyJobProgress = [PSCustomObject]@{ + JobState = 0 + Logs = @( + '{"Event":"JobError","Message":"Could not copy /sites/hr/Shared Documents/report.docx because access denied"}' + ) + } + } + } + } + Mock Get-CIPPSharePointCopyJobQueueLogs { @() } + + $Result = Get-CIPPSharePointCopyJobProgress -TenantFilter 'contoso.com' -SourceSiteUrl 'https://contoso.sharepoint.com/sites/a' -CopyJobInfo @{ + JobId = 'job-sanitize'; JobQueueUri = 'https://queue'; EncryptionKey = 'key' + } + + ($Result.ErrorMessages -join ' ') | Should -Not -Match 'Shared Documents' + ($Result.ErrorMessages -join ' ') | Should -Not -Match 'report\.docx' + ($Result.ErrorMessages -join ' ') | Should -Match 'access denied' + } + + It 'sends copyJobInfo wrapper in GetCopyJobProgress POST body' { + $script:CapturedBody = $null + Mock New-GraphPOSTRequest { + param($body) + $script:CapturedBody = $body + [PSCustomObject]@{ + d = [PSCustomObject]@{ + GetCopyJobProgress = [PSCustomObject]@{ + JobState = 0 + Logs = @() + } + } + } + } + Mock Get-CIPPSharePointCopyJobQueueLogs { @() } + + $null = Get-CIPPSharePointCopyJobProgress -TenantFilter 'contoso.com' -SourceSiteUrl 'https://contoso.sharepoint.com/sites/a' -CopyJobInfo @{ + JobId = 'job-body'; JobQueueUri = 'https://queue'; EncryptionKey = 'key' + } + + $ParsedBody = $script:CapturedBody | ConvertFrom-Json + $ParsedBody.copyJobInfo.JobId | Should -Be 'job-body' + $ParsedBody.copyJobInfo.JobQueueUri | Should -Be 'https://queue' + $ParsedBody.copyJobInfo.EncryptionKey | Should -Be 'key' + $ParsedBody.copyJobInfo.__metadata.type | Should -Be 'SP.CopyMigrationInfo' + } + + It 'unwraps OData collection wrappers stored as CopyJobInfo' { + $script:CapturedBody = $null + Mock New-GraphPOSTRequest { + param($body) + $script:CapturedBody = $body + [PSCustomObject]@{ + d = [PSCustomObject]@{ + GetCopyJobProgress = [PSCustomObject]@{ + JobState = 0 + Logs = @('{"Event":"JobEnd","TotalErrors":0}') + } + } + } + } + Mock Get-CIPPSharePointCopyJobQueueLogs { @() } + + $Result = Get-CIPPSharePointCopyJobProgress -TenantFilter 'contoso.com' -SourceSiteUrl 'https://contoso.sharepoint.com/sites/a' -CopyJobInfo ([PSCustomObject]@{ + __metadata = [PSCustomObject]@{ type = 'Collection(SP.CopyMigrationInfo)' } + results = @( + [PSCustomObject]@{ + JobId = 'job-from-results' + JobQueueUri = 'https://queue/messages' + EncryptionKey = 'key' + } + ) + }) + + $ParsedBody = $script:CapturedBody | ConvertFrom-Json + $ParsedBody.copyJobInfo.JobId | Should -Be 'job-from-results' + $Result.IsComplete | Should -Be $true + } + + It 'uses queue logs when GetCopyJobProgress REST fails' { + Mock New-GraphPOSTRequest { throw 'REST unavailable' } + Mock Get-CIPPSharePointCopyJobQueueLogs { + @([PSCustomObject]@{ + Event = 'JobEnd' + TotalErrors = 1 + ObjectsProcessed = 0 + }, + [PSCustomObject]@{ + Event = 'JobError' + ObjectType = 'File' + Message = 'Access denied.' + }) + } + + $Result = Get-CIPPSharePointCopyJobProgress -TenantFilter 'contoso.com' -SourceSiteUrl 'https://contoso.sharepoint.com/sites/a' -CopyJobInfo @{ + JobId = 'job-rest-fail'; JobQueueUri = 'https://queue.example/messages?sas=1'; EncryptionKey = 'key' + } + + $Result.IsComplete | Should -Be $true + $Result.TotalErrors | Should -BeGreaterThan 0 + ($Result.ErrorMessages -join ' ') | Should -Match 'Access denied' + } +} diff --git a/Tests/Private/Get-CIPPSharePointLibraryCopyOperation.Tests.ps1 b/Tests/Private/Get-CIPPSharePointLibraryCopyOperation.Tests.ps1 new file mode 100644 index 0000000000000..b1ff13b26a694 --- /dev/null +++ b/Tests/Private/Get-CIPPSharePointLibraryCopyOperation.Tests.ps1 @@ -0,0 +1,117 @@ +# Pester tests for SharePointLibraryCopy operation store helpers + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Get-CIPPSharePointLibraryCopyOperation.ps1' + $SetPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Set-CIPPSharePointLibraryCopyOperation.ps1' + if (-not (Test-Path $FunctionPath)) { throw "Could not locate $FunctionPath" } + if (-not (Test-Path $SetPath)) { throw "Could not locate $SetPath" } + + function Get-CIPPTable { + param($TableName) + @{ Context = [pscustomobject]@{ TableName = $TableName } } + } + + $script:TableRows = @() + + function Get-CIPPAzDataTableEntity { + param($Context, $Filter) + if ($Filter -match "PartitionKey eq '([^']+)' and RowKey eq '([^']+)'") { + $tenant = $Matches[1] + $rowKey = $Matches[2] + return @($script:TableRows | Where-Object { $_.PartitionKey -eq $tenant -and $_.RowKey -eq $rowKey }) + } + if ($Filter -match "PartitionKey eq '([^']+)' and startswith\(RowKey, '([^']+)'\)") { + $tenant = $Matches[1] + $prefix = $Matches[2] + return @($script:TableRows | Where-Object { $_.PartitionKey -eq $tenant -and $_.RowKey.StartsWith($prefix) }) + } + if ($Filter -match "PartitionKey eq '([^']+)'$") { + $tenant = $Matches[1] + return @($script:TableRows | Where-Object { $_.PartitionKey -eq $tenant }) + } + return @() + } + + function Remove-CIPPAzDataTableEntity { + param($Context, $Entity, [switch]$Force) + foreach ($Row in @($Entity)) { + $script:TableRows = @($script:TableRows | Where-Object { + -not ($_.PartitionKey -eq $Row.PartitionKey -and $_.RowKey -eq $Row.RowKey) + }) + } + } + + function Add-CIPPAzDataTableEntity { + param($Context, $Entity, [switch]$Force, [string]$OperationType = 'Add') + foreach ($Row in @($Entity)) { + $Existing = $script:TableRows | Where-Object { + $_.PartitionKey -eq $Row.PartitionKey -and $_.RowKey -eq $Row.RowKey + } | Select-Object -First 1 + if ($Existing -and $OperationType -eq 'UpsertMerge') { + foreach ($Key in $Row.Keys) { + $Existing.$Key = $Row[$Key] + } + } elseif ($Existing -and $Force) { + $script:TableRows = @($script:TableRows | Where-Object { + -not ($_.PartitionKey -eq $Row.PartitionKey -and $_.RowKey -eq $Row.RowKey) + }) + $script:TableRows += [pscustomobject]$Row + } else { + $script:TableRows += [pscustomobject]$Row + } + } + } + + . $FunctionPath + . $SetPath +} + +Describe 'Get-CIPPSharePointLibraryCopyOperation' { + BeforeEach { + $script:TableRows = @() + } + + It 'loads the primary row by exact RowKey' { + $OpId = [guid]::NewGuid().Guid + $script:TableRows = @( + [pscustomobject]@{ + PartitionKey = 'contoso.com' + RowKey = $OpId + JobHandleCount = 2 + CopyJobInfos = '[{"JobId":"a"}]' + HandleStates = '[]' + Status = 'Processing' + } + ) + + $Result = Get-CIPPSharePointLibraryCopyOperation -TenantFilter 'contoso.com' -OperationId $OpId + + $Result.OperationId | Should -Be $OpId + $Result.CopyJobInfos.Count | Should -Be 1 + } + + It 'merges status updates without deleting CopyJobInfos' { + $OpId = [guid]::NewGuid().Guid + $script:TableRows = @( + [pscustomobject]@{ + PartitionKey = 'contoso.com' + RowKey = $OpId + JobHandleCount = 1 + CopyJobInfos = '[{"JobId":"a"}]' + HandleStates = '[]' + Status = 'Processing' + } + ) + + Set-CIPPSharePointLibraryCopyOperation -TenantFilter 'contoso.com' -OperationId $OpId -Entity @{ + Status = 'Completed' + HandleStates = '[{"Status":"Success","IsComplete":true}]' + } + + $Result = Get-CIPPSharePointLibraryCopyOperation -TenantFilter 'contoso.com' -OperationId $OpId + $Result.Status | Should -Be 'Completed' + $Result.CopyJobInfos.Count | Should -Be 1 + ($script:TableRows | Measure-Object).Count | Should -Be 1 + } +} diff --git a/Tests/Private/Get-CippApiClient.Tests.ps1 b/Tests/Private/Get-CippApiClient.Tests.ps1 new file mode 100644 index 0000000000000..1c54ed75d2884 --- /dev/null +++ b/Tests/Private/Get-CippApiClient.Tests.ps1 @@ -0,0 +1,102 @@ +# Pester tests for Get-CippApiClient. +# +# IPRange is stored as a JSON array string. Blank or unparseable means unrestricted (Any). +# A stored literal '[]' must mean the same thing, and must never leak an extra element into +# the returned client list. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Authentication/Get-CippApiClient.ps1' + + function Get-CIPPTable { param($TableName) } + function Get-CIPPAzDataTableEntity { param($TableName, $Filter) } + + . $FunctionPath + + $script:AppId = '11111111-2222-3333-4444-555555555555' +} + +Describe 'Get-CippApiClient' { + BeforeEach { + Mock -CommandName Get-CIPPTable -MockWith { @{ TableName = 'ApiClients' } } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { $script:Rows } + } + + Context 'IPRange stored as an empty array' { + BeforeEach { + $script:Rows = @( + [pscustomobject]@{ PartitionKey = 'ApiClients'; RowKey = $script:AppId; AppName = 'Empty'; Role = 'readonly'; IPRange = '[]'; Enabled = $true; MCPAllowed = $false } + ) + } + + It 'returns only the client, no extra element' { + $Result = @(Get-CippApiClient -AppId $script:AppId) + $Result.Count | Should -Be 1 + $Result[0].ClientId | Should -Be $script:AppId + } + + It 'treats the empty array as Any' { + $Result = Get-CippApiClient -AppId $script:AppId + @($Result.IPRange) | Should -Be @('Any') + } + } + + Context 'IPRange stored with ranges' { + BeforeEach { + $script:Rows = @( + [pscustomobject]@{ PartitionKey = 'ApiClients'; RowKey = $script:AppId; AppName = 'Ranged'; Role = 'readonly'; IPRange = '["10.0.0.0/8","192.168.1.1"]'; Enabled = $true; MCPAllowed = $true } + ) + } + + It 'returns the parsed ranges' { + $Result = Get-CippApiClient -AppId $script:AppId + @($Result.IPRange) | Should -Be @('10.0.0.0/8', '192.168.1.1') + } + + It 'does not add Any alongside real ranges' { + $Result = Get-CippApiClient -AppId $script:AppId + @($Result.IPRange) | Should -Not -Contain 'Any' + } + } + + Context 'IPRange blank' { + BeforeEach { + $script:Rows = @( + [pscustomobject]@{ PartitionKey = 'ApiClients'; RowKey = $script:AppId; AppName = 'Blank'; Role = 'readonly'; IPRange = ''; Enabled = $true; MCPAllowed = $false } + ) + } + + It 'treats blank as Any' { + $Result = Get-CippApiClient -AppId $script:AppId + @($Result.IPRange) | Should -Be @('Any') + } + } + + Context 'IPRange unparseable' { + BeforeEach { + $script:Rows = @( + [pscustomobject]@{ PartitionKey = 'ApiClients'; RowKey = $script:AppId; AppName = 'Broken'; Role = 'readonly'; IPRange = 'not json'; Enabled = $true; MCPAllowed = $false } + ) + } + + It 'treats unparseable as Any' { + $Result = Get-CippApiClient -AppId $script:AppId + @($Result.IPRange) | Should -Be @('Any') + } + } + + Context 'Multiple clients, one with an empty array' { + BeforeEach { + $script:Rows = @( + [pscustomobject]@{ PartitionKey = 'ApiClients'; RowKey = 'aaaaaaaa-0000-0000-0000-000000000001'; AppName = 'One'; Role = 'readonly'; IPRange = '[]'; Enabled = $true; MCPAllowed = $false } + [pscustomobject]@{ PartitionKey = 'ApiClients'; RowKey = 'aaaaaaaa-0000-0000-0000-000000000002'; AppName = 'Two'; Role = 'editor'; IPRange = '["10.0.0.0/8"]'; Enabled = $true; MCPAllowed = $false } + ) + } + + It 'returns exactly one object per stored row' { + $Result = @(Get-CippApiClient) + $Result.Count | Should -Be 2 + $Result.ClientId | Should -Be @('aaaaaaaa-0000-0000-0000-000000000001', 'aaaaaaaa-0000-0000-0000-000000000002') + } + } +} diff --git a/Tests/Private/Get-CippHttpPermissions.Tests.ps1 b/Tests/Private/Get-CippHttpPermissions.Tests.ps1 new file mode 100644 index 0000000000000..36c1319517cb2 --- /dev/null +++ b/Tests/Private/Get-CippHttpPermissions.Tests.ps1 @@ -0,0 +1,114 @@ +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + + function Get-CIPPTable { param($tablename) @{} } + function Get-CIPPAzDataTableEntity { param($Filter, $Property) } + function Add-CIPPAzDataTableEntity { param($Entity, [switch]$Force) } + function Get-CIPPHttpFunctions { param([switch]$ByRole, [switch]$ByRoleGroup) } + + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Authentication/Test-CippHttpPermissionUniverse.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Authentication/Get-CippHttpPermissions.ps1') + + # A plausible universe: 60 names shaped Area.Object.Read/ReadWrite, including the core one. + $script:FullUniverse = @( + 'CIPP.Core.Read' + 'CIPP.Core.ReadWrite' + foreach ($Area in 'Identity', 'Exchange', 'Tenant', 'Endpoint', 'Security', 'Teams', 'Sharepoint') { + foreach ($Object in 'User', 'Group', 'Device', 'Mailbox', 'Policy') { + "$Area.$Object.Read" + "$Area.$Object.ReadWrite" + } + } + ) + + $env:CIPPNG = 'true' + $env:APP_VERSION = '10.9.1' +} + +Describe 'Test-CippHttpPermissionUniverse' { + It 'accepts a full universe' { + Test-CippHttpPermissionUniverse -Permissions $script:FullUniverse | Should -BeTrue + } + + It 'accepts the None placeholder among real permissions' { + Test-CippHttpPermissionUniverse -Permissions (@('None') + $script:FullUniverse) | Should -BeTrue + } + + It 'rejects an empty or missing list' { + Test-CippHttpPermissionUniverse -Permissions @() | Should -BeFalse + Test-CippHttpPermissionUniverse -Permissions $null | Should -BeFalse + } + + It 'rejects a truncated enumeration' { + Test-CippHttpPermissionUniverse -Permissions ($script:FullUniverse | Select-Object -First 12) | Should -BeFalse + } + + It 'rejects a universe without the core read permission' { + $NoCore = @($script:FullUniverse | Where-Object { $_ -ne 'CIPP.Core.Read' }) + Test-CippHttpPermissionUniverse -Permissions $NoCore | Should -BeFalse + } + + It 'rejects an error string persisted as a permission' { + $WithError = @('Function Error Exception of type System.OutOfMemoryException was thrown') + $script:FullUniverse + Test-CippHttpPermissionUniverse -Permissions $WithError | Should -BeFalse + } +} + +Describe 'Get-CippHttpPermissions' { + BeforeEach { + $script:CippHttpPermissions = $null + $script:CippHttpPermissionsVersion = $null + Mock Add-CIPPAzDataTableEntity {} + } + + It 'serves a valid cached universe without enumerating' { + Mock Get-CIPPAzDataTableEntity { [PSCustomObject]@{ Permissions = ($script:FullUniverse | ConvertTo-Json -Compress) } } + Mock Get-CIPPHttpFunctions { throw 'should not enumerate' } + + $Result = Get-CippHttpPermissions + + @($Result).Count | Should -Be $script:FullUniverse.Count + Should -Invoke Add-CIPPAzDataTableEntity -Times 0 -Exactly + } + + It 'recomputes and replaces a cached universe that is an error string' { + Mock Get-CIPPAzDataTableEntity { [PSCustomObject]@{ Permissions = '"Function Error Exception of type System.OutOfMemoryException was thrown"' } } + Mock Get-CIPPHttpFunctions { $script:FullUniverse | ForEach-Object { [PSCustomObject]@{ Permission = $_; Count = 1 } } } + + $Result = Get-CippHttpPermissions 3>$null + + $Result | Should -Contain 'CIPP.Core.Read' + @($Result).Count | Should -Be $script:FullUniverse.Count + Should -Invoke Add-CIPPAzDataTableEntity -Times 1 -Exactly + } + + It 'recomputes a cached universe that is too short to be real' { + Mock Get-CIPPAzDataTableEntity { [PSCustomObject]@{ Permissions = (@('CIPP.Core.Read', 'Identity.User.Read') | ConvertTo-Json -Compress) } } + Mock Get-CIPPHttpFunctions { $script:FullUniverse | ForEach-Object { [PSCustomObject]@{ Permission = $_; Count = 1 } } } + + $Result = Get-CippHttpPermissions 3>$null + + @($Result).Count | Should -Be $script:FullUniverse.Count + Should -Invoke Add-CIPPAzDataTableEntity -Times 1 -Exactly + } + + It 'serves but does not persist a truncated enumeration' { + Mock Get-CIPPAzDataTableEntity { $null } + Mock Get-CIPPHttpFunctions { @('CIPP.Core.Read', 'Identity.User.Read', 'None') | ForEach-Object { [PSCustomObject]@{ Permission = $_; Count = 1 } } } + + $Result = Get-CippHttpPermissions 3>$null + + @($Result).Count | Should -Be 3 + Should -Invoke Add-CIPPAzDataTableEntity -Times 0 -Exactly + # Nothing memoized either: the next call must try again. + $script:CippHttpPermissions | Should -BeNullOrEmpty + } + + It 'lets an enumeration failure surface instead of caching it' { + Mock Get-CIPPAzDataTableEntity { $null } + Mock Get-CIPPHttpFunctions { throw 'Failed to enumerate HTTP function permissions: boom' } + + { Get-CippHttpPermissions } | Should -Throw -ExpectedMessage '*Failed to enumerate*' + Should -Invoke Add-CIPPAzDataTableEntity -Times 0 -Exactly + } +} diff --git a/Tests/Private/Get-DefenderCves.Tests.ps1 b/Tests/Private/Get-DefenderCves.Tests.ps1 index 46e1b2d5c750f..e7218a5f7deb2 100644 --- a/Tests/Private/Get-DefenderCves.Tests.ps1 +++ b/Tests/Private/Get-DefenderCves.Tests.ps1 @@ -47,8 +47,9 @@ BeforeAll { } } - # A record the fold cannot bucket: Hashtable.ContainsKey rejects a null key, so this - # trips the per-record catch instead of producing a row. + # A TVM software-inventory row with no CVE. The fold skips these up front (counting them + # as skipped) rather than trying to bucket a null key, which previously threw and was + # logged per-record as an 'Allover Build' error. function New-UnbucketableRecord { param($deviceId = 'd-bad') [pscustomobject]@{ cveId = $null; deviceId = $deviceId } @@ -246,21 +247,17 @@ Describe 'get-DefenderCVEs' { $Received | ForEach-Object { @($_).Count | Should -Be 1 } } - It 'folds each record as it arrives rather than after the whole fetch completes' { + It 'skips records with no CVE without throwing or logging an error' { Mock -CommandName Get-DefenderTvmRaw -MockWith { - New-TvmRecord -cveId 'CVE-A' -deviceId 'd1' - New-UnbucketableRecord - throw 'page 3 failed' + New-UnbucketableRecord -deviceId 'd0' + New-TvmRecord -cveId 'CVE-2024-0009' -deviceId 'd1' } - { get-DefenderCVEs -TenantFilter $script:Tenant } | Should -Throw + $Result = @(get-DefenderCVEs -TenantFilter $script:Tenant) - # The unbucketable record is only ever logged from inside the fold. A buffered - # fetch would throw before a single record reached the fold, so this log is the - # observable proof that stage 1 streams. - Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { - $message -like 'Allover Build*' - } + $Result.cveId | Should -Be 'CVE-2024-0009' + Should -Invoke Write-LogMessage -Times 0 -Exactly -ParameterFilter { $message -like 'Allover Build*' } + Should -Invoke Write-LogMessage -Times 0 -Exactly -ParameterFilter { $sev -eq 'Error' } } } diff --git a/Tests/Private/Get-GraphRequestList.Paging.Tests.ps1 b/Tests/Private/Get-GraphRequestList.Paging.Tests.ps1 new file mode 100644 index 0000000000000..f618f77f44897 --- /dev/null +++ b/Tests/Private/Get-GraphRequestList.Paging.Tests.ps1 @@ -0,0 +1,219 @@ +# Pester tests for the paged AllTenants cache serve in Get-GraphRequestList +# Validates that ManualPagination + RawJsonArray serves pages bounded by the byte budget +# alone (never a tenant count), fetched as span range queries, with a continuation token; +# that the key scan never fetches Data payloads; that the queue fallback still triggers on +# a cold cache; and that the unpaged fast path is intact. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + + # Stub every CIPP helper the exercised paths call so Pester's Mock has a command to replace. + function Get-CIPPTable { param($TableName) } + function Get-CIPPAzDataTableEntity { param($Context, $Filter, $Property, $First) } + function Get-Tenants { param($TenantFilter, [switch]$IncludeErrors) } + function Get-CIPPQueueData { param($Reference) } + function Get-StringHash { param($String) } + function New-CippQueueEntry { param($Name, $Link, $Reference, $TotalTasks) } + function Start-CIPPOrchestrator { param($InputObject) } + function New-GraphGetRequest { param($uri, $tenantid) } + + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/ConvertTo-CIPPODataFilterValue.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/GraphRequests/Get-GraphRequestList.ps1') + + # Emulates a span fetch: blobs inside the [From, To] RowKey range in ordinal order. + function Select-FakeBlobRows { + param([string]$Filter) + if ($Filter -notmatch "RowKey ge '([^']*)' and RowKey le '([^']*)~'") { return @() } + $From = $Matches[1] + $To = $Matches[2] + $Keys = [string[]]@($script:TenantBlobs.Keys) + [System.Array]::Sort($Keys, [System.Collections.IComparer][StringComparer]::Ordinal) + @($Keys | Where-Object { + [string]::CompareOrdinal($_, $From) -ge 0 -and [string]::CompareOrdinal($_, $To) -le 0 + } | ForEach-Object { + [PSCustomObject]@{ PartitionKey = 'PKHASH'; RowKey = $_; Data = $script:TenantBlobs[$_] } + }) + } +} + +Describe 'Get-GraphRequestList paged AllTenants cache serve' { + BeforeEach { + Mock -CommandName Get-StringHash -MockWith { 'PKHASH' } + Mock -CommandName Get-CIPPTable -MockWith { @{ Context = 'fake' } } + Mock -CommandName Get-CIPPQueueData -MockWith { $null } + Mock -CommandName Get-Tenants -MockWith { + @( + [PSCustomObject]@{ defaultDomainName = 'a.com' } + [PSCustomObject]@{ defaultDomainName = 'b.com' } + [PSCustomObject]@{ defaultDomainName = 'c.com' } + ) + } + + # Key scans (Property set) return raw physical rows incl. '-part' rows and an + # unmanaged tenant (b0gus.com) inside the span range; data fetches return blobs. + $script:TenantBlobs = @{ + 'a.com' = '[{"id":"a1"},{"id":"a2"}]' + 'b.com' = '[{"id":"b1"}]' + 'b0gus.com' = '[{"id":"bogus"}]' + 'c.com' = '[{"id":"c1"}]' + } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + if ($Property) { + @( + [PSCustomObject]@{ PartitionKey = 'PKHASH'; RowKey = 'a.com' } + [PSCustomObject]@{ PartitionKey = 'PKHASH'; RowKey = 'b.com' } + [PSCustomObject]@{ PartitionKey = 'PKHASH'; RowKey = 'b.com-part1' } + [PSCustomObject]@{ PartitionKey = 'PKHASH'; RowKey = 'b.com-part2' } + [PSCustomObject]@{ PartitionKey = 'PKHASH'; RowKey = 'b0gus.com' } + [PSCustomObject]@{ PartitionKey = 'PKHASH'; RowKey = 'c.com' } + ) + } else { + Select-FakeBlobRows -Filter $Filter + } + } + } + + It 'serves all managed tenants in one span when they fit, with valid JSON and no token' { + $Result = Get-GraphRequestList -TenantFilter 'AllTenants' -Endpoint 'users' -ManualPagination -RawJsonArray + + $Result.PSObject.Properties.Name | Should -Contain 'CippPagedJson' + $Result.CippNextLink | Should -BeNullOrEmpty + $Parsed = $Result.CippPagedJson | ConvertFrom-Json + # a.com's two rows plus one each from b.com and c.com. b0gus.com sits inside the + # span's RowKey range but is unmanaged, so it must be dropped; the part rows + # deduplicate into b.com's count. + $Parsed | Should -HaveCount 4 + $Parsed.id | Should -Be @('a1', 'a2', 'b1', 'c1') + # The key scan must project keys only - never Data payloads, and never a subset of + # the split-entity markers (a partial marker projection makes the module fail + # reassembly and drop split tenants from the plan entirely). + Should -Invoke Get-CIPPAzDataTableEntity -Times 1 -ParameterFilter { + $null -ne $Property -and $Property -notcontains 'OriginalEntityId' -and $Property -notcontains 'Data' + } + # One span covers all three tenants: exactly one range fetch. + Should -Invoke Get-CIPPAzDataTableEntity -Times 1 -ParameterFilter { $Filter -like "*RowKey ge*" } + } + + It 'resumes after the tenant named by the incoming nextLink token' { + $Result = Get-GraphRequestList -TenantFilter 'AllTenants' -Endpoint 'users' -ManualPagination -RawJsonArray -nextLink 'a.com' + + $Parsed = $Result.CippPagedJson | ConvertFrom-Json + $Parsed.id | Should -Be @('b1', 'c1') + $Result.CippNextLink | Should -BeNullOrEmpty + Should -Invoke Get-CIPPAzDataTableEntity -Times 0 -ParameterFilter { $Filter -like "*RowKey ge 'a.com'*" } + } + + It 'ends the page on the character budget mid-span and resumes from the token' { + # b.com's blob alone exceeds the 4M character budget. All three tenants share one + # span, so the budget must end the page inside the span: c.com's fetched blob is + # discarded and served by the next page. + $script:TenantBlobs['b.com'] = '[{"id":"b1","pad":"' + ('x' * 4200000) + '"}]' + + $Result = Get-GraphRequestList -TenantFilter 'AllTenants' -Endpoint 'users' -ManualPagination -RawJsonArray + + $Result.CippNextLink | Should -Be 'b.com' + $Ids = ($Result.CippPagedJson | ConvertFrom-Json).id + $Ids | Should -Contain 'a1' + $Ids | Should -Not -Contain 'c1' + + $Next = Get-GraphRequestList -TenantFilter 'AllTenants' -Endpoint 'users' -ManualPagination -RawJsonArray -nextLink $Result.CippNextLink + ($Next.CippPagedJson | ConvertFrom-Json).id | Should -Be @('c1') + $Next.CippNextLink | Should -BeNullOrEmpty + } + + It 'honours a MaxPageBytes override, clamped to the floor' { + # a.com's ~300KB exceeds the 256KB floor that the 1-byte request clamps up to, so + # the page ends after the first tenant even though all three share a span. + $script:TenantBlobs['a.com'] = '[{"id":"a1","pad":"' + ('x' * 300000) + '"}]' + + $Result = Get-GraphRequestList -TenantFilter 'AllTenants' -Endpoint 'users' -ManualPagination -RawJsonArray -MaxPageBytes 1 + + $Result.CippNextLink | Should -Be 'a.com' + $Next = Get-GraphRequestList -TenantFilter 'AllTenants' -Endpoint 'users' -ManualPagination -RawJsonArray -MaxPageBytes 1 -nextLink $Result.CippNextLink + ($Next.CippPagedJson | ConvertFrom-Json).id | Should -Be @('b1', 'c1') + $Next.CippNextLink | Should -BeNullOrEmpty + } + + It 'never re-serves earlier tenants on resume when tenant casing is mixed' { + # Ordinal order puts 'CyberDrainDev.com' (uppercase C) before 'cipp.com'; a + # culture-aware plan sort ordered them the other way round, so resuming after + # CyberDrainDev re-served cipp's rows and the chain returned duplicates. + Mock -CommandName Get-Tenants -MockWith { + @( + [PSCustomObject]@{ defaultDomainName = 'CyberDrainDev.com' } + [PSCustomObject]@{ defaultDomainName = 'cipp.com' } + ) + } + $script:TenantBlobs = @{ + 'CyberDrainDev.com' = '[{"id":"cdd1"}]' + 'cipp.com' = '[{"id":"cipp1"}]' + } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + if ($Property) { + @( + [PSCustomObject]@{ PartitionKey = 'PKHASH'; RowKey = 'cipp.com' } + [PSCustomObject]@{ PartitionKey = 'PKHASH'; RowKey = 'CyberDrainDev.com' } + ) + } else { + Select-FakeBlobRows -Filter $Filter + } + } + + $Full = Get-GraphRequestList -TenantFilter 'AllTenants' -Endpoint 'users' -ManualPagination -RawJsonArray + ($Full.CippPagedJson | ConvertFrom-Json).id | Should -Be @('cdd1', 'cipp1') + + $Resumed = Get-GraphRequestList -TenantFilter 'AllTenants' -Endpoint 'users' -ManualPagination -RawJsonArray -nextLink 'CyberDrainDev.com' + ($Resumed.CippPagedJson | ConvertFrom-Json).id | Should -Be @('cipp1') + } + + It 'serves many small tenants in one page regardless of tenant count' { + # The complaint span fetching answers: 60 tiny tenants must not need 60 requests + # or 60 queries - they share spans and land in a single page. + $script:ManyTenants = @(1..60 | ForEach-Object { 'tenant{0:D3}.example' -f $_ }) + Mock -CommandName Get-Tenants -MockWith { + @($script:ManyTenants | ForEach-Object { [PSCustomObject]@{ defaultDomainName = $_ } }) + } + $script:TenantBlobs = @{} + foreach ($Tenant in $script:ManyTenants) { $script:TenantBlobs[$Tenant] = ('[{{"id":"{0}"}}]' -f $Tenant) } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + if ($Property) { + @($script:ManyTenants | ForEach-Object { [PSCustomObject]@{ PartitionKey = 'PKHASH'; RowKey = $_ } }) + } else { + Select-FakeBlobRows -Filter $Filter + } + } + + $Result = Get-GraphRequestList -TenantFilter 'AllTenants' -Endpoint 'users' -ManualPagination -RawJsonArray + + $Result.CippNextLink | Should -BeNullOrEmpty + ($Result.CippPagedJson | ConvertFrom-Json) | Should -HaveCount 60 + # 60 one-row tenants at 40 rows per span: two range fetches, one page. + Should -Invoke Get-CIPPAzDataTableEntity -Times 2 -ParameterFilter { $Filter -like "*RowKey ge*" } + } + + It 'falls through to the queue flow when the cache is cold' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { @() } + Mock -CommandName New-CippQueueEntry -MockWith { @{ RowKey = 'queue-1' } } + Mock -CommandName Start-CIPPOrchestrator -MockWith { 'instance-1' } + + $Result = Get-GraphRequestList -TenantFilter 'AllTenants' -Endpoint 'users' -ManualPagination -RawJsonArray + + $Result.Queued | Should -BeTrue + $Result.QueueId | Should -Be 'queue-1' + Should -Invoke Start-CIPPOrchestrator -Times 1 + } + + It 'keeps the unpaged raw concat path when ManualPagination is not set' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @( + [PSCustomObject]@{ PartitionKey = 'PKHASH'; RowKey = 'a.com'; OriginalEntityId = $null; Data = '[{"id":"a1"}]' } + [PSCustomObject]@{ PartitionKey = 'PKHASH'; RowKey = 'c.com'; OriginalEntityId = $null; Data = '[{"id":"c1"}]' } + ) + } + + $Result = Get-GraphRequestList -TenantFilter 'AllTenants' -Endpoint 'users' -RawJsonArray + + $Result | Should -BeOfType [string] + ($Result | ConvertFrom-Json).id | Should -Be @('a1', 'c1') + } +} diff --git a/Tests/Private/Invoke-CIPPCustomDomainCertificate.Tests.ps1 b/Tests/Private/Invoke-CIPPCustomDomainCertificate.Tests.ps1 new file mode 100644 index 0000000000000..4808b7a483b55 --- /dev/null +++ b/Tests/Private/Invoke-CIPPCustomDomainCertificate.Tests.ps1 @@ -0,0 +1,120 @@ +# Pester tests for Invoke-CIPPCustomDomainCertificate +# Managed certificate issuance outlives a request, so the function has to hand off to a hidden +# retry task - and stop handing off once the certificate is bound, the domain is gone, or the +# attempt budget is spent. An unbounded reschedule is the bug these tests guard. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Functions/Invoke-CIPPCustomDomainCertificate.ps1' + + function Get-CIPPAppServiceSite { param($ApiVersion) } + function New-CIPPAzRestRequest { param($Uri, $Method, $Body) } + function Add-CIPPScheduledTask { param($Task, $Hidden) } + function Write-LogMessage { param($API, $message, $sev, $tenant, $headers, $LogData) } + function Get-CippException { param($Exception) @{ NormalizedError = "$Exception" } } + + . $FunctionPath +} + +Describe 'Invoke-CIPPCustomDomainCertificate' { + BeforeEach { + $script:Hostname = 'portal.contoso.com' + $script:SiteState = [pscustomobject]@{ + SiteName = 'cippxyz' + ArmBase = 'https://management.azure.com/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/sites/cippxyz' + CertBase = 'https://management.azure.com/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/certificates' + ApiVersion = '2024-11-01' + Site = [pscustomobject]@{ + location = 'westeurope' + properties = [pscustomobject]@{ + serverFarmId = '/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/serverfarms/plan' + hostNames = @('cippxyz.azurewebsites.net', $script:Hostname) + hostNameSslStates = @([pscustomobject]@{ name = $script:Hostname; sslState = 'Disabled' }) + } + } + Certificates = @() + } + Mock -CommandName Get-CIPPAppServiceSite -MockWith { $script:SiteState } + # Nothing issued yet: the PUT returns a cert without a thumbprint and the poll list stays empty + Mock -CommandName New-CIPPAzRestRequest -MockWith { + if ($Method -eq 'PUT') { [pscustomobject]@{ properties = [pscustomobject]@{ thumbprint = $null } } } + else { [pscustomobject]@{ value = @() } } + } + Mock -CommandName Add-CIPPScheduledTask -MockWith { 'Task created' } + Mock -CommandName Write-LogMessage + Mock -CommandName Start-Sleep + } + + It 'does nothing when the hostname is not bound (the domain was removed)' { + $script:SiteState.Site.properties.hostNames = @('cippxyz.azurewebsites.net') + + $Result = Invoke-CIPPCustomDomainCertificate -Hostname $script:Hostname + + $Result | Should -Match 'No hostname binding' + Should -Invoke New-CIPPAzRestRequest -Times 0 + Should -Invoke Add-CIPPScheduledTask -Times 0 + } + + It 'does nothing when the binding is already secured' { + $script:SiteState.Site.properties.hostNameSslStates[0].sslState = 'SniEnabled' + + Invoke-CIPPCustomDomainCertificate -Hostname $script:Hostname | Should -Match 'already secured' + Should -Invoke New-CIPPAzRestRequest -Times 0 + Should -Invoke Add-CIPPScheduledTask -Times 0 + } + + It 'binds an already-issued certificate for the hostname without creating another' { + $script:SiteState.Certificates = @([pscustomobject]@{ + name = 'whatever-the-portal-called-it' + properties = [pscustomobject]@{ canonicalName = $script:Hostname; thumbprint = 'ABC123' } + }) + + Invoke-CIPPCustomDomainCertificate -Hostname $script:Hostname | Should -Match 'SNI SSL enabled' + + Should -Invoke New-CIPPAzRestRequest -Times 1 -Exactly -ParameterFilter { + $Method -eq 'PUT' -and $Uri -like '*/hostNameBindings/portal.contoso.com?*' -and $Body.properties.thumbprint -eq 'ABC123' -and $Body.properties.sslState -eq 'SniEnabled' + } + Should -Invoke Add-CIPPScheduledTask -Times 0 + } + + It 'creates the certificate in the plan resource group and schedules a hidden retry when it is not issued yet' { + $Result = Invoke-CIPPCustomDomainCertificate -Hostname $script:Hostname + + $Result | Should -Match 'attempt 1 of 4' + Should -Invoke New-CIPPAzRestRequest -Times 1 -Exactly -ParameterFilter { + $Method -eq 'PUT' -and $Uri -like "*/certificates/portal.contoso.com-cippxyz?*" -and $Body.properties.canonicalName -eq 'portal.contoso.com' + } + Should -Invoke New-CIPPAzRestRequest -Times 0 -ParameterFilter { $Uri -like '*/hostNameBindings/*' } + Should -Invoke Add-CIPPScheduledTask -Times 1 -Exactly -ParameterFilter { + $Hidden -eq $true -and $Task.Parameters.Attempt -eq 2 -and $Task.Parameters.Hostname -eq 'portal.contoso.com' -and $Task.Reference -eq 'CustomDomainCert-portal.contoso.com' + } + } + + It 'treats a 409 on create as an issuance already in flight rather than a failure' { + Mock -CommandName New-CIPPAzRestRequest -MockWith { + if ($Method -eq 'PUT') { throw 'Azure REST API call failed: Found a duplicate certificate (Status: Conflict)' } + [pscustomobject]@{ value = @() } + } + + $Result = Invoke-CIPPCustomDomainCertificate -Hostname $script:Hostname + + $Result | Should -Match 'still being issued' + Should -Invoke Add-CIPPScheduledTask -Times 1 + } + + It 'gives up on the last attempt instead of rescheduling again' { + $Result = Invoke-CIPPCustomDomainCertificate -Hostname $script:Hostname -Attempt 4 + + $Result | Should -Match 'Giving up after 4 attempts' + Should -Invoke Add-CIPPScheduledTask -Times 0 + } + + It 'reschedules after a failed attempt so a transient ARM error does not strand the domain' { + Mock -CommandName New-CIPPAzRestRequest -MockWith { throw 'Azure REST API call failed: boom (Status: 500)' } + + $Result = Invoke-CIPPCustomDomainCertificate -Hostname $script:Hostname -Attempt 2 + + $Result | Should -Match 'failed: .*boom.*attempt 2 of 4' + Should -Invoke Add-CIPPScheduledTask -Times 1 -Exactly -ParameterFilter { $Task.Parameters.Attempt -eq 3 } + } +} diff --git a/Tests/Private/Invoke-CIPPOffboardingJob.DeleteUser.Tests.ps1 b/Tests/Private/Invoke-CIPPOffboardingJob.DeleteUser.Tests.ps1 new file mode 100644 index 0000000000000..5c0115a89265a --- /dev/null +++ b/Tests/Private/Invoke-CIPPOffboardingJob.DeleteUser.Tests.ps1 @@ -0,0 +1,77 @@ +# Pester tests for the DeleteUser guard in Invoke-CIPPOffboardingJob: +# - When DeleteUser is true, only Remove-CIPPUser and Set-CIPPSharePointPerms may run +# - When DeleteUser is false/absent, other selected tasks still run + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $JobPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Invoke-CIPPOffboardingJob.ps1' + $HtmlPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Test-CIPPHtmlIsEmpty.ps1' + if (-not (Test-Path $JobPath)) { throw "Could not locate Invoke-CIPPOffboardingJob.ps1 at $JobPath" } + if (-not (Test-Path $HtmlPath)) { throw "Could not locate Test-CIPPHtmlIsEmpty.ps1 at $HtmlPath" } + + function New-GraphGetRequest { param($uri, $tenantid) } + function Get-CIPPTextReplacement { param($TenantFilter, $Text, [switch]$EscapeForJson) } + function Start-CIPPOrchestrator { param($InputObject) } + function Write-LogMessage { param($API, $tenant, $message, $sev, $headers, $LogData) } + function Get-CippException { param($Exception) @{ NormalizedError = "$Exception" } } + function Write-Information { param($MessageData) } + + . $HtmlPath + . $JobPath +} + +Describe 'Invoke-CIPPOffboardingJob DeleteUser guard' { + BeforeEach { + $script:CapturedInput = $null + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Write-Information -MockWith { } + Mock -CommandName Get-CIPPTextReplacement -MockWith { $Text } + Mock -CommandName New-GraphGetRequest -MockWith { + [pscustomobject]@{ + id = 'user-id-1' + displayName = 'Pat Lee' + onPremisesSyncEnabled = $false + onPremisesImmutableId = $null + } + } + Mock -CommandName Start-CIPPOrchestrator -MockWith { + $script:CapturedInput = $InputObject + 'orch-1' + } + } + + It 'only runs Remove-CIPPUser and Set-CIPPSharePointPerms when DeleteUser is true' { + $Options = [pscustomobject]@{ + DeleteUser = $true + ConvertToShared = $true + RemoveLicenses = $true + RevokeSessions = $true + HideFromGAL = $true + RemoveMFADevices = $true + OnedriveAccess = @(@{ value = 'helper-id-1' }) + } + + $null = Invoke-CIPPOffboardingJob -TenantFilter 'contoso.com' -Username 'pat@contoso.com' -Options $Options + + $script:CapturedInput.Batch | Should -Not -BeNullOrEmpty + $Cmdlets = $script:CapturedInput.Batch | ForEach-Object { $_.Cmdlet } | Sort-Object -Unique + $Cmdlets | Should -Be @('Remove-CIPPUser', 'Set-CIPPSharePointPerms') + } + + It 'runs other selected tasks when DeleteUser is false' { + $Options = [pscustomobject]@{ + DeleteUser = $false + ConvertToShared = $true + RemoveLicenses = $true + RevokeSessions = $true + } + + $null = Invoke-CIPPOffboardingJob -TenantFilter 'contoso.com' -Username 'pat@contoso.com' -Options $Options + + $Cmdlets = $script:CapturedInput.Batch | ForEach-Object { $_.Cmdlet } | Sort-Object -Unique + $Cmdlets | Should -Contain 'Set-CIPPMailboxType' + $Cmdlets | Should -Contain 'Remove-CIPPLicense' + $Cmdlets | Should -Contain 'Revoke-CIPPSessions' + $Cmdlets | Should -Not -Contain 'Remove-CIPPUser' + } +} diff --git a/Tests/Private/Invoke-CIPPOffboardingJob.Progress.Tests.ps1 b/Tests/Private/Invoke-CIPPOffboardingJob.Progress.Tests.ps1 new file mode 100644 index 0000000000000..263ad80e78646 --- /dev/null +++ b/Tests/Private/Invoke-CIPPOffboardingJob.Progress.Tests.ps1 @@ -0,0 +1,128 @@ +# Pester tests for the live-progress wiring in Invoke-CIPPOffboardingJob. +# +# The wizard hands the job a DeploymentId. The job must turn the selected tasks into the step list of +# that user's status row, stamp every queued task with its step so the workers (which run in parallel) +# report to the right place, and close the row as failed when the job never gets as far as queueing. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $JobPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Invoke-CIPPOffboardingJob.ps1' + $HtmlPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Test-CIPPHtmlIsEmpty.ps1' + if (-not (Test-Path $JobPath)) { throw "Could not locate Invoke-CIPPOffboardingJob.ps1 at $JobPath" } + + function New-GraphGetRequest { param($uri, $tenantid) } + function Get-CIPPTextReplacement { param($TenantFilter, $Text, [switch]$EscapeForJson) } + function Start-CIPPOrchestrator { param($InputObject) } + function Write-LogMessage { param($API, $tenant, $message, $sev, $headers, $LogData) } + function Get-CippException { param($Exception) @{ NormalizedError = "$Exception" } } + function Write-Information { param($MessageData) } + function New-CIPPAsyncDeployment { param($JobId, $Names, $StepTitles, $Source, $TaskId, $TenantFilter) } + function Set-CIPPAsyncDeploymentStep { param($JobId, $Name, $StepIndex, $StepStatus, $Message) } + function Set-CIPPAsyncDeploymentStatus { param($JobId, $Name, $Status, $Logs) } + + . $HtmlPath + . $JobPath +} + +Describe 'Invoke-CIPPOffboardingJob live progress' { + BeforeEach { + $script:CapturedInput = $null + $script:StepTitles = $null + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Write-Information -MockWith { } + Mock -CommandName Get-CIPPTextReplacement -MockWith { $Text } + Mock -CommandName New-GraphGetRequest -MockWith { + [pscustomobject]@{ id = 'user-id-1'; displayName = 'Pat Lee'; onPremisesSyncEnabled = $false; onPremisesImmutableId = $null } + } + Mock -CommandName Start-CIPPOrchestrator -MockWith { $script:CapturedInput = $InputObject; 'orch-1' } + Mock -CommandName New-CIPPAsyncDeployment -MockWith { $script:StepTitles = @($StepTitles); $JobId } + Mock -CommandName Set-CIPPAsyncDeploymentStatus -MockWith { } + Mock -CommandName Set-CIPPAsyncDeploymentStep -MockWith { } + } + + It 'gives the row one step per selected task, in execution order, and stamps each task with its step' { + $Options = [pscustomobject]@{ RevokeSessions = $true; DisableSignIn = $true; RemoveLicenses = $true } + + $null = Invoke-CIPPOffboardingJob -TenantFilter 'contoso.com' -Username 'pat@contoso.com' -Options $Options -DeploymentId 'job-1' -TaskInfo ([pscustomobject]@{ RowKey = 'task-1' }) + + Should -Invoke New-CIPPAsyncDeployment -Times 1 -Exactly -ParameterFilter { + $JobId -eq 'job-1' -and (@($Names) -join ',') -eq 'pat@contoso.com' -and $Source -eq 'Offboarding' -and $TaskId -eq 'task-1' -and $TenantFilter -eq 'contoso.com' + } + $script:StepTitles | Should -Be @('Revoke all sessions', 'Disable sign in', 'Remove licenses') + $Batch = @($script:CapturedInput.Batch) + $Batch.Cmdlet | Should -Be @('Revoke-CIPPSessions', 'Set-CIPPSignInState', 'Remove-CIPPLicense') + $Batch.StepIndex | Should -Be @(0, 1, 2) + $Batch.DeploymentId | Should -Be @('job-1', 'job-1', 'job-1') + $Batch.DeploymentName | Should -Be @('pat@contoso.com', 'pat@contoso.com', 'pat@contoso.com') + $script:CapturedInput.PostExecution.Parameters.DeploymentId | Should -Be 'job-1' + Should -Invoke Set-CIPPAsyncDeploymentStatus -Times 1 -Exactly -ParameterFilter { + $JobId -eq 'job-1' -and $Name -eq 'pat@contoso.com' -and $Status -eq 'running' + } + } + + It 'closes the row as failed when the job cannot even be queued' { + Mock -CommandName New-GraphGetRequest -MockWith { throw 'user not found' } + + { Invoke-CIPPOffboardingJob -TenantFilter 'contoso.com' -Username 'pat@contoso.com' -Options ([pscustomobject]@{ RevokeSessions = $true }) -DeploymentId 'job-1' } | Should -Throw + + Should -Invoke Set-CIPPAsyncDeploymentStatus -Times 1 -Exactly -ParameterFilter { + $JobId -eq 'job-1' -and $Status -eq 'failed' -and $Logs -like '*user not found*' + } + } + + It 'runs only the requested step and resets just that step on the existing row' { + $Options = [pscustomobject]@{ RevokeSessions = $true; DisableSignIn = $true; RemoveLicenses = $true } + + $null = Invoke-CIPPOffboardingJob -TenantFilter 'contoso.com' -Username 'pat@contoso.com' -Options $Options -DeploymentId 'job-1' -StepIndexes @(1) + + $Batch = @($script:CapturedInput.Batch) + $Batch.Count | Should -Be 1 + $Batch[0].Cmdlet | Should -Be 'Set-CIPPSignInState' + $Batch[0].StepIndex | Should -Be 1 + Should -Invoke New-CIPPAsyncDeployment -Times 0 -Exactly + Should -Invoke Set-CIPPAsyncDeploymentStep -Times 1 -Exactly -ParameterFilter { + $JobId -eq 'job-1' -and $Name -eq 'pat@contoso.com' -and $StepIndex -eq 1 -and $StepStatus -eq 'pending' + } + Should -Invoke Set-CIPPAsyncDeploymentStatus -Times 1 -Exactly -ParameterFilter { $Status -eq 'running' } + } + + It 'refuses a step re-run for a step that does not exist' { + $Options = [pscustomobject]@{ RevokeSessions = $true } + + { Invoke-CIPPOffboardingJob -TenantFilter 'contoso.com' -Username 'pat@contoso.com' -Options $Options -DeploymentId 'job-1' -StepIndexes @(7) } | Should -Throw + + Should -Invoke Start-CIPPOrchestrator -Times 0 -Exactly + } + + It 'puts a pending notification step per configured channel on the row from the start' { + $Options = [pscustomobject]@{ RevokeSessions = $true; DisableSignIn = $true } + + $null = Invoke-CIPPOffboardingJob -TenantFilter 'contoso.com' -Username 'pat@contoso.com' -Options $Options -DeploymentId 'job-1' -TaskInfo ([pscustomobject]@{ RowKey = 'task-1'; PostExecution = 'Webhook,Email' }) + + $script:StepTitles.Count | Should -Be 4 + $script:StepTitles[0] | Should -Be 'Revoke all sessions' + $script:StepTitles[2].Title | Should -Be 'Notify via Webhook' + $script:StepTitles[2].Kind | Should -Be 'notify' + $script:StepTitles[3].Title | Should -Be 'Notify via Email' + # Notification steps are not tasks: nothing extra is queued for them + @($script:CapturedInput.Batch).Count | Should -Be 2 + } + + It 'still starts the offboarding when the progress row cannot be written' { + Mock -CommandName New-CIPPAsyncDeployment -MockWith { throw 'An error occurred while sending the request.' } + + $Result = Invoke-CIPPOffboardingJob -TenantFilter 'contoso.com' -Username 'pat@contoso.com' -Options ([pscustomobject]@{ RevokeSessions = $true }) -DeploymentId 'job-1' + + Should -Invoke Start-CIPPOrchestrator -Times 1 -Exactly + $Result | Should -BeLike 'Offboarding job started*' + Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { $sev -eq 'Warn' -and $message -like '*progress row*' } + } + + It 'leaves progress alone when no job id was given' { + $null = Invoke-CIPPOffboardingJob -TenantFilter 'contoso.com' -Username 'pat@contoso.com' -Options ([pscustomobject]@{ RevokeSessions = $true }) + + Should -Invoke New-CIPPAsyncDeployment -Times 0 -Exactly + Should -Invoke Set-CIPPAsyncDeploymentStatus -Times 0 -Exactly + @($script:CapturedInput.Batch)[0].Keys | Should -Not -Contain 'DeploymentId' + } +} diff --git a/Tests/Private/Invoke-CIPPPIMAssignmentAction.Tests.ps1 b/Tests/Private/Invoke-CIPPPIMAssignmentAction.Tests.ps1 new file mode 100644 index 0000000000000..f8e14f355e425 --- /dev/null +++ b/Tests/Private/Invoke-CIPPPIMAssignmentAction.Tests.ps1 @@ -0,0 +1,220 @@ +# Pester tests for Invoke-CIPPPIMAssignmentAction - the guards around PIM assignment changes. +# +# What must hold regardless of caller: +# - no PIM write on a tenant without Entra ID P2; +# - the last active Global Administrator is never converted or removed; +# - CIPP-SAM's own assignment, group-inherited rows and service-principal conversions are refused; +# - ConvertToEligible confirms the eligibility exists BEFORE the active assignment is removed; +# - lifetimes above the policy cap are refused (never clamped); +# - nothing is ever posted with a noExpiration schedule. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/PIM/New-CIPPPIMScheduleRequest.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/PIM/Invoke-CIPPPIMAssignmentAction.ps1') + + function Test-CIPPStandardLicense { param($StandardName, $TenantFilter, $Preset, [switch]$SkipLog) $true } + function Get-CIPPPIMRoleAssignments { param($TenantFilter, $PrincipalId, $RoleDefinitionId, [switch]$FromCache, [switch]$IncludePolicy) } + function Get-CIPPPIMRolePolicies { param($TenantFilter, $RoleDefinitionId, [switch]$FromCache) } + function New-GraphPOSTRequest { param($uri, $tenantid, $body, $type, $AsApp) } + function New-GraphGetRequest { param($uri, $tenantid, $AsApp) } + function Get-CIPPTable { param($TableName) @{} } + function Get-CIPPAzDataTableEntity { param($Filter) } + function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev, $LogData) } + + $script:GA = '62e90394-69f5-4237-9190-012177145e10' + $script:Target = 'user-1' + + function New-Row { + param([string]$Principal = 'user-1', [string]$Type = 'Permanent', [string]$MemberType = 'Direct', [string]$PrincipalType = 'User', [string]$Role = $script:GA, [string]$AppId = $null, [datetime]$End = [datetime]::MinValue) + [pscustomobject]@{ + PrincipalId = $Principal + PrincipalDisplayName = "Name $Principal" + PrincipalUserPrincipalName = "$Principal@contoso.com" + PrincipalType = $PrincipalType + PrincipalAppId = $AppId + RoleDefinitionId = $Role + RoleDisplayName = 'Global Administrator' + AssignmentType = $Type + MemberType = $MemberType + DirectoryScopeId = '/' + EndDateTime = if ($End -eq [datetime]::MinValue) { $null } else { $End } + RoleAssignmentId = 'ra-1' + } + } + + function Invoke-Convert { + param([hashtable]$Extra = @{}) + Invoke-CIPPPIMAssignmentAction -TenantFilter 'contoso.onmicrosoft.com' -Action ConvertToEligible -PrincipalId $script:Target -RoleDefinitionId $script:GA -AssignmentType Permanent -Justification 'test' @Extra + } +} + +Describe 'Invoke-CIPPPIMAssignmentAction' { + BeforeEach { + $script:Posts = [System.Collections.Generic.List[object]]::new() + $env:ApplicationID = 'sam-app-id' + Mock Test-CIPPStandardLicense { $true } + Mock Get-CIPPPIMRolePolicies { [pscustomobject]@{ RoleDefinitionId = $script:GA; PolicyId = 'pol'; Settings = [pscustomobject]@{ eligibilityMaxDuration = 'P365D'; activeAssignmentMaxDuration = 'P180D' } } } + Mock Get-CIPPTable { @{} } + Mock Get-CIPPAzDataTableEntity { $null } + Mock Write-LogMessage {} + Mock Start-Sleep {} + Mock New-GraphPOSTRequest { + $script:Posts.Add([pscustomobject]@{ Uri = $uri; Body = ($body | ConvertFrom-Json); Type = $type }) + [pscustomobject]@{ id = 'req' } + } + # Default tenant: the target holds GA permanently, another admin also holds it permanently. + Mock Get-CIPPPIMRoleAssignments { + $Rows = @((New-Row -Principal 'user-1'), (New-Row -Principal 'user-2')) + if ($PrincipalId) { $Rows = $Rows | Where-Object { $_.PrincipalId -eq $PrincipalId } } + if ($RoleDefinitionId) { $Rows = $Rows | Where-Object { $_.RoleDefinitionId -eq $RoleDefinitionId } } + @($Rows) + } + # Eligibility read-back after creation: present. + # Eligibility read-back: present. Post-removal instance poll: already gone. + Mock New-GraphGetRequest { + if ($uri -match 'roleAssignmentScheduleInstances') { @() } + else { @([pscustomobject]@{ id = 'elig-1'; principalId = 'user-1'; roleDefinitionId = $script:GA; directoryScopeId = '/' }) } + } + } + + It 'refuses on a tenant without Entra ID P2 and posts nothing' { + Mock Test-CIPPStandardLicense { $false } + { Invoke-Convert } | Should -Throw '*not licensed for Entra ID P2*' + Should -Invoke New-GraphPOSTRequest -Times 0 -Exactly + } + + It 'refuses to convert the last active Global Administrator' { + Mock Get-CIPPPIMRoleAssignments { + $Rows = @((New-Row -Principal 'user-1'), (New-Row -Principal 'user-2' -Type 'Eligible')) + if ($PrincipalId) { $Rows = $Rows | Where-Object { $_.PrincipalId -eq $PrincipalId } } + @($Rows) + } + { Invoke-Convert } | Should -Throw '*last active Global Administrator*' + Should -Invoke New-GraphPOSTRequest -Times 0 -Exactly + } + + It 'refuses to remove the last active Global Administrator' { + Mock Get-CIPPPIMRoleAssignments { + $Rows = @((New-Row -Principal 'user-1')) + if ($PrincipalId) { $Rows = $Rows | Where-Object { $_.PrincipalId -eq $PrincipalId } } + @($Rows) + } + { Invoke-CIPPPIMAssignmentAction -TenantFilter 'contoso.onmicrosoft.com' -Action Remove -PrincipalId 'user-1' -RoleDefinitionId $script:GA -AssignmentType Permanent -Justification 'test' } | Should -Throw '*last active Global Administrator*' + Should -Invoke New-GraphPOSTRequest -Times 0 -Exactly + } + + It 'refuses to touch the CIPP-SAM application' { + Mock Get-CIPPPIMRoleAssignments { @((New-Row -Principal 'user-1' -PrincipalType 'ServicePrincipal' -AppId 'sam-app-id'), (New-Row -Principal 'user-2')) | Where-Object { -not $PrincipalId -or $_.PrincipalId -eq $PrincipalId } } + { Invoke-Convert } | Should -Throw '*CIPP-SAM*' + Should -Invoke New-GraphPOSTRequest -Times 0 -Exactly + } + + It 'refuses a row inherited through a group' { + Mock Get-CIPPPIMRoleAssignments { @((New-Row -Principal 'user-1' -MemberType 'Group'), (New-Row -Principal 'user-2')) | Where-Object { -not $PrincipalId -or $_.PrincipalId -eq $PrincipalId } } + { Invoke-Convert } | Should -Throw '*role-assignable group*' + Should -Invoke New-GraphPOSTRequest -Times 0 -Exactly + } + + It 'refuses to convert a service principal (PIM eligibility is users and groups only)' { + Mock Get-CIPPPIMRoleAssignments { @((New-Row -Principal 'user-1' -PrincipalType 'ServicePrincipal' -AppId 'other-app'), (New-Row -Principal 'user-2')) | Where-Object { -not $PrincipalId -or $_.PrincipalId -eq $PrincipalId } } + { Invoke-Convert } | Should -Throw '*service principal*' + Should -Invoke New-GraphPOSTRequest -Times 0 -Exactly + } + + Context 'ConvertToEligible' { + It 'creates the eligibility, confirms it, then removes the active assignment - in that order' { + $Result = Invoke-Convert + $Result.state | Should -Be 'success' + $script:Posts.Count | Should -Be 2 + $script:Posts[0].Uri | Should -Match 'roleEligibilityScheduleRequests$' + $script:Posts[0].Body.action | Should -Be 'adminAssign' + $script:Posts[0].Body.scheduleInfo.expiration.type | Should -Be 'afterDuration' + $script:Posts[0].Body.scheduleInfo.expiration.duration | Should -Be 'P365D' + $script:Posts[1].Uri | Should -Match 'roleAssignmentScheduleRequests$' + $script:Posts[1].Body.action | Should -Be 'adminRemove' + Should -Invoke New-GraphGetRequest -Times 1 -ParameterFilter { $uri -match 'roleEligibilitySchedules' } + $Result.Before | Should -Be 'Permanent' + $Result.After | Should -Match '^Eligible until' + } + + It 'leaves the active assignment alone when the eligibility cannot be confirmed' { + Mock New-GraphGetRequest { @() } + { Invoke-Convert } | Should -Throw '*could not be confirmed*' + $script:Posts.Count | Should -Be 1 + $script:Posts[0].Body.action | Should -Be 'adminAssign' + } + + It 'refuses an eligibility lifetime above the policy cap instead of clamping it' { + Mock Get-CIPPPIMRolePolicies { [pscustomobject]@{ RoleDefinitionId = $script:GA; PolicyId = 'pol'; Settings = [pscustomobject]@{ eligibilityMaxDuration = 'P180D'; activeAssignmentMaxDuration = 'P180D' } } } + { Invoke-Convert @{ Duration = 'P365D' } } | Should -Throw '*exceeds the maximum allowed*' + Should -Invoke New-GraphPOSTRequest -Times 0 -Exactly + } + + It 'never posts a noExpiration schedule' { + $null = Invoke-Convert + foreach ($Post in $script:Posts) { + (ConvertTo-Json -InputObject $Post.Body -Depth 10 -Compress) | Should -Not -Match 'noExpiration' + } + } + } + + Context 'GrantActive' { + It 'posts a time-bound assignment within the cap' { + Mock Get-CIPPPIMRoleAssignments { @((New-Row -Principal 'user-1' -Type 'Eligible'), (New-Row -Principal 'user-2')) | Where-Object { -not $PrincipalId -or $_.PrincipalId -eq $PrincipalId } } + $Result = Invoke-CIPPPIMAssignmentAction -TenantFilter 'contoso.onmicrosoft.com' -Action GrantActive -PrincipalId 'user-1' -RoleDefinitionId $script:GA -AssignmentType Eligible -Duration 'PT4H' -Justification 'test' + $Result.state | Should -Be 'success' + $script:Posts.Count | Should -Be 1 + $script:Posts[0].Uri | Should -Match 'roleAssignmentScheduleRequests$' + $script:Posts[0].Body.scheduleInfo.expiration.duration | Should -Be 'PT4H' + } + + It 'applies the JIT admin maximum duration as a cap' { + Mock Get-CIPPAzDataTableEntity { [pscustomobject]@{ MaxDuration = 'PT2H' } } + Mock Get-CIPPPIMRoleAssignments { @((New-Row -Principal 'user-1' -Type 'Eligible'), (New-Row -Principal 'user-2')) | Where-Object { -not $PrincipalId -or $_.PrincipalId -eq $PrincipalId } } + { Invoke-CIPPPIMAssignmentAction -TenantFilter 'contoso.onmicrosoft.com' -Action GrantActive -PrincipalId 'user-1' -RoleDefinitionId $script:GA -AssignmentType Eligible -Duration 'PT4H' -Justification 'test' } | Should -Throw '*exceeds the maximum allowed*' + Should -Invoke New-GraphPOSTRequest -Times 0 -Exactly + } + + It 'refuses without an expiration' { + { Invoke-CIPPPIMAssignmentAction -TenantFilter 'contoso.onmicrosoft.com' -Action GrantActive -PrincipalId 'user-1' -RoleDefinitionId $script:GA -AssignmentType Eligible -Justification 'test' } | Should -Throw '*needs a Duration or EndDateTime*' + Should -Invoke New-GraphPOSTRequest -Times 0 -Exactly + } + + It 'words the end time in the caller''s time zone, and in labelled UTC when the zone is unknown or absent' { + Mock Get-CIPPPIMRoleAssignments { @((New-Row -Principal 'user-1' -Type 'Eligible'), (New-Row -Principal 'user-2')) | Where-Object { -not $PrincipalId -or $_.PrincipalId -eq $PrincipalId } } + # A future instant (the builder refuses a past end), at 08:06 UTC on some day next month: + # Perth is UTC+8 all year, so the worded time is 16:06 on the same date. + $End = [datetime]::SpecifyKind([datetime]::UtcNow.AddDays(30).Date.AddHours(8).AddMinutes(6).AddSeconds(36), 'Utc') + $UtcText = $End.ToString('yyyy-MM-dd') + ' 08:06' + $PerthText = $End.ToString('yyyy-MM-dd') + ' 16:06' + $Perth = Invoke-CIPPPIMAssignmentAction -TenantFilter 'contoso.onmicrosoft.com' -Action GrantActive -PrincipalId 'user-1' -RoleDefinitionId $script:GA -AssignmentType Eligible -EndDateTime $End -Justification 'test' -TimeZone 'Australia/Perth' + $Perth.resultText | Should -Match "until $PerthText \(Australia/Perth\)\.$" + $Perth.After | Should -Be "Active until $PerthText (Australia/Perth)" + $Perth.EndDateTime | Should -Be $End -Because 'the zone only changes the wording, never the stored end' + $Unknown = Invoke-CIPPPIMAssignmentAction -TenantFilter 'contoso.onmicrosoft.com' -Action GrantActive -PrincipalId 'user-1' -RoleDefinitionId $script:GA -AssignmentType Eligible -EndDateTime $End -Justification 'test' -TimeZone 'Mars/Olympus_Mons' + $Unknown.resultText | Should -Match "until $UtcText UTC\.$" + $None = Invoke-CIPPPIMAssignmentAction -TenantFilter 'contoso.onmicrosoft.com' -Action GrantActive -PrincipalId 'user-1' -RoleDefinitionId $script:GA -AssignmentType Eligible -EndDateTime $End -Justification 'test' + $None.resultText | Should -Match "until $UtcText UTC\.$" + # the Graph request itself always carries the UTC instant + ([datetime]$script:Posts[-1].Body.scheduleInfo.expiration.endDateTime).ToUniversalTime().ToString('s') | Should -Be $End.ToString('s') + } + } + + Context 'Remove' { + It 'removes an eligibility through the eligibility schedule' { + Mock Get-CIPPPIMRoleAssignments { @((New-Row -Principal 'user-1' -Type 'Eligible'), (New-Row -Principal 'user-2')) | Where-Object { -not $PrincipalId -or $_.PrincipalId -eq $PrincipalId } } + $Result = Invoke-CIPPPIMAssignmentAction -TenantFilter 'contoso.onmicrosoft.com' -Action Remove -PrincipalId 'user-1' -RoleDefinitionId $script:GA -AssignmentType Eligible -Justification 'test' + $Result.After | Should -Be 'None' + $script:Posts[0].Uri | Should -Match 'roleEligibilityScheduleRequests$' + $script:Posts[0].Body.action | Should -Be 'adminRemove' + } + + It 'removes an active assignment when another Global Administrator remains' { + $Result = Invoke-CIPPPIMAssignmentAction -TenantFilter 'contoso.onmicrosoft.com' -Action Remove -PrincipalId 'user-1' -RoleDefinitionId $script:GA -AssignmentType Permanent -Justification 'test' + $Result.After | Should -Be 'None' + $script:Posts[0].Uri | Should -Match 'roleAssignmentScheduleRequests$' + $script:Posts[0].Body.action | Should -Be 'adminRemove' + } + } +} diff --git a/Tests/Private/Invoke-CIPPSharePointCreateCopyJobs.Tests.ps1 b/Tests/Private/Invoke-CIPPSharePointCreateCopyJobs.Tests.ps1 new file mode 100644 index 0000000000000..032f51ba94ba2 --- /dev/null +++ b/Tests/Private/Invoke-CIPPSharePointCreateCopyJobs.Tests.ps1 @@ -0,0 +1,47 @@ +# Pester tests for Invoke-CIPPSharePointCreateCopyJobs OData verbose handle parsing + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Invoke-CIPPSharePointCreateCopyJobs.ps1' + if (-not (Test-Path $FunctionPath)) { throw "Could not locate $FunctionPath" } + + function Get-SharePointAdminLink { param($Public, $tenantFilter) [PSCustomObject]@{ SharePointUrl = 'https://contoso.sharepoint.com' } } + function New-GraphPOSTRequest { param($uri, $tenantid, $body, $contentType) } + + . $FunctionPath +} + +Describe 'Invoke-CIPPSharePointCreateCopyJobs' { + It 'unwraps OData verbose CreateCopyJobs.results into normalized handles' { + Mock New-GraphPOSTRequest { + [PSCustomObject]@{ + d = [PSCustomObject]@{ + CreateCopyJobs = [PSCustomObject]@{ + __metadata = [PSCustomObject]@{ type = 'Collection(SP.CopyMigrationInfo)' } + results = @( + [PSCustomObject]@{ + EncryptionKey = 'abc123base64=' + JobId = 'd0a42793-f995-4ce2-b0fb-cc3c0e819e19' + JobQueueUri = 'https://queue.core.windows.net/job?sv=1&sig=x' + SourceListItemUniqueIds = [PSCustomObject]@{ + results = @('208875e4-2659-433d-acd8-4d77fc76e1ef') + } + } + ) + } + } + } + } + + $Result = Invoke-CIPPSharePointCreateCopyJobs -TenantFilter 'contoso.com' ` + -SourceSiteUrl 'https://contoso.sharepoint.com/sites/a' ` + -ExportObjectUris @('https://contoso.sharepoint.com/sites/a/Shared%20Documents/Folder') ` + -DestinationUri 'https://contoso.sharepoint.com/sites/b/Shared%20Documents' + + $Result.Count | Should -Be 1 + $Result[0].JobId | Should -Be 'd0a42793-f995-4ce2-b0fb-cc3c0e819e19' + $Result[0].JobQueueUri | Should -Match 'queue.core.windows.net' + $Result[0].EncryptionKey | Should -Be 'abc123base64=' + ($Result[0] | Get-Member -Name SourceListItemUniqueIds -ErrorAction SilentlyContinue) | Should -BeNullOrEmpty + } +} diff --git a/Tests/Private/New-CIPPPIMScheduleRequest.Tests.ps1 b/Tests/Private/New-CIPPPIMScheduleRequest.Tests.ps1 new file mode 100644 index 0000000000000..fffb8d45eed0f --- /dev/null +++ b/Tests/Private/New-CIPPPIMScheduleRequest.Tests.ps1 @@ -0,0 +1,120 @@ +# Pester tests for New-CIPPPIMScheduleRequest - the only builder of PIM schedule request bodies. +# +# The security rule these tests pin down: CIPP never creates a permanent (no-expiration) role +# assignment or eligibility. Every caller - endpoint, standard, scheduled task - goes through this +# function, so "it refuses to build without an expiration" is the whole guarantee. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/PIM/New-CIPPPIMScheduleRequest.ps1') + + $script:Common = @{ + PrincipalId = 'aaaaaaaa-0000-0000-0000-000000000001' + RoleDefinitionId = '62e90394-69f5-4237-9190-012177145e10' + Justification = 'Ticket 1234' + } +} + +Describe 'New-CIPPPIMScheduleRequest' { + Context 'refuses permanent / no-expiration input' { + It 'throws when neither Duration nor EndDateTime is given for <_>' -ForEach @('adminAssign', 'adminUpdate', 'adminExtend', 'adminRenew') { + { New-CIPPPIMScheduleRequest -Kind Assignment -Action $_ @script:Common } | Should -Throw '*never creates permanent*' + { New-CIPPPIMScheduleRequest -Kind Eligibility -Action $_ @script:Common } | Should -Throw '*never creates permanent*' + } + + It 'throws when the duration asks for permanence by name: <_>' -ForEach @('noExpiration', 'permanent', 'never', 'unlimited', 'none', ' NoExpiration ') { + { New-CIPPPIMScheduleRequest -Kind Eligibility -Action adminAssign -Duration $_ @script:Common } | Should -Throw '*permanent (no-expiration)*' + } + + It 'throws on a duration that is not ISO 8601' { + { New-CIPPPIMScheduleRequest -Kind Assignment -Action adminAssign -Duration '8 hours' @script:Common } | Should -Throw '*not a valid ISO 8601 duration*' + } + + It 'throws on a zero-length duration' { + { New-CIPPPIMScheduleRequest -Kind Assignment -Action adminAssign -Duration 'PT0S' @script:Common } | Should -Throw '*greater than zero*' + } + + It 'throws when EndDateTime is in the past' { + { New-CIPPPIMScheduleRequest -Kind Assignment -Action adminAssign -EndDateTime ([datetime]::UtcNow.AddHours(-1)) @script:Common } | Should -Throw '*not in the future*' + } + + It 'throws when both Duration and EndDateTime are given' { + { New-CIPPPIMScheduleRequest -Kind Assignment -Action adminAssign -Duration 'PT1H' -EndDateTime ([datetime]::UtcNow.AddHours(2)) @script:Common } | Should -Throw '*not both*' + } + + It 'throws when a duration exceeds MaxDuration instead of clamping it' { + { New-CIPPPIMScheduleRequest -Kind Assignment -Action adminAssign -Duration 'PT10H' -MaxDuration 'PT8H' @script:Common } | Should -Throw '*exceeds the maximum allowed*' + } + + It 'throws when an end date exceeds MaxDuration instead of clamping it' { + { New-CIPPPIMScheduleRequest -Kind Eligibility -Action adminAssign -EndDateTime ([datetime]::UtcNow.AddDays(400)) -MaxDuration 'P365D' @script:Common } | Should -Throw '*exceeds the maximum allowed*' + } + + It 'throws on an invalid MaxDuration rather than ignoring the cap' { + { New-CIPPPIMScheduleRequest -Kind Assignment -Action adminAssign -Duration 'PT1H' -MaxDuration 'forever' @script:Common } | Should -Throw '*MaxDuration*' + } + } + + Context 'builds valid time-bound bodies' { + It 'emits an afterDuration expiration for a duration' { + $Request = New-CIPPPIMScheduleRequest -Kind Assignment -Action adminAssign -Duration 'PT4H' -MaxDuration 'PT8H' @script:Common + + $Request.Uri | Should -Be 'https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignmentScheduleRequests' + $Request.Body.action | Should -Be 'adminAssign' + $Request.Body.principalId | Should -Be $script:Common.PrincipalId + $Request.Body.roleDefinitionId | Should -Be $script:Common.RoleDefinitionId + $Request.Body.directoryScopeId | Should -Be '/' + $Request.Body.justification | Should -Be 'Ticket 1234' + $Request.Body.scheduleInfo.expiration.type | Should -Be 'afterDuration' + $Request.Body.scheduleInfo.expiration.duration | Should -Be 'PT4H' + $Request.ExpirationType | Should -Be 'afterDuration' + ($Request.EndDateTime - [datetime]::UtcNow).TotalHours | Should -BeGreaterThan 3.9 + ($Request.EndDateTime - [datetime]::UtcNow).TotalHours | Should -BeLessThan 4.1 + } + + It 'emits an afterDateTime expiration for an end date, in UTC' { + $End = [datetime]::UtcNow.AddDays(30) + $Request = New-CIPPPIMScheduleRequest -Kind Eligibility -Action adminAssign -EndDateTime $End -MaxDuration 'P365D' @script:Common + + $Request.Uri | Should -Be 'https://graph.microsoft.com/v1.0/roleManagement/directory/roleEligibilityScheduleRequests' + $Request.Body.scheduleInfo.expiration.type | Should -Be 'afterDateTime' + $Request.Body.scheduleInfo.expiration.endDateTime | Should -Match '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$' + $Request.Body.scheduleInfo.startDateTime | Should -Match 'Z$' + $Request.EndDateTime | Should -Be $End + } + + It 'accepts a year-long eligibility within the P365D cap' { + $Request = New-CIPPPIMScheduleRequest -Kind Eligibility -Action adminAssign -Duration 'P1Y' -MaxDuration 'P365D' @script:Common + $Request.Body.scheduleInfo.expiration.duration | Should -Be 'P1Y' + } + + It 'uses the explicit scope and start when given' { + $Start = [datetime]::UtcNow.AddHours(1) + $Request = New-CIPPPIMScheduleRequest -Kind Assignment -Action adminAssign -Duration 'PT1H' -StartDateTime $Start -DirectoryScopeId '/administrativeUnits/abc' @script:Common + $Request.Body.directoryScopeId | Should -Be '/administrativeUnits/abc' + $Request.StartDateTime | Should -Be $Start + } + + It 'adds ticketInfo when a ticket is supplied' { + $Request = New-CIPPPIMScheduleRequest -Kind Assignment -Action adminExtend -Duration 'PT2H' -TicketNumber 'INC-1' -TicketSystem 'Halo' @script:Common + $Request.Body.ticketInfo.ticketNumber | Should -Be 'INC-1' + $Request.Body.ticketInfo.ticketSystem | Should -Be 'Halo' + } + + It 'never emits noExpiration for any schedule-creating action' -ForEach @('adminAssign', 'adminUpdate', 'adminExtend', 'adminRenew') { + $Request = New-CIPPPIMScheduleRequest -Kind Assignment -Action $_ -Duration 'PT1H' @script:Common + (ConvertTo-Json -InputObject $Request.Body -Depth 10 -Compress) | Should -Not -Match 'noExpiration' + $Request.Body.scheduleInfo.expiration.type | Should -BeIn @('afterDuration', 'afterDateTime') + } + } + + Context 'adminRemove' { + It 'needs no schedule and carries no expiration' { + $Request = New-CIPPPIMScheduleRequest -Kind Eligibility -Action adminRemove @script:Common + $Request.Body.action | Should -Be 'adminRemove' + $Request.Body.Contains('scheduleInfo') | Should -BeFalse + $Request.EndDateTime | Should -BeNullOrEmpty + $Request.ExpirationType | Should -BeNullOrEmpty + } + } +} diff --git a/Tests/Private/New-CIPPUserTask.Tests.ps1 b/Tests/Private/New-CIPPUserTask.Tests.ps1 index d00318ffe2783..33e2d9c86ba8b 100644 --- a/Tests/Private/New-CIPPUserTask.Tests.ps1 +++ b/Tests/Private/New-CIPPUserTask.Tests.ps1 @@ -225,6 +225,39 @@ Describe 'New-CIPPUserTask' { $Result.Results | Should -Contain 'Scheduled SendAs on the shared mailbox Facility in 15 minutes.' } + It 'grants FullAccess without automapping when the no-automap variant is selected' { + $UserObj = New-TestUserObj -SharedMailboxes @( + [pscustomobject]@{ label = 'Facility'; value = 'facility@contoso.com' } + ) -SharedMailboxPermission ([pscustomobject]@{ label = 'Full Access (no Automapping)'; value = 'FullAccessNoAutoMap' }) + + $Result = New-CIPPUserTask -UserObj $UserObj + + Should -Invoke Add-CIPPScheduledTask -Times 1 -Exactly -ParameterFilter { + $Task.Parameters.PermissionLevel -eq 'FullAccess' -and + $Task.Parameters.AutoMap -eq $false + } + $Result.Results | Should -Contain 'Scheduled FullAccess (no automapping) on the shared mailbox Facility in 15 minutes. Automapping is off, so the user adds the mailbox to Outlook themselves.' + } + + It 'lets the no-automap variant win when both FullAccess variants are selected' { + # One grant carries one automapping flag; scheduling both would make the second + # Add-MailboxPermission fail on the already existing permission entry anyway. + $UserObj = New-TestUserObj -SharedMailboxes @( + [pscustomobject]@{ label = 'Facility'; value = 'facility@contoso.com' } + ) -SharedMailboxPermission @( + [pscustomobject]@{ label = 'Full Access'; value = 'FullAccess' } + [pscustomobject]@{ label = 'Full Access (no Automapping)'; value = 'FullAccessNoAutoMap' } + ) + + $null = New-CIPPUserTask -UserObj $UserObj + + Should -Invoke Add-CIPPScheduledTask -Times 1 -Exactly + Should -Invoke Add-CIPPScheduledTask -Times 1 -Exactly -ParameterFilter { + $Task.Parameters.PermissionLevel -eq 'FullAccess' -and + $Task.Parameters.AutoMap -eq $false + } + } + It 'schedules one task per permission level when several are selected' { $UserObj = New-TestUserObj -SharedMailboxes @( [pscustomobject]@{ label = 'Facility'; value = 'facility@contoso.com' } diff --git a/Tests/Private/Push-CIPPOffboardingComplete.PostExecution.Tests.ps1 b/Tests/Private/Push-CIPPOffboardingComplete.PostExecution.Tests.ps1 new file mode 100644 index 0000000000000..81c7d192dbf2d --- /dev/null +++ b/Tests/Private/Push-CIPPOffboardingComplete.PostExecution.Tests.ps1 @@ -0,0 +1,115 @@ +# Pester tests for the post-execution tracking in Push-CIPPOffboardingComplete. +# +# When an offboarding task has notification channels configured, the delivery outcome of each one +# must be kept with the task (PostExecutionResults) and appended to the user's progress row as a +# step, so a webhook that returned 500 is as visible as a cmdlet that failed. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPOffboardingComplete.ps1' + if (-not (Test-Path $FunctionPath)) { throw "Could not locate Push-CIPPOffboardingComplete.ps1 at $FunctionPath" } + + function Get-CippTable { param($tablename) } + function Update-AzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function Add-CIPPAzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function Write-LogMessage { param($API, $tenant, $message, $sev, $headers, $LogData) } + function Write-Information { param($MessageData) } + function Get-CippException { param($Exception) @{ NormalizedError = "$Exception" } } + function Send-CIPPScheduledTaskAlert { param($Results, $TaskInfo, $TenantFilter, $TaskType) } + function Get-CIPPAsyncDeployment { param($JobId) } + function Set-CIPPAsyncDeploymentStatus { param($JobId, $Name, $Status, $Logs) } + function Add-CIPPAsyncDeploymentStep { param($JobId, $Name, $Title, $StepStatus, $Message, $Kind) } + function Set-CIPPAsyncDeploymentStep { param($JobId, $Name, $StepIndex, $StepStatus, $Message) } + + . $FunctionPath + + function New-CompletionItem { + param([string]$PostExecution = 'Webhook,Email') + [pscustomobject]@{ + Parameters = [pscustomobject]@{ + TaskInfo = [pscustomobject]@{ PartitionKey = 'ScheduledTask'; RowKey = 'task-1'; PostExecution = $PostExecution } + TenantFilter = 'contoso.com' + Username = 'pat@contoso.com' + Headers = @{} + DeploymentId = 'job-1' + } + Results = @('Successfully revoked sessions for pat@contoso.com') + } + } +} + +Describe 'Push-CIPPOffboardingComplete post-execution tracking' { + BeforeEach { + Mock -CommandName Get-CippTable -MockWith { @{ Context = 'ctx' } } + Mock -CommandName Update-AzDataTableEntity -MockWith { } + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { } + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Write-Information -MockWith { } + Mock -CommandName Set-CIPPAsyncDeploymentStatus -MockWith { } + Mock -CommandName Add-CIPPAsyncDeploymentStep -MockWith { } + Mock -CommandName Set-CIPPAsyncDeploymentStep -MockWith { } + Mock -CommandName Send-CIPPScheduledTaskAlert -MockWith { + @( + [pscustomobject]@{ Channel = 'Webhook'; Result = 'Error: Webhook returned status code 500 for https://hooks.example' } + [pscustomobject]@{ Channel = 'Email'; Result = 'Sent an email alert: Offboarding' } + ) + } + # The row as the job created it: the action steps, then one pending notification step per channel. + Mock -CommandName Get-CIPPAsyncDeployment -MockWith { + [pscustomobject]@{ + Name = 'pat@contoso.com' + Steps = @( + [pscustomobject]@{ Title = 'Revoke all sessions'; Status = 'succeeded' } + [pscustomobject]@{ Title = 'Remove from all groups'; Status = 'failed' } + [pscustomobject]@{ Title = 'Notify via Webhook'; Status = 'pending'; Kind = 'notify' } + [pscustomobject]@{ Title = 'Notify via Email'; Status = 'pending'; Kind = 'notify' } + ) + } + } + } + + It 'stores each delivery outcome on the task and fills in the matching notification step' { + $null = Push-CIPPOffboardingComplete -Item (New-CompletionItem) + + Should -Invoke Send-CIPPScheduledTaskAlert -Times 1 -Exactly -ParameterFilter { $TaskType -eq 'User Offboarding' } + Should -Invoke Update-AzDataTableEntity -Times 1 -Exactly -ParameterFilter { + $Entity.RowKey -eq 'task-1' -and $Entity.PostExecutionResults -like '*"Channel":"Webhook"*' -and $Entity.PostExecutionResults -like '*status code 500*' + } + # Both notification steps go running while the deliveries are made... + Should -Invoke Set-CIPPAsyncDeploymentStep -Times 2 -Exactly -ParameterFilter { $StepStatus -eq 'running' -and $Message -eq 'Sending' } + # ...then each one gets its own outcome, at its own index. + Should -Invoke Set-CIPPAsyncDeploymentStep -Times 1 -Exactly -ParameterFilter { + $JobId -eq 'job-1' -and $Name -eq 'pat@contoso.com' -and $StepIndex -eq 2 -and $StepStatus -eq 'failed' -and $Message -like 'Error: Webhook*' + } + Should -Invoke Set-CIPPAsyncDeploymentStep -Times 1 -Exactly -ParameterFilter { $StepIndex -eq 3 -and $StepStatus -eq 'succeeded' } + Should -Invoke Add-CIPPAsyncDeploymentStep -Times 0 -Exactly + } + + It 'appends the notification step when the row was created without one' { + Mock -CommandName Get-CIPPAsyncDeployment -MockWith { + [pscustomobject]@{ Name = 'pat@contoso.com'; Steps = @([pscustomobject]@{ Title = 'Revoke all sessions'; Status = 'succeeded' }) } + } + + $null = Push-CIPPOffboardingComplete -Item (New-CompletionItem) + + Should -Invoke Add-CIPPAsyncDeploymentStep -Times 1 -Exactly -ParameterFilter { + $Title -eq 'Notify via Webhook' -and $StepStatus -eq 'failed' -and $Kind -eq 'notify' -and $Message -like 'Error: Webhook*' + } + Should -Invoke Add-CIPPAsyncDeploymentStep -Times 1 -Exactly -ParameterFilter { $Title -eq 'Notify via Email' -and $StepStatus -eq 'succeeded' } + } + + It 'closes the progress row as failed when a notification failed' { + $null = Push-CIPPOffboardingComplete -Item (New-CompletionItem) + + Should -Invoke Set-CIPPAsyncDeploymentStatus -Times 1 -Exactly -ParameterFilter { $JobId -eq 'job-1' -and $Status -eq 'failed' } + } + + It 'sends nothing and records nothing when no channel is configured' { + $null = Push-CIPPOffboardingComplete -Item (New-CompletionItem -PostExecution '') + + Should -Invoke Send-CIPPScheduledTaskAlert -Times 0 -Exactly + Should -Invoke Add-CIPPAsyncDeploymentStep -Times 0 -Exactly + Should -Invoke Set-CIPPAsyncDeploymentStep -Times 0 -Exactly + Should -Invoke Update-AzDataTableEntity -Times 0 -Exactly -ParameterFilter { $null -ne $Entity.PostExecutionResults } + } +} diff --git a/Tests/Private/Push-CIPPOffboardingTask.Progress.Tests.ps1 b/Tests/Private/Push-CIPPOffboardingTask.Progress.Tests.ps1 new file mode 100644 index 0000000000000..38ab9e6ac6885 --- /dev/null +++ b/Tests/Private/Push-CIPPOffboardingTask.Progress.Tests.ps1 @@ -0,0 +1,96 @@ +# Pester tests for the live-progress reporting in Push-CIPPOffboardingTask. +# +# The activity runs one offboarding cmdlet and reports to the step the job stamped on it. Most +# cmdlets do not throw for per-item problems - Remove-CIPPGroups returns 'Error: ...' lines next to +# 'Successfully removed ...' lines - so a returned error line must show as a failed step, or the +# progress view says Succeeded over a message that starts with Error. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPOffboardingTask.ps1' + if (-not (Test-Path $FunctionPath)) { throw "Could not locate Push-CIPPOffboardingTask.ps1 at $FunctionPath" } + + function Set-CIPPAsyncDeploymentStep { param($JobId, $Name, $StepIndex, $StepStatus, $Message) } + function Write-Information { param($MessageData) } + + . $FunctionPath + + # The activity only runs cmdlets it can find in the CIPPCore module, so the fakes live in a + # throwaway module of that name. Remove-CIPPGroups hands back whatever the test put in + # $global:CippTestResult; Set-CIPPSignInState always throws. + $script:FakeCore = New-Module -Name CIPPCore -ScriptBlock { + function Remove-CIPPGroups { param($userid, $tenantFilter) $global:CippTestResult } + function Set-CIPPSignInState { param($userid, $TenantFilter) throw 'boom' } + } | Import-Module -PassThru -Force + + function New-TaskItem { + param([string]$Cmdlet, [switch]$NoJob) + $Item = [pscustomobject]@{ + FunctionName = 'CIPPOffboardingTask' + Cmdlet = $Cmdlet + Parameters = @{ userid = 'user-id-1'; tenantFilter = 'contoso.com' } + } + if (-not $NoJob) { + $Item | Add-Member -NotePropertyName DeploymentId -NotePropertyValue 'job-1' + $Item | Add-Member -NotePropertyName DeploymentName -NotePropertyValue 'pat@contoso.com' + $Item | Add-Member -NotePropertyName StepIndex -NotePropertyValue 2 + } + $Item + } +} + +AfterAll { + Remove-Module -Name CIPPCore -Force -ErrorAction SilentlyContinue + Remove-Variable -Name CippTestResult -Scope Global -ErrorAction SilentlyContinue +} + +Describe 'Push-CIPPOffboardingTask live progress' { + BeforeEach { + Mock -CommandName Write-Information -MockWith { } + Mock -CommandName Set-CIPPAsyncDeploymentStep -MockWith { } + } + + It 'marks the step failed when the cmdlet returns an error line without throwing' { + $global:CippTestResult = @( + "Error: Could not remove pat@contoso.com from group 'All Users' because it is a Dynamic Group." + "Successfully removed pat@contoso.com from group 'Sales'" + ) + + $null = Push-CIPPOffboardingTask -Item (New-TaskItem -Cmdlet 'Remove-CIPPGroups') + + Should -Invoke Set-CIPPAsyncDeploymentStep -Times 1 -Exactly -ParameterFilter { + $JobId -eq 'job-1' -and $Name -eq 'pat@contoso.com' -and $StepIndex -eq 2 -and $StepStatus -eq 'running' + } + Should -Invoke Set-CIPPAsyncDeploymentStep -Times 1 -Exactly -ParameterFilter { + $StepStatus -eq 'failed' -and $Message -eq "Error: Could not remove pat@contoso.com from group 'All Users' because it is a Dynamic Group.`nSuccessfully removed pat@contoso.com from group 'Sales'" + } + } + + It 'marks the step succeeded and keeps every returned line' { + $global:CippTestResult = @('Successfully removed pat@contoso.com from group Sales', 'Successfully removed pat@contoso.com from group Ops') + + $Result = Push-CIPPOffboardingTask -Item (New-TaskItem -Cmdlet 'Remove-CIPPGroups') + + Should -Invoke Set-CIPPAsyncDeploymentStep -Times 1 -Exactly -ParameterFilter { + $StepStatus -eq 'succeeded' -and $Message -eq "Successfully removed pat@contoso.com from group Sales`nSuccessfully removed pat@contoso.com from group Ops" + } + @($Result).Count | Should -Be 2 + } + + It 'marks the step failed with the error text when the cmdlet throws' { + $Result = Push-CIPPOffboardingTask -Item (New-TaskItem -Cmdlet 'Set-CIPPSignInState') + + $Result | Should -Be 'Failed to execute Set-CIPPSignInState : boom' + Should -Invoke Set-CIPPAsyncDeploymentStep -Times 1 -Exactly -ParameterFilter { + $StepStatus -eq 'failed' -and $Message -eq 'Failed to execute Set-CIPPSignInState : boom' + } + } + + It 'leaves progress alone when the task carries no job id' { + $global:CippTestResult = 'done' + + $null = Push-CIPPOffboardingTask -Item (New-TaskItem -Cmdlet 'Remove-CIPPGroups' -NoJob) + + Should -Invoke Set-CIPPAsyncDeploymentStep -Times 0 -Exactly + } +} diff --git a/Tests/Private/Remove-CIPPUserTeamsPhoneDIDs.Tests.ps1 b/Tests/Private/Remove-CIPPUserTeamsPhoneDIDs.Tests.ps1 new file mode 100644 index 0000000000000..667711a787cb5 --- /dev/null +++ b/Tests/Private/Remove-CIPPUserTeamsPhoneDIDs.Tests.ps1 @@ -0,0 +1,90 @@ +BeforeAll { + # Resolve by name under Modules/ so the test survives the function moving between modules. + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Remove-CIPPUserTeamsPhoneDIDs.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Remove-CIPPUserTeamsPhoneDIDs.ps1 under Modules/' } + + # Stub every CIPP helper the function calls so Pester's Mock has a command to replace. + function New-GraphGetRequest { param($uri, $tenantid) } + function New-GraphPOSTRequest { param($uri, $tenantid, $body, $type) } + function New-GraphBulkRequest { param($tenantid, $Requests) } + function Get-CippTeamsNumberType { param($NumberType) } + function Get-CippException { param($Exception) } + function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev, $LogData) } + + . $FunctionPath +} + +Describe 'Remove-CIPPUserTeamsPhoneDIDs' { + BeforeEach { + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName New-GraphBulkRequest -MockWith { } + Mock -CommandName New-GraphPOSTRequest -MockWith { } + Mock -CommandName Get-CippException -MockWith { [pscustomobject]@{ NormalizedError = 'boom' } } + Mock -CommandName Get-CippTeamsNumberType -MockWith { 'directRouting' } + Mock -CommandName New-GraphGetRequest -MockWith { + @( + [pscustomobject]@{ telephoneNumber = '+15551000001'; numberType = 'DirectRouting'; assignmentTargetId = 'user-1'; assignmentStatus = 'userAssigned' } + [pscustomobject]@{ telephoneNumber = '+15551000002'; numberType = 'DirectRouting'; assignmentTargetId = 'user-1'; assignmentStatus = 'userAssigned' } + [pscustomobject]@{ telephoneNumber = '+15551000003'; numberType = 'CallingPlan'; assignmentTargetId = 'user-2'; assignmentStatus = 'userAssigned' } + [pscustomobject]@{ telephoneNumber = '+15551000004'; numberType = 'DirectRouting'; assignmentTargetId = 'user-1'; assignmentStatus = 'unassigned' } + ) + } + } + + It 'reads the assignments from v1.0 and unassigns each of the user''s numbers on its own request' { + $Result = Remove-CIPPUserTeamsPhoneDIDs -UserID 'user-1' -Username 'pat@contoso.com' -TenantFilter 'contoso.com' + + Should -Invoke New-GraphGetRequest -Times 1 -Exactly -ParameterFilter { + $uri -eq 'https://graph.microsoft.com/v1.0/admin/teams/telephoneNumberManagement/numberAssignments' + } + Should -Invoke New-GraphPOSTRequest -Times 2 -Exactly -ParameterFilter { + $uri -eq 'https://graph.microsoft.com/v1.0/admin/teams/telephoneNumberManagement/numberAssignments/unassignNumber' + } + Should -Invoke New-GraphPOSTRequest -Times 1 -Exactly -ParameterFilter { $body -match '\+15551000001' } + Should -Invoke New-GraphPOSTRequest -Times 1 -Exactly -ParameterFilter { $body -match '\+15551000002' } + # An unassigned number and another user's number are left alone. + Should -Invoke New-GraphPOSTRequest -Times 0 -Exactly -ParameterFilter { $body -match '\+15551000003|\+15551000004' } + @($Result)[-1] | Should -Be "Completed processing 2 DIDs for user 'pat@contoso.com': 2 successful, 0 failed" + } + + It 'never uses a bulk request' { + $null = Remove-CIPPUserTeamsPhoneDIDs -UserID 'user-1' -TenantFilter 'contoso.com' + + Should -Invoke New-GraphBulkRequest -Times 0 -Exactly + } + + It 'normalises the number type for the action' { + $null = Remove-CIPPUserTeamsPhoneDIDs -UserID 'user-1' -TenantFilter 'contoso.com' + + Should -Invoke Get-CippTeamsNumberType -Times 2 -Exactly -ParameterFilter { $NumberType -eq 'DirectRouting' } + Should -Invoke New-GraphPOSTRequest -Times 2 -Exactly -ParameterFilter { $body -match 'directRouting' } + } + + It 'reports a failure per number and keeps going' { + Mock -CommandName New-GraphPOSTRequest -ParameterFilter { $body -match '\+15551000001' } -MockWith { throw 'Number is locked' } + + $Result = Remove-CIPPUserTeamsPhoneDIDs -UserID 'user-1' -Username 'pat@contoso.com' -TenantFilter 'contoso.com' + + @($Result)[0] | Should -BeLike "Failed to remove Teams Phone DID: '+15551000001'*boom" + @($Result)[1] | Should -BeLike "Successfully removed Teams Phone DID: '+15551000002'*" + @($Result)[-1] | Should -Be "Completed processing 2 DIDs for user 'pat@contoso.com': 1 successful, 1 failed" + } + + It 'returns a message and posts nothing when the user has no assigned numbers' { + $Result = Remove-CIPPUserTeamsPhoneDIDs -UserID 'user-99' -Username 'sam@contoso.com' -TenantFilter 'contoso.com' + + $Result | Should -Be "No Teams Phone DIDs found assigned to user: 'sam@contoso.com' - 'user-99'" + Should -Invoke New-GraphPOSTRequest -Times 0 -Exactly + } + + It 'returns a message when the tenant has no numbers at all' { + Mock -CommandName New-GraphGetRequest -MockWith { } + + $Result = Remove-CIPPUserTeamsPhoneDIDs -UserID 'user-1' -TenantFilter 'contoso.com' + + $Result | Should -Be 'No Teams Phone DIDs found in tenant' + Should -Invoke New-GraphPOSTRequest -Times 0 -Exactly + } +} diff --git a/Tests/Private/Repair-CIPPPIMRoleSettingsFloor.Tests.ps1 b/Tests/Private/Repair-CIPPPIMRoleSettingsFloor.Tests.ps1 new file mode 100644 index 0000000000000..a27de3b5df664 --- /dev/null +++ b/Tests/Private/Repair-CIPPPIMRoleSettingsFloor.Tests.ps1 @@ -0,0 +1,143 @@ +# Pester tests for Repair-CIPPPIMRoleSettingsFloor. +# +# Capturing a role's live PIM settings into a template must never store anything below the +# secure floor: offending values are raised to the closest value the floor allows, every raise +# is reported, and settings already at or above the floor pass through untouched. Whatever goes +# in, the repaired output must always satisfy Test-CIPPPIMRoleSettingsFloor. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/PIM/Repair-CIPPPIMRoleSettingsFloor.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/PIM/Test-CIPPPIMRoleSettingsFloor.ps1') + + function New-CapturedSettings { + param([hashtable]$Overrides = @{}) + $Settings = @{ + activationMaxDuration = 'PT4H' + activationRequires = 'MFA' + authenticationContextClaimValue = '' + activationRequiresJustification = $true + activationRequiresTicket = $false + activationRequiresApproval = $false + approvers = '' + eligibilityMaxDuration = 'P180D' + activeAssignmentMaxDuration = 'P90D' + activeAssignmentRequiresMfa = $true + activeAssignmentRequiresJustification = $true + notificationRecipients = '' + notificationLevel = 'All' + } + foreach ($Key in $Overrides.Keys) { $Settings[$Key] = $Overrides[$Key] } + [pscustomobject]$Settings + } +} + +Describe 'Repair-CIPPPIMRoleSettingsFloor' { + It 'passes compliant settings through untouched with no adjustments' { + $Input = New-CapturedSettings + $Result = Repair-CIPPPIMRoleSettingsFloor -Settings $Input + $Result.Adjustments.Count | Should -Be 0 + foreach ($Property in $Input.PSObject.Properties.Name) { + $Result.Settings.$Property | Should -Be $Input.$Property -Because $Property + } + } + + It 'raises permanent (null) durations to the floor maximum' { + $Result = Repair-CIPPPIMRoleSettingsFloor -Settings (New-CapturedSettings @{ + activationMaxDuration = $null + eligibilityMaxDuration = $null + activeAssignmentMaxDuration = $null + }) + $Result.Settings.activationMaxDuration | Should -Be 'PT24H' + $Result.Settings.eligibilityMaxDuration | Should -Be 'P365D' + $Result.Settings.activeAssignmentMaxDuration | Should -Be 'P365D' + $Result.Adjustments.Count | Should -Be 3 + ($Result.Adjustments -join ' ') | Should -Match 'permanent allowed' + } + + It 'lowers durations above the floor maximum instead of refusing them' { + $Result = Repair-CIPPPIMRoleSettingsFloor -Settings (New-CapturedSettings @{ + activationMaxDuration = 'P2D' + eligibilityMaxDuration = 'P10Y' + }) + $Result.Settings.activationMaxDuration | Should -Be 'PT24H' + $Result.Settings.eligibilityMaxDuration | Should -Be 'P365D' + $Result.Adjustments.Count | Should -Be 2 + } + + It 'requires MFA when activation demanded neither MFA nor an authentication context' { + $Result = Repair-CIPPPIMRoleSettingsFloor -Settings (New-CapturedSettings @{ activationRequires = 'None' }) + $Result.Settings.activationRequires | Should -Be 'MFA' + ($Result.Adjustments -join ' ') | Should -Match 'MFA' + } + + It 'keeps a valid authentication context in place of MFA' { + $Result = Repair-CIPPPIMRoleSettingsFloor -Settings (New-CapturedSettings @{ + activationRequires = 'AuthenticationContext' + authenticationContextClaimValue = 'c1' + }) + $Result.Adjustments.Count | Should -Be 0 + $Result.Settings.activationRequires | Should -Be 'AuthenticationContext' + $Result.Settings.authenticationContextClaimValue | Should -Be 'c1' + } + + It 'falls back to MFA when the authentication context has no usable claim value' { + $Result = Repair-CIPPPIMRoleSettingsFloor -Settings (New-CapturedSettings @{ + activationRequires = 'AuthenticationContext' + authenticationContextClaimValue = '' + }) + $Result.Settings.activationRequires | Should -Be 'MFA' + ($Result.Adjustments -join ' ') | Should -Match 'claim value' + } + + It 'enables missing justifications' { + $Result = Repair-CIPPPIMRoleSettingsFloor -Settings (New-CapturedSettings @{ + activationRequiresJustification = $false + activeAssignmentRequiresJustification = $false + }) + $Result.Settings.activationRequiresJustification | Should -BeTrue + $Result.Settings.activeAssignmentRequiresJustification | Should -BeTrue + $Result.Adjustments.Count | Should -Be 2 + } + + It 'disables approval when no approver could be captured' { + $Result = Repair-CIPPPIMRoleSettingsFloor -Settings (New-CapturedSettings @{ + activationRequiresApproval = $true + approvers = '' + }) + $Result.Settings.activationRequiresApproval | Should -BeFalse + ($Result.Adjustments -join ' ') | Should -Match 'approval disabled' + } + + It 'keeps approval with captured approvers' { + $Result = Repair-CIPPPIMRoleSettingsFloor -Settings (New-CapturedSettings @{ + activationRequiresApproval = $true + approvers = 'SOC Approvers' + }) + $Result.Adjustments.Count | Should -Be 0 + $Result.Settings.activationRequiresApproval | Should -BeTrue + $Result.Settings.approvers | Should -Be 'SOC Approvers' + } + + It 'drops notification recipients that are not e-mail addresses and fixes an invalid level' { + $Result = Repair-CIPPPIMRoleSettingsFloor -Settings (New-CapturedSettings @{ + notificationRecipients = 'soc@msp.example, not-an-address' + notificationLevel = 'Everything' + }) + $Result.Settings.notificationRecipients | Should -Be 'soc@msp.example' + $Result.Settings.notificationLevel | Should -Be 'All' + $Result.Adjustments.Count | Should -Be 2 + } + + It 'always produces settings that satisfy the secure floor' { + $Cases = @( + (New-CapturedSettings @{ activationMaxDuration = $null; activationRequires = 'None'; activationRequiresJustification = $false; eligibilityMaxDuration = $null; activeAssignmentMaxDuration = $null; activeAssignmentRequiresJustification = $false }) + (New-CapturedSettings @{ activationMaxDuration = 'garbage'; eligibilityMaxDuration = '-P1D'; notificationRecipients = 'nope'; notificationLevel = 'x' }) + (New-CapturedSettings @{ activationRequires = 'AuthenticationContext'; authenticationContextClaimValue = 'zzz'; activationRequiresApproval = $true; approvers = '' }) + ) + foreach ($Case in $Cases) { + $Result = Repair-CIPPPIMRoleSettingsFloor -Settings $Case + (Test-CIPPPIMRoleSettingsFloor -Settings $Result.Settings).Valid | Should -BeTrue + } + } +} diff --git a/Tests/Private/Resolve-CIPPSharePointRestContext.Tests.ps1 b/Tests/Private/Resolve-CIPPSharePointRestContext.Tests.ps1 new file mode 100644 index 0000000000000..3eb35a58c77cc --- /dev/null +++ b/Tests/Private/Resolve-CIPPSharePointRestContext.Tests.ps1 @@ -0,0 +1,46 @@ +# Pester tests for Resolve-CIPPSharePointRestContext + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Resolve-CIPPSharePointRestContext.ps1' + + function Get-SharePointAdminLink { param($Public, $tenantFilter) } + + . $FunctionPath +} + +Describe 'Resolve-CIPPSharePointRestContext' { + BeforeEach { + $script:SharePointInfo = [PSCustomObject]@{ + SharePointUrl = 'https://contoso.sharepoint.com' + AdminUrl = 'https://contoso-admin.sharepoint.com' + } + + Mock -CommandName Get-SharePointAdminLink -MockWith { $script:SharePointInfo } + } + + It 'builds scope, headers and site-scoped REST URIs from the admin link' { + $Result = Resolve-CIPPSharePointRestContext -TenantFilter 'contoso.onmicrosoft.com' -SiteUrl 'https://contoso.sharepoint.com/sites/HR/' + + $Result.Scope | Should -Be 'https://contoso.sharepoint.com/.default' + $Result.Headers.Accept | Should -Be 'application/json;odata=nometadata' + $Result.SiteUrl | Should -Be 'https://contoso.sharepoint.com/sites/HR' + $Result.BaseUri | Should -Be 'https://contoso.sharepoint.com/sites/HR/_api' + $Result.WebUri | Should -Be 'https://contoso.sharepoint.com/sites/HR/_api/web' + $Result.SharePointInfo | Should -Be $script:SharePointInfo + } + + It 'reuses SharePointInfo when supplied' { + Resolve-CIPPSharePointRestContext -TenantFilter 'contoso.onmicrosoft.com' -SiteUrl 'https://contoso.sharepoint.com/sites/HR' -SharePointInfo $script:SharePointInfo | Out-Null + + Should -Invoke Get-SharePointAdminLink -Times 0 -Exactly + } + + It 'looks up SharePointInfo when it was not supplied' { + Resolve-CIPPSharePointRestContext -TenantFilter 'contoso.onmicrosoft.com' -SiteUrl 'https://contoso.sharepoint.com/sites/HR' | Out-Null + + Should -Invoke Get-SharePointAdminLink -Times 1 -Exactly -ParameterFilter { + $Public -eq $false -and $tenantFilter -eq 'contoso.onmicrosoft.com' + } + } +} diff --git a/Tests/Private/Set-CIPPAsyncDeploymentStep.Tests.ps1 b/Tests/Private/Set-CIPPAsyncDeploymentStep.Tests.ps1 new file mode 100644 index 0000000000000..34b399be2993c --- /dev/null +++ b/Tests/Private/Set-CIPPAsyncDeploymentStep.Tests.ps1 @@ -0,0 +1,95 @@ +# Pester tests for Set-CIPPAsyncDeploymentStep. +# +# Offboarding steps run on different workers at the same time and all write into one Steps JSON +# property of the same row. The write is ETag-checked; when it is rejected the function must re-read +# the row so the retry carries the other worker's update instead of overwriting it. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/AsyncDeployment/Set-CIPPAsyncDeploymentStep.ps1' + if (-not (Test-Path $FunctionPath)) { throw "Could not locate Set-CIPPAsyncDeploymentStep.ps1 at $FunctionPath" } + + function Get-CIPPTable { param($TableName) } + function Get-CIPPAzDataTableEntity { param($Context, $Filter) } + function Update-CIPPAzDataTableEntity { param($Context, $Entity, $OperationType, [switch]$Force, $MaxRetries) } + + . $FunctionPath + + function New-Row { + param([string]$FirstStepStatus = 'pending') + [pscustomobject]@{ + PartitionKey = 'job-1' + RowKey = 'pat@contoso.com' + ETag = 'W/"1"' + Status = 'running' + Steps = (ConvertTo-Json -Compress -InputObject @( + @{ Title = 'Revoke all sessions'; Status = $FirstStepStatus; Message = '' } + @{ Title = 'Disable sign in'; Status = 'pending'; Message = '' } + )) + } + } +} + +Describe 'Set-CIPPAsyncDeploymentStep' { + BeforeEach { + $script:Written = [System.Collections.Generic.List[object]]::new() + Mock -CommandName Get-CIPPTable -MockWith { @{ Context = 'ctx' } } + Mock -CommandName Start-Sleep -MockWith { } + } + + It 'writes the step without -Force so a concurrent update is detected rather than overwritten' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { New-Row } + Mock -CommandName Update-CIPPAzDataTableEntity -MockWith { $script:Written.Add($Entity) } + + Set-CIPPAsyncDeploymentStep -JobId 'job-1' -Name 'pat@contoso.com' -StepIndex 1 -StepStatus 'succeeded' -Message 'Done' + + Should -Invoke Update-CIPPAzDataTableEntity -Times 1 -Exactly -ParameterFilter { -not $Force } + $Steps = @($script:Written[0].Steps | ConvertFrom-Json) + $Steps[1].Status | Should -Be 'succeeded' + $Steps[1].Message | Should -Be 'Done' + $Steps[0].Status | Should -Be 'pending' + } + + It 're-reads the row and retries when the write is rejected, keeping the other worker''s step' { + # First read: nothing done yet. Second read, after the rejected write: another worker has + # finished step 0 in between, and that must survive. + $script:Reads = 0 + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + $script:Reads++ + if ($script:Reads -eq 1) { New-Row } else { New-Row -FirstStepStatus 'succeeded' } + } + $script:Writes = 0 + Mock -CommandName Update-CIPPAzDataTableEntity -MockWith { + $script:Writes++ + if ($script:Writes -eq 1) { throw 'Precondition Failed' } + $script:Written.Add($Entity) + } + + Set-CIPPAsyncDeploymentStep -JobId 'job-1' -Name 'pat@contoso.com' -StepIndex 1 -StepStatus 'running' -Message 'In progress' + + Should -Invoke Get-CIPPAzDataTableEntity -Times 2 -Exactly + Should -Invoke Update-CIPPAzDataTableEntity -Times 2 -Exactly + $Steps = @($script:Written[0].Steps | ConvertFrom-Json) + $Steps[0].Status | Should -Be 'succeeded' + $Steps[1].Status | Should -Be 'running' + } + + It 'gives up quietly after five rejected writes' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { New-Row } + Mock -CommandName Update-CIPPAzDataTableEntity -MockWith { throw 'Precondition Failed' } + + { Set-CIPPAsyncDeploymentStep -JobId 'job-1' -Name 'pat@contoso.com' -StepIndex 0 -StepStatus 'failed' -Message 'x' } | Should -Not -Throw + + Should -Invoke Update-CIPPAzDataTableEntity -Times 5 -Exactly + } + + It 'trims an oversized message so the row stays writable' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { New-Row } + Mock -CommandName Update-CIPPAzDataTableEntity -MockWith { $script:Written.Add($Entity) } + + Set-CIPPAsyncDeploymentStep -JobId 'job-1' -Name 'pat@contoso.com' -StepIndex 0 -StepStatus 'succeeded' -Message ('x' * 5000) + + $Steps = @($script:Written[0].Steps | ConvertFrom-Json) + $Steps[0].Message.Length | Should -BeLessThan 2100 + } +} diff --git a/Tests/Private/Set-CIPPFeatureFlag.Force.Tests.ps1 b/Tests/Private/Set-CIPPFeatureFlag.Force.Tests.ps1 new file mode 100644 index 0000000000000..3226e4af8a4aa --- /dev/null +++ b/Tests/Private/Set-CIPPFeatureFlag.Force.Tests.ps1 @@ -0,0 +1,44 @@ +# Hidden, system-managed feature flags (e.g. CertificateAuthentication) have AllowUserToggle=false so +# they never appear on the user settings page. The flows that own them (the Setup Wizard) set them +# with -Force. If -Force stopped bypassing the AllowUserToggle guard, the wizard could no longer +# enable certificate authentication - so the bypass is pinned here, along with the guard it bypasses. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + + function Get-CippTable { param($TableName) } + function Add-CIPPAzDataTableEntity { param($Context, $Entity, [switch]$Force) } + + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Set-CIPPFeatureFlag.ps1') + + # Minimal FeatureFlags.json with a system-managed flag the user may not toggle. + $ConfigDir = Join-Path $TestDrive 'Config' + New-Item -ItemType Directory -Path $ConfigDir -Force | Out-Null + @( + @{ Id = 'SystemManagedFlag'; Name = 'System Managed'; Description = ''; Enabled = $false; AllowUserToggle = $false; Timers = @(); Endpoints = @(); Pages = @(); Hidden = $true } + ) | ConvertTo-Json -Depth 5 -AsArray | Set-Content -Path (Join-Path $ConfigDir 'FeatureFlags.json') + $env:CIPPRootPath = $TestDrive +} + +Describe 'Set-CIPPFeatureFlag -Force' { + BeforeEach { + Mock Get-CippTable { @{} } + Mock Add-CIPPAzDataTableEntity {} + } + + It 'refuses to set a non-user-toggleable flag without -Force' { + $Result = Set-CIPPFeatureFlag -Id 'SystemManagedFlag' -Enabled $true -WarningAction SilentlyContinue + + $Result | Should -BeFalse + Should -Invoke Add-CIPPAzDataTableEntity -Times 0 -Exactly + } + + It 'sets a non-user-toggleable flag when -Force is passed' { + $Result = Set-CIPPFeatureFlag -Id 'SystemManagedFlag' -Enabled $true -Force + + $Result | Should -BeTrue + Should -Invoke Add-CIPPAzDataTableEntity -Times 1 -Exactly -ParameterFilter { + $Entity.RowKey -eq 'SystemManagedFlag' -and $Entity.Enabled -eq $true + } + } +} diff --git a/Tests/Private/Set-CIPPSAMCertificate.Tests.ps1 b/Tests/Private/Set-CIPPSAMCertificate.Tests.ps1 new file mode 100644 index 0000000000000..c84734e881577 --- /dev/null +++ b/Tests/Private/Set-CIPPSAMCertificate.Tests.ps1 @@ -0,0 +1,60 @@ +# Pester tests for Set-CIPPSAMCertificate +# Dev-mode storage writes the PFX into the DevSecrets Secret row. A certificate-only First Setup +# registers the certificate before that row exists, so the function must create it rather than throw. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Set-CIPPSAMCertificate.ps1' + + # Minimal stubs so Mock has commands to replace during tests + function Get-CIPPTable { param($TableName) } + function Get-CIPPAzDataTableEntity { param($Context, $Filter) } + function Add-AzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function Update-CIPPSAMCertificateEnvCache { param($Name, $PfxBase64) } + function Get-CippKeyVaultName { } + + . $FunctionPath +} + +Describe 'Set-CIPPSAMCertificate dev-mode storage' { + BeforeEach { + $script:OriginalStorage = $env:AzureWebJobsStorage + $script:OriginalNonLocal = $env:NonLocalHostAzurite + $env:AzureWebJobsStorage = 'UseDevelopmentStorage=true' + $env:NonLocalHostAzurite = $null + + $script:Written = $null + Mock -CommandName Get-CIPPTable -MockWith { @{ Context = 'stub-table' } } + Mock -CommandName Add-AzDataTableEntity -MockWith { $script:Written = $Entity } + Mock -CommandName Update-CIPPSAMCertificateEnvCache -MockWith { } + } + + AfterEach { + $env:AzureWebJobsStorage = $script:OriginalStorage + $env:NonLocalHostAzurite = $script:OriginalNonLocal + } + + It 'creates the Secret row when DevSecrets is empty (fresh certificate-only setup)' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { $null } + + $Result = Set-CIPPSAMCertificate -PfxBase64 'cGZ4' + + $Result.StorageMode | Should -Be 'DevTable' + Should -Invoke -CommandName Add-AzDataTableEntity -Times 1 -Exactly + $script:Written.PartitionKey | Should -Be 'Secret' + $script:Written.RowKey | Should -Be 'Secret' + $script:Written.SAMCertificate | Should -Be 'cGZ4' + } + + It 'updates the existing Secret row without touching its other properties' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + [PSCustomObject]@{ PartitionKey = 'Secret'; RowKey = 'Secret'; tenantid = 'tenant-a'; applicationid = 'app-a' } + } + + $null = Set-CIPPSAMCertificate -PfxBase64 'bmV3' + + $script:Written.tenantid | Should -Be 'tenant-a' + $script:Written.applicationid | Should -Be 'app-a' + $script:Written.SAMCertificate | Should -Be 'bmV3' + } +} diff --git a/Tests/Private/Start-UserSyncTimer.Tests.ps1 b/Tests/Private/Start-UserSyncTimer.Tests.ps1 new file mode 100644 index 0000000000000..7bea3fd39af86 --- /dev/null +++ b/Tests/Private/Start-UserSyncTimer.Tests.ps1 @@ -0,0 +1,183 @@ +# Pester tests for the stale-role self-heal in Start-UserSyncTimer (the 15-minute user sync). +# +# The sync derives each user's auto-roles from the AccessRoleGroups table. When a role's +# group mapping survives (a migration, say) but its definition in CustomRoles does not, the +# user is left carrying an auto-role that Test-CIPPAccess cannot resolve - which denies every +# request, base role included. The fix makes the sync skip mappings whose role no longer +# exists, so the orphaned auto-role drops off every affected user on the next run instead of +# being re-stamped forever. A failed CustomRoles lookup must NOT be read as "no roles exist" +# (that would strip every custom role from everyone), so it degrades to pruning nothing. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Start-UserSyncTimer.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Start-UserSyncTimer.ps1 under Modules/' } + + # Shims so Mock has real commands to intercept. Get-CippTable stays a plain shim - it just + # tags each table so the storage mocks can route on TableName. + function Get-CippTable { param($TableName) @{ TableName = $TableName } } + function Get-CIPPAzDataTableEntity { param($TableName, $Filter, $Property) } + function Add-CIPPAzDataTableEntity { param($TableName, $Entity, [switch]$Force) } + function Remove-CIPPAzDataTableEntity { param($TableName, $Entity, [switch]$Force) } + function New-GraphGetRequest { param($uri, $NoAuthCheck, $AsApp) } + function Write-LogMessage { param($API, $tenant, $message, $sev, $LogData) } + function Get-CippException { param($Exception) @{ NormalizedError = "$Exception" } } + + . $FunctionPath +} + +Describe 'Start-UserSyncTimer - stale role self-heal' { + BeforeEach { + # editor is a base role and maps to its group; 'service team' maps to a group too but + # has no CustomRoles definition on this instance - it is the orphan to prune. + $script:AccessGroups = @( + [pscustomobject]@{ PartitionKey = 'AccessRoleGroups'; RowKey = 'editor'; GroupId = 'grp-editor'; GroupName = 'SG-APP-CIPP-editor' } + [pscustomobject]@{ PartitionKey = 'AccessRoleGroups'; RowKey = 'service team'; GroupId = 'grp-serviceteam'; GroupName = 'SG-APP-CIPP-serviceteam' } + ) + # Only servicedesk is actually defined - 'service team' is deliberately absent. + $script:CustomRoles = @( + [pscustomobject]@{ PartitionKey = 'CustomRoles'; RowKey = 'servicedesk' } + ) + $script:CustomRolesThrow = $false + # The affected user already carries the phantom role from a prior (pre-fix) run. + $script:ExistingUsers = @( + [pscustomobject]@{ + PartitionKey = 'User' + RowKey = 'aaron.macleod@centaris.com' + Roles = '["editor","service team"]' + AutoRoles = '["editor","service team"]' + ManualRoles = '[]' + Source = 'Auto' + LastSync = '2026-08-17T00:00:00.0000000Z' + } + ) + + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { } + Mock -CommandName Remove-CIPPAzDataTableEntity -MockWith { } + + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + switch ($TableName) { + 'AccessRoleGroups' { return $script:AccessGroups } + 'CustomRoles' { if ($script:CustomRolesThrow) { throw 'storage unavailable' }; return $script:CustomRoles } + 'allowedUsers' { return $script:ExistingUsers } + default { return @() } + } + } + + # Both groups contain the same single member. + Mock -CommandName New-GraphGetRequest -MockWith { + if ($uri -like '*grp-editor*' -or $uri -like '*grp-serviceteam*') { + return @([pscustomobject]@{ '@odata.type' = '#microsoft.graph.user'; userPrincipalName = 'Aaron.MacLeod@centaris.com'; accountEnabled = $true }) + } + return @() + } + } + + It 'never queries the group whose role has no definition' { + $null = Start-UserSyncTimer + + Should -Invoke New-GraphGetRequest -Times 0 -Exactly -ParameterFilter { $uri -like '*grp-serviceteam*' } + Should -Invoke New-GraphGetRequest -Times 1 -Exactly -ParameterFilter { $uri -like '*grp-editor*' } + } + + It 'rewrites the affected user with the orphaned auto-role stripped' { + $null = Start-UserSyncTimer + + Should -Invoke Add-CIPPAzDataTableEntity -Times 1 -Exactly -ParameterFilter { + $TableName -eq 'allowedUsers' -and + $Entity.RowKey -eq 'aaron.macleod@centaris.com' -and + $Entity.Roles -eq '["editor"]' -and + $Entity.AutoRoles -eq '["editor"]' + } + } + + It 'logs which orphaned role it pruned, on the run that prunes it' { + $null = Start-UserSyncTimer + + Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { + $sev -eq 'Info' -and $message -like '*Pruned auto-role*' -and $message -like '*service team*' + } + } + + It 'keeps the role when the CustomRoles lookup fails, rather than stripping everything' { + $script:CustomRolesThrow = $true + + $null = Start-UserSyncTimer + + # Unknown validity => prune nothing: the phantom group is still queried and the role kept. + Should -Invoke New-GraphGetRequest -Times 1 -Exactly -ParameterFilter { $uri -like '*grp-serviceteam*' } + Should -Invoke Add-CIPPAzDataTableEntity -Times 1 -Exactly -ParameterFilter { + $TableName -eq 'allowedUsers' -and $Entity.Roles -eq '["editor","service team"]' + } + Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { + $sev -eq 'Warning' -and $message -like '*could not load custom roles*' + } + } +} + +# B2B guests carry a UPN like user_home.com#EXT#@tenant.onmicrosoft.com. '#' is illegal in a Table +# Storage RowKey (the OutOfRangeInput crash in issue #458), and with a multi-tenant sign-in the token +# presents the guest's home email (their 'mail'), not the #EXT# UPN. The sync must key the row on a +# clean, matchable identity so the write succeeds AND the auth layer can look the guest up. +Describe 'Start-UserSyncTimer - B2B guest keying' { + BeforeEach { + $script:AccessGroups = @( + [pscustomobject]@{ PartitionKey = 'AccessRoleGroups'; RowKey = 'editor'; GroupId = 'grp-editor'; GroupName = 'SG-APP-CIPP-editor' } + ) + $script:CustomRoles = @() + $script:ExistingUsers = @() + $script:GuestMember = $null + + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { } + Mock -CommandName Remove-CIPPAzDataTableEntity -MockWith { } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + switch ($TableName) { + 'AccessRoleGroups' { return $script:AccessGroups } + 'CustomRoles' { return $script:CustomRoles } + 'allowedUsers' { return $script:ExistingUsers } + default { return @() } + } + } + Mock -CommandName New-GraphGetRequest -MockWith { + if ($uri -like '*grp-editor*') { return @($script:GuestMember) } + return @() + } + } + + It 'keys a guest on their mail (home email), never the #EXT# UPN' { + $script:GuestMember = [pscustomobject]@{ + '@odata.type' = '#microsoft.graph.user' + userPrincipalName = 'zr-dev_dev.johnwduprey.com#EXT#@contoso.onmicrosoft.com' + mail = 'ZR-Dev@dev.johnwduprey.com' + accountEnabled = $true + } + + $null = Start-UserSyncTimer + + Should -Invoke Add-CIPPAzDataTableEntity -Times 1 -Exactly -ParameterFilter { + $TableName -eq 'allowedUsers' -and + $Entity.RowKey -eq 'zr-dev@dev.johnwduprey.com' -and + $Entity.RowKey -notmatch '#' + } + } + + It 'decodes the #EXT# UPN back to the invited address when mail is missing' { + $script:GuestMember = [pscustomobject]@{ + '@odata.type' = '#microsoft.graph.user' + userPrincipalName = 'bob_smith_fabrikam.com#EXT#@contoso.onmicrosoft.com' + mail = $null + accountEnabled = $true + } + + $null = Start-UserSyncTimer + + Should -Invoke Add-CIPPAzDataTableEntity -Times 1 -Exactly -ParameterFilter { + $TableName -eq 'allowedUsers' -and + $Entity.RowKey -eq 'bob_smith@fabrikam.com' -and + $Entity.RowKey -notmatch '#' + } + } +} diff --git a/Tests/Private/Test-CIPPAccess.BlockedEndpoints.Tests.ps1 b/Tests/Private/Test-CIPPAccess.BlockedEndpoints.Tests.ps1 new file mode 100644 index 0000000000000..56feb718ecd9a --- /dev/null +++ b/Tests/Private/Test-CIPPAccess.BlockedEndpoints.Tests.ps1 @@ -0,0 +1,219 @@ +# Regression tests for tenant-scoped BlockedEndpoints in Test-CIPPAccess. +# +# BlockedEndpoints used to throw before tenant resolution, so a role scoped to T1 that blocked an +# endpoint also blocked that endpoint on T2. Deny still wins, but only when the blocking role's +# tenant scope covers the request target. +# +# Driven through the APIClient path (aad idp + GUID principal name). + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $AuthDir = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Authentication' + $FunctionPath = Join-Path $AuthDir 'Test-CIPPAccess.ps1' + $ScopeHelperPath = Join-Path $AuthDir 'Test-CippRoleTenantScope.ps1' + + function Get-CippApiClient { param($AppId) } + function Test-IpInRange { param($IPAddress, $Range) $false } + function Get-CIPPRolePermissions { param($Role) } + function Get-Tenants { param([switch]$IncludeErrors) @() } + function Get-CippAccessScopeRule { param($Role) } + function Expand-CIPPTenantGroups { param($TenantFilter) @() } + + . $ScopeHelperPath + . $FunctionPath + + $script:CIPPFunctionPermissions = @{ + 'Invoke-ExecResetPass' = @{ Role = 'Identity.User.ReadWrite'; Functionality = 'Entrypoint' } + } + $script:CIPPBaseRoles = [pscustomobject]@{} + + $script:Tenant1 = [pscustomobject]@{ customerId = 'tenant-1'; defaultDomainName = 't1.example.com' } + $script:Tenant2 = [pscustomobject]@{ customerId = 'tenant-2'; defaultDomainName = 't2.example.com' } + + function New-EndpointRequest { + param( + [string]$TenantId, + [object]$TenantFilterBody + ) + $Body = if ($PSBoundParameters.ContainsKey('TenantFilterBody')) { + @{ tenantFilter = $TenantFilterBody } + } elseif ($TenantId) { + @{ tenantFilter = $TenantId } + } else { + @{} + } + [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecResetPass' } + Headers = @{ + 'x-ms-client-principal-idp' = 'aad' + 'x-ms-client-principal-name' = '11111111-1111-1111-1111-111111111111' + 'x-forwarded-for' = '1.2.3.4' + } + Query = @{} + Body = $Body + } + } + + function New-RoleObject { + param( + [string]$Name, + [string[]]$Permissions = @('Identity.User.ReadWrite'), + [object[]]$AllowedTenants = @('AllTenants'), + [object[]]$BlockedTenants = @(), + [string[]]$BlockedEndpoints = @() + ) + [pscustomobject]@{ + Role = $Name + Permissions = $Permissions + AllowedTenants = $AllowedTenants + BlockedTenants = $BlockedTenants + BlockedEndpoints = $BlockedEndpoints + } + } +} + +Describe 'Test-CIPPAccess BlockedEndpoints tenant scoping' { + BeforeEach { + Mock -CommandName Get-Tenants -MockWith { @($script:Tenant1, $script:Tenant2) } + Mock -CommandName Expand-CIPPTenantGroups -MockWith { @() } + } + + It 'blocks when AllTenants role lists the endpoint' { + Mock -CommandName Get-CippApiClient -MockWith { + [pscustomobject]@{ AppName = 'TestApp'; Role = @('allrole'); IPRange = @('Any') } + } + Mock -CommandName Get-CIPPRolePermissions -MockWith { + New-RoleObject -Name 'allrole' -BlockedEndpoints @('ExecResetPass') + } + + { Test-CIPPAccess -Request (New-EndpointRequest -TenantId 'tenant-1') } | + Should -Throw -ExpectedMessage '*has blocked this endpoint: ExecResetPass*' + } + + It 'blocks when scoped role covers the request tenant' { + Mock -CommandName Get-CippApiClient -MockWith { + [pscustomobject]@{ AppName = 'TestApp'; Role = @('t1role'); IPRange = @('Any') } + } + Mock -CommandName Get-CIPPRolePermissions -MockWith { + New-RoleObject -Name 't1role' -AllowedTenants @('tenant-1') -BlockedEndpoints @('ExecResetPass') + } + + { Test-CIPPAccess -Request (New-EndpointRequest -TenantId 'tenant-1') } | + Should -Throw -ExpectedMessage '*has blocked this endpoint: ExecResetPass*' + } + + It 'allows T2 when RoleA blocks on T1 and RoleB grants T2 without a block' { + Mock -CommandName Get-CippApiClient -MockWith { + [pscustomobject]@{ AppName = 'TestApp'; Role = @('roleA', 'roleB'); IPRange = @('Any') } + } + Mock -CommandName Get-CIPPRolePermissions -MockWith { + param($Role) + switch ($Role) { + 'roleA' { + New-RoleObject -Name 'roleA' -AllowedTenants @('tenant-1') -BlockedEndpoints @('ExecResetPass') + } + 'roleB' { + New-RoleObject -Name 'roleB' -AllowedTenants @('tenant-2') -BlockedEndpoints @() + } + default { throw "Unexpected role $Role" } + } + } + + $result = Test-CIPPAccess -Request (New-EndpointRequest -TenantId 'tenant-2') + $result | Should -BeTrue + } + + It 'denies out-of-scope tenant with tenant message when only a blocking scoped role is held' { + Mock -CommandName Get-CippApiClient -MockWith { + [pscustomobject]@{ AppName = 'TestApp'; Role = @('t1role'); IPRange = @('Any') } + } + Mock -CommandName Get-CIPPRolePermissions -MockWith { + New-RoleObject -Name 't1role' -AllowedTenants @('tenant-1') -BlockedEndpoints @('ExecResetPass') + } + + { Test-CIPPAccess -Request (New-EndpointRequest -TenantId 'tenant-2') } | + Should -Throw -ExpectedMessage '*Access to this tenant is not allowed*' + } + + It 'allows when role grants the endpoint with no block and tenant is in scope' { + Mock -CommandName Get-CippApiClient -MockWith { + [pscustomobject]@{ AppName = 'TestApp'; Role = @('t1role'); IPRange = @('Any') } + } + Mock -CommandName Get-CIPPRolePermissions -MockWith { + New-RoleObject -Name 't1role' -AllowedTenants @('tenant-1') -BlockedEndpoints @() + } + + $result = Test-CIPPAccess -Request (New-EndpointRequest -TenantId 'tenant-1') + $result | Should -BeTrue + } + + It 'fail-closes the block when tenantFilter does not map to a known tenant' { + Mock -CommandName Get-CippApiClient -MockWith { + [pscustomobject]@{ AppName = 'TestApp'; Role = @('t1role'); IPRange = @('Any') } + } + Mock -CommandName Get-CIPPRolePermissions -MockWith { + New-RoleObject -Name 't1role' -AllowedTenants @('tenant-1') -BlockedEndpoints @('ExecResetPass') + } + + { Test-CIPPAccess -Request (New-EndpointRequest -TenantId 'tenant-unknown') } | + Should -Throw -ExpectedMessage '*has blocked this endpoint: ExecResetPass*' + } + + It 'fail-closes the block when the request has no tenantFilter (does not invent partner TenantID)' { + Mock -CommandName Get-CippApiClient -MockWith { + [pscustomobject]@{ AppName = 'TestApp'; Role = @('t1role'); IPRange = @('Any') } + } + Mock -CommandName Get-CIPPRolePermissions -MockWith { + New-RoleObject -Name 't1role' -AllowedTenants @('tenant-1') -BlockedEndpoints @('ExecResetPass') + } + + $env:TenantID = 'partner-tenant-id' + { Test-CIPPAccess -Request (New-EndpointRequest) } | + Should -Throw -ExpectedMessage '*has blocked this endpoint: ExecResetPass*' + } + + It 'blocks a granted group-shaped tenantFilter when the endpoint is blocked' { + Mock -CommandName Get-CippApiClient -MockWith { + [pscustomobject]@{ AppName = 'TestApp'; Role = @('grouprole'); IPRange = @('Any') } + } + Mock -CommandName Get-CIPPRolePermissions -MockWith { + New-RoleObject -Name 'grouprole' ` + -AllowedTenants @([pscustomobject]@{ type = 'Group'; value = 'group-allowed'; label = 'Allowed Group' }) ` + -BlockedEndpoints @('ExecResetPass') + } + + $GroupBody = [pscustomobject]@{ type = 'Group'; value = 'group-allowed'; label = 'Allowed Group' } + { Test-CIPPAccess -Request (New-EndpointRequest -TenantFilterBody $GroupBody) } | + Should -Throw -ExpectedMessage '*has blocked this endpoint: ExecResetPass*' + } + + It 'does not apply the block for an ungranted group; allow path denies the group' { + Mock -CommandName Get-CippApiClient -MockWith { + [pscustomobject]@{ AppName = 'TestApp'; Role = @('grouprole'); IPRange = @('Any') } + } + Mock -CommandName Get-CIPPRolePermissions -MockWith { + New-RoleObject -Name 'grouprole' ` + -AllowedTenants @([pscustomobject]@{ type = 'Group'; value = 'group-allowed'; label = 'Allowed Group' }) ` + -BlockedEndpoints @('ExecResetPass') + } + + $GroupBody = [pscustomobject]@{ type = 'Group'; value = 'group-notgranted'; label = 'Other Group' } + { Test-CIPPAccess -Request (New-EndpointRequest -TenantFilterBody $GroupBody) } | + Should -Throw -ExpectedMessage '*Access to this tenant is not allowed*' + } + + It 'does not block when the role lists BlockedEndpoints but does not grant the permission' { + Mock -CommandName Get-CippApiClient -MockWith { + [pscustomobject]@{ AppName = 'TestApp'; Role = @('norole'); IPRange = @('Any') } + } + Mock -CommandName Get-CIPPRolePermissions -MockWith { + New-RoleObject -Name 'norole' ` + -Permissions @('Identity.User.Read') ` + -AllowedTenants @('AllTenants') ` + -BlockedEndpoints @('ExecResetPass') + } + + { Test-CIPPAccess -Request (New-EndpointRequest -TenantId 'tenant-1') } | + Should -Throw -ExpectedMessage '*required permission: Identity.User.ReadWrite*' + } +} diff --git a/Tests/Private/Test-CIPPAccess.TenantGroupAuth.Tests.ps1 b/Tests/Private/Test-CIPPAccess.TenantGroupAuth.Tests.ps1 new file mode 100644 index 0000000000000..4b817dee8e107 --- /dev/null +++ b/Tests/Private/Test-CIPPAccess.TenantGroupAuth.Tests.ps1 @@ -0,0 +1,87 @@ +# Regression tests for the tenant-group authorization gap in Test-CIPPAccess. +# +# A requested tenant GROUP ({type:'Group', value:}) used to resolve to a null $Tenant and fall +# through to an unconditional allow, so a restricted role could target a group it was never granted. +# The fix authorizes a group request by group identity: allow iff the requested group GUID is one of +# the role's granted group entries; otherwise hard-deny. Members are never expanded for the decision. +# +# Driven through the APIClient path (aad idp + GUID principal name), which skips the user/impersonation +# branch and reaches the per-endpoint permission evaluation with a restricted role. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $AuthDir = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Authentication' + $FunctionPath = Join-Path $AuthDir 'Test-CIPPAccess.ps1' + $ScopeHelperPath = Join-Path $AuthDir 'Test-CippRoleTenantScope.ps1' + + # Stubs for the surface the APIClient path touches. + function Get-CippApiClient { param($AppId) } + function Test-IpInRange { param($IPAddress, $Range) $false } + function Get-CIPPRolePermissions { param($Role) } + function Get-Tenants { param([switch]$IncludeErrors) @() } + function Get-CippAccessScopeRule { param($Role) } + function Expand-CIPPTenantGroups { param($TenantFilter) @() } + + . $ScopeHelperPath + . $FunctionPath + + # Bypass the config-file reads by pre-seeding the runspace caches the function guards on. + $script:CIPPFunctionPermissions = @{ + 'Invoke-AddScheduledItem' = @{ Role = 'CIPP.Scheduler.ReadWrite'; Functionality = 'Entrypoint' } + } + $script:CIPPBaseRoles = [pscustomobject]@{} + + # A request from an API client, scoped by a restricted custom role, asking to act on a group. + function New-GroupRequest { + param([string]$RequestedGroupId) + [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'AddScheduledItem' } + Headers = @{ + 'x-ms-client-principal-idp' = 'aad' + 'x-ms-client-principal-name' = '11111111-1111-1111-1111-111111111111' + 'x-forwarded-for' = '1.2.3.4' + } + Query = @{} + Body = @{ tenantFilter = [pscustomobject]@{ type = 'Group'; value = $RequestedGroupId; label = 'Requested Group' } } + } + } + + # The restricted role grants exactly one group ('group-allowed') and nothing else. + function Set-RestrictedRoleMocks { + Mock -CommandName Get-CippApiClient -MockWith { + [pscustomobject]@{ AppName = 'TestApp'; Role = @('grouprole'); IPRange = @('Any') } + } + Mock -CommandName Get-CIPPRolePermissions -MockWith { + [pscustomobject]@{ + Role = 'grouprole' + Permissions = @('CIPP.Scheduler.ReadWrite') + AllowedTenants = @([pscustomobject]@{ type = 'Group'; value = 'group-allowed'; label = 'Allowed Group' }) + BlockedTenants = @() + BlockedEndpoints = @() + } + } + Mock -CommandName Get-Tenants -MockWith { @() } + } +} + +Describe 'Test-CIPPAccess tenant-group authorization' { + BeforeEach { Set-RestrictedRoleMocks } + + It 'allows a request for a group the role was granted' { + $result = Test-CIPPAccess -Request (New-GroupRequest -RequestedGroupId 'group-allowed') + $result | Should -BeTrue + } + + It 'denies a request for a group the role was NOT granted' { + { Test-CIPPAccess -Request (New-GroupRequest -RequestedGroupId 'group-notgranted') } | + Should -Throw -ExpectedMessage '*not allowed*' + } + + It 'authorizes by group identity, never by expanding members' { + # If the decision expanded members it would have to call Expand-CIPPTenantGroups; it must not. + Mock -CommandName Expand-CIPPTenantGroups -MockWith { throw 'membership must not be expanded for the access decision' } + $result = Test-CIPPAccess -Request (New-GroupRequest -RequestedGroupId 'group-allowed') + $result | Should -BeTrue + Should -Invoke -CommandName Expand-CIPPTenantGroups -Times 0 + } +} diff --git a/Tests/Private/Test-CIPPPIMRoleSettingsFloor.Tests.ps1 b/Tests/Private/Test-CIPPPIMRoleSettingsFloor.Tests.ps1 new file mode 100644 index 0000000000000..691e9b94237a3 --- /dev/null +++ b/Tests/Private/Test-CIPPPIMRoleSettingsFloor.Tests.ps1 @@ -0,0 +1,197 @@ +# Pester tests for the PIM role settings secure floor and its normaliser. +# +# The floor is what stops a PIM role settings template - saved through the editor, hand-edited in +# the templates table, or deployed by the PIMRoleSettings standard - from weakening a tenant's +# privileged access. Templates below it are rejected, never clamped, so each rule gets a test +# proving it rejects, plus the one case that is allowed-but-warned (activation above 8h, up to 24h). + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/PIM/Test-CIPPPIMRoleSettingsFloor.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/PIM/ConvertTo-CIPPPIMRoleSettings.ps1') + + function New-SecureSettings { + param([hashtable]$Override = @{}) + $Settings = @{ + activationMaxDuration = 'PT8H' + activationRequires = 'MFA' + authenticationContextClaimValue = '' + activationRequiresJustification = $true + activationRequiresTicket = $false + activationRequiresApproval = $false + approvers = '' + eligibilityMaxDuration = 'P365D' + activeAssignmentMaxDuration = 'P180D' + activeAssignmentRequiresMfa = $true + activeAssignmentRequiresJustification = $true + notificationRecipients = '' + notificationLevel = 'All' + } + foreach ($Key in $Override.Keys) { $Settings[$Key] = $Override[$Key] } + return ConvertTo-CIPPPIMRoleSettings -InputObject $Settings + } +} + +Describe 'Test-CIPPPIMRoleSettingsFloor' { + It 'accepts the secure defaults with no warnings' { + $Result = Test-CIPPPIMRoleSettingsFloor -Settings (New-SecureSettings) + $Result.Valid | Should -BeTrue + $Result.Errors | Should -BeNullOrEmpty + $Result.Warnings | Should -BeNullOrEmpty + } + + It 'rejects a null settings object' { + (Test-CIPPPIMRoleSettingsFloor -Settings $null).Valid | Should -BeFalse + } + + Context 'activation' { + It 'warns, but allows, an activation maximum between 8h and 24h' { + $Result = Test-CIPPPIMRoleSettingsFloor -Settings (New-SecureSettings @{ activationMaxDuration = 'PT12H' }) + $Result.Valid | Should -BeTrue + $Result.Warnings | Should -Match 'exceeds the recommended PT8H' + } + + It 'allows exactly 24h' { + (Test-CIPPPIMRoleSettingsFloor -Settings (New-SecureSettings @{ activationMaxDuration = 'PT24H' })).Valid | Should -BeTrue + } + + It 'rejects an activation maximum above 24h' { + $Result = Test-CIPPPIMRoleSettingsFloor -Settings (New-SecureSettings @{ activationMaxDuration = 'PT25H' }) + $Result.Valid | Should -BeFalse + $Result.Errors | Should -Match 'exceeds the maximum of PT24H' + } + + It 'rejects an activation with no expiration' { + $Result = Test-CIPPPIMRoleSettingsFloor -Settings (New-SecureSettings @{ activationMaxDuration = $null }) + # The normaliser substitutes the secure default for an absent value, so feed the + # canonical object directly to simulate a policy that does not require expiration. + $Settings = New-SecureSettings + $Settings.activationMaxDuration = $null + $Result = Test-CIPPPIMRoleSettingsFloor -Settings $Settings + $Result.Valid | Should -BeFalse + $Result.Errors | Should -Match 'Role activation must expire' + } + + It 'rejects activation without MFA or an authentication context' { + $Result = Test-CIPPPIMRoleSettingsFloor -Settings (New-SecureSettings @{ activationRequires = 'None' }) + $Result.Valid | Should -BeFalse + $Result.Errors | Should -Match 'must require MFA or an authentication context' + } + + It 'accepts an authentication context with a claim value in place of MFA' { + $Result = Test-CIPPPIMRoleSettingsFloor -Settings (New-SecureSettings @{ activationRequires = 'AuthenticationContext'; authenticationContextClaimValue = 'c1' }) + $Result.Valid | Should -BeTrue + } + + It 'rejects an authentication context without a claim value' { + $Result = Test-CIPPPIMRoleSettingsFloor -Settings (New-SecureSettings @{ activationRequires = 'AuthenticationContext' }) + $Result.Valid | Should -BeFalse + $Result.Errors | Should -Match 'claim value' + } + + It 'rejects activation without a justification' { + $Result = Test-CIPPPIMRoleSettingsFloor -Settings (New-SecureSettings @{ activationRequiresJustification = $false }) + $Result.Valid | Should -BeFalse + $Result.Errors | Should -Match 'Role activation must require a justification' + } + + It 'rejects approval without approvers' { + $Result = Test-CIPPPIMRoleSettingsFloor -Settings (New-SecureSettings @{ activationRequiresApproval = $true }) + $Result.Valid | Should -BeFalse + $Result.Errors | Should -Match 'no approvers' + } + + It 'accepts approval with approvers' { + (Test-CIPPPIMRoleSettingsFloor -Settings (New-SecureSettings @{ activationRequiresApproval = $true; approvers = 'Security Team' })).Valid | Should -BeTrue + } + } + + Context 'eligibility and active assignments' { + It 'rejects eligibility beyond a year' { + $Result = Test-CIPPPIMRoleSettingsFloor -Settings (New-SecureSettings @{ eligibilityMaxDuration = 'P400D' }) + $Result.Valid | Should -BeFalse + $Result.Errors | Should -Match 'Eligible assignments.*exceeds the maximum of P365D' + } + + It 'rejects eligibility with no expiration (permanent eligibility)' { + $Settings = New-SecureSettings + $Settings.eligibilityMaxDuration = '' + $Result = Test-CIPPPIMRoleSettingsFloor -Settings $Settings + $Result.Valid | Should -BeFalse + $Result.Errors | Should -Match 'Eligible assignments must expire' + } + + It 'rejects active assignments beyond a year' { + $Result = Test-CIPPPIMRoleSettingsFloor -Settings (New-SecureSettings @{ activeAssignmentMaxDuration = 'P2Y' }) + $Result.Valid | Should -BeFalse + $Result.Errors | Should -Match 'Active assignments.*exceeds the maximum of P365D' + } + + It 'rejects active assignments with no expiration (permanent active)' { + $Settings = New-SecureSettings + $Settings.activeAssignmentMaxDuration = $null + $Result = Test-CIPPPIMRoleSettingsFloor -Settings $Settings + $Result.Valid | Should -BeFalse + $Result.Errors | Should -Match 'Active assignments must expire' + } + + It 'rejects active assignments without a justification' { + $Result = Test-CIPPPIMRoleSettingsFloor -Settings (New-SecureSettings @{ activeAssignmentRequiresJustification = $false }) + $Result.Valid | Should -BeFalse + $Result.Errors | Should -Match 'active assignment must require a justification' + } + + It 'reports every violation at once' { + $Settings = New-SecureSettings @{ activationRequires = 'None'; activationRequiresJustification = $false; eligibilityMaxDuration = 'P2Y' } + $Result = Test-CIPPPIMRoleSettingsFloor -Settings $Settings + $Result.Errors.Count | Should -Be 3 + } + } + + Context 'notifications' { + It 'rejects an invalid recipient address' { + $Result = Test-CIPPPIMRoleSettingsFloor -Settings (New-SecureSettings @{ notificationRecipients = 'soc@contoso.com, not-an-address' }) + $Result.Valid | Should -BeFalse + $Result.Errors | Should -Match "'not-an-address' is not a valid" + } + + It 'rejects an unknown notification level when recipients are set' { + $Result = Test-CIPPPIMRoleSettingsFloor -Settings (New-SecureSettings @{ notificationRecipients = 'soc@contoso.com'; notificationLevel = 'Everything' }) + $Result.Valid | Should -BeFalse + $Result.Errors | Should -Match 'notificationLevel' + } + + It 'accepts valid recipients' { + (Test-CIPPPIMRoleSettingsFloor -Settings (New-SecureSettings @{ notificationRecipients = 'soc@contoso.com; ops@contoso.com'; notificationLevel = 'Critical' })).Valid | Should -BeTrue + } + } +} + +Describe 'ConvertTo-CIPPPIMRoleSettings' { + It 'unwraps autoComplete label/value objects and string booleans from a request body' { + $Body = [pscustomobject]@{ + activationMaxDuration = [pscustomobject]@{ label = '4 hours'; value = 'PT4H' } + activationRequires = @{ label = 'MFA'; value = 'MFA' } + activationRequiresJustification = 'true' + activationRequiresTicket = 'false' + notificationRecipients = @('a@contoso.com', [pscustomobject]@{ label = 'b@contoso.com'; value = 'b@contoso.com' }) + } + $Settings = ConvertTo-CIPPPIMRoleSettings -InputObject $Body + $Settings.activationMaxDuration | Should -Be 'PT4H' + $Settings.activationRequires | Should -Be 'MFA' + $Settings.activationRequiresJustification | Should -BeTrue + $Settings.activationRequiresTicket | Should -BeFalse + $Settings.notificationRecipients | Should -Be 'a@contoso.com, b@contoso.com' + } + + It 'applies the secure defaults for missing properties' { + $Settings = ConvertTo-CIPPPIMRoleSettings -InputObject @{} + $Settings.activationMaxDuration | Should -Be 'PT8H' + $Settings.activationRequires | Should -Be 'MFA' + $Settings.activationRequiresJustification | Should -BeTrue + $Settings.eligibilityMaxDuration | Should -Be 'P365D' + $Settings.activeAssignmentMaxDuration | Should -Be 'P180D' + $Settings.activeAssignmentRequiresJustification | Should -BeTrue + (Test-CIPPPIMRoleSettingsFloor -Settings $Settings).Valid | Should -BeTrue + } +} diff --git a/Tests/Private/Test-CIPPSharePointLibraryCopyEligible.Tests.ps1 b/Tests/Private/Test-CIPPSharePointLibraryCopyEligible.Tests.ps1 new file mode 100644 index 0000000000000..dfcab1c5ee499 --- /dev/null +++ b/Tests/Private/Test-CIPPSharePointLibraryCopyEligible.Tests.ps1 @@ -0,0 +1,26 @@ +# Pester tests for Test-CIPPSharePointLibraryCopyEligible.ps1 + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Test-CIPPSharePointLibraryCopyEligible.ps1' + if (-not (Test-Path $FunctionPath)) { throw "Could not locate $FunctionPath" } + . $FunctionPath +} + +Describe 'Test-CIPPSharePointLibraryCopyEligible' { + It 'accepts a normal document library' { + (Test-CIPPSharePointLibraryCopyEligible -Template 'documentLibrary' -Title 'HR Docs' -Name 'HRDocs').Eligible | Should -Be $true + } + + It 'rejects Site Pages template' { + (Test-CIPPSharePointLibraryCopyEligible -Template 'webPageLibrary' -Title 'Site Pages').Eligible | Should -Be $false + } + + It 'rejects Site Assets by title' { + (Test-CIPPSharePointLibraryCopyEligible -Template 'documentLibrary' -Title 'Site Assets').Eligible | Should -Be $false + } + + It 'rejects SiteAssets internal name' { + (Test-CIPPSharePointLibraryCopyEligible -Template 'documentLibrary' -Title 'Docs' -Name 'SiteAssets').Eligible | Should -Be $false + } +} diff --git a/Tests/Private/Update-CIPPSharePointLibraryCopyStatus.Tests.ps1 b/Tests/Private/Update-CIPPSharePointLibraryCopyStatus.Tests.ps1 new file mode 100644 index 0000000000000..3633140a6dd90 --- /dev/null +++ b/Tests/Private/Update-CIPPSharePointLibraryCopyStatus.Tests.ps1 @@ -0,0 +1,111 @@ +# Pester tests for Update-CIPPSharePointLibraryCopyStatus + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $UpdatePath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Update-CIPPSharePointLibraryCopyStatus.ps1' + if (-not (Test-Path $UpdatePath)) { throw "Could not locate $UpdatePath" } + + function Set-CIPPSharePointLibraryCopyOperation { param([string]$TenantFilter, [string]$OperationId, [hashtable]$Entity) } + function Get-CIPPSharePointLibraryCopyOperation { param([string]$TenantFilter, [string]$OperationId) } + function Get-CIPPSharePointCopyJobProgress { param([string]$TenantFilter, [string]$SourceSiteUrl, $CopyJobInfo) } + + . $UpdatePath +} + +Describe 'Update-CIPPSharePointLibraryCopyStatus' { + BeforeEach { + $script:ProgressCalls = 0 + + Mock Get-CIPPSharePointLibraryCopyOperation { + [PSCustomObject]@{ + OperationId = $OperationId + SourceSiteUrl = 'https://contoso.sharepoint.com/sites/a' + SourceSiteName = 'Site A' + SourceLibraryName = 'Docs' + DestSiteName = 'Site B' + DestLibraryName = 'Archive' + StartedBy = 'admin' + Status = 'Processing' + JobHandleCount = 1 + Expiry = ([DateTime]::UtcNow.AddDays(7)).ToString('o') + CopyJobInfos = @([PSCustomObject]@{ JobId = 'job-1'; JobQueueUri = 'https://queue'; EncryptionKey = 'key' }) + HandleStates = @([PSCustomObject]@{ Status = 'Queued'; IsComplete = $false }) + SanitizedSnapshot = $null + } + } + + Mock Get-CIPPSharePointCopyJobProgress { + $script:ProgressCalls++ + [PSCustomObject]@{ + Status = 'Processing' + IsComplete = $false + ObjectsProcessed = 1 + TotalExpectedObjects = 5 + FilesCreated = 0 + BytesProcessed = 0 + TotalErrors = 0 + TotalWarnings = 0 + ErrorMessages = @() + WarningMessages = @() + } + } + } + + It 'polls unfinished handles once per request' { + $null = Update-CIPPSharePointLibraryCopyStatus -TenantFilter 'contoso.com' -OperationId ([guid]::NewGuid().Guid) + + $script:ProgressCalls | Should -Be 1 + } + + It 'skips already-complete handles' { + Mock Get-CIPPSharePointLibraryCopyOperation { + [PSCustomObject]@{ + OperationId = $OperationId + SourceSiteUrl = 'https://contoso.sharepoint.com/sites/a' + SourceSiteName = 'Site A' + SourceLibraryName = 'Docs' + DestSiteName = 'Site B' + DestLibraryName = 'Archive' + StartedBy = 'admin' + Status = 'Processing' + JobHandleCount = 2 + Expiry = ([DateTime]::UtcNow.AddDays(7)).ToString('o') + CopyJobInfos = @( + [PSCustomObject]@{ JobId = 'job-1'; JobQueueUri = 'https://queue'; EncryptionKey = 'key' } + [PSCustomObject]@{ JobId = 'job-2'; JobQueueUri = 'https://queue'; EncryptionKey = 'key' } + ) + HandleStates = @( + [PSCustomObject]@{ Status = 'Complete'; IsComplete = $true; TotalErrors = 0 } + [PSCustomObject]@{ Status = 'Queued'; IsComplete = $false } + ) + SanitizedSnapshot = $null + } + } + + $null = Update-CIPPSharePointLibraryCopyStatus -TenantFilter 'contoso.com' -OperationId ([guid]::NewGuid().Guid) + + $script:ProgressCalls | Should -Be 1 + } + + It 'returns cached snapshot for terminal operations without live polling' { + Mock Get-CIPPSharePointLibraryCopyOperation { + [PSCustomObject]@{ + Status = 'Completed' + HandleStates = @() + SanitizedSnapshot = [PSCustomObject]@{ + OperationId = 'done-op' + Status = 'Completed' + JobsComplete = 1 + JobsTotal = 1 + TotalErrors = 0 + Message = 'Library copy completed.' + } + } + } + + $Result = Update-CIPPSharePointLibraryCopyStatus -TenantFilter 'contoso.com' -OperationId 'done-op' + + $Result.Status | Should -Be 'Completed' + $script:ProgressCalls | Should -Be 0 + } +} diff --git a/Tests/Reports/ConvertTo-CippMarkdownCell.Tests.ps1 b/Tests/Reports/ConvertTo-CippMarkdownCell.Tests.ps1 index bfe47f0e2e403..856f77189f875 100644 --- a/Tests/Reports/ConvertTo-CippMarkdownCell.Tests.ps1 +++ b/Tests/Reports/ConvertTo-CippMarkdownCell.Tests.ps1 @@ -26,6 +26,16 @@ Describe 'ConvertTo-CippMarkdownCell' { } } + Context 'Backslash escaping' { + It 'doubles a literal backslash so the parser does not treat it as an escape' { + ConvertTo-CippMarkdownCell -Value 'CONTOSO\jdoe' | Should -Be 'CONTOSO\\jdoe' + } + + It 'keeps a backslash before a pipe from swallowing the pipe escape' { + ConvertTo-CippMarkdownCell -Value 'C:\|x' | Should -Be 'C:\\\|x' + } + } + Context 'Newline handling' { It 'collapses a newline that would split the row in two' { ConvertTo-CippMarkdownCell -Value "Line1`nLine2" | Should -Be 'Line1 Line2' diff --git a/Tests/Reports/Get-CIPPDrift.Tests.ps1 b/Tests/Reports/Get-CIPPDrift.Tests.ps1 index 4f1fbe8964a3f..baafcef363a34 100644 --- a/Tests/Reports/Get-CIPPDrift.Tests.ps1 +++ b/Tests/Reports/Get-CIPPDrift.Tests.ps1 @@ -529,6 +529,47 @@ Describe 'Get-CIPPDrift - stale drift entity pruning' { $script:RemovedDriftEntities.Count | Should -Be 0 } + It 'does not remove a decided IntuneTemplates row when a continuation page of the policy inventory failed' { + # New-GraphBulkRequest keeps status 200 on an item whose later page failed and flags it + # PagingIncomplete instead. Policies on the missing page are absent from the inventory, so + # without this gate their accepted rows were pruned as "gone" and re-created as New on the + # next run (the reported reset of customer-specific deviations every couple of days). + $script:DriftEntityRows = @(New-DriftEntity -StandardName 'IntuneTemplates.policy-on-page-two' -Status 'CustomerSpecific' -Reason 'customer specific' -User 'admin@msp.com') + Mock -CommandName Test-CIPPStandardLicense -MockWith { param($StandardName, $TenantFilter, $Preset) $Preset -eq 'Intune' } + Mock -CommandName New-GraphBulkRequest -MockWith { + param($Requests, $tenantid, $asapp) + foreach ($r in $Requests) { + $Item = [pscustomobject]@{ id = $r.id; status = 200; body = @{ value = @() } } + if ($r.id -eq 'deviceManagement/configurationPolicies') { + $Item | Add-Member -NotePropertyName 'PagingIncomplete' -NotePropertyValue $true + $Item | Add-Member -NotePropertyName 'PagingError' -NotePropertyValue 'continuation page returned 429' + } + $Item + } + } + + Get-CIPPDrift -TenantFilter 'contoso.onmicrosoft.com' -WarningVariable Warnings -WarningAction SilentlyContinue | Out-Null + + $script:RemovedDriftEntities.Count | Should -Be 0 + @($Warnings) | Where-Object { $_ -match 'incomplete' -and $_ -match '429' } | Should -Not -BeNullOrEmpty + } + + It 'still prunes a vanished IntuneTemplates row when every page of the inventory succeeded' { + # Control for the test above: same rows, same inventory, no incomplete flag - the row has no + # matching tenant policy and is correctly removed. + $script:DriftEntityRows = @(New-DriftEntity -StandardName 'IntuneTemplates.policy-really-gone' -Status 'CustomerSpecific' -Reason 'customer specific' -User 'admin@msp.com') + Mock -CommandName Test-CIPPStandardLicense -MockWith { param($StandardName, $TenantFilter, $Preset) $Preset -eq 'Intune' } + Mock -CommandName New-GraphBulkRequest -MockWith { + param($Requests, $tenantid, $asapp) + foreach ($r in $Requests) { [pscustomobject]@{ id = $r.id; status = 200; body = @{ value = @() } } } + } + + Get-CIPPDrift -TenantFilter 'contoso.onmicrosoft.com' | Out-Null + + $script:RemovedDriftEntities.Count | Should -Be 1 + $script:RemovedDriftEntities[0].StandardName | Should -Be 'IntuneTemplates.policy-really-gone' + } + It 'does not remove a drift row that is still referenced by the current alignment' { $script:DriftEntityRows = @(New-DriftEntity -StandardName 'standards.StillRelevant') Mock -CommandName Get-CIPPTenantAlignment -MockWith { diff --git a/Tests/Reports/Get-CIPPLicenseOverview.Tests.ps1 b/Tests/Reports/Get-CIPPLicenseOverview.Tests.ps1 new file mode 100644 index 0000000000000..93f783dd43b34 --- /dev/null +++ b/Tests/Reports/Get-CIPPLicenseOverview.Tests.ps1 @@ -0,0 +1,120 @@ +# Pester tests for Get-CIPPLicenseOverview +# Focus: the exclusion/dropdown gate that decides which SKUs each caller sees. +# - Reporting/alert callers (no -IncludeExcluded) drop every excluded SKU. +# - Picker callers (-IncludeExcluded) keep excluded SKUs so they stay assignable, +# unless the SKU has been explicitly hidden from the dropdown (ShowInLicenseDropdown = $false). +# Regression guard for free/self-service SKUs (e.g. Power Automate Free) vanishing from the +# add-license picker because they ship as default reporting exclusions. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Get-CIPPLicenseOverview.ps1' + + # ConversionTable.csv is read directly off disk (not via a mockable command), so point the + # function at the real backend config. Pretty-name resolution falls back to skuPartNumber for + # SKUs missing from the CSV, which is all this test relies on. + $env:CIPPRootPath = $RepoRoot + + # Minimal stubs so Mock has commands to replace (only Get-CIPPLicenseOverview is dot-sourced, + # not the whole module). + function New-GraphGetRequest { param($uri, $scope, $TenantID, $AsApp) } + function New-GraphBulkRequest { param($Requests, $TenantID, $asapp) } + function Get-CIPPTable { param($TableName) } + function Get-CIPPAzDataTableEntity { param($Table) } + function Initialize-CIPPExcludedLicenses { } + + . $FunctionPath + + $script:FlowFreeGuid = 'f30db892-07e9-47e9-837c-80727f46fd3d' # Microsoft Power Automate Free (default exclusion) + $script:E5Guid = '06ebc4ee-1bb5-47dd-8120-11324bc54e06' # SPE_E5 (not excluded) + + # Builds the New-GraphBulkRequest response shape from a set of subscribedSkus. + function New-BulkResult { + param($Skus) + @( + [pscustomobject]@{ id = 'subscribedSkus'; body = [pscustomobject]@{ value = @($Skus) } } + [pscustomobject]@{ id = 'directorySubscriptions'; body = [pscustomobject]@{ value = @() } } + [pscustomobject]@{ id = 'licensedUsers'; body = [pscustomobject]@{ value = @() } } + [pscustomobject]@{ id = 'licensedGroups'; body = [pscustomobject]@{ value = @() } } + ) + } + + function New-Sku { + param($SkuId, $PartNumber) + [pscustomobject]@{ + skuId = $SkuId + skuPartNumber = $PartNumber + consumedUnits = 1 + prepaidUnits = [pscustomobject]@{ enabled = 10000 } + subscriptionIds = @() + servicePlans = @() + } + } +} + +Describe 'Get-CIPPLicenseOverview exclusion/dropdown gate' { + BeforeEach { + $script:Tenant = 'contoso.onmicrosoft.com' + Mock -CommandName New-GraphGetRequest -MockWith { @() } + Mock -CommandName Get-CIPPTable -MockWith { @{ TableName = 'ExcludedLicenses' } } + Mock -CommandName Initialize-CIPPExcludedLicenses -MockWith { } + Mock -CommandName New-GraphBulkRequest -MockWith { + New-BulkResult -Skus @( + (New-Sku -SkuId $script:FlowFreeGuid -PartNumber 'FLOW_FREE'), + (New-Sku -SkuId $script:E5Guid -PartNumber 'SPE_E5') + ) + } + } + + It 'keeps a default-excluded free SKU in the picker (IncludeExcluded) so it stays assignable' { + # Default-config exclusion: GUID present, no ExcludedEverywhere / ShowInLicenseDropdown set. + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ GUID = $script:FlowFreeGuid; Product_Display_Name = 'Microsoft Power Automate Free' }) + } + + $Result = Get-CIPPLicenseOverview -TenantFilter $script:Tenant -IncludeExcluded + + @($Result).skuId | Should -Contain $script:FlowFreeGuid + @($Result).skuId | Should -Contain $script:E5Guid + } + + It 'drops the excluded SKU from reporting callers (no IncludeExcluded)' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ GUID = $script:FlowFreeGuid; Product_Display_Name = 'Microsoft Power Automate Free' }) + } + + $Result = Get-CIPPLicenseOverview -TenantFilter $script:Tenant + + @($Result).skuId | Should -Not -Contain $script:FlowFreeGuid + @($Result).skuId | Should -Contain $script:E5Guid + } + + It 'hides an excluded SKU from the picker only when ShowInLicenseDropdown is explicitly false' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ + GUID = $script:FlowFreeGuid + Product_Display_Name = 'Microsoft Power Automate Free' + ShowInLicenseDropdown = $false + }) + } + + $Result = Get-CIPPLicenseOverview -TenantFilter $script:Tenant -IncludeExcluded + + @($Result).skuId | Should -Not -Contain $script:FlowFreeGuid + @($Result).skuId | Should -Contain $script:E5Guid + } + + It 'treats ExcludedEverywhere = false as alert-only, leaving the SKU visible to reporting' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ + GUID = $script:FlowFreeGuid + Product_Display_Name = 'Microsoft Power Automate Free' + ExcludedEverywhere = $false + }) + } + + $Result = Get-CIPPLicenseOverview -TenantFilter $script:Tenant + + @($Result).skuId | Should -Contain $script:FlowFreeGuid + } +} diff --git a/Tests/Scheduler/Add-CIPPScheduledTask.TenantCoercion.Tests.ps1 b/Tests/Scheduler/Add-CIPPScheduledTask.TenantCoercion.Tests.ps1 new file mode 100644 index 0000000000000..8949e1027e75c --- /dev/null +++ b/Tests/Scheduler/Add-CIPPScheduledTask.TenantCoercion.Tests.ps1 @@ -0,0 +1,143 @@ +# Regression tests for the scheduler tenant-authorization gap (INC-2026-003 Finding 3). +# +# A scheduled command's own tenant parameter (Tenant / TenantId, or an explicit TenantFilter in the +# stored Parameters) used to be persisted verbatim and executed with no authorization check against +# the caller's allowed-tenant scope. Only the picker's TenantFilter was authorized. This let a task +# created against tenant A carry a Parameters.Tenant of tenant B and run unchecked. +# +# Creation-time defense (this file): Add-CIPPScheduledTask strips any tenant-identifying parameter +# from the stored Parameters and logs at Error when the stored value pointed at a different tenant +# than the picked one. The authorized tenant is injected at execution instead (see the companion +# Push-ExecScheduledCommand.TenantCoercion.Tests.ps1). + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1' + + # Stubs so Mock has commands to replace. + function Get-CIPPTable { param($TableName) } + function Get-CIPPAzDataTableEntity { param($Context, $Filter, $Property) } + function Add-CIPPAzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function Update-AzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function Add-CippQueueMessage { param($Cmdlet, $Parameters) } + function Get-CIPPSchedulerBlockedCommands { @() } + function Get-NormalizedError { param($Message) $Message } + function Write-LogMessage { param($headers, $API, $message, $Sev, $tenant, $tenantid, $LogData) } + function New-CIPPTaskDeltaQuery { param($Trigger, $TenantFilter, $PartitionKey) } + + . $FunctionPath + + # Build a synthetic Get-Command result: the real Add-CIPPScheduledTask gates on $Command.Module + # (must be an allowed CIPP module) and reads $Command.Parameters.ContainsKey(...). Rather than + # register real functions in a fake module, hand back an object with just those two surfaces. + function New-FakeCommand { + param([string]$Module = 'CIPPCore', [string[]]$ParamNames) + $params = @{} + foreach ($p in $ParamNames) { $params[$p] = [pscustomobject]@{ Name = $p } } + [pscustomobject]@{ Module = $Module; Parameters = $params } + } + + # Capture what actually gets written to the ScheduledTasks table. + $script:CapturedEntity = $null + $script:LoggedErrors = [System.Collections.Generic.List[string]]::new() +} + +Describe 'Add-CIPPScheduledTask tenant-parameter coercion' { + BeforeEach { + $script:CapturedEntity = $null + $script:LoggedErrors = [System.Collections.Generic.List[string]]::new() + Mock -CommandName Get-CIPPTable -MockWith { @{ Context = 'stub' } } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { $null } + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { $script:CapturedEntity = $Entity } + Mock -CommandName Update-AzDataTableEntity -MockWith { } + Mock -CommandName Add-CippQueueMessage -MockWith { } + Mock -CommandName Write-LogMessage -MockWith { + if ($Sev -eq 'Error') { $script:LoggedErrors.Add([string]$message) } + } + # Default: a command that declares its own -Tenant parameter (the gap shape). Individual + # tests override this for the no-tenant-parameter case. + Mock -CommandName Get-Command -MockWith { + New-FakeCommand -ParamNames @('TenantFilter', 'AuthenticationMethodId', 'Enabled') + } + } + + It 'strips a mismatched Tenant parameter so it is never persisted' { + $Task = [pscustomobject]@{ + Name = 'Evil cross-tenant task' + Command = 'Set-CIPPAuthenticationPolicy' + TenantFilter = 'authorized.onmicrosoft.com' + Parameters = [pscustomobject]@{ + Tenant = 'victim.onmicrosoft.com' + AuthenticationMethodId = 'SMS' + Enabled = $true + } + } + + Add-CIPPScheduledTask -Task $Task + + $script:CapturedEntity | Should -Not -BeNullOrEmpty + $StoredParams = $script:CapturedEntity.Parameters | ConvertFrom-Json + # The tenant-identifying key must be gone; the benign parameters survive. + $StoredParams.PSObject.Properties.Name | Should -Not -Contain 'Tenant' + $StoredParams.AuthenticationMethodId | Should -Be 'SMS' + # The task's own tenant scope is still the authorized picker value. + $script:CapturedEntity.Tenant | Should -Be 'authorized.onmicrosoft.com' + } + + It 'logs an Error naming both tenants when the stored value points elsewhere' { + $Task = [pscustomobject]@{ + Name = 'Evil cross-tenant task' + Command = 'Set-CIPPAuthenticationPolicy' + TenantFilter = 'authorized.onmicrosoft.com' + Parameters = [pscustomobject]@{ + Tenant = 'victim.onmicrosoft.com' + AuthenticationMethodId = 'SMS' + Enabled = $true + } + } + + Add-CIPPScheduledTask -Task $Task + + $script:LoggedErrors.Count | Should -BeGreaterThan 0 + ($script:LoggedErrors -join "`n") | Should -Match 'victim\.onmicrosoft\.com' + ($script:LoggedErrors -join "`n") | Should -Match 'authorized\.onmicrosoft\.com' + } + + It 'strips a matching Tenant parameter silently (no Error) since execution re-injects it' { + $Task = [pscustomobject]@{ + Name = 'Legit task' + Command = 'Set-CIPPAuthenticationPolicy' + TenantFilter = 'authorized.onmicrosoft.com' + Parameters = [pscustomobject]@{ + Tenant = 'authorized.onmicrosoft.com' + AuthenticationMethodId = 'SMS' + Enabled = $true + } + } + + Add-CIPPScheduledTask -Task $Task + + $StoredParams = $script:CapturedEntity.Parameters | ConvertFrom-Json + $StoredParams.PSObject.Properties.Name | Should -Not -Contain 'Tenant' + $script:LoggedErrors.Count | Should -Be 0 + } + + It 'leaves non-tenant parameters untouched for a command with no tenant parameter' { + function Get-CIPPHarmlessThing { param($Foo, $Bar) } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { $null } + + $Task = [pscustomobject]@{ + Name = 'Harmless task' + Command = 'Get-CIPPHarmlessThing' + TenantFilter = 'authorized.onmicrosoft.com' + Parameters = [pscustomobject]@{ Foo = 'a'; Bar = 'b' } + } + + Add-CIPPScheduledTask -Task $Task + + $StoredParams = $script:CapturedEntity.Parameters | ConvertFrom-Json + $StoredParams.Foo | Should -Be 'a' + $StoredParams.Bar | Should -Be 'b' + $script:LoggedErrors.Count | Should -Be 0 + } +} diff --git a/Tests/Scheduler/Add-CIPPScheduledTask.TenantSelection.Tests.ps1 b/Tests/Scheduler/Add-CIPPScheduledTask.TenantSelection.Tests.ps1 new file mode 100644 index 0000000000000..8a767e9cebfe2 --- /dev/null +++ b/Tests/Scheduler/Add-CIPPScheduledTask.TenantSelection.Tests.ps1 @@ -0,0 +1,95 @@ +# Pins the storage contract for a multi-entry tenant selection on a scheduled task. +# +# The selection is stored verbatim for Start-UserTasksOrchestrator to expand, and +# TenantSelectionVersion marks excludedTenants as the operator's own picks rather than the snapshot +# older rows carry. It must land in the single task write - the selection used to be added by a +# second table call afterwards, so a failure there left an alert scoped to every tenant. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1' + + # Stubs so Mock has commands to replace. + function Get-CIPPTable { param($TableName) } + function Get-CIPPAzDataTableEntity { param($Context, $Filter, $Property) } + function Add-CIPPAzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function Update-AzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function Add-CippQueueMessage { param($Cmdlet, $Parameters) } + function Get-CIPPSchedulerBlockedCommands { @() } + function Get-NormalizedError { param($Message) $Message } + function Write-LogMessage { param($headers, $API, $message, $Sev, $tenant, $tenantid, $LogData) } + function New-CIPPTaskDeltaQuery { param($Trigger, $TenantFilter, $PartitionKey) } + + . $FunctionPath + + function New-FakeCommand { + param([string]$Module = 'CIPPCore', [string[]]$ParamNames) + $params = @{} + foreach ($p in $ParamNames) { $params[$p] = [pscustomobject]@{ Name = $p } } + [pscustomobject]@{ Module = $Module; Parameters = $params } + } + + function New-SelectionTask { + param($Tenants) + $Task = [pscustomobject]@{ + Name = 'Scripted alert fixture' + Command = 'Get-CIPPAlertFixture' + TenantFilter = [pscustomobject]@{ value = 'AllTenants'; label = '*All Tenants'; type = 'Tenant' } + Parameters = [pscustomobject]@{ Threshold = 5 } + } + if ($Tenants) { $Task | Add-Member -MemberType NoteProperty -Name 'Tenants' -Value $Tenants } + $Task + } +} + +Describe 'Add-CIPPScheduledTask tenant selection storage' { + BeforeEach { + $script:Persisted = [System.Collections.Generic.List[object]]::new() + Mock -CommandName Get-CIPPTable -MockWith { @{ Context = 'stub' } } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { $null } + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { $script:Persisted.Add($Entity) } + Mock -CommandName Update-AzDataTableEntity -MockWith { } + Mock -CommandName Add-CippQueueMessage -MockWith { } + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Get-Command -MockWith { + New-FakeCommand -ParamNames @('TenantFilter', 'Threshold') + } + } + + It 'persists the selection and the version marker in the same entity as the task' { + $Selection = @( + [pscustomobject]@{ value = 'group-1'; label = 'Group 1'; type = 'Group' } + [pscustomobject]@{ value = 'group-2'; label = 'Group 2'; type = 'Group' } + ) + + Add-CIPPScheduledTask -Task (New-SelectionTask -Tenants $Selection) + + $script:Persisted | Should -HaveCount 1 + $Entity = $script:Persisted[0] + $Entity.TenantSelectionVersion | Should -Be 2 + $Entity.Tenant | Should -Be 'AllTenants' + + $Stored = @($Entity.Tenants | ConvertFrom-Json) + $Stored | Should -HaveCount 2 + $Stored.value | Should -Contain 'group-1' + $Stored.value | Should -Contain 'group-2' + } + + It 'leaves an already-serialized selection alone so a restored backup is not double-encoded' { + $Json = '[{"value":"group-1","label":"Group 1","type":"Group"}]' + + Add-CIPPScheduledTask -Task (New-SelectionTask -Tenants $Json) + + $script:Persisted[0].Tenants | Should -Be $Json + } + + It 'writes no selection or marker for a single-tenant task' { + # The entity write is a replace, so omitting both clears them when an alert is edited down + # to one tenant. + Add-CIPPScheduledTask -Task (New-SelectionTask) + + $Entity = $script:Persisted[0] + $Entity.ContainsKey('Tenants') | Should -BeFalse + $Entity.ContainsKey('TenantSelectionVersion') | Should -BeFalse + } +} diff --git a/Tests/Scheduler/Get-CIPPScheduledTaskNextRun.Tests.ps1 b/Tests/Scheduler/Get-CIPPScheduledTaskNextRun.Tests.ps1 new file mode 100644 index 0000000000000..4e68e730c7d31 --- /dev/null +++ b/Tests/Scheduler/Get-CIPPScheduledTaskNextRun.Tests.ps1 @@ -0,0 +1,41 @@ +# Recurrence parsing for scheduled tasks. The orchestrator uses this to close out a run that had no +# tenants in scope; 0 means the task does not repeat and should be completed instead of rescheduled. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Get-CIPPScheduledTaskNextRun.ps1') + + function Get-UnixNow { [int64](([datetime]::UtcNow) - (Get-Date '1/1/1970')).TotalSeconds } +} + +Describe 'Get-CIPPScheduledTaskNextRun' { + It 'adds the interval to the last scheduled time' { + $Last = (Get-UnixNow) - 60 + Get-CIPPScheduledTaskNextRun -Recurrence '1d' -ScheduledTime $Last | Should -Be ($Last + 86400) + } + + It 'parses minutes and hours' { + $Last = (Get-UnixNow) - 60 + Get-CIPPScheduledTaskNextRun -Recurrence '30m' -ScheduledTime $Last | Should -Be ($Last + 1800) + Get-CIPPScheduledTaskNextRun -Recurrence '4h' -ScheduledTime $Last | Should -Be ($Last + 14400) + } + + It 'treats a bare number as days, the shape older tasks carry' { + $Last = (Get-UnixNow) - 60 + Get-CIPPScheduledTaskNextRun -Recurrence '7' -ScheduledTime $Last | Should -Be ($Last + 604800) + } + + It 'returns 0 for a task that does not repeat' { + Get-CIPPScheduledTaskNextRun -Recurrence '0' -ScheduledTime 1 | Should -Be 0 + Get-CIPPScheduledTaskNextRun -Recurrence $null -ScheduledTime 1 | Should -Be 0 + Get-CIPPScheduledTaskNextRun -Recurrence 'never' -ScheduledTime 1 | Should -Be 0 + } + + It 'does not replay a backlog when the last run is far in the past' { + # A task stuck or disabled for a year must schedule one run from now, not catch up. + $Now = Get-UnixNow + $Next = Get-CIPPScheduledTaskNextRun -Recurrence '1d' -ScheduledTime 1 + $Next | Should -BeGreaterOrEqual ($Now + 86400) + $Next | Should -BeLessOrEqual ($Now + 86400 + 5) + } +} diff --git a/Tests/Scheduler/Push-ExecScheduledCommand.TenantCoercion.Tests.ps1 b/Tests/Scheduler/Push-ExecScheduledCommand.TenantCoercion.Tests.ps1 new file mode 100644 index 0000000000000..2fe31294c847d --- /dev/null +++ b/Tests/Scheduler/Push-ExecScheduledCommand.TenantCoercion.Tests.ps1 @@ -0,0 +1,128 @@ +# Regression tests for the scheduler tenant-authorization gap (INC-2026-003 Finding 3), execution side. +# +# Even if a stored task somehow carries a tenant-identifying parameter (legacy rows created before the +# creation-time strip, or a direct table write), Push-ExecScheduledCommand must force every tenant +# parameter the command declares to the task's own authorized tenant before invoking it. This is the +# class-closing defense: it protects every current and future cmdlet regardless of its parameter name. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Push-ExecScheduledCommand.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Push-ExecScheduledCommand.ps1 under Modules/' } + + # Stubs for the module surface Push-ExecScheduledCommand touches. + function Set-CippScheduledTaskContext { param($TaskId) } + function Set-CippUserAgentContext { param($Headers, $Source, $TaskId) } + function Get-CippTable { param($tablename) } + function Get-AzDataTableEntity { param($Context, $Filter) } + function Update-AzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function Add-CIPPAzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function Get-Tenants { param($TenantFilter) } + function Get-CIPPSchedulerBlockedCommands { @() } + function Write-LogMessage { param($headers, $API, $message, $sev, $tenant, $tenantid, $LogData) } + function Send-CIPPScheduledTaskAlert { param($Results, $TaskInfo, $TenantFilter, $TaskType, $Attachments) } + function Get-CippException { param($Exception) @{ NormalizedError = "$Exception" } } + + # The command under test records exactly which tenant it was invoked against. It declares its own + # -Tenant (the report-cmdlet shape that is NOT alias-renamed, so the parameter really is 'Tenant' + # and survives the SUT's unknown-parameter strip). + $script:InvokedWith = $null + function Get-CIPPTenantScopedReport { + [CmdletBinding()] + param([Parameter(Mandatory = $true)]$Tenant, $AuthenticationMethodId, $Enabled) + $script:InvokedWith = @{ Tenant = $Tenant; AuthenticationMethodId = $AuthenticationMethodId } + return 'ok' + } + + # The SUT reads $Command.Module (must be an allowed CIPP module) and $Command.Parameters for the + # unknown-parameter strip and the tenant coercion, but invokes the command by name string. Mock + # Get-Command so the real in-scope function is what actually runs, while the metadata gate passes. + function New-FakeCommand { + param([string]$Module = 'CIPPCore', [string[]]$ParamNames) + $params = @{} + foreach ($p in $ParamNames) { $params[$p] = [pscustomobject]@{ Name = $p } } + [pscustomobject]@{ Module = $Module; Parameters = $params } + } + + . $FunctionPath +} + +Describe 'Push-ExecScheduledCommand tenant-parameter coercion' { + BeforeEach { + $script:InvokedWith = $null + Mock -CommandName Set-CippScheduledTaskContext -MockWith { } + Mock -CommandName Set-CippUserAgentContext -MockWith { } + Mock -CommandName Get-CippTable -MockWith { @{ Context = 'stub' } } + Mock -CommandName Update-AzDataTableEntity -MockWith { } + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { } + Mock -CommandName Get-Tenants -MockWith { [pscustomobject]@{ customerId = 'cust-authorized' } } + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Send-CIPPScheduledTaskAlert -MockWith { } + # A live, non-completed task row so execution proceeds. + Mock -CommandName Get-AzDataTableEntity -MockWith { + [pscustomobject]@{ PartitionKey = 'ScheduledTask'; RowKey = 'task-1'; TaskState = 'Running' } + } + # Metadata for the module gate + parameter strip; the real function runs via & by name. + Mock -CommandName Get-Command -MockWith { + New-FakeCommand -ParamNames @('Tenant', 'AuthenticationMethodId', 'Enabled') + } -ParameterFilter { $Name -eq 'Get-CIPPTenantScopedReport' } + + $script:BaseItem = @{ + Command = 'Get-CIPPTenantScopedReport' + TaskInfo = [pscustomobject]@{ + PartitionKey = 'ScheduledTask' + RowKey = 'task-1' + Name = 'Auth policy task' + Tenant = 'authorized.onmicrosoft.com' + Recurrence = '0' + } + } + } + + It 'overrides a mismatched stored Tenant with the authorized task tenant' { + $Item = $script:BaseItem.Clone() + $Item.Parameters = @{ + Tenant = 'victim.onmicrosoft.com' + AuthenticationMethodId = 'SMS' + Enabled = $true + } + + Push-ExecScheduledCommand -Item ([pscustomobject]$Item) + + $script:InvokedWith | Should -Not -BeNullOrEmpty + # The command must have run against the task's authorized tenant, never the stored one. + $script:InvokedWith.Tenant | Should -Be 'authorized.onmicrosoft.com' + $script:InvokedWith.Tenant | Should -Not -Be 'victim.onmicrosoft.com' + } + + It 'logs an Error when the stored tenant value differed from the authorized tenant' { + $Item = $script:BaseItem.Clone() + $Item.Parameters = @{ + Tenant = 'victim.onmicrosoft.com' + AuthenticationMethodId = 'SMS' + Enabled = $true + } + + Push-ExecScheduledCommand -Item ([pscustomobject]$Item) + + Should -Invoke -CommandName Write-LogMessage -Times 1 -ParameterFilter { + $sev -eq 'Error' -and $message -match 'victim\.onmicrosoft\.com' -and $message -match 'authorized\.onmicrosoft\.com' + } + } + + It 'runs cleanly with no Error log when no tenant parameter is stored' { + $Item = $script:BaseItem.Clone() + $Item.Parameters = @{ + AuthenticationMethodId = 'SMS' + Enabled = $true + } + + Push-ExecScheduledCommand -Item ([pscustomobject]$Item) + + $script:InvokedWith.Tenant | Should -Be 'authorized.onmicrosoft.com' + Should -Invoke -CommandName Write-LogMessage -Times 0 -ParameterFilter { + $sev -eq 'Error' -and $message -match 'does not match the authorized tenant' + } + } +} diff --git a/Tests/Scheduler/Start-UpdateTokensTimer.CertificateAuth.Tests.ps1 b/Tests/Scheduler/Start-UpdateTokensTimer.CertificateAuth.Tests.ps1 new file mode 100644 index 0000000000000..cbf22c9ba9859 --- /dev/null +++ b/Tests/Scheduler/Start-UpdateTokensTimer.CertificateAuth.Tests.ps1 @@ -0,0 +1,69 @@ +# The weekly token timer renews the SAM app's client secret. In certificate-exclusive mode CIPP adds +# no secret, so the timer must NOT call addPassword - otherwise it fails on tenants blocking password +# addition, or silently re-creates a secret on a secret-less install, defeating the feature. Pinned here. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + + function Get-GraphToken { param([switch]$ReturnRefresh, $TenantId) } + function Get-CIPPTable { param($tablename) } + function Get-CIPPAzDataTableEntity { param($Context, $Filter) } + function Get-Tenants { param([switch]$IncludeAll, [switch]$SkipList) } + function Add-AzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function New-GraphGetRequest { param($uri, $NoAuthCheck, $AsApp) } + function New-GraphPostRequest { param($uri, $type, $Body, $NoAuthCheck, $AsApp) } + function Update-AppManagementPolicy {} + function Update-CIPPSAMCertificate {} + function Write-LogMessage { param($API, $message, $sev, $tenant, $tenantid, $LogData) } + function Get-CippException { param($Exception) } + + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1') +} + +Describe 'Start-UpdateTokensTimer certificate mode' { + BeforeEach { + $script:SavedEnv = @{} + foreach ($Name in 'CertificateAuthMode', 'ApplicationID', 'TenantID', 'AzureWebJobsStorage') { $script:SavedEnv[$Name] = [Environment]::GetEnvironmentVariable($Name) } + $env:ApplicationID = 'sam-app-id' + $env:TenantID = '11111111-2222-3333-4444-555555555555' + $env:AzureWebJobsStorage = 'UseDevelopmentStorage=true' # dev branch - no Key Vault + + Mock Get-GraphToken { @{ Refresh_token = 'refresh-token' } } + Mock Get-CIPPTable { @{} } + Mock Get-CIPPAzDataTableEntity { $null } # no dev secret row, no direct tenants + Mock Get-Tenants { @() } # no direct tenants to refresh + Mock Add-AzDataTableEntity {} + # Secret-less install: the app registration has no password credentials. + Mock New-GraphGetRequest { + [pscustomobject]@{ id = 'app-obj-id'; passwordCredentials = @(); servicePrincipalLockConfiguration = [pscustomobject]@{ isEnabled = $true } } + } + Mock New-GraphPostRequest { [pscustomobject]@{ secretText = 's'; keyId = 'k'; endDateTime = (Get-Date).AddYears(1) } } + Mock Update-AppManagementPolicy { [pscustomobject]@{ PolicyAction = 'none' } } + Mock Update-CIPPSAMCertificate { [pscustomobject]@{ Renewed = $false; Thumbprint = 'abc'; NotAfter = (Get-Date).AddYears(1) } } + Mock Write-LogMessage {} + Mock Get-CippException { @{} } + } + + AfterEach { + foreach ($Name in $script:SavedEnv.Keys) { + if ($null -eq $script:SavedEnv[$Name]) { Remove-Item "env:$Name" -ErrorAction SilentlyContinue } + else { Set-Item "env:$Name" -Value $script:SavedEnv[$Name] } + } + } + + It 'does NOT generate a client secret when certificate mode is on' { + $env:CertificateAuthMode = $true + + Start-UpdateTokensTimer -Confirm:$false + + Should -Invoke New-GraphPostRequest -Times 0 -Exactly -ParameterFilter { $uri -match 'addPassword' } + } + + It 'still generates a client secret when certificate mode is off (guard proven)' { + Remove-Item env:CertificateAuthMode -ErrorAction SilentlyContinue + + Start-UpdateTokensTimer -Confirm:$false + + Should -Invoke New-GraphPostRequest -Times 1 -Exactly -ParameterFilter { $uri -match 'addPassword' } + } +} diff --git a/Tests/Scheduler/Start-UserTasksOrchestrator.TenantGroups.Tests.ps1 b/Tests/Scheduler/Start-UserTasksOrchestrator.TenantGroups.Tests.ps1 new file mode 100644 index 0000000000000..5766fa847f4f5 --- /dev/null +++ b/Tests/Scheduler/Start-UserTasksOrchestrator.TenantGroups.Tests.ps1 @@ -0,0 +1,353 @@ +# Regression tests for scripted-alert tenant scope. +# +# Groups used to be expanded at save time, with the complement of the selection frozen into +# excludedTenants, so a tenant joining a targeted group afterwards never received the alert. Scope is +# now resolved here on every run from the verbatim Tenants selection. Rows written by the old code +# lack TenantSelectionVersion; their excludedTenants is that snapshot and is ignored, while +# excludedTenantGroups was never part of it and always applies. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Start-UserTasksOrchestrator.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Start-UserTasksOrchestrator.ps1 under Modules/' } + + # Stubs so Mock has commands to replace. + function Get-CippTable { param($tablename) } + function Get-CIPPAzDataTableEntity { param($Context, $Filter, $Property) } + function Update-AzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function Get-Tenants { param($TenantFilter, [switch]$IncludeErrors) } + function Get-CIPPSchedulerBlockedCommands { @() } + function Expand-CIPPTenantGroups { param($TenantFilter) } + function New-CippQueueEntry { param($Name, $Reference, $TotalTasks) } + function Start-CIPPOrchestrator { param($InputObject) } + function Get-CIPPScheduledTaskNextRun { param($Recurrence, $ScheduledTime) } + # Parameter names bind case-insensitively, so one $Sev covers the SUT's -Sev and -sev calls. + function Write-LogMessage { param($headers, $API, $message, $Sev, $tenant, $tenantid, $LogData) } + + # A real function, so the SUT's Get-Command lookup resolves without mocking Pester's own + # Get-Command. It declares TenantFilter, which is what drives the per-tenant parameter stamping. + function Get-CIPPAlertFixture { param($TenantFilter, $Threshold) } + + . $FunctionPath + + # Four managed tenants; group-1 holds a + b, group-2 holds c, group-excluded holds c. + function New-TenantList { + @( + [pscustomobject]@{ defaultDomainName = 'a.onmicrosoft.com'; customerId = 'cust-a'; displayName = 'A' } + [pscustomobject]@{ defaultDomainName = 'b.onmicrosoft.com'; customerId = 'cust-b'; displayName = 'B' } + [pscustomobject]@{ defaultDomainName = 'c.onmicrosoft.com'; customerId = 'cust-c'; displayName = 'C' } + [pscustomobject]@{ defaultDomainName = 'd.onmicrosoft.com'; customerId = 'cust-d'; displayName = 'D' } + ) + } + + function New-TaskRow { + param([hashtable]$Overrides = @{}) + $Row = @{ + PartitionKey = 'ScheduledTask' + RowKey = 'task-1' + Name = 'Scripted alert fixture' + Command = 'Get-CIPPAlertFixture' + Parameters = '{}' + ScheduledTime = 1 + TaskState = 'Planned' + Recurrence = '0' + Tenant = 'AllTenants' + ETag = 'etag-1' + } + foreach ($Key in $Overrides.Keys) { $Row[$Key] = $Overrides[$Key] } + [pscustomobject]$Row + } + + # The tenant each fanned-out command was stamped with, in batch order. + function Get-ScopedTenants { + @($script:StartedBatches | ForEach-Object { $_.Parameters.TenantFilter }) + } + + # The two groups a multi-select alert stores verbatim. + $script:TwoGroupSelection = ConvertTo-Json -Compress -Depth 5 -InputObject @( + [pscustomobject]@{ value = 'group-1'; label = 'Group 1'; type = 'Group' } + [pscustomobject]@{ value = 'group-2'; label = 'Group 2'; type = 'Group' } + ) +} + +Describe 'Start-UserTasksOrchestrator tenant scope resolution' { + BeforeEach { + $script:StartedBatches = [System.Collections.Generic.List[object]]::new() + $script:LoggedMessages = [System.Collections.Generic.List[string]]::new() + $script:TaskUpdates = [System.Collections.Generic.List[object]]::new() + + Mock -CommandName Get-CippTable -MockWith { @{ Context = 'stub' } } + Mock -CommandName Update-AzDataTableEntity -MockWith { $script:TaskUpdates.Add($Entity) } + Mock -CommandName Get-CIPPScheduledTaskNextRun -MockWith { 0 } + Mock -CommandName Get-CIPPSchedulerBlockedCommands -MockWith { @() } + Mock -CommandName New-CippQueueEntry -MockWith { [pscustomobject]@{ RowKey = 'queue-1' } } + Mock -CommandName Get-Tenants -MockWith { New-TenantList } + Mock -CommandName Write-LogMessage -MockWith { + $script:LoggedMessages.Add([string]$message) + } + Mock -CommandName Start-CIPPOrchestrator -MockWith { + foreach ($Item in @($InputObject.Batch)) { $script:StartedBatches.Add($Item) } + } + # Mirrors the real helper: group entries expand to their members, everything else - the + # AllTenants sentinel included - passes through untouched. + Mock -CommandName Expand-CIPPTenantGroups -MockWith { + foreach ($Entry in @($TenantFilter)) { + switch ($Entry.value) { + 'group-1' { + [pscustomobject]@{ value = 'a.onmicrosoft.com'; type = 'Tenant' } + [pscustomobject]@{ value = 'b.onmicrosoft.com'; type = 'Tenant' } + } + 'group-2' { [pscustomobject]@{ value = 'c.onmicrosoft.com'; type = 'Tenant' } } + 'group-excluded' { [pscustomobject]@{ value = 'c.onmicrosoft.com'; type = 'Tenant' } } + 'group-empty' { } + default { $Entry } + } + } + } + } + + It 'includes a group member that a legacy snapshot still lists as excluded' { + # The reported bug: b joined group-1 after the alert was saved, so it sits in the frozen + # complement. Without TenantSelectionVersion that column is a snapshot and must not apply. + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + New-TaskRow @{ + Tenants = $script:TwoGroupSelection + excludedTenants = 'b.onmicrosoft.com,d.onmicrosoft.com' + } + } + + Start-UserTasksOrchestrator + + $Scoped = Get-ScopedTenants + $Scoped | Should -Contain 'b.onmicrosoft.com' + $Scoped | Should -Contain 'a.onmicrosoft.com' + $Scoped | Should -Contain 'c.onmicrosoft.com' + # d is in neither group, so it is out of scope on the selection alone. + $Scoped | Should -Not -Contain 'd.onmicrosoft.com' + } + + It 'logs only the snapshot exclusions that were actually in scope' { + # b is in group-1 and was being wrongly excluded; d is in neither group, so dropping it from + # the snapshot changes nothing and is not worth reporting. + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + New-TaskRow @{ + Tenants = $script:TwoGroupSelection + excludedTenants = 'b.onmicrosoft.com,d.onmicrosoft.com' + } + } + + Start-UserTasksOrchestrator + + ($script:LoggedMessages -join "`n") | Should -Match 'ignored 1 stale snapshot exclusions' + } + + It 'stays quiet when a legacy snapshot would not have changed the outcome' { + # Otherwise every legacy row logs the same no-op on every run, forever. + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + New-TaskRow @{ + Tenants = $script:TwoGroupSelection + excludedTenants = 'd.onmicrosoft.com' + } + } + + Start-UserTasksOrchestrator + + ($script:LoggedMessages -join "`n") | Should -Not -Match 'stale snapshot exclusions' + } + + It 'applies excludedTenants on a versioned row' { + # Written by the current code, so the column holds only the operator's own picks. + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + New-TaskRow @{ + Tenants = $script:TwoGroupSelection + TenantSelectionVersion = 2 + excludedTenants = 'b.onmicrosoft.com' + } + } + + Start-UserTasksOrchestrator + + $Scoped = Get-ScopedTenants + $Scoped | Should -Not -Contain 'b.onmicrosoft.com' + $Scoped | Should -Contain 'a.onmicrosoft.com' + $Scoped | Should -Contain 'c.onmicrosoft.com' + } + + It 'expands excludedTenantGroups on a legacy row, since it was never part of the snapshot' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + New-TaskRow @{ + Tenants = $script:TwoGroupSelection + excludedTenants = 'b.onmicrosoft.com' + excludedTenantGroups = (ConvertTo-Json -Compress -Depth 5 -InputObject @( + [pscustomobject]@{ value = 'group-excluded'; label = 'Excluded'; type = 'Group' })) + } + } + + Start-UserTasksOrchestrator + + $Scoped = Get-ScopedTenants + # c is excluded via the group; b is not, because the snapshot column is ignored. + $Scoped | Should -Not -Contain 'c.onmicrosoft.com' + $Scoped | Should -Contain 'b.onmicrosoft.com' + } + + It 'fans out to every tenant when the selection carries the AllTenants sentinel' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + New-TaskRow @{ + Tenants = (ConvertTo-Json -Compress -Depth 5 -InputObject @( + [pscustomobject]@{ value = 'AllTenants'; label = '*All Tenants'; type = 'Tenant' } + [pscustomobject]@{ value = 'group-1'; label = 'Group 1'; type = 'Group' })) + TenantSelectionVersion = 2 + } + } + + Start-UserTasksOrchestrator + + Get-ScopedTenants | Should -HaveCount 4 + } + + It 'still resolves a single stored group when no Tenants column is present' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + New-TaskRow @{ + Tenant = 'group-1' + TenantGroup = '{"value":"group-1","label":"Group 1","type":"Group"}' + } + } + + Start-UserTasksOrchestrator + + $Scoped = Get-ScopedTenants + $Scoped | Should -HaveCount 2 + $Scoped | Should -Contain 'a.onmicrosoft.com' + $Scoped | Should -Contain 'b.onmicrosoft.com' + } + + It 'keeps operator exclusions on a legacy selection that includes AllTenants' { + # The old save path skipped the complement when the selection carried the sentinel, so these + # exclusions are the operator's own and must survive despite the missing version marker. + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + New-TaskRow @{ + Tenants = (ConvertTo-Json -Compress -Depth 5 -InputObject @( + [pscustomobject]@{ value = 'AllTenants'; label = '*All Tenants'; type = 'Tenant' } + [pscustomobject]@{ value = 'group-1'; label = 'Group 1'; type = 'Group' })) + excludedTenants = 'b.onmicrosoft.com' + } + } + + Start-UserTasksOrchestrator + + $Scoped = Get-ScopedTenants + $Scoped | Should -HaveCount 3 + $Scoped | Should -Not -Contain 'b.onmicrosoft.com' + } + + It 'fails the task rather than queuing an AllTenants run when expansion throws' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + New-TaskRow @{ Tenants = $script:TwoGroupSelection; TenantSelectionVersion = 2 } + } + Mock -CommandName Expand-CIPPTenantGroups -MockWith { throw 'tenant group store unavailable' } + + Start-UserTasksOrchestrator + + Get-ScopedTenants | Should -HaveCount 0 + $Failed = @($script:TaskUpdates | Where-Object { $_.TaskState -eq 'Failed' }) + $Failed | Should -HaveCount 1 + $Failed[0].Results | Should -Match 'Failed to expand tenant selection' + } + + It 'keeps a recurring task alive when expansion throws' { + # Failed is terminal, so parking a recurring task there on a transient table read would stop + # it permanently. + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + New-TaskRow @{ Tenants = $script:TwoGroupSelection; TenantSelectionVersion = 2; Recurrence = '1d' } + } + Mock -CommandName Expand-CIPPTenantGroups -MockWith { throw 'tenant group store unavailable' } + Mock -CommandName Get-CIPPScheduledTaskNextRun -MockWith { 1700000000 } + + Start-UserTasksOrchestrator + + $Failed = @($script:TaskUpdates | Where-Object { $_.TaskState -like 'Failed*' }) + $Failed | Should -HaveCount 1 + $Failed[0].TaskState | Should -Be 'Failed - Planned' + $Failed[0].ScheduledTime | Should -Be '1700000000' + } + + It 'ignores a stored selection on a row the execution gates read as single-tenant' { + # Tenant is not the AllTenants literal, so Push-ExecScheduledCommand would treat any fan-out + # here as a single-tenant run: no per-tenant results, and concurrent parent-row writes. + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + New-TaskRow @{ Tenant = 'a.onmicrosoft.com'; Tenants = $script:TwoGroupSelection } + } + + Start-UserTasksOrchestrator + + $Scoped = Get-ScopedTenants + $Scoped | Should -HaveCount 1 + $Scoped | Should -Contain 'a.onmicrosoft.com' + } + + It 'reschedules a recurring task whose groups all resolved empty' { + # Otherwise the row stays Pending, is reclaimed as stale every hour, and never advances. + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + New-TaskRow @{ + Tenants = (ConvertTo-Json -Compress -Depth 5 -InputObject @( + [pscustomobject]@{ value = 'group-empty'; label = 'Empty'; type = 'Group' })) + TenantSelectionVersion = 2 + Recurrence = '1d' + } + } + Mock -CommandName Get-CIPPScheduledTaskNextRun -MockWith { 1700000000 } + + Start-UserTasksOrchestrator + + Get-ScopedTenants | Should -HaveCount 0 + $Closed = @($script:TaskUpdates | Where-Object { $_.Results -eq 'No tenants in scope for this task.' }) + $Closed | Should -HaveCount 1 + $Closed[0].TaskState | Should -Be 'Planned' + $Closed[0].ScheduledTime | Should -Be '1700000000' + } + + It 'completes a one-off task whose groups all resolved empty' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + New-TaskRow @{ + Tenants = (ConvertTo-Json -Compress -Depth 5 -InputObject @( + [pscustomobject]@{ value = 'group-empty'; label = 'Empty'; type = 'Group' })) + TenantSelectionVersion = 2 + } + } + + Start-UserTasksOrchestrator + + $Closed = @($script:TaskUpdates | Where-Object { $_.Results -eq 'No tenants in scope for this task.' }) + $Closed | Should -HaveCount 1 + $Closed[0].TaskState | Should -Be 'Completed' + $Closed[0].ContainsKey('ScheduledTime') | Should -BeFalse + } + + It 'leaves a plain single-tenant task alone' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + New-TaskRow @{ Tenant = 'a.onmicrosoft.com' } + } + + Start-UserTasksOrchestrator + + $Scoped = Get-ScopedTenants + $Scoped | Should -HaveCount 1 + $Scoped | Should -Contain 'a.onmicrosoft.com' + Should -Invoke -CommandName Expand-CIPPTenantGroups -Times 0 + } + + It 'fans out to an AllTenants task that stored no selection' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + New-TaskRow @{ excludedTenants = 'd.onmicrosoft.com' } + } + + Start-UserTasksOrchestrator + + $Scoped = Get-ScopedTenants + $Scoped | Should -HaveCount 3 + # No Tenants column means no snapshot, so the exclusion is the operator's and still applies. + $Scoped | Should -Not -Contain 'd.onmicrosoft.com' + } +} diff --git a/Tests/Standards/Invoke-CIPPStandardConditionalAccessTemplate.Tests.ps1 b/Tests/Standards/Invoke-CIPPStandardConditionalAccessTemplate.Tests.ps1 index ffdc7f6f5e017..b764e37651a20 100644 --- a/Tests/Standards/Invoke-CIPPStandardConditionalAccessTemplate.Tests.ps1 +++ b/Tests/Standards/Invoke-CIPPStandardConditionalAccessTemplate.Tests.ps1 @@ -125,7 +125,7 @@ Describe 'Invoke-CIPPStandardConditionalAccessTemplate template resolution' { Invoke-CIPPStandardConditionalAccessTemplate -Tenant $script:Tenant -Settings $Settings ($script:logs | Where-Object { $_.Message -match 'could not be loaded from the template store' -and $_.Sev -eq 'Error' }) | Should -Not -BeNullOrEmpty - $script:compareFields[0].Current.Differences | Should -Match 'could not be loaded from the template store' + $script:compareFields[0].Current.Differences | Should -Match 'no longer exists in the template library' } It 'does not attempt a deployment with a null template body' { diff --git a/Tests/Standards/Invoke-CIPPStandardDisableGuests.Tests.ps1 b/Tests/Standards/Invoke-CIPPStandardDisableGuests.Tests.ps1 new file mode 100644 index 0000000000000..06b4ff7946737 --- /dev/null +++ b/Tests/Standards/Invoke-CIPPStandardDisableGuests.Tests.ps1 @@ -0,0 +1,230 @@ +# Pester tests for Invoke-CIPPStandardDisableGuests +# +# Pins the selection rules behind the "guest disabled despite recent activity" reports: +# - inactivity is judged on the newest sign-in ATTEMPT, interactive or non-interactive - the +# view the Entra portal and the inactive-guest alert give - not on the last successful +# sign-in alone, which stays old while a blocked or disabled guest keeps trying; +# - guests with no sign-in on record are skipped unless IncludeNeverSignedIn is on, and a +# template that predates the switch behaves as off; +# - a guest an admin re-enabled in the last 7 days is left alone. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $Modules = Join-Path $RepoRoot 'Modules' + # Resolve by name under Modules/ so the test survives the functions moving between modules. + $StandardPath = Get-ChildItem -Path $Modules -Recurse -Filter 'Invoke-CIPPStandardDisableGuests.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $StandardPath) { throw 'Could not locate Invoke-CIPPStandardDisableGuests.ps1 under Modules/' } + $HelperPath = Get-ChildItem -Path $Modules -Recurse -Filter 'Get-CIPPLastSignInDateTime.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $HelperPath) { throw 'Could not locate Get-CIPPLastSignInDateTime.ps1 under Modules/' } + + # Stubs mirror the real signatures and are advanced functions on purpose: strict parameter + # binding makes signature drift in the standard fail loudly here instead of silently + # landing in $args. + function Test-CIPPStandardLicense { [CmdletBinding()] param($StandardName, $TenantFilter, $Preset, [switch]$SkipLog) } + function New-GraphGetRequest { [CmdletBinding()] param($uri, $tenantid, $scope, $AsApp, $noPagination, $NoAuthCheck, $skipTokenCache, $ComplexFilter, $CountOnly) } + function New-GraphBulkRequest { [CmdletBinding()] param($tenantid, $NoAuthCheck, $scope, $asapp, $Requests, $NoPaginateIds, $Version, $Headers) } + function Write-LogMessage { [CmdletBinding()] param($API, $tenant, $Tenant2, $message, $sev, $headers, $LogData) } + function Write-StandardsAlert { [CmdletBinding()] param($message, $object, $tenant, $standardName, $standardId) } + function Set-CIPPStandardsCompareField { [CmdletBinding()] param($FieldName, $FieldValue, $CurrentValue, $ExpectedValue, $TenantFilter, $Tenant) } + function Add-CIPPBPAField { [CmdletBinding()] param($FieldName, $FieldValue, $StoreAs, $Tenant) } + function Get-NormalizedError { [CmdletBinding()] param($Message) $Message } + function Get-CippException { [CmdletBinding()] param($Exception) @{ NormalizedError = $Exception.Exception.Message } } + + . $HelperPath + . $StandardPath + + # Script scope: Pester 5 evaluates the Describe body at discovery, so plain variables + # declared there are not in scope inside It blocks or mocks at run time. + $script:Tenant = 'contoso.onmicrosoft.com' + $script:Now = (Get-Date).ToUniversalTime() + + # Guests go through ConvertFrom-Json, the shape New-GraphGetRequest hands the standard. Each + # *DaysAgo is how far back that signInActivity timestamp sits; leave all three out for a guest + # with no sign-in on record. + function script:New-Guest { + param( + [string]$Id, + [string]$Upn, + [string]$State = 'Accepted', + [int]$CreatedDaysAgo = 400, + [Nullable[int]]$InteractiveDaysAgo, + [Nullable[int]]$NonInteractiveDaysAgo, + [Nullable[int]]$SuccessfulDaysAgo + ) + $Stamp = { param($DaysAgo) if ($null -ne $DaysAgo) { $script:Now.AddDays(-$DaysAgo).ToString('o') } else { $null } } + $Guest = [ordered]@{ + id = $Id + userPrincipalName = $Upn + mail = $Upn + userType = 'Guest' + accountEnabled = $true + createdDateTime = $script:Now.AddDays(-$CreatedDaysAgo).ToString('o') + externalUserState = $State + } + if ($null -ne $InteractiveDaysAgo -or $null -ne $NonInteractiveDaysAgo -or $null -ne $SuccessfulDaysAgo) { + $Guest.signInActivity = [ordered]@{ + lastSignInDateTime = & $Stamp $InteractiveDaysAgo + lastNonInteractiveSignInDateTime = & $Stamp $NonInteractiveDaysAgo + lastSuccessfulSignInDateTime = & $Stamp $SuccessfulDaysAgo + } + } + $Guest | ConvertTo-Json -Depth 5 | ConvertFrom-Json + } +} + +Describe 'Invoke-CIPPStandardDisableGuests' { + BeforeEach { + $script:logs = [System.Collections.Generic.List[object]]::new() + $script:alerts = [System.Collections.Generic.List[object]]::new() + $script:compare = [System.Collections.Generic.List[object]]::new() + $script:disabled = [System.Collections.Generic.List[string]]::new() + $script:guests = @() + $script:audits = @() + + Mock -CommandName Test-CIPPStandardLicense -MockWith { $true } + Mock -CommandName Add-CIPPBPAField -MockWith { } + Mock -CommandName Write-LogMessage -MockWith { + param($API, $tenant, $message, $sev, $LogData) + $script:logs.Add(@{ Message = $message; Sev = $sev }) + } + Mock -CommandName Write-StandardsAlert -MockWith { + param($message, $object, $tenant, $standardName, $standardId) + $script:alerts.Add(@{ Message = $message; Object = @($object) }) + } + Mock -CommandName Set-CIPPStandardsCompareField -MockWith { + param($FieldName, $FieldValue, $CurrentValue, $ExpectedValue, $TenantFilter, $Tenant) + $script:compare.Add(@{ Current = $CurrentValue; Expected = $ExpectedValue }) + } + Mock -CommandName New-GraphGetRequest -MockWith { + param($uri, $tenantid, $scope) + if ($uri -like '*directoryAudits*') { return $script:audits } + return $script:guests + } + Mock -CommandName New-GraphBulkRequest -MockWith { + param($tenantid, $Requests) + @(foreach ($Request in $Requests) { + $script:disabled.Add(($Request.url -replace '^users/', '')) + [pscustomobject]@{ id = $Request.id; status = 204; body = $null } + }) + } + } + + Context 'inactivity is judged on the newest sign-in attempt' { + It 'keeps a guest whose last successful sign-in is old but who attempted a sign-in inside the window' { + # The reported shape: a successful sign-in 300 days back, an interactive attempt 154 days + # back and a non-interactive attempt 3 days back, against a 180-day threshold. + $script:guests = @(New-Guest -Id 'g1' -Upn 'bas_example.com#EXT#@contoso.onmicrosoft.com' -SuccessfulDaysAgo 300 -InteractiveDaysAgo 154 -NonInteractiveDaysAgo 3) + + Invoke-CIPPStandardDisableGuests -Tenant $script:Tenant -Settings @{ remediate = $true; days = 180 } + + Should -Invoke New-GraphBulkRequest -Times 0 -Exactly + @($script:disabled) | Should -BeNullOrEmpty + @($script:logs | Where-Object { $_.Message -like '*already compliant*' }).Count | Should -Be 1 + } + + It 'disables a guest whose newest attempt of any kind is outside the window, and logs that date' { + $script:guests = @( + New-Guest -Id 'stale' -Upn 'stale@example.com' -SuccessfulDaysAgo 250 -InteractiveDaysAgo 200 -NonInteractiveDaysAgo 190 + New-Guest -Id 'active' -Upn 'active@example.com' -SuccessfulDaysAgo 250 -InteractiveDaysAgo 200 -NonInteractiveDaysAgo 100 + ) + + Invoke-CIPPStandardDisableGuests -Tenant $script:Tenant -Settings @{ remediate = $true; days = 180 } + + @($script:disabled) | Should -Be @('stale') + $Lines = @($script:logs | Where-Object { $_.Message -like 'Disabled guest stale@example.com (stale). Reason: last sign-in: *' }) + $Lines.Count | Should -Be 1 + # The newest attempt (non-interactive, 190 days back) is the one reported - not the successful one. + $Logged = ([datetime]($Lines[0].Message -replace '^.*Reason: last sign-in: ', '')).ToUniversalTime() + [math]::Abs(($Logged - $script:Now.AddDays(-190)).TotalMinutes) | Should -BeLessThan 1 + } + + It 'counts a lastSuccessfulSignInDateTime that runs ahead of both attempt timestamps as activity' { + $script:guests = @(New-Guest -Id 'ahead' -Upn 'ahead@example.com' -InteractiveDaysAgo 200 -SuccessfulDaysAgo 10) + + Invoke-CIPPStandardDisableGuests -Tenant $script:Tenant -Settings @{ remediate = $true; days = 180 } + + @($script:disabled) | Should -BeNullOrEmpty + } + } + + Context 'guests with no sign-in on record' { + It 'are skipped when the template predates the switch or has it off' { + $script:guests = @(New-Guest -Id 'pending' -Upn 'pending@example.com' -State 'PendingAcceptance') + + Invoke-CIPPStandardDisableGuests -Tenant $script:Tenant -Settings @{ remediate = $true; days = 90 } + Invoke-CIPPStandardDisableGuests -Tenant $script:Tenant -Settings @{ remediate = $true; days = 90; IncludeNeverSignedIn = $false } + + Should -Invoke New-GraphBulkRequest -Times 0 -Exactly + @($script:disabled) | Should -BeNullOrEmpty + } + + It 'are disabled only when IncludeNeverSignedIn is on, with the invitation age as the reason' { + $script:guests = @( + New-Guest -Id 'pending' -Upn 'pending@example.com' -State 'PendingAcceptance' + New-Guest -Id 'fresh' -Upn 'fresh@example.com' -InteractiveDaysAgo 5 + ) + + Invoke-CIPPStandardDisableGuests -Tenant $script:Tenant -Settings @{ remediate = $true; days = 90; IncludeNeverSignedIn = $true } + + @($script:disabled) | Should -Be @('pending') + @($script:logs | Where-Object { $_.Message -like 'Disabled guest pending@example.com (pending). Reason: never signed in, created *' }).Count | Should -Be 1 + } + } + + Context 'recently re-enabled guests' { + It 'are left alone for 7 days after an admin re-enables them' { + $script:guests = @(New-Guest -Id 'stale' -Upn 'stale@example.com' -InteractiveDaysAgo 200) + $script:audits = @([pscustomobject]@{ targetResources = @([pscustomobject]@{ id = 'stale' }) }) + + Invoke-CIPPStandardDisableGuests -Tenant $script:Tenant -Settings @{ remediate = $true; days = 90 } + + Should -Invoke New-GraphBulkRequest -Times 0 -Exactly + @($script:disabled) | Should -BeNullOrEmpty + } + } + + Context 'alert and report' { + It 'splits stale sign-ins from never-signed-in guests and records the switch' { + $script:guests = @( + New-Guest -Id 'stale' -Upn 'stale@example.com' -InteractiveDaysAgo 200 + New-Guest -Id 'pending' -Upn 'pending@example.com' -State 'PendingAcceptance' + New-Guest -Id 'active' -Upn 'active@example.com' -NonInteractiveDaysAgo 2 + ) + + Invoke-CIPPStandardDisableGuests -Tenant $script:Tenant -Settings @{ alert = $true; report = $true; days = 90; IncludeNeverSignedIn = $true } + + $script:alerts.Count | Should -Be 1 + $script:alerts[0].Message | Should -Match '2 total \(1 with no sign-in attempt in 90 days, 1 never signed in' + @($script:alerts[0].Object | Where-Object { $_.NeverSignedIn }).id | Should -Be 'pending' + + $script:compare.Count | Should -Be 1 + $Current = $script:compare[0].Current + $Current.GuestsDisabledAfterDays | Should -Be 90 + $Current.GuestsIncludeNeverSignedIn | Should -BeTrue + $Current.GuestsDisabledAccountCount | Should -Be 2 + $Current.GuestsStaleSignInCount | Should -Be 1 + $Current.GuestsNeverSignedInCount | Should -Be 1 + @($Current.GuestsNeverSignedInDetails).id | Should -Be 'pending' + @($Current.GuestsDisabledAccountDetails | Where-Object { -not $_.NeverSignedIn }).LastSignInDateTime | Should -Not -BeNullOrEmpty + + $Expected = $script:compare[0].Expected + $Expected.GuestsDisabledAccountCount | Should -Be 0 + $Expected.GuestsStaleSignInCount | Should -Be 0 + $Expected.GuestsNeverSignedInCount | Should -Be 0 + $Expected.GuestsIncludeNeverSignedIn | Should -BeTrue + } + + It 'reports the switch off and no never-signed-in guests when the template omits it' { + $script:guests = @(New-Guest -Id 'pending' -Upn 'pending@example.com' -State 'PendingAcceptance') + + Invoke-CIPPStandardDisableGuests -Tenant $script:Tenant -Settings @{ report = $true; days = 90 } + + $Current = $script:compare[0].Current + $Current.GuestsIncludeNeverSignedIn | Should -BeFalse + $Current.GuestsDisabledAccountCount | Should -Be 0 + $Current.GuestsNeverSignedInCount | Should -Be 0 + } + } +} diff --git a/Tests/Standards/Invoke-CIPPStandardDisableSharedMailbox.Tests.ps1 b/Tests/Standards/Invoke-CIPPStandardDisableSharedMailbox.Tests.ps1 new file mode 100644 index 0000000000000..9eafd3f7d686d --- /dev/null +++ b/Tests/Standards/Invoke-CIPPStandardDisableSharedMailbox.Tests.ps1 @@ -0,0 +1,228 @@ +# Pester tests for Invoke-CIPPStandardDisableSharedMailbox +# +# Pins the behaviour behind the "denied deviation that never remediates" reports: +# - a remediating run reports what it leaves behind, not the list it found. It used to disable +# the accounts and then write the pre-remediation list to the compare field, so drift showed +# the same deviation after every successful run; +# - the selection itself covers both mailbox types and only accounts that are still enabled and +# cloud-only. -and and -or share one precedence level in PowerShell and associate left to +# right, so the unparenthesised original meant the same thing - these pin it against a +# "fix" that regroups it as A -or (B -and C); +# - the user cache and the Exchange mailbox list fail with distinct messages. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $Modules = Join-Path $RepoRoot 'Modules' + # Resolve by name under Modules/ so the test survives the function moving between modules. + $StandardPath = Get-ChildItem -Path $Modules -Recurse -Filter 'Invoke-CIPPStandardDisableSharedMailbox.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $StandardPath) { throw 'Could not locate Invoke-CIPPStandardDisableSharedMailbox.ps1 under Modules/' } + + # Stubs mirror the real signatures and are advanced functions on purpose: strict parameter + # binding makes signature drift in the standard fail loudly here instead of silently + # landing in $args. + function New-CIPPDbRequest { [CmdletBinding()] param($TenantFilter, $Type, $Fields) } + function New-GraphGetRequest { [CmdletBinding()] param($uri, $tenantid, $scope, $AsApp, $noPagination, $NoAuthCheck, $skipTokenCache, $ComplexFilter, $CountOnly) } + function New-GraphBulkRequest { [CmdletBinding()] param($tenantid, $NoAuthCheck, $scope, $asapp, $Requests, $NoPaginateIds, $Version, $Headers) } + function Set-CIPPDBCacheUsers { [CmdletBinding()] param($TenantFilter) } + function Write-LogMessage { [CmdletBinding()] param($API, $tenant, $Tenant2, $message, $sev, $headers, $LogData) } + function Write-StandardsAlert { [CmdletBinding()] param($message, $object, $tenant, $standardName, $standardId) } + function Set-CIPPStandardsCompareField { [CmdletBinding()] param($FieldName, $FieldValue, $CurrentValue, $ExpectedValue, $TenantFilter, $Tenant) } + function Add-CIPPBPAField { [CmdletBinding()] param($FieldName, $FieldValue, $StoreAs, $Tenant) } + function Get-NormalizedError { [CmdletBinding()] param($Message) $Message } + function Get-CippException { [CmdletBinding()] param($Exception) @{ NormalizedError = $Exception.Exception.Message } } + + . $StandardPath + + # Script scope: Pester 5 evaluates the Describe body at discovery, so plain variables + # declared there are not in scope inside It blocks or mocks at run time. + $script:Tenant = 'contoso.onmicrosoft.com' + + # Both shapes go through ConvertFrom-Json, matching what the cache and the adminapi hand back. + function script:New-CachedUser { + param( + [string]$Id, + [string]$Upn, + [bool]$Enabled = $true, + [bool]$OnPremSynced = $false + ) + [ordered]@{ + id = $Id + userPrincipalName = $Upn + accountEnabled = $Enabled + onPremisesSyncEnabled = $OnPremSynced + } | ConvertTo-Json -Depth 5 | ConvertFrom-Json + } + + function script:New-Mailbox { + param( + [string]$Id, + [string]$Upn, + [string]$Type = 'SharedMailbox' + ) + [ordered]@{ + ObjectKey = $Id + UserPrincipalName = $Upn + DisplayName = $Upn + RecipientTypeDetails = $Type + } | ConvertTo-Json -Depth 5 | ConvertFrom-Json + } +} + +Describe 'Invoke-CIPPStandardDisableSharedMailbox' { + BeforeEach { + $script:logs = [System.Collections.Generic.List[object]]::new() + $script:alerts = [System.Collections.Generic.List[object]]::new() + $script:compare = [System.Collections.Generic.List[object]]::new() + $script:patched = [System.Collections.Generic.List[string]]::new() + $script:users = @() + $script:mailboxes = @() + # Object keys the bulk PATCH should answer with a failure instead of a 204. + $script:failKeys = @() + + Mock -CommandName Add-CIPPBPAField -MockWith { } + Mock -CommandName Set-CIPPDBCacheUsers -MockWith { } + Mock -CommandName Write-LogMessage -MockWith { + param($API, $tenant, $message, $sev, $LogData) + $script:logs.Add(@{ Message = $message; Sev = $sev }) + } + Mock -CommandName Write-StandardsAlert -MockWith { + param($message, $object, $tenant, $standardName, $standardId) + $script:alerts.Add(@{ Message = $message; Object = @($object) }) + } + Mock -CommandName Set-CIPPStandardsCompareField -MockWith { + param($FieldName, $FieldValue, $CurrentValue, $ExpectedValue, $TenantFilter, $Tenant) + $script:compare.Add(@{ Current = $CurrentValue; Expected = $ExpectedValue }) + } + Mock -CommandName New-CIPPDbRequest -MockWith { $script:users } + Mock -CommandName New-GraphGetRequest -MockWith { $script:mailboxes } + Mock -CommandName New-GraphBulkRequest -MockWith { + param($tenantid, $Requests) + @(foreach ($Request in $Requests) { + $Key = $Request.url -replace '^users/', '' + $script:patched.Add($Key) + if ($script:failKeys -contains $Key) { + [pscustomobject]@{ id = $Request.id; status = 403; body = [pscustomobject]@{ error = [pscustomobject]@{ message = 'Insufficient privileges' } } } + } else { + [pscustomobject]@{ id = $Request.id; status = 204; body = $null } + } + }) + } + } + + Context 'selection' { + It 'reports a shared mailbox whose Entra account is already disabled as compliant' { + $script:users = @(New-CachedUser -Id 'shared1' -Upn 'shared@contoso.com' -Enabled $false) + $script:mailboxes = @(New-Mailbox -Id 'shared1' -Upn 'shared@contoso.com') + + Invoke-CIPPStandardDisableSharedMailbox -Tenant $script:Tenant -Settings @{ report = $true } + + $script:compare.Count | Should -Be 1 + @($script:compare[0].Current.DisableSharedMailbox) | Should -BeNullOrEmpty + } + + It 'reports a shared mailbox whose Entra account is still enabled as non-compliant' { + $script:users = @(New-CachedUser -Id 'shared1' -Upn 'shared@contoso.com') + $script:mailboxes = @(New-Mailbox -Id 'shared1' -Upn 'shared@contoso.com') + + Invoke-CIPPStandardDisableSharedMailbox -Tenant $script:Tenant -Settings @{ report = $true } + + @($script:compare[0].Current.DisableSharedMailbox).UserPrincipalName | Should -Be 'shared@contoso.com' + @($script:compare[0].Expected.DisableSharedMailbox) | Should -BeNullOrEmpty + } + + It 'applies the same join to scheduling mailboxes and skips directory-synced accounts' { + $script:users = @( + New-CachedUser -Id 'room1' -Upn 'room@contoso.com' + New-CachedUser -Id 'sched1' -Upn 'sched@contoso.com' -Enabled $false + New-CachedUser -Id 'synced1' -Upn 'synced@contoso.com' -OnPremSynced $true + ) + $script:mailboxes = @( + New-Mailbox -Id 'room1' -Upn 'room@contoso.com' -Type 'SchedulingMailbox' + New-Mailbox -Id 'sched1' -Upn 'sched@contoso.com' -Type 'SchedulingMailbox' + New-Mailbox -Id 'synced1' -Upn 'synced@contoso.com' + New-Mailbox -Id 'user1' -Upn 'user@contoso.com' -Type 'UserMailbox' + ) + + Invoke-CIPPStandardDisableSharedMailbox -Tenant $script:Tenant -Settings @{ report = $true } + + @($script:compare[0].Current.DisableSharedMailbox).UserPrincipalName | Should -Be 'room@contoso.com' + } + } + + Context 'remediation' { + It 'disables only the mailboxes whose account is still enabled' { + $script:users = @( + New-CachedUser -Id 'enabled1' -Upn 'enabled@contoso.com' + New-CachedUser -Id 'disabled1' -Upn 'disabled@contoso.com' -Enabled $false + ) + $script:mailboxes = @( + New-Mailbox -Id 'enabled1' -Upn 'enabled@contoso.com' + New-Mailbox -Id 'disabled1' -Upn 'disabled@contoso.com' + ) + + Invoke-CIPPStandardDisableSharedMailbox -Tenant $script:Tenant -Settings @{ remediate = $true } + + @($script:patched) | Should -Be @('enabled1') + } + + It 'reports the post-remediation state, not the state it found' { + $script:users = @(New-CachedUser -Id 'enabled1' -Upn 'enabled@contoso.com') + $script:mailboxes = @(New-Mailbox -Id 'enabled1' -Upn 'enabled@contoso.com') + + Invoke-CIPPStandardDisableSharedMailbox -Tenant $script:Tenant -Settings @{ remediate = $true; alert = $true; report = $true } + + @($script:patched) | Should -Be @('enabled1') + @($script:compare[0].Current.DisableSharedMailbox) | Should -BeNullOrEmpty + $script:alerts.Count | Should -Be 0 + } + + It 'keeps a mailbox it failed to disable in the report' { + $script:users = @( + New-CachedUser -Id 'ok1' -Upn 'ok@contoso.com' + New-CachedUser -Id 'bad1' -Upn 'bad@contoso.com' + ) + $script:mailboxes = @( + New-Mailbox -Id 'ok1' -Upn 'ok@contoso.com' + New-Mailbox -Id 'bad1' -Upn 'bad@contoso.com' + ) + $script:failKeys = @('bad1') + + Invoke-CIPPStandardDisableSharedMailbox -Tenant $script:Tenant -Settings @{ remediate = $true; report = $true } + + @($script:compare[0].Current.DisableSharedMailbox).UserPrincipalName | Should -Be 'bad@contoso.com' + } + } + + Context 'failure reporting' { + It 'names the cached user list when the database read fails' { + Mock -CommandName New-CIPPDbRequest -MockWith { throw 'table unavailable' } + + Invoke-CIPPStandardDisableSharedMailbox -Tenant $script:Tenant -Settings @{ report = $true } + + Should -Invoke New-GraphGetRequest -Times 0 -Exactly + @($script:logs | Where-Object { $_.Message -like '*could not read the cached user list*' }).Count | Should -Be 1 + $script:compare.Count | Should -Be 0 + } + + It 'names Exchange when the mailbox list fails' { + $script:users = @(New-CachedUser -Id 'shared1' -Upn 'shared@contoso.com') + Mock -CommandName New-GraphGetRequest -MockWith { throw 'Exchange is down' } + + Invoke-CIPPStandardDisableSharedMailbox -Tenant $script:Tenant -Settings @{ report = $true } + + @($script:logs | Where-Object { $_.Message -like '*could not list mailboxes from Exchange*' }).Count | Should -Be 1 + $script:compare.Count | Should -Be 0 + } + + It 'reports nothing at all when the user cache is empty' { + $script:users = @() + + Invoke-CIPPStandardDisableSharedMailbox -Tenant $script:Tenant -Settings @{ report = $true } + + Should -Invoke New-GraphGetRequest -Times 0 -Exactly + @($script:logs | Where-Object { $_.Message -like '*cached user list is empty*' }).Count | Should -Be 1 + $script:compare.Count | Should -Be 0 + } + } +} diff --git a/Tests/Standards/Invoke-CIPPStandardExternalComplianceTrusted.Tests.ps1 b/Tests/Standards/Invoke-CIPPStandardExternalComplianceTrusted.Tests.ps1 new file mode 100644 index 0000000000000..f11203bd218db --- /dev/null +++ b/Tests/Standards/Invoke-CIPPStandardExternalComplianceTrusted.Tests.ps1 @@ -0,0 +1,62 @@ +# Pester tests for Invoke-CIPPStandardExternalComplianceTrusted. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $StandardPath = Join-Path $RepoRoot 'Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardExternalComplianceTrusted.ps1' + + function Test-CIPPStandardLicense { [CmdletBinding()] param($StandardName, $TenantFilter, $Preset, [switch]$SkipLog) } + function New-GraphGetRequest { [CmdletBinding()] param($uri, $tenantid, $AsApp) } + function New-GraphPostRequest { [CmdletBinding()] param($tenantid, $uri, $type, $body, $AsApp, $ContentType) } + function Write-LogMessage { [CmdletBinding()] param($API, $tenant, $message, $sev, $LogData) } + function Write-StandardsAlert { [CmdletBinding()] param($message, $object, $tenant, $standardName, $standardId) } + function Set-CIPPStandardsCompareField { [CmdletBinding()] param($FieldName, $CurrentValue, $ExpectedValue, $TenantFilter) } + function Add-CIPPBPAField { [CmdletBinding()] param($FieldName, $FieldValue, $StoreAs, $Tenant) } + function Get-NormalizedError { [CmdletBinding()] param($Message) $Message } + + . $StandardPath + $script:Tenant = 'contoso.onmicrosoft.com' +} + +Describe 'Invoke-CIPPStandardExternalComplianceTrusted' { + BeforeEach { + Mock Test-CIPPStandardLicense { $true } + Mock New-GraphGetRequest { + [PSCustomObject]@{ + inboundTrust = [PSCustomObject]@{ + isMfaAccepted = $false + isCompliantDeviceAccepted = $false + isHybridAzureADJoinedDeviceAccepted = $true + } + } + } + Mock New-GraphPostRequest { } + Mock Write-LogMessage { } + Mock Write-StandardsAlert { } + Mock Set-CIPPStandardsCompareField { } + Mock Add-CIPPBPAField { } + } + + It 'remediates compliant-device trust without resetting sibling trust flags' { + Invoke-CIPPStandardExternalComplianceTrusted -Tenant $script:Tenant -Settings @{ remediate = $true; state = 'true' } + + Should -Invoke New-GraphPostRequest -Times 1 -Exactly -ParameterFilter { + $type -eq 'patch' -and + ($body | ConvertFrom-Json).inboundTrust.isCompliantDeviceAccepted -eq $true -and + ($body | ConvertFrom-Json).inboundTrust.isMfaAccepted -eq $false -and + ($body | ConvertFrom-Json).inboundTrust.isHybridAzureADJoinedDeviceAccepted -eq $true + } + } + + It 'reports the compliant-device property as the comparison field' { + Invoke-CIPPStandardExternalComplianceTrusted -Tenant $script:Tenant -Settings @{ report = $true; state = 'true' } + + Should -Invoke Set-CIPPStandardsCompareField -Times 1 -Exactly -ParameterFilter { + $FieldName -eq 'standards.ExternalComplianceTrusted' -and + $CurrentValue.isCompliantDeviceAccepted -eq $false -and + $ExpectedValue.isCompliantDeviceAccepted -eq $true + } + Should -Invoke Add-CIPPBPAField -Times 1 -Exactly -ParameterFilter { + $FieldName -eq 'ExternalComplianceTrusted' -and $FieldValue -eq $false + } + } +} diff --git a/Tests/Standards/Invoke-CIPPStandardFIDO2PasskeyProfiles.Tests.ps1 b/Tests/Standards/Invoke-CIPPStandardFIDO2PasskeyProfiles.Tests.ps1 new file mode 100644 index 0000000000000..33b4d529a0802 --- /dev/null +++ b/Tests/Standards/Invoke-CIPPStandardFIDO2PasskeyProfiles.Tests.ps1 @@ -0,0 +1,162 @@ +# Pester tests for Invoke-CIPPStandardFIDO2PasskeyProfiles +# +# HubSpot 47469698699: AAGUIDs added to the passkey profile did not take effect. A profile's AAGUID +# allow/block list only applies while keyRestrictions.isEnforced = $true; sent with isEnforced = $false +# the list is stored but inert - the profile keeps no active restriction (confirmed live against Graph +# beta). The standard exposed the AAGUIDs field and the "Enforce AAGUID Key Restrictions" switch as +# independent inputs, so an operator who added AAGUIDs without toggling the switch got an unenforced, +# ineffective list. The remediation must now enforce key restrictions whenever AAGUIDs are supplied. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $StandardPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-CIPPStandardFIDO2PasskeyProfiles.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $StandardPath) { throw 'Could not locate Invoke-CIPPStandardFIDO2PasskeyProfiles.ps1 under Modules/' } + + function New-GraphGetRequest { [CmdletBinding()] param($Uri, $tenantid, $AsApp) $script:mockCurrentConfig } + function New-GraphPostRequest { [CmdletBinding()] param($tenantid, $Uri, $Type, $Body, $ContentType, $AsApp) $script:lastBody = $Body } + function Write-LogMessage { [CmdletBinding()] param($API, $tenant, $message, $sev, $LogData, $headers) $script:logs.Add(@{ Message = $message; Sev = $sev }) } + function Write-StandardsAlert { [CmdletBinding()] param($message, $object, $tenant, $standardName, $standardId) } + function Set-CIPPStandardsCompareField { [CmdletBinding()] param($FieldName, $CurrentValue, $ExpectedValue, $TenantFilter) } + function Add-CIPPBPAField { [CmdletBinding()] param($FieldName, $FieldValue, $StoreAs, $Tenant) } + function Get-CippException { [CmdletBinding()] param($Exception) @{ NormalizedError = $Exception.Exception.Message } } + + . $StandardPath + + $script:Tenant = 'contoso.onmicrosoft.com' + $script:AndroidAAGUID = 'de1e552d-db1d-4423-a619-566b625cdc84' + $script:OperatorAAGUID = '11111111-1111-1111-1111-111111111111' + + # A tenant with the Microsoft default profile (no restrictions) plus an operator-managed profile + # that already carries an AAGUID - the remediation must resend the operator profile untouched. + function script:New-MockConfig { + [pscustomobject]@{ + '@odata.type' = '#microsoft.graph.fido2AuthenticationMethodConfiguration' + id = 'fido2' + state = 'enabled' + defaultPasskeyProfile = 'p-default' + passkeyProfiles = @( + [pscustomobject]@{ id = 'p-default'; name = 'Microsoft default profile'; passkeyTypes = 'deviceBound'; attestationEnforcement = 'disabled'; keyRestrictions = [pscustomobject]@{ isEnforced = $false; enforcementType = 'block'; aaGuids = @() } } + [pscustomobject]@{ id = 'p-custom'; name = 'Operator profile'; passkeyTypes = 'synced'; attestationEnforcement = 'registrationOnly'; keyRestrictions = [pscustomobject]@{ isEnforced = $true; enforcementType = 'allow'; aaGuids = @($script:OperatorAAGUID) } } + ) + } + } +} + +Describe 'Invoke-CIPPStandardFIDO2PasskeyProfiles remediation' { + BeforeEach { + $script:logs = [System.Collections.Generic.List[object]]::new() + $script:lastBody = $null + $script:mockCurrentConfig = script:New-MockConfig + } + + It 'enforces key restrictions when AAGUIDs are supplied even though the enforce switch is off' { + # The core regression: without this, isEnforced was $false and Graph dropped the AAGUIDs. + $Settings = @{ + remediate = $true + PasskeyTypes = 'deviceBound,synced' + AttestationEnforcement = 'disabled' + EnforceKeyRestrictions = $false + EnforcementType = 'allow' + AAGUIDs = $script:AndroidAAGUID + } + + Invoke-CIPPStandardFIDO2PasskeyProfiles -Tenant $script:Tenant -Settings $Settings + + $script:lastBody | Should -Not -BeNullOrEmpty + $body = $script:lastBody | ConvertFrom-Json + $default = @($body.passkeyProfiles) | Where-Object { $_.id -eq 'p-default' } + $default.keyRestrictions.isEnforced | Should -BeTrue + @($default.keyRestrictions.aaGuids) | Should -Be @($script:AndroidAAGUID) + $default.keyRestrictions.enforcementType | Should -Be 'allow' + } + + It 'serializes a single AAGUID as a JSON array' { + $Settings = @{ + remediate = $true + PasskeyTypes = 'deviceBound,synced' + AttestationEnforcement = 'disabled' + EnforceKeyRestrictions = $true + EnforcementType = 'allow' + AAGUIDs = $script:AndroidAAGUID + } + + Invoke-CIPPStandardFIDO2PasskeyProfiles -Tenant $script:Tenant -Settings $Settings + + # Guards the classic ConvertTo-Json single-element unwrap: aaGuids must be [".."], not "..". + $script:lastBody | Should -Match '"aaGuids":\[' + } + + It 'resends every other passkey profile untouched (the PATCH replaces the whole collection)' { + $Settings = @{ + remediate = $true + PasskeyTypes = 'deviceBound,synced' + AttestationEnforcement = 'disabled' + EnforceKeyRestrictions = $true + EnforcementType = 'allow' + AAGUIDs = $script:AndroidAAGUID + } + + Invoke-CIPPStandardFIDO2PasskeyProfiles -Tenant $script:Tenant -Settings $Settings + + $body = $script:lastBody | ConvertFrom-Json + @($body.passkeyProfiles).Count | Should -Be 2 + $operator = @($body.passkeyProfiles) | Where-Object { $_.id -eq 'p-custom' } + $operator | Should -Not -BeNullOrEmpty + @($operator.keyRestrictions.aaGuids) | Should -Be @($script:OperatorAAGUID) + } + + It 'preserves multiple supplied AAGUIDs on the default profile' { + $Settings = @{ + remediate = $true + PasskeyTypes = 'deviceBound,synced' + AttestationEnforcement = 'disabled' + EnforceKeyRestrictions = $false + EnforcementType = 'allow' + AAGUIDs = "$script:AndroidAAGUID, 90a3ccdf-635c-4729-a248-9b709135078f" + } + + Invoke-CIPPStandardFIDO2PasskeyProfiles -Tenant $script:Tenant -Settings $Settings + + $body = $script:lastBody | ConvertFrom-Json + $default = @($body.passkeyProfiles) | Where-Object { $_.id -eq 'p-default' } + @($default.keyRestrictions.aaGuids).Count | Should -Be 2 + @($default.keyRestrictions.aaGuids) | Should -Contain '90a3ccdf-635c-4729-a248-9b709135078f' + $default.keyRestrictions.isEnforced | Should -BeTrue + } + + It 'does not enforce or send AAGUIDs when none are supplied' { + # passkeyTypes differs from the tenant so remediation still fires and we can inspect the body. + $Settings = @{ + remediate = $true + PasskeyTypes = 'synced' + AttestationEnforcement = 'disabled' + EnforceKeyRestrictions = $false + EnforcementType = 'allow' + AAGUIDs = '' + } + + Invoke-CIPPStandardFIDO2PasskeyProfiles -Tenant $script:Tenant -Settings $Settings + + $body = $script:lastBody | ConvertFrom-Json + $default = @($body.passkeyProfiles) | Where-Object { $_.id -eq 'p-default' } + $default.keyRestrictions.isEnforced | Should -BeFalse + @($default.keyRestrictions.aaGuids).Count | Should -Be 0 + } + + It 'refuses to enforce with no AAGUIDs and does not PATCH' { + $Settings = @{ + remediate = $true + PasskeyTypes = 'deviceBound,synced' + AttestationEnforcement = 'disabled' + EnforceKeyRestrictions = $true + EnforcementType = 'allow' + AAGUIDs = '' + } + + Invoke-CIPPStandardFIDO2PasskeyProfiles -Tenant $script:Tenant -Settings $Settings + + $script:lastBody | Should -BeNullOrEmpty + @($script:logs | Where-Object { $_.Sev -eq 'Error' }).Count | Should -Be 1 + } +} diff --git a/Tests/Standards/Invoke-CIPPStandardGroupTemplate.Tests.ps1 b/Tests/Standards/Invoke-CIPPStandardGroupTemplate.Tests.ps1 index 0d57c7b166ba2..e21a988a8dbcc 100644 --- a/Tests/Standards/Invoke-CIPPStandardGroupTemplate.Tests.ps1 +++ b/Tests/Standards/Invoke-CIPPStandardGroupTemplate.Tests.ps1 @@ -27,6 +27,8 @@ BeforeAll { function Set-CIPPStandardsCompareField { [CmdletBinding()] param($FieldName, $CurrentValue, $ExpectedValue, $TenantFilter) } function Write-LogMessage { [CmdletBinding()] param($API, $tenant, $message, $sev, $headers, $LogData, $User) } function Get-NormalizedError { [CmdletBinding()] param($Message) $Message } + # Default no-op: only the variable-name tests mock this to actually substitute tokens. + function Get-CIPPTextReplacement { [CmdletBinding()] param($TenantFilter, $Text, [switch]$EscapeForJson) $Text } . $StandardPath @@ -61,6 +63,23 @@ BeforeAll { } } + # A generic template whose displayName carries a %tenantname% token, the way an operator writes + # a per-tenant group name. New-GraphPostRequest resolves the token at creation time, so the real + # group is named 'Contoso-Group'. + $script:VariableGroupName = '%tenantname%-Group' + $script:ResolvedGroupName = 'Contoso-Group' + function script:New-VariableTemplateEntity { + [pscustomobject]@{ + JSON = ([pscustomobject]@{ + displayName = $script:VariableGroupName + description = 'Test description' + groupType = 'generic' + membershipRules = $null + GUID = '33333333-3333-3333-3333-333333333333' + } | ConvertTo-Json -Depth 10) + } + } + function script:New-Settings { param([switch]$Remediate, [switch]$Report) [pscustomobject]@{ @@ -107,6 +126,40 @@ Describe 'Invoke-CIPPStandardGroupTemplate' { } } + Context 'template display name contains a %variable%' { + BeforeEach { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { script:New-VariableTemplateEntity } + # Resolve %tenantname% the way Get-CIPPTextReplacement does at runtime, so comparisons run + # against the same name New-GraphPostRequest gives the real group. + Mock -CommandName Get-CIPPTextReplacement -MockWith { + param($TenantFilter, $Text, [switch]$EscapeForJson) + $Text -replace '%tenantname%', 'Contoso' + } + } + + It 'does not recreate the group when the resolved-name group already exists' { + Mock -CommandName New-GraphGetRequest -MockWith { + @([pscustomobject]@{ id = 'existing-id'; displayName = $script:ResolvedGroupName; description = 'Test description'; membershipRule = $null }) + } + + Invoke-CIPPStandardGroupTemplate -Tenant $script:Tenant -Settings (script:New-Settings -Remediate) + + Should -Invoke -CommandName New-CIPPGroup -Times 0 -Exactly -Because 'the group exists under its resolved name, so recreating it would make a duplicate' + } + + It 'reports compliant when the resolved-name group exists' { + Mock -CommandName New-GraphGetRequest -MockWith { + @([pscustomobject]@{ id = 'existing-id'; displayName = $script:ResolvedGroupName; description = 'Test description'; membershipRule = $null }) + } + + Invoke-CIPPStandardGroupTemplate -Tenant $script:Tenant -Settings (script:New-Settings -Report) + + Should -Invoke -CommandName Set-CIPPStandardsCompareField -Times 1 -Exactly -ParameterFilter { + @($CurrentValue.MissingGroups).Count -eq 0 + } -Because 'the resolved name matches an existing group, so nothing is missing' + } + } + Context 'existing groups cannot be read' { It 'creates no groups when the Graph read fails, avoiding duplicate twins' { Mock -CommandName New-GraphGetRequest -MockWith { throw 'Request not authorised for tenant' } diff --git a/Tests/Standards/Invoke-CIPPStandardOneDriveLicensedQuota.Tests.ps1 b/Tests/Standards/Invoke-CIPPStandardOneDriveLicensedQuota.Tests.ps1 index f18b714f4da79..ad9d488808345 100644 --- a/Tests/Standards/Invoke-CIPPStandardOneDriveLicensedQuota.Tests.ps1 +++ b/Tests/Standards/Invoke-CIPPStandardOneDriveLicensedQuota.Tests.ps1 @@ -18,7 +18,7 @@ BeforeAll { function Test-CIPPStandardLicense { [CmdletBinding()] param($StandardName, $TenantFilter, $RequiredCapabilities, $Preset, [switch]$SkipLog) } function New-GraphGetRequest { [CmdletBinding()] param($uri, $tenantid, $AsApp, $NoAuthCheck, $skipTokenCache, [switch]$ComplexFilter, $CountOnly) } function New-GraphBulkRequest { [CmdletBinding()] param($tenantid, $NoAuthCheck, $scope, $asapp, $Requests, $NoPaginateIds, $Version, $Headers) } - function Set-CIPPSPOSite { [CmdletBinding()] param($TenantFilter, $SiteUrl, $Properties) } + function Set-CIPPSPOSiteBulk { [CmdletBinding()] param($TenantFilter, $Sites, $MaxConcurrency, $MaxRetries, [switch]$UseCertificate) } function Write-LogMessage { [CmdletBinding()] param($API, $tenant, $message, $sev, $headers, $LogData) } function Write-StandardsAlert { [CmdletBinding()] param($message, $object, $tenant, $standardName, $standardId) } function Set-CIPPStandardsCompareField { [CmdletBinding()] param($FieldName, $CurrentValue, $ExpectedValue, $Tenant) } @@ -77,10 +77,15 @@ Describe 'Invoke-CIPPStandardOneDriveLicensedQuota' { param($API, $tenant, $message, $sev, $LogData) $script:logs.Add(@{ Message = $message; Sev = $sev }) } - Mock -CommandName Set-CIPPSPOSite -MockWith { - param($TenantFilter, $SiteUrl, $Properties) - $script:setCalls.Add(@{ SiteUrl = $SiteUrl; Properties = $Properties }) - return @([pscustomobject]@{ SchemaVersion = '15.0.0.0'; ErrorInfo = $null }) + # The standard raises quotas through one concurrent Set-CIPPSPOSiteBulk fan-out rather than + # a Set-CIPPSPOSite call per drive. Expand its -Sites back into the per-site records the + # assertions read, and grade every site a success unless a test overrides this mock. + Mock -CommandName Set-CIPPSPOSiteBulk -MockWith { + param($TenantFilter, $Sites, $MaxConcurrency, $MaxRetries, [switch]$UseCertificate) + @(foreach ($Site in $Sites) { + $script:setCalls.Add(@{ SiteUrl = $Site.SiteUrl; Properties = $Site.Properties }) + [pscustomobject]@{ SiteUrl = $Site.SiteUrl; Success = $true; Error = $null } + }) } } @@ -162,13 +167,16 @@ Describe 'Invoke-CIPPStandardOneDriveLicensedQuota' { } It 'continues with the remaining drives when one CSOM update fails' { - Mock -CommandName Set-CIPPSPOSite -MockWith { - param($TenantFilter, $SiteUrl, $Properties) - $script:setCalls.Add(@{ SiteUrl = $SiteUrl; Properties = $Properties }) - if ($SiteUrl -match 'u1_contoso_com') { - return @([pscustomobject]@{ ErrorInfo = [pscustomobject]@{ ErrorMessage = 'Access denied.' } }) - } - return @([pscustomobject]@{ ErrorInfo = $null }) + Mock -CommandName Set-CIPPSPOSiteBulk -MockWith { + param($TenantFilter, $Sites, $MaxConcurrency, $MaxRetries, [switch]$UseCertificate) + @(foreach ($Site in $Sites) { + $script:setCalls.Add(@{ SiteUrl = $Site.SiteUrl; Properties = $Site.Properties }) + if ($Site.SiteUrl -match 'u1_contoso_com') { + [pscustomobject]@{ SiteUrl = $Site.SiteUrl; Success = $false; Error = 'Access denied.' } + } else { + [pscustomobject]@{ SiteUrl = $Site.SiteUrl; Success = $true; Error = $null } + } + }) } { Invoke-CIPPStandardOneDriveLicensedQuota -Tenant $script:Tenant -Settings @{ remediate = $true } } | diff --git a/Tests/Standards/Invoke-CIPPStandardQuarantineRequestAlert.Tests.ps1 b/Tests/Standards/Invoke-CIPPStandardQuarantineRequestAlert.Tests.ps1 new file mode 100644 index 0000000000000..4a2b4a963e88c --- /dev/null +++ b/Tests/Standards/Invoke-CIPPStandardQuarantineRequestAlert.Tests.ps1 @@ -0,0 +1,178 @@ +# Pester tests for Invoke-CIPPStandardQuarantineRequestAlert +# +# The standard manages a CIPP-owned Protection Alert ('CIPP User requested to release a quarantined +# message'). The regression these tests guard against: report/compliance was computed from a snapshot +# read BEFORE remediation, so a freshly created or updated alert still reported its old (empty) +# recipients until the next scheduled run - which the tenant saw as "force ran, nothing changed". +# The fix re-reads the live state after remediating so the compare field reflects what was applied. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $StandardPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-CIPPStandardQuarantineRequestAlert.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $StandardPath) { throw 'Could not locate Invoke-CIPPStandardQuarantineRequestAlert.ps1 under Modules/' } + + function Test-CIPPStandardLicense { [CmdletBinding()] param($StandardName, $TenantFilter, $Preset, [switch]$SkipLog) } + function New-ExoRequest { [CmdletBinding()] param($tenantid, $cmdlet, $cmdParams, [switch]$Compliance, $UseSystemMailbox) } + function Write-LogMessage { [CmdletBinding()] param($API, $Tenant, $Message, $sev, $LogData) } + function Write-StandardsAlert { [CmdletBinding()] param($message, $object, $tenant, $standardName, $standardId) } + function Set-CIPPStandardsCompareField { [CmdletBinding()] param($FieldName, $FieldValue, $CurrentValue, $ExpectedValue, $TenantFilter) } + function Add-CIPPBPAField { [CmdletBinding()] param($FieldName, $FieldValue, $StoreAs, $Tenant) } + function Get-NormalizedError { [CmdletBinding()] param($Message) $Message } + + . $StandardPath + + $script:Tenant = 'contoso.onmicrosoft.com' + $script:PolicyName = 'CIPP User requested to release a quarantined message' + $script:NotifyUser = 'helpdesk@dpndbl.com' +} + +Describe 'Invoke-CIPPStandardQuarantineRequestAlert' { + BeforeEach { + # $script:alertObj models the tenant's live Protection Alert: $null = it does not exist. + $script:alertObj = $null + $script:compareFields = [System.Collections.Generic.List[object]]::new() + $script:bpaFields = [System.Collections.Generic.List[object]]::new() + + Mock -CommandName Test-CIPPStandardLicense -MockWith { $true } + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Write-StandardsAlert -MockWith { } + Mock -CommandName Add-CIPPBPAField -MockWith { + param($FieldName, $FieldValue, $StoreAs, $Tenant) + $script:bpaFields.Add([pscustomobject]@{ Field = $FieldName; FieldValue = $FieldValue }) + } + Mock -CommandName Set-CIPPStandardsCompareField -MockWith { + param($FieldName, $FieldValue, $CurrentValue, $ExpectedValue, $TenantFilter) + $script:compareFields.Add([pscustomobject]@{ Field = $FieldName; Current = $CurrentValue; Expected = $ExpectedValue }) + } + # Stateful EXO mock: reads reflect the current alert; writes mutate it, so a re-read after + # remediation sees the applied change. + Mock -CommandName New-ExoRequest -MockWith { + param($tenantid, $cmdlet, $cmdParams, [switch]$Compliance, $UseSystemMailbox) + switch ($cmdlet) { + 'Get-ProtectionAlert' { return $script:alertObj } + 'New-ProtectionAlert' { $script:alertObj = [pscustomobject]@{ Name = $cmdParams.name; NotifyUser = @($cmdParams.NotifyUser) }; return } + 'Set-ProtectionAlert' { $script:alertObj = [pscustomobject]@{ Name = $cmdParams.Identity; NotifyUser = @($cmdParams.NotifyUser) }; return } + 'Remove-ProtectionAlert' { $script:alertObj = $null; return } + default { return } + } + } + } + + It 'reports the freshly created alert as compliant in the same run' { + # Core regression: with no alert present, a remediate+report run must create the alert AND + # report the applied recipients as compliant - not the pre-remediation empty snapshot. + Invoke-CIPPStandardQuarantineRequestAlert -Tenant $script:Tenant -Settings @{ + NotifyUser = $script:NotifyUser + state = 'enabled' + remediate = $true + report = $true + } + + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { $cmdlet -eq 'New-ProtectionAlert' } + $script:compareFields.Count | Should -Be 1 + $script:compareFields[0].Current.NotifyUser | Should -Contain $script:NotifyUser + $script:bpaFields[0].FieldValue | Should -BeTrue + } + + It 'updates recipients on an existing alert and reports the applied value' { + $script:alertObj = [pscustomobject]@{ Name = $script:PolicyName; NotifyUser = @('old@contoso.com') } + + Invoke-CIPPStandardQuarantineRequestAlert -Tenant $script:Tenant -Settings @{ + NotifyUser = $script:NotifyUser + state = 'enabled' + remediate = $true + report = $true + } + + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { $cmdlet -eq 'Set-ProtectionAlert' } + $script:compareFields[0].Current.NotifyUser | Should -Contain $script:NotifyUser + $script:bpaFields[0].FieldValue | Should -BeTrue + } + + It 'reports an existing correctly configured alert as compliant without writing' { + $script:alertObj = [pscustomobject]@{ Name = $script:PolicyName; NotifyUser = @($script:NotifyUser) } + + Invoke-CIPPStandardQuarantineRequestAlert -Tenant $script:Tenant -Settings @{ + NotifyUser = $script:NotifyUser + state = 'enabled' + report = $true + } + + Should -Invoke New-ExoRequest -Times 0 -ParameterFilter { $cmdlet -in @('New-ProtectionAlert', 'Set-ProtectionAlert') } + $script:compareFields[0].Current.NotifyUser | Should -Contain $script:NotifyUser + $script:bpaFields[0].FieldValue | Should -BeTrue + } + + It 'writes Current and Expected as matching arrays when compliant' { + # Compliance is decided by CurrentValue -eq ExpectedValue, so both sides must carry the same + # shape. A single-element Expected must stay an array (a bare @() around an if-expression would + # unroll it to a scalar and never match the array-shaped Current value). + $script:alertObj = [pscustomobject]@{ Name = $script:PolicyName; NotifyUser = @($script:NotifyUser) } + + Invoke-CIPPStandardQuarantineRequestAlert -Tenant $script:Tenant -Settings @{ + NotifyUser = $script:NotifyUser + state = 'enabled' + report = $true + } + + $Current = $script:compareFields[0].Current.NotifyUser + $Expected = $script:compareFields[0].Expected.NotifyUser + ($Current -is [array]) | Should -BeTrue + ($Expected -is [array]) | Should -BeTrue + ($Current -join '|') | Should -Be ($Expected -join '|') + } + + It 'treats an alert with an unexpected extra recipient as non-compliant' { + # Exact-set semantics: an address CIPP did not configure is drift, even though the configured + # address is present. + $script:alertObj = [pscustomobject]@{ Name = $script:PolicyName; NotifyUser = @($script:NotifyUser, 'extra@contoso.com') } + + Invoke-CIPPStandardQuarantineRequestAlert -Tenant $script:Tenant -Settings @{ + NotifyUser = $script:NotifyUser + state = 'enabled' + report = $true + } + + $script:bpaFields[0].FieldValue | Should -BeFalse + $script:compareFields[0].Current.NotifyUser | Should -Contain 'extra@contoso.com' + } + + It 'reports a missing alert as non-compliant with no recipients' { + Invoke-CIPPStandardQuarantineRequestAlert -Tenant $script:Tenant -Settings @{ + NotifyUser = $script:NotifyUser + state = 'enabled' + report = $true + } + + $script:bpaFields[0].FieldValue | Should -BeFalse + @($script:compareFields[0].Current.NotifyUser) | Should -BeNullOrEmpty + } + + It 'removes the alert and reports compliant when state is removed' { + $script:alertObj = [pscustomobject]@{ Name = $script:PolicyName; NotifyUser = @($script:NotifyUser) } + + Invoke-CIPPStandardQuarantineRequestAlert -Tenant $script:Tenant -Settings @{ + state = 'removed' + remediate = $true + report = $true + } + + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { $cmdlet -eq 'Remove-ProtectionAlert' } + $script:bpaFields[0].FieldValue | Should -BeTrue + } + + It 'skips everything when the tenant is not licensed for Exchange' { + Mock -CommandName Test-CIPPStandardLicense -MockWith { $false } + + Invoke-CIPPStandardQuarantineRequestAlert -Tenant $script:Tenant -Settings @{ + NotifyUser = $script:NotifyUser + state = 'enabled' + remediate = $true + report = $true + } + + Should -Invoke New-ExoRequest -Times 0 + $script:compareFields.Count | Should -Be 0 + } +} diff --git a/Tests/Standards/Invoke-CIPPStandardSPOVersionControl.Tests.ps1 b/Tests/Standards/Invoke-CIPPStandardSPOVersionControl.Tests.ps1 new file mode 100644 index 0000000000000..c64e96c63123d --- /dev/null +++ b/Tests/Standards/Invoke-CIPPStandardSPOVersionControl.Tests.ps1 @@ -0,0 +1,149 @@ +# Pester tests for Invoke-CIPPStandardSPOVersionControl +# +# Issue #503: with automatic version trimming enabled the standard still wrote null placeholders +# for MajorVersionLimit / ExpireVersionsAfterDays into the expected object while the current +# object carried the tenant-managed numbers. The compare report and drift detection grade the +# two objects as a whole, so every auto-trim tenant showed as non-compliant even though the +# standard itself considered the state correct. In auto-trim mode only the trim flag is graded. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $StandardPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-CIPPStandardSPOVersionControl.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $StandardPath) { throw 'Could not locate Invoke-CIPPStandardSPOVersionControl.ps1 under Modules/' } + + function Test-CIPPStandardLicense { [CmdletBinding()] param($StandardName, $TenantFilter, $Preset, [switch]$SkipLog) } + function Get-CIPPSPOTenant { [CmdletBinding()] param($TenantFilter, [switch]$UseCertificate) } + function Set-CIPPSPOTenant { [CmdletBinding()] param([Parameter(ValueFromPipeline)]$InputObject, $MethodName, $MethodParameters, [switch]$UseCertificate) } + function Set-CIPPSPOSiteBulk { [CmdletBinding()] param($TenantFilter, $Sites, [switch]$UseCertificate) } + function New-GraphGetRequest { [CmdletBinding()] param($uri, $tenantid, $AsApp, $NoAuthCheck, $skipTokenCache) } + function Write-LogMessage { [CmdletBinding()] param($API, $tenant, $Tenant2, $message, $sev, $headers, $LogData) } + function Write-StandardsAlert { [CmdletBinding()] param($message, $object, $tenant, $standardName, $standardId) } + function Set-CIPPStandardsCompareField { [CmdletBinding()] param($FieldName, $CurrentValue, $ExpectedValue, $TenantFilter) } + function Add-CIPPBPAField { [CmdletBinding()] param($FieldName, $FieldValue, $StoreAs, $Tenant) } + function Get-CippException { [CmdletBinding()] param($Exception) @{ NormalizedError = $Exception.Exception.Message } } + + . $StandardPath + + $script:Tenant = 'contoso.onmicrosoft.com' +} + +Describe 'Invoke-CIPPStandardSPOVersionControl report' { + BeforeEach { + $script:Compare = $null + $script:Bpa = $null + $script:Alerts = [System.Collections.Generic.List[object]]::new() + + Mock -CommandName Test-CIPPStandardLicense -MockWith { $true } + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Set-CIPPSPOTenant -MockWith { } + Mock -CommandName Write-StandardsAlert -MockWith { + param($message, $object, $tenant, $standardName, $standardId) + $script:Alerts.Add($message) + } + Mock -CommandName Set-CIPPStandardsCompareField -MockWith { + param($FieldName, $CurrentValue, $ExpectedValue, $TenantFilter) + $script:Compare = @{ FieldName = $FieldName; Current = $CurrentValue; Expected = $ExpectedValue } + } + Mock -CommandName Add-CIPPBPAField -MockWith { + param($FieldName, $FieldValue, $StoreAs, $Tenant) + $script:Bpa = $FieldValue + } + } + + Context 'automatic trimming desired' { + BeforeEach { + # A tenant with auto-trim on still reports the limits SharePoint manages for it. + Mock -CommandName Get-CIPPSPOTenant -MockWith { + [pscustomobject]@{ + _ObjectIdentity_ = 'id' + TenantFilter = $script:Tenant + EnableAutoExpirationVersionTrim = $true + MajorVersionLimit = 500 + ExpireVersionsAfterDays = 30 + } + } + } + + It 'grades only the trim flag so tenant-managed limits are not drift' { + Invoke-CIPPStandardSPOVersionControl -Tenant $script:Tenant -Settings @{ EnableAutoTrim = $true; report = $true } + + $script:Compare.FieldName | Should -Be 'standards.SPOVersionControl' + @($script:Compare.Current.PSObject.Properties.Name) | Should -Be @('EnableAutoExpirationVersionTrim') + @($script:Compare.Expected.PSObject.Properties.Name) | Should -Be @('EnableAutoExpirationVersionTrim') + $script:Compare.Current.EnableAutoExpirationVersionTrim | Should -BeTrue + $script:Compare.Expected.EnableAutoExpirationVersionTrim | Should -BeTrue + # The same whole-object comparison the report and drift paths perform. + ($script:Compare.Current | ConvertTo-Json -Compress) | Should -Be ($script:Compare.Expected | ConvertTo-Json -Compress) + $script:Bpa | Should -BeTrue + } + + It 'still reports a tenant that has automatic trimming off' { + Mock -CommandName Get-CIPPSPOTenant -MockWith { + [pscustomobject]@{ + _ObjectIdentity_ = 'id' + TenantFilter = $script:Tenant + EnableAutoExpirationVersionTrim = $false + MajorVersionLimit = 500 + ExpireVersionsAfterDays = 0 + } + } + + Invoke-CIPPStandardSPOVersionControl -Tenant $script:Tenant -Settings @{ EnableAutoTrim = $true; report = $true; alert = $true } + + $script:Compare.Current.EnableAutoExpirationVersionTrim | Should -BeFalse + $script:Compare.Expected.EnableAutoExpirationVersionTrim | Should -BeTrue + $script:Bpa | Should -BeFalse + $script:Alerts.Count | Should -Be 1 + $script:Alerts[0] | Should -Match 'managed by Microsoft' + $script:Alerts[0] | Should -Not -Match 'Expected: .*MajorVersionLimit=' + } + + It 'does not write when the tenant already trims automatically' { + Invoke-CIPPStandardSPOVersionControl -Tenant $script:Tenant -Settings @{ EnableAutoTrim = $true; remediate = $true } + + Should -Invoke -CommandName Set-CIPPSPOTenant -Times 0 -Exactly + } + } + + Context 'fixed limits desired' { + It 'grades the flag and both limits' { + Mock -CommandName Get-CIPPSPOTenant -MockWith { + [pscustomobject]@{ + _ObjectIdentity_ = 'id' + TenantFilter = $script:Tenant + EnableAutoExpirationVersionTrim = $false + MajorVersionLimit = 50 + ExpireVersionsAfterDays = 365 + } + } + + Invoke-CIPPStandardSPOVersionControl -Tenant $script:Tenant -Settings @{ EnableAutoTrim = $false; MajorVersionLimit = 50; ExpireVersionsAfterDays = 365; report = $true } + + @($script:Compare.Expected.PSObject.Properties.Name | Sort-Object) | Should -Be @('EnableAutoExpirationVersionTrim', 'ExpireVersionsAfterDays', 'MajorVersionLimit') + $script:Compare.Expected.EnableAutoExpirationVersionTrim | Should -BeFalse + $script:Compare.Expected.MajorVersionLimit | Should -Be 50 + $script:Compare.Expected.ExpireVersionsAfterDays | Should -Be 365 + ($script:Compare.Current | ConvertTo-Json -Compress) | Should -Be ($script:Compare.Expected | ConvertTo-Json -Compress) + $script:Bpa | Should -BeTrue + } + + It 'reports a limit that differs from the configured one' { + Mock -CommandName Get-CIPPSPOTenant -MockWith { + [pscustomobject]@{ + _ObjectIdentity_ = 'id' + TenantFilter = $script:Tenant + EnableAutoExpirationVersionTrim = $false + MajorVersionLimit = 500 + ExpireVersionsAfterDays = 365 + } + } + + Invoke-CIPPStandardSPOVersionControl -Tenant $script:Tenant -Settings @{ EnableAutoTrim = $false; MajorVersionLimit = 50; ExpireVersionsAfterDays = 365; report = $true } + + $script:Compare.Current.MajorVersionLimit | Should -Be 500 + $script:Compare.Expected.MajorVersionLimit | Should -Be 50 + $script:Bpa | Should -BeFalse + } + } +} diff --git a/Tests/Standards/Invoke-CIPPStandardUserSubmissions.Tests.ps1 b/Tests/Standards/Invoke-CIPPStandardUserSubmissions.Tests.ps1 index fa625aa7d5b9f..4c72eb5038a6b 100644 --- a/Tests/Standards/Invoke-CIPPStandardUserSubmissions.Tests.ps1 +++ b/Tests/Standards/Invoke-CIPPStandardUserSubmissions.Tests.ps1 @@ -12,7 +12,7 @@ BeforeAll { function Test-CIPPStandardLicense { [CmdletBinding()] param($StandardName, $TenantFilter, $Preset, [switch]$SkipLog) } function Get-CIPPTextReplacement { [CmdletBinding()] param($TenantFilter, $Text, [switch]$EscapeForJson) } - function New-ExoRequest { [CmdletBinding()] param($tenantid, $cmdlet, $cmdParams, [switch]$UseSystemMailbox) } + function New-ExoRequest { [CmdletBinding()] param($tenantid, $cmdlet, $cmdParams, $UseSystemMailbox) } function Write-LogMessage { [CmdletBinding()] param($API, $tenant, $message, $sev, $LogData) } function Write-StandardsAlert { [CmdletBinding()] param($message, $object, $tenant, $standardName, $standardId) } function Set-CIPPStandardsCompareField { @@ -117,6 +117,74 @@ Describe 'Invoke-CIPPStandardUserSubmissions comparison payload' { $Comparison.Current.CustomDestinationRule.SentTo | Should -Be $Email } + It 'expects reporting to Microsoft OFF when the destination is the reporting mailbox only' { + $Email = 'phish@contoso.com' + $script:policyState = [pscustomobject]@{ + EnableReportToMicrosoft = $false + ReportJunkToCustomizedAddress = $true + ReportNotJunkToCustomizedAddress = $true + ReportPhishToCustomizedAddress = $true + ReportJunkAddresses = $Email + ReportNotJunkAddresses = $Email + ReportPhishAddresses = $Email + } + $script:ruleState = [pscustomobject]@{ + State = 'Enabled' + SentTo = $Email + } + + Invoke-CIPPStandardUserSubmissions -Tenant $script:Tenant -Settings @{ + state = 'enable' + email = $Email + reportDestination = 'Mailbox' + report = $true + } + + $Comparison = $script:compareFields[0] + $Comparison.Expected.EnableReportToMicrosoft | Should -BeFalse + $Comparison.Expected.CustomDestinationRule.State | Should -Be 'Enabled' + $Comparison.Expected.CustomDestinationRule.SentTo | Should -Be $Email + $Comparison.Current.EnableReportToMicrosoft | Should -BeFalse + } + + It 'remediates to the reporting mailbox only without re-enabling reporting to Microsoft' { + # The exact regression from issue #409: a tenant set to 'My reporting mailbox only' + # must not be flipped back to reporting to Microsoft by the standard. + $Email = 'phish@contoso.com' + $script:ruleState = @() + + Invoke-CIPPStandardUserSubmissions -Tenant $script:Tenant -Settings @{ + state = 'enable' + email = $Email + reportDestination = 'Mailbox' + remediate = $true + } + + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { + $cmdlet -eq 'Set-ReportSubmissionPolicy' -and + $cmdParams.EnableReportToMicrosoft -eq $false -and + $cmdParams.ReportPhishToCustomizedAddress -eq $true -and + $cmdParams.ReportPhishAddresses -eq $Email + } + } + + It 'still remediates to Microsoft and the reporting mailbox when no destination is chosen' { + $Email = 'phish@contoso.com' + $script:ruleState = @() + + Invoke-CIPPStandardUserSubmissions -Tenant $script:Tenant -Settings @{ + state = 'enable' + email = $Email + remediate = $true + } + + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { + $cmdlet -eq 'Set-ReportSubmissionPolicy' -and + $cmdParams.EnableReportToMicrosoft -eq $true -and + $cmdParams.ReportPhishAddresses -eq $Email + } + } + It 'shows both reporting and the custom destination rule disabled when the standard is disabled' { $script:policyState = [pscustomobject]@{ EnableReportToMicrosoft = $false diff --git a/Tests/Standards/Invoke-CIPPStandardcalDefault.Coverage.Tests.ps1 b/Tests/Standards/Invoke-CIPPStandardcalDefault.Coverage.Tests.ps1 new file mode 100644 index 0000000000000..a399c1fbf5358 --- /dev/null +++ b/Tests/Standards/Invoke-CIPPStandardcalDefault.Coverage.Tests.ps1 @@ -0,0 +1,125 @@ +# Pester tests for the coverage guard in Invoke-CIPPStandardcalDefault. +# +# Regression under test: only mailboxes with a cached 'Default' row are graded, so a missing +# mailbox could never enter $NeedsUpdate and an incomplete collection read as a clean sweep. +# On a real tenant 44 of 79 went uncollected; the standard saw 35 rows, all correct, and +# logged the all-clear while 8 of the unseen mailboxes were misconfigured. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardcalDefault.ps1' + if (-not (Test-Path $FunctionPath)) { throw "Could not locate $FunctionPath" } + + # Minimal stubs so Mock has commands to replace during tests. + function Write-LogMessage { param($API, $tenant, $message, $sev, $LogData) } + function Test-CIPPStandardLicense { param($StandardName, $TenantFilter, $Preset) } + function New-CIPPDbRequest { param($TenantFilter, $Type, [string[]]$Fields) } + function Set-CIPPStandardsCompareField { param($FieldName, $CurrentValue, $ExpectedValue, $TenantFilter) } + function Add-CIPPBPAField { param($FieldName, $FieldValue, $StoreAs, $Tenant) } + function Write-StandardsAlert { param($message, $object, $tenant, $standardName, $standardId) } + function New-ExoRequest { param($tenantid, $cmdlet, $cmdParams) } + function Set-CIPPDBCacheMailboxes { param($TenantFilter) } + function Get-CippException { param($Exception) } + + . $FunctionPath + + function New-CalRow { + param($Upn, $Rights) + [PSCustomObject]@{ Identity = "$Upn`:\Calendar"; User = 'Default'; AccessRights = @($Rights) } + } +} + +Describe 'Invoke-CIPPStandardcalDefault coverage guard' { + BeforeEach { + $script:Messages = [System.Collections.Generic.List[object]]::new() + $script:Compared = $null + + Mock -CommandName Test-CIPPStandardLicense -MockWith { $true } + Mock -CommandName Write-LogMessage -MockWith { $script:Messages.Add([PSCustomObject]@{ Message = $message; Sev = $sev }) } + Mock -CommandName Set-CIPPStandardsCompareField -MockWith { $script:Compared = $CurrentValue } + Mock -CommandName Add-CIPPBPAField -MockWith { } + Mock -CommandName Write-StandardsAlert -MockWith { } + # Nothing here may reach Exchange: every case below is a no-drift case. + Mock -CommandName New-ExoRequest -MockWith { throw 'New-ExoRequest must not be called' } + + # Pester 5 runs a Describe body at discovery, so shared fixtures have to be built here + # to exist when an It actually runs. + $script:Settings = @{ permissionLevel = 'Reviewer'; remediate = $true; alert = $true; report = $true } + } + + It 'reports a clean sweep only when every cached mailbox was graded' { + Mock -CommandName New-CIPPDbRequest -MockWith { + if ($Type -eq 'Mailboxes') { @(1..3 | ForEach-Object { [PSCustomObject]@{ UPN = "u$_@x.com" } }) } + else { @(1..3 | ForEach-Object { New-CalRow -Upn "u$_@x.com" -Rights 'Reviewer' }) } + } + + Invoke-CIPPStandardcalDefault -Tenant 'contoso.onmicrosoft.com' -Settings $script:Settings + + $script:Compared.state | Should -Be 'Configured correctly' + @($script:Messages | Where-Object { $_.Sev -eq 'Warning' }).Count | Should -Be 0 + @($script:Messages | Where-Object { $_.Message -like 'All 3 calendars already*' }).Count | Should -Be 1 + } + + It 'does not claim alignment when mailboxes were never evaluated' { + # 3 mailboxes cached, but only 1 has a Default calendar row - the shape the collector bug + # produced. Every graded row is already correct, so the old code logged the all-clear. + Mock -CommandName New-CIPPDbRequest -MockWith { + if ($Type -eq 'Mailboxes') { @(1..3 | ForEach-Object { [PSCustomObject]@{ UPN = "u$_@x.com" } }) } + else { @(New-CalRow -Upn 'u1@x.com' -Rights 'Reviewer') } + } + + Invoke-CIPPStandardcalDefault -Tenant 'contoso.onmicrosoft.com' -Settings $script:Settings + + $script:Compared.state | Should -BeNullOrEmpty + $script:Compared.UncheckedMailboxes | Should -Be 2 + @($script:Compared.NonCompliantCalendars).Count | Should -Be 0 + + $Warnings = @($script:Messages | Where-Object { $_.Sev -eq 'Warning' }) + $Warnings.Count | Should -Be 2 # one from the remediate branch, one from the alert branch + $Warnings[0].Message | Should -BeLike '*2 of 3 mailboxes have no cached Default calendar permission and were NOT evaluated*' + $Warnings[0].Message | Should -BeLike '*u2@x.com*' # names them, not just a count + } + + It 'does not let a stale row for a deleted mailbox mask an uncovered one' { + # ghost@x.com was deleted but still has a Default row; u2@x.com is genuinely uncovered. + # Counts net to 2 - 2 = 0 and read as full coverage; identities see the gap. + Mock -CommandName New-CIPPDbRequest -MockWith { + if ($Type -eq 'Mailboxes') { @(1..2 | ForEach-Object { [PSCustomObject]@{ UPN = "u$_@x.com" } }) } + else { @(New-CalRow -Upn 'u1@x.com' -Rights 'Reviewer'; New-CalRow -Upn 'ghost@x.com' -Rights 'Reviewer') } + } + + Invoke-CIPPStandardcalDefault -Tenant 'contoso.onmicrosoft.com' -Settings $script:Settings + + $script:Compared.UncheckedMailboxes | Should -Be 1 + @($script:Messages | Where-Object { $_.Sev -eq 'Warning' -and $_.Message -like '*u2@x.com*' }).Count | Should -Be 2 + } + + It 'matches a mailbox whose calendar Identity is an Exchange GUID, not a UPN' { + # Get-MailboxFolderPermission echoes its own canonical identity, which on the real tenant + # was the ExternalDirectoryObjectId - without the key fan-out this reads as uncovered. + Mock -CommandName New-CIPPDbRequest -MockWith { + if ($Type -eq 'Mailboxes') { + @([PSCustomObject]@{ UPN = 'u1@x.com'; ExternalDirectoryObjectId = '25a48edf-ef11-423e-aa2d-ce4831b94b51' }) + } else { + @([PSCustomObject]@{ Identity = '25a48edf-ef11-423e-aa2d-ce4831b94b51:\Calendar'; User = 'Default'; AccessRights = @('Reviewer') }) + } + } + + Invoke-CIPPStandardcalDefault -Tenant 'contoso.onmicrosoft.com' -Settings $script:Settings + + $script:Compared.state | Should -Be 'Configured correctly' + } + + It 'still flags real drift, and counts coverage alongside it' { + Mock -CommandName New-CIPPDbRequest -MockWith { + if ($Type -eq 'Mailboxes') { @(1..4 | ForEach-Object { [PSCustomObject]@{ UPN = "u$_@x.com" } }) } + else { @(New-CalRow -Upn 'u1@x.com' -Rights 'Reviewer'; New-CalRow -Upn 'u2@x.com' -Rights 'AvailabilityOnly') } + } + Mock -CommandName New-ExoRequest -MockWith { } + + Invoke-CIPPStandardcalDefault -Tenant 'contoso.onmicrosoft.com' -Settings $script:Settings + + @($script:Compared.NonCompliantCalendars).Count | Should -Be 1 + $script:Compared.UncheckedMailboxes | Should -Be 2 + } +} diff --git a/Tests/Static/PIMSecureDirection.Tests.ps1 b/Tests/Static/PIMSecureDirection.Tests.ps1 new file mode 100644 index 0000000000000..403a2984d722d --- /dev/null +++ b/Tests/Static/PIMSecureDirection.Tests.ps1 @@ -0,0 +1,80 @@ +# Source invariant: CIPP's PIM surfaces only move privileged access in the secure direction. +# +# The maintainer's rule is that CIPP must be able to convert permanent assignments to eligible and +# create time-bound active assignments, but must never - through any UI, standard, template, API +# or MCP path - create a permanent assignment, convert eligible to permanent, or weaken PIM role +# settings below the secure floor. New-CIPPPIMScheduleRequest enforces the expiration rule and +# Test-CIPPPIMRoleSettingsFloor the settings rule; this test makes sure nothing routes around them: +# +# 1. the PIM schedule-request endpoints are only addressed from the builder, so every request +# body passes its expiration checks; +# 2. no PIM-related source assigns a 'noExpiration' schedule; +# 3. no PIM-related source re-introduces the legacy permanent directoryRoles/members/$ref write; +# 4. every file that PATCHes a PIM role policy rule validates against the floor first. + +BeforeAll { + $BackendRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $ModulesRoot = Join-Path $BackendRoot 'Modules' + + $script:PIMFiles = @( + Get-ChildItem -Path (Join-Path $ModulesRoot 'CIPPCore/Public/PIM') -Filter '*.ps1' -File + Get-ChildItem -Path (Join-Path $ModulesRoot 'CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Roles') -Filter '*.ps1' -File + Get-ChildItem -Path (Join-Path $ModulesRoot 'CIPPStandards/Public/Standards') -Filter 'Invoke-CIPPStandardPIM*.ps1' -File + Get-ChildItem -Path (Join-Path $ModulesRoot 'CIPPAlerts/Public/Alerts') -Filter 'Get-CIPPAlertPermanentActiveAdminAssigned.ps1' -File + ) + if ($script:PIMFiles.Count -lt 10) { throw "Expected the PIM source set to be present; found $($script:PIMFiles.Count) files" } + + $script:Sources = foreach ($File in $script:PIMFiles) { + [pscustomobject]@{ Name = $File.Name; Text = [System.IO.File]::ReadAllText($File.FullName) } + } + + $script:AllSources = foreach ($File in (Get-ChildItem -Path $ModulesRoot -Filter '*.ps1' -File -Recurse)) { + [pscustomobject]@{ Name = $File.Name; Text = [System.IO.File]::ReadAllText($File.FullName) } + } +} + +Describe 'PIM secure-direction invariants' { + It 'addresses the schedule-request endpoints only from New-CIPPPIMScheduleRequest' { + $Pattern = [regex]'role(Eligibility|Assignment)ScheduleRequests' + $Offenders = @($script:AllSources | Where-Object { $_.Name -ne 'New-CIPPPIMScheduleRequest.ps1' -and $Pattern.IsMatch($_.Text) } | ForEach-Object { $_.Name }) + # The builder's tests mention the URIs in assertions; sources elsewhere may not. + $Offenders | Should -BeNullOrEmpty -Because 'every schedule request must go through the builder that refuses no-expiration schedules' + } + + It 'never assigns a noExpiration schedule type' { + # The string may appear in a rejection regex or in read-side detection, never as a value + # being set on a request. + $Pattern = [regex]"(type|expiration)\s*=\s*['""]noExpiration['""]" + $Offenders = @($script:Sources | Where-Object { $Pattern.IsMatch($_.Text) } | ForEach-Object { $_.Name }) + $Offenders | Should -BeNullOrEmpty + } + + It 'does not re-introduce the permanent directoryRoles member write' { + $Pattern = [regex]'directoryRoles[^\r\n]*members/\$ref' + $Offenders = @($script:Sources | Where-Object { $Pattern.IsMatch($_.Text) } | ForEach-Object { $_.Name }) + $Offenders | Should -BeNullOrEmpty -Because 'the PIM surfaces must not create permanent role memberships' + } + + It 'validates against the secure floor wherever role policy rules are written' { + $Writers = @($script:AllSources | Where-Object { $_.Text -match 'roleManagementPolicies/[^\r\n]*?/rules/' -and $_.Text -match 'PATCH' }) + $Writers.Count | Should -BeGreaterThan 0 + foreach ($Writer in $Writers) { + # Set-CIPPPIMRoleSettings is the writer; its callers must have run the floor check. + $Writer.Name | Should -Be 'Set-CIPPPIMRoleSettings.ps1' + } + $Callers = @($script:AllSources | Where-Object { $_.Name -ne 'Set-CIPPPIMRoleSettings.ps1' -and $_.Text -match 'Set-CIPPPIMRoleSettings\b' -and $_.Name -notlike '*.Tests.ps1' }) + $Callers.Count | Should -BeGreaterThan 0 + foreach ($Caller in $Callers) { + $Caller.Text | Should -Match 'Test-CIPPPIMRoleSettingsFloor' -Because "$($Caller.Name) writes PIM policy rules and must validate the floor first" + } + } + + It 'keeps every secure-direction action inside Invoke-CIPPPIMAssignmentAction or the builder' { + # The endpoint and the standards must delegate; they may not post schedule requests directly. + $Direct = @($script:Sources | Where-Object { + $_.Name -notin @('New-CIPPPIMScheduleRequest.ps1', 'Invoke-CIPPPIMAssignmentAction.ps1') -and + $_.Text -match 'New-CIPPPIMScheduleRequest\b' + } | ForEach-Object { $_.Name }) + $Direct | Should -BeNullOrEmpty + } +} diff --git a/Tests/Tenant/Invoke-ExecGDAPRepairRoleMappings.Tests.ps1 b/Tests/Tenant/Invoke-ExecGDAPRepairRoleMappings.Tests.ps1 new file mode 100644 index 0000000000000..6af4b4006d98d --- /dev/null +++ b/Tests/Tenant/Invoke-ExecGDAPRepairRoleMappings.Tests.ps1 @@ -0,0 +1,81 @@ +# Repair must recreate groups that no longer exist, not just report them: the UI promises +# "Recreate ... as a new, empty security group", and a group created by the registry pass must be +# visible to the template pass so shared mappings re-link instead of creating a duplicate. + +BeforeAll { + $BackendRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $BackendRoot 'Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPRepairRoleMappings.ps1' + + ([PSObject].Assembly.GetType('System.Management.Automation.TypeAccelerators')).GetMethod('Add').Invoke( + $null, @('HttpStatusCode', [System.Net.HttpStatusCode])) + + class HttpResponseContext { + [int]$StatusCode + [object]$Body + } + + function Get-CIPPTable { param($TableName) } + function Get-CIPPAzDataTableEntity { param($Context, $Filter, $Property) } + function New-GraphGetRequest { param($uri, $tenantid, $NoAuthCheck, $AsApp) } + function Test-CIPPGDAPGroupMappings { param($RoleMappings, $PartnerGroups, [switch]$CreateMissing, [switch]$WriteBack, $TemplateId, $APIName, $Headers) } + function Test-CIPPGDAPRelationships { param($Headers) } + function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev, $LogData) } + function Get-CippException { param($Exception) @{ NormalizedError = $Exception.Exception.Message } } + + . $FunctionPath + + $script:Request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecGDAPRepairRoleMappings' } + Headers = @{} + } +} + +Describe 'Invoke-ExecGDAPRepairRoleMappings' { + BeforeEach { + $script:Calls = [System.Collections.Generic.List[object]]::new() + Mock -CommandName Get-CIPPTable -MockWith { @{ Context = $TableName } } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + if ($Context -eq 'GDAPRoles') { + @([PSCustomObject]@{ RoleName = 'Helpdesk Administrator'; GroupName = 'M365 GDAP Helpdesk Administrator'; GroupId = 'gone'; roleDefinitionId = 'role-helpdesk' }) + } else { + @([PSCustomObject]@{ RowKey = 'Template A'; RoleMappings = '[{"RoleName":"Helpdesk Administrator","GroupName":"M365 GDAP Helpdesk Administrator","GroupId":"gone","roleDefinitionId":"role-helpdesk"}]' }) + } + } + Mock -CommandName New-GraphGetRequest -MockWith { @([PSCustomObject]@{ id = 'existing-1'; displayName = 'M365 GDAP Global Reader' }) } + Mock -CommandName Test-CIPPGDAPRelationships -MockWith { } + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Test-CIPPGDAPGroupMappings -MockWith { + $script:Calls.Add(@{ CreateMissing = [bool]$CreateMissing; PartnerGroupIds = @($PartnerGroups.id); TemplateId = $TemplateId }) + $Status = if ($PartnerGroups.displayName -contains 'M365 GDAP Helpdesk Administrator') { 'Stale' } elseif ($CreateMissing) { 'Created' } else { 'Missing' } + [PSCustomObject]@{ + Results = @([PSCustomObject]@{ RoleName = 'Helpdesk Administrator'; GroupName = 'M365 GDAP Helpdesk Administrator'; GroupId = 'new-1'; Status = $Status; Message = "status $Status"; OldGroupId = 'gone' }) + RoleMappings = @() + Valid = ($Status -ne 'Missing') + MissingGroups = @() + } + } + } + + It 'asks the validator to recreate missing groups on both passes' { + $null = Invoke-ExecGDAPRepairRoleMappings -Request $script:Request -TriggerMetadata $null + + $script:Calls.Count | Should -Be 2 + $script:Calls[0].CreateMissing | Should -BeTrue + $script:Calls[1].CreateMissing | Should -BeTrue + } + + It 'reports a recreated group as success' { + $Response = Invoke-ExecGDAPRepairRoleMappings -Request $script:Request -TriggerMetadata $null + + $Response.StatusCode | Should -Be 200 + @($Response.Body.Results | Where-Object { $_.state -eq 'error' }).Count | Should -Be 0 + $Response.Body.Results[0].resultText | Should -Match 'status Created' + } + + It 'feeds groups created by the registry pass into the template pass' { + $Response = Invoke-ExecGDAPRepairRoleMappings -Request $script:Request -TriggerMetadata $null + + $script:Calls[1].PartnerGroupIds | Should -Contain 'new-1' + $Response.Body.Results[1].resultText | Should -Match 'status Stale' + } +} diff --git a/Tests/Tenant/Invoke-ListGDAPRoles.Validate.Tests.ps1 b/Tests/Tenant/Invoke-ListGDAPRoles.Validate.Tests.ps1 new file mode 100644 index 0000000000000..50cbce2f03594 --- /dev/null +++ b/Tests/Tenant/Invoke-ListGDAPRoles.Validate.Tests.ps1 @@ -0,0 +1,97 @@ +# The ?validate=true annotation is opt-in: other consumers of ListGDAPRoles read the plain +# four-property shape, and a Graph failure must degrade to Unknown rather than break the list. + +BeforeAll { + $BackendRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $BackendRoot 'Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ListGDAPRoles.ps1' + + # The Functions worker exposes [HttpStatusCode] as an accelerator; register it for tests. + ([PSObject].Assembly.GetType('System.Management.Automation.TypeAccelerators')).GetMethod('Add').Invoke( + $null, @('HttpStatusCode', [System.Net.HttpStatusCode])) + + class HttpResponseContext { + [int]$StatusCode + [object]$Body + } + + function Get-CIPPTable { param($TableName) } + function Get-CIPPAzDataTableEntity { param($Context, $Filter, $Property) } + function New-GraphGetRequest { param($uri, $tenantid, $NoAuthCheck, $AsApp) } + function Test-CIPPGDAPGroupMappings { param($RoleMappings, $PartnerGroups, $APIName, $Headers) } + function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev, $LogData) } + function Get-CippException { param($Exception) @{ NormalizedError = $Exception.Exception.Message } } + + . $FunctionPath + + function New-Request { + param($Validate) + [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ListGDAPRoles' } + Headers = @{} + Query = @{ validate = $Validate } + } + } +} + +Describe 'Invoke-ListGDAPRoles' { + BeforeEach { + Mock -CommandName Get-CIPPTable -MockWith { @{ Context = 'stub' } } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @( + [PSCustomObject]@{ + GroupName = 'M365 GDAP User Administrator' + GroupId = 'group-user-admin' + RoleName = 'User Administrator' + roleDefinitionId = 'role-user-admin' + } + [PSCustomObject]@{ + GroupName = 'M365 GDAP Intune Administrator' + GroupId = 'group-intune' + RoleName = 'Intune Administrator' + roleDefinitionId = 'role-intune' + } + ) + } + Mock -CommandName New-GraphGetRequest -MockWith { @() } + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Test-CIPPGDAPGroupMappings -MockWith { + [PSCustomObject]@{ + Results = @( + [PSCustomObject]@{ RoleName = 'User Administrator'; GroupName = 'M365 GDAP User Administrator'; GroupId = 'group-user-admin'; Status = 'Valid'; Message = ''; OldGroupId = $null } + [PSCustomObject]@{ RoleName = 'Intune Administrator'; GroupName = 'M365 GDAP Intune Administrator'; GroupId = 'group-intune-new'; Status = 'Stale'; Message = 'stale id'; OldGroupId = 'group-intune' } + ) + } + } + } + + It 'returns the plain mapping shape without the flag' { + $Response = Invoke-ListGDAPRoles -Request (New-Request) -TriggerMetadata $null + + Should -Invoke Test-CIPPGDAPGroupMappings -Times 0 + Should -Invoke New-GraphGetRequest -Times 0 + $Response.Body.Count | Should -Be 2 + $Response.Body[0].PSObject.Properties.Name | Should -Be @('GroupName', 'GroupId', 'RoleName', 'roleDefinitionId') + } + + It 'annotates each mapping with its group status when asked' { + $Response = Invoke-ListGDAPRoles -Request (New-Request -Validate $true) -TriggerMetadata $null + + Should -Invoke Test-CIPPGDAPGroupMappings -Times 1 + $Valid = $Response.Body | Where-Object -Property GroupId -EQ 'group-user-admin' + $Valid.GroupStatus | Should -Be 'Valid' + # A stale result carries the original id as OldGroupId, so it still lands on its own row. + $Stale = $Response.Body | Where-Object -Property GroupId -EQ 'group-intune' + $Stale.GroupStatus | Should -Be 'Stale' + $Stale.GroupStatusMessage | Should -Be 'stale id' + } + + It 'degrades to Unknown when the group check fails' { + Mock -CommandName Test-CIPPGDAPGroupMappings -MockWith { throw 'graph is down' } + + $Response = Invoke-ListGDAPRoles -Request (New-Request -Validate $true) -TriggerMetadata $null + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $Response.Body.GroupStatus | Should -Be @('Unknown', 'Unknown') + Should -Invoke Write-LogMessage -Times 1 -ParameterFilter { $Sev -eq 'Warning' } + } +} diff --git a/Tests/Tenant/New-CIPPGDAPRoleMapping.Tests.ps1 b/Tests/Tenant/New-CIPPGDAPRoleMapping.Tests.ps1 new file mode 100644 index 0000000000000..7225afac67c77 --- /dev/null +++ b/Tests/Tenant/New-CIPPGDAPRoleMapping.Tests.ps1 @@ -0,0 +1,119 @@ +# Group creation behind GDAP role mappings: a role whose 'M365 GDAP ' group already +# exists must be reused rather than recreated, and the rows written to GDAPRoles must carry the +# shape Invoke-ListGDAPRoles and the role templates read back. + +BeforeAll { + $BackendRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $BackendRoot 'Modules/CIPPCore/Public/New-CIPPGDAPRoleMapping.ps1' + + # Stubs so Mock has commands to replace. + function Get-CIPPTable { param($TableName) } + function Get-CIPPAzDataTableEntity { param($Context, $Filter, $Property) } + function Add-CIPPAzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function New-GraphGetRequest { param($uri, $tenantid, $NoAuthCheck, $AsApp) } + function New-GraphBulkRequest { param($Requests, $tenantid, $NoAuthCheck, $asapp) } + + . $FunctionPath + + $script:WrittenEntities = [System.Collections.Generic.List[object]]::new() + $script:BulkRequests = $null +} + +Describe 'New-CIPPGDAPRoleMapping' { + BeforeEach { + $script:WrittenEntities = [System.Collections.Generic.List[object]]::new() + $script:BulkRequests = $null + + Mock -CommandName Get-CIPPTable -MockWith { @{ Context = 'stub' } } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { $null } + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { + foreach ($Item in @($Entity)) { $script:WrittenEntities.Add($Item) } + } + Mock -CommandName New-GraphGetRequest -MockWith { + @( + [PSCustomObject]@{ id = 'group-existing'; displayName = 'M365 GDAP User Administrator' } + [PSCustomObject]@{ id = 'group-suffixed'; displayName = 'M365 GDAP User Administrator - Helpdesk' } + ) + } + Mock -CommandName New-GraphBulkRequest -MockWith { + $script:BulkRequests = $Requests + foreach ($Item in $Requests) { + [PSCustomObject]@{ + id = $Item.id + body = [PSCustomObject]@{ + id = "new-$($Item.id)" + displayName = $Item.body.displayName + } + } + } + } + } + + It 'reuses an existing group with the default name' { + $Result = New-CIPPGDAPRoleMapping -Roles @( + @{ label = 'User Administrator'; value = 'role-user-admin' } + ) + + Should -Invoke New-GraphBulkRequest -Times 0 + $Result.RoleMappings.Count | Should -Be 1 + $Result.RoleMappings[0].GroupId | Should -Be 'group-existing' + $Result.Results | Should -Contain 'M365 GDAP User Administrator already exists' + } + + It 'creates a group for a role that has none' { + $Result = New-CIPPGDAPRoleMapping -Roles @( + @{ label = 'Intune Administrator'; value = 'role-intune' } + ) + + $script:BulkRequests.Count | Should -Be 1 + $script:BulkRequests[0].body.displayName | Should -Be 'M365 GDAP Intune Administrator' + $script:BulkRequests[0].body.mailNickname | Should -Be 'M365GDAPIntuneAdministrator' + $Result.RoleMappings[0].GroupId | Should -Be 'new-role-intune' + $Result.RoleMappings[0].RoleName | Should -Be 'Intune Administrator' + $Result.RoleMappings[0].roleDefinitionId | Should -Be 'role-intune' + $Result.Results | Should -Contain 'Created M365 GDAP Intune Administrator' + } + + It 'honours a custom suffix for both reused and created groups' { + $Result = New-CIPPGDAPRoleMapping -Roles @( + @{ label = 'User Administrator'; value = 'role-user-admin' } + @{ label = 'Exchange Administrator'; value = 'role-exchange' } + ) -CustomSuffix 'Helpdesk' + + $Reused = $Result.RoleMappings | Where-Object -Property RoleName -EQ 'User Administrator' + $Reused.GroupId | Should -Be 'group-suffixed' + $Reused.GroupName | Should -Be 'M365 GDAP User Administrator - Helpdesk' + + $script:BulkRequests[0].body.displayName | Should -Be 'M365 GDAP Exchange Administrator - Helpdesk' + $script:BulkRequests[0].body.mailNickname | Should -Be 'M365GDAPExchangeAdministratorHelpdesk' + + # The suffix is stripped back off for the stored role name. + $Created = $Result.RoleMappings | Where-Object -Property RoleName -EQ 'Exchange Administrator' + $Created.GroupName | Should -Be 'M365 GDAP Exchange Administrator - Helpdesk' + } + + It 'writes GDAPRoles rows in the expected shape' { + $null = New-CIPPGDAPRoleMapping -Roles @( + @{ label = 'User Administrator'; value = 'role-user-admin' } + @{ label = 'Intune Administrator'; value = 'role-intune' } + ) + + $script:WrittenEntities.Count | Should -Be 2 + foreach ($Entity in $script:WrittenEntities) { + $Entity.PartitionKey | Should -Be 'Roles' + $Entity.RowKey | Should -Be $Entity.GroupId + $Entity.Keys | Should -Contain 'RoleName' + $Entity.Keys | Should -Contain 'GroupName' + $Entity.Keys | Should -Contain 'roleDefinitionId' + } + } + + It 'accepts the catalog shape (Name/ObjectId) as well as label/value' { + $Result = New-CIPPGDAPRoleMapping -Roles @( + [PSCustomObject]@{ Name = 'User Administrator'; ObjectId = 'role-user-admin' } + ) + + $Result.RoleMappings[0].GroupId | Should -Be 'group-existing' + $Result.RoleMappings[0].roleDefinitionId | Should -Be 'role-user-admin' + } +} diff --git a/Tests/Webhooks/Test-CIPPAuditLogRules.Tests.ps1 b/Tests/Webhooks/Test-CIPPAuditLogRules.Tests.ps1 index 05382be2e0600..9aa6d74db31a4 100644 --- a/Tests/Webhooks/Test-CIPPAuditLogRules.Tests.ps1 +++ b/Tests/Webhooks/Test-CIPPAuditLogRules.Tests.ps1 @@ -89,21 +89,22 @@ Describe 'Test-CIPPAuditLogRules record shaping' { switch ($TableName) { 'WebhookRules' { [pscustomobject]@{ - PartitionKey = 'WebhookRules' - RowKey = 'rule-1' - Tenants = (@('AllTenants') | ConvertTo-Json -Compress) - excludedTenants = $null - Conditions = (@( + PartitionKey = 'WebhookRules' + RowKey = 'rule-1' + Tenants = (@('AllTenants') | ConvertTo-Json -Compress) + excludedTenants = $null + Conditions = (@( @{ Property = @{ label = 'Operation' } Operator = @{ label = 'eq' } Input = @{ value = 'Set-Mailbox' } } ) | ConvertTo-Json -Compress -Depth 5) - Actions = (@('generatemail') | ConvertTo-Json -Compress) - Type = 'Audit' - AlertComment = 'test comment' - CustomSubject = '' + Actions = (@('generatemail') | ConvertTo-Json -Compress) + Type = 'Audit' + AlertComment = 'test comment' + CustomSubject = '' + PsaTicketPriority = '5' } } 'cacheauditloglookups' { @@ -243,6 +244,9 @@ Describe 'Test-CIPPAuditLogRules record shaping' { $data.CIPPAction | Should -Not -BeNullOrEmpty $data.CIPPClause | Should -Not -BeNullOrEmpty $data.CIPPAlertComment | Should -Be 'test comment' + # Covers the full per-alert priority chain through this function: the config + # projection, the where-clause object, and the stamp onto the matched record. + $data.CIPPPsaTicketPriority | Should -Be '5' } It 'dispatches the matched record to webhook processing' { diff --git a/version_latest.txt b/version_latest.txt index 8709113af4b9a..4843a6d662fa9 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.9.1 \ No newline at end of file +10.10.0 \ No newline at end of file