diff --git a/.gitignore b/.gitignore index 073300712d7e..93317828cfe5 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ yarn.lock # Cursor IDE .cursor/rules +.claude # Ignore all root PowerShell files except profile.ps1 /*.ps1 diff --git a/CIPPDBCacheTypes.json b/CIPPDBCacheTypes.json new file mode 100644 index 000000000000..28fac3a6fde8 --- /dev/null +++ b/CIPPDBCacheTypes.json @@ -0,0 +1,332 @@ +[ + { + "type": "Users", + "friendlyName": "Users", + "description": "All Azure AD users with sign-in activity" + }, + { + "type": "Groups", + "friendlyName": "Groups", + "description": "All Azure AD groups with members" + }, + { + "type": "Guests", + "friendlyName": "Guest Users", + "description": "All guest users in the tenant" + }, + { + "type": "ServicePrincipals", + "friendlyName": "Service Principals", + "description": "All service principals (applications)" + }, + { + "type": "Apps", + "friendlyName": "Application Registrations", + "description": "All application registrations with owners" + }, + { + "type": "Devices", + "friendlyName": "Azure AD Devices", + "description": "All Azure AD registered devices" + }, + { + "type": "Organization", + "friendlyName": "Organization", + "description": "Tenant organization information" + }, + { + "type": "Roles", + "friendlyName": "Directory Roles", + "description": "All Azure AD directory roles with members" + }, + { + "type": "AdminConsentRequestPolicy", + "friendlyName": "Admin Consent Request Policy", + "description": "Admin consent request policy settings" + }, + { + "type": "AuthorizationPolicy", + "friendlyName": "Authorization Policy", + "description": "Tenant authorization policy" + }, + { + "type": "AuthenticationMethodsPolicy", + "friendlyName": "Authentication Methods Policy", + "description": "Authentication methods policy configuration" + }, + { + "type": "DeviceSettings", + "friendlyName": "Device Settings", + "description": "Device management settings" + }, + { + "type": "DirectoryRecommendations", + "friendlyName": "Directory Recommendations", + "description": "Azure AD directory recommendations" + }, + { + "type": "CrossTenantAccessPolicy", + "friendlyName": "Cross-Tenant Access Policy", + "description": "Cross-tenant access policy configuration" + }, + { + "type": "DefaultAppManagementPolicy", + "friendlyName": "Default App Management Policy", + "description": "Default application management policy" + }, + { + "type": "Settings", + "friendlyName": "Directory Settings", + "description": "Directory settings configuration" + }, + { + "type": "SecureScore", + "friendlyName": "Secure Score", + "description": "Microsoft Secure Score and control profiles" + }, + { + "type": "PIMSettings", + "friendlyName": "PIM Settings", + "description": "Privileged Identity Management settings and assignments" + }, + { + "type": "Domains", + "friendlyName": "Domains", + "description": "All verified and unverified domains" + }, + { + "type": "RoleEligibilitySchedules", + "friendlyName": "Role Eligibility Schedules", + "description": "PIM role eligibility schedules" + }, + { + "type": "RoleManagementPolicies", + "friendlyName": "Role Management Policies", + "description": "Role management policies" + }, + { + "type": "RoleAssignmentScheduleInstances", + "friendlyName": "Role Assignment Schedule Instances", + "description": "Active role assignment instances" + }, + { + "type": "B2BManagementPolicy", + "friendlyName": "B2B Management Policy", + "description": "B2B collaboration policy settings" + }, + { + "type": "AuthenticationFlowsPolicy", + "friendlyName": "Authentication Flows Policy", + "description": "Authentication flows policy configuration" + }, + { + "type": "DeviceRegistrationPolicy", + "friendlyName": "Device Registration Policy", + "description": "Device registration policy settings" + }, + { + "type": "CredentialUserRegistrationDetails", + "friendlyName": "Credential User Registration Details", + "description": "User credential registration details" + }, + { + "type": "UserRegistrationDetails", + "friendlyName": "User Registration Details", + "description": "MFA registration details for users" + }, + { + "type": "OAuth2PermissionGrants", + "friendlyName": "OAuth2 Permission Grants", + "description": "OAuth2 permission grants" + }, + { + "type": "AppRoleAssignments", + "friendlyName": "App Role Assignments", + "description": "Application role assignments" + }, + { + "type": "LicenseOverview", + "friendlyName": "License Overview", + "description": "License usage overview" + }, + { + "type": "MFAState", + "friendlyName": "MFA State", + "description": "Multi-factor authentication state" + }, + { + "type": "ExoAntiPhishPolicies", + "friendlyName": "Exchange Anti-Phish Policies", + "description": "Exchange Online anti-phishing policies" + }, + { + "type": "ExoMalwareFilterPolicies", + "friendlyName": "Exchange Malware Filter Policies", + "description": "Exchange Online malware filter policies" + }, + { + "type": "ExoSafeLinksPolicies", + "friendlyName": "Exchange Safe Links Policies", + "description": "Exchange Online Safe Links policies" + }, + { + "type": "ExoSafeAttachmentPolicies", + "friendlyName": "Exchange Safe Attachment Policies", + "description": "Exchange Online Safe Attachment policies" + }, + { + "type": "ExoTransportRules", + "friendlyName": "Exchange Transport Rules", + "description": "Exchange Online transport rules" + }, + { + "type": "ExoDkimSigningConfig", + "friendlyName": "Exchange DKIM Signing Config", + "description": "Exchange Online DKIM signing configuration" + }, + { + "type": "ExoOrganizationConfig", + "friendlyName": "Exchange Organization Config", + "description": "Exchange Online organization configuration" + }, + { + "type": "ExoAcceptedDomains", + "friendlyName": "Exchange Accepted Domains", + "description": "Exchange Online accepted domains" + }, + { + "type": "ExoHostedContentFilterPolicy", + "friendlyName": "Exchange Hosted Content Filter Policy", + "description": "Exchange Online hosted content filter policy" + }, + { + "type": "ExoHostedOutboundSpamFilterPolicy", + "friendlyName": "Exchange Hosted Outbound Spam Filter Policy", + "description": "Exchange Online hosted outbound spam filter policy" + }, + { + "type": "ExoAntiPhishPolicy", + "friendlyName": "Exchange Anti-Phish Policy", + "description": "Exchange Online anti-phishing policy" + }, + { + "type": "ExoSafeLinksPolicy", + "friendlyName": "Exchange Safe Links Policy", + "description": "Exchange Online Safe Links policy" + }, + { + "type": "ExoSafeAttachmentPolicy", + "friendlyName": "Exchange Safe Attachment Policy", + "description": "Exchange Online Safe Attachment policy" + }, + { + "type": "ExoMalwareFilterPolicy", + "friendlyName": "Exchange Malware Filter Policy", + "description": "Exchange Online malware filter policy" + }, + { + "type": "ExoAtpPolicyForO365", + "friendlyName": "Exchange ATP Policy for O365", + "description": "Exchange Online Advanced Threat Protection policy" + }, + { + "type": "ExoQuarantinePolicy", + "friendlyName": "Exchange Quarantine Policy", + "description": "Exchange Online quarantine policy" + }, + { + "type": "ExoRemoteDomain", + "friendlyName": "Exchange Remote Domain", + "description": "Exchange Online remote domain configuration" + }, + { + "type": "ExoSharingPolicy", + "friendlyName": "Exchange Sharing Policy", + "description": "Exchange Online sharing policies" + }, + { + "type": "ExoAdminAuditLogConfig", + "friendlyName": "Exchange Admin Audit Log Config", + "description": "Exchange Online admin audit log configuration" + }, + { + "type": "ExoPresetSecurityPolicy", + "friendlyName": "Exchange Preset Security Policy", + "description": "Exchange Online preset security policy" + }, + { + "type": "ExoTenantAllowBlockList", + "friendlyName": "Exchange Tenant Allow/Block List", + "description": "Exchange Online tenant allow/block list" + }, + { + "type": "Mailboxes", + "friendlyName": "Mailboxes", + "description": "All Exchange Online mailboxes" + }, + { + "type": "CASMailboxes", + "friendlyName": "CAS Mailboxes", + "description": "Client Access Server mailbox settings" + }, + { + "type": "MailboxUsage", + "friendlyName": "Mailbox Usage", + "description": "Exchange Online mailbox usage statistics" + }, + { + "type": "OneDriveUsage", + "friendlyName": "OneDrive Usage", + "description": "OneDrive usage statistics" + }, + { + "type": "ConditionalAccessPolicies", + "friendlyName": "Conditional Access Policies", + "description": "Azure AD Conditional Access policies" + }, + { + "type": "RiskyUsers", + "friendlyName": "Risky Users", + "description": "Users flagged as risky by Identity Protection" + }, + { + "type": "RiskyServicePrincipals", + "friendlyName": "Risky Service Principals", + "description": "Service principals flagged as risky by Identity Protection" + }, + { + "type": "ServicePrincipalRiskDetections", + "friendlyName": "Service Principal Risk Detections", + "description": "Risk detections for service principals" + }, + { + "type": "RiskDetections", + "friendlyName": "Risk Detections", + "description": "Identity Protection risk detections" + }, + { + "type": "ManagedDevices", + "friendlyName": "Managed Devices", + "description": "Intune managed devices" + }, + { + "type": "IntunePolicies", + "friendlyName": "Intune Policies", + "description": "All Intune policies including compliance, configuration, and app protection" + }, + { + "type": "ManagedDeviceEncryptionStates", + "friendlyName": "Managed Device Encryption States", + "description": "BitLocker encryption states for managed devices" + }, + { + "type": "IntuneAppProtectionPolicies", + "friendlyName": "Intune App Protection Policies", + "description": "Intune app protection policies for iOS and Android" + }, + { + "type": "DetectedApps", + "friendlyName": "Detected Apps", + "description": "All detected applications with devices where each app is installed" + } +] diff --git a/CIPPTimers.json b/CIPPTimers.json index f76dba8941e2..ed7cd4c31819 100644 --- a/CIPPTimers.json +++ b/CIPPTimers.json @@ -135,8 +135,8 @@ "Id": "c2ebde3f-fa35-45aa-8a6b-91c835050b79", "Command": "Start-DomainOrchestrator", "Description": "Orchestrator to process domains", - "Cron": "0 0 0 * * *", - "Priority": 10, + "Cron": "0 30 3 * * *", + "Priority": 22, "RunOnProcessor": true }, { @@ -223,12 +223,21 @@ "RunOnProcessor": true, "IsSystem": true }, + { + "Id": "a9e8d7c6-b5a4-3f2e-1d0c-9b8a7f6e5d4c", + "Command": "Start-LogRetentionCleanup", + "Description": "Timer to cleanup old logs based on retention policy", + "Cron": "0 30 2 * * *", + "Priority": 22, + "RunOnProcessor": true, + "IsSystem": true + }, { "Id": "9a7f8e6d-5c4b-3a2d-1e0f-9b8c7d6e5f4a", "Command": "Start-CIPPDBCacheOrchestrator", "Description": "Timer to collect and cache Microsoft Graph data for all tenants", "Cron": "0 0 3 * * *", - "Priority": 22, + "Priority": 23, "RunOnProcessor": true, "IsSystem": true }, @@ -237,7 +246,7 @@ "Command": "Start-TestsOrchestrator", "Description": "Timer to run security and compliance tests against cached data", "Cron": "0 0 4 * * *", - "Priority": 23, + "Priority": 24, "RunOnProcessor": true, "IsSystem": true } diff --git a/Modules/CIPPCore/Public/Add-CIPPDbItem.ps1 b/Modules/CIPPCore/Public/Add-CIPPDbItem.ps1 index be862eabe375..9dd5a5af2abf 100644 --- a/Modules/CIPPCore/Public/Add-CIPPDbItem.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPDbItem.ps1 @@ -22,6 +22,10 @@ function Add-CIPPDbItem { .PARAMETER AddCount If specified, automatically records the total count after processing all items + .PARAMETER Append + If specified, adds items without clearing existing entries for this type/tenant and automatically + increments the count. Useful for accumulating report data over time. By default, existing entries are replaced. + .EXAMPLE Add-CIPPDbItem -TenantFilter 'contoso.onmicrosoft.com' -Type 'Groups' -Data $GroupsData @@ -30,6 +34,9 @@ function Add-CIPPDbItem { .EXAMPLE Add-CIPPDbItem -TenantFilter 'contoso.onmicrosoft.com' -Type 'Groups' -Data $GroupsData -Count + + .EXAMPLE + Add-CIPPDbItem -TenantFilter 'contoso.onmicrosoft.com' -Type 'AlertHistory' -Data $AlertData -Append -AddCount #> [CmdletBinding()] param( @@ -49,7 +56,10 @@ function Add-CIPPDbItem { [switch]$Count, [Parameter(Mandatory = $false)] - [switch]$AddCount + [switch]$AddCount, + + [Parameter(Mandatory = $false)] + [switch]$Append ) begin { @@ -104,7 +114,7 @@ function Add-CIPPDbItem { } } - if (-not $Count.IsPresent) { + if (-not $Count.IsPresent -and -not $Append.IsPresent) { # Delete existing entries for this type $Filter = "PartitionKey eq '{0}' and RowKey ge '{1}-' and RowKey lt '{1}0'" -f $TenantFilter, $Type $ExistingEntities = Get-CIPPAzDataTableEntity @Table -Filter $Filter -Property PartitionKey, RowKey, ETag @@ -168,18 +178,29 @@ function Add-CIPPDbItem { Invoke-FlushBatch -State $State } - if ($Count.IsPresent) { + if ($Count.IsPresent -or $Append.IsPresent) { # Store count record + if ($Append.IsPresent) { + # When appending, always increment the existing count + $Filter = "PartitionKey eq '{0}' and RowKey eq '{1}-Count'" -f $TenantFilter, $Type + $ExistingCount = Get-CIPPAzDataTableEntity @Table -Filter $Filter + $PreviousCount = if ($ExistingCount -and $ExistingCount.DataCount) { [int]$ExistingCount.DataCount } else { 0 } + $NewCount = $PreviousCount + $State.TotalProcessed + } else { + # Normal mode - replace count + $NewCount = $State.TotalProcessed + } + $Entity = @{ PartitionKey = $TenantFilter RowKey = Format-RowKey "$Type-Count" - DataCount = [int]$State.TotalProcessed + DataCount = [int]$NewCount } Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force | Out-Null } Write-LogMessage -API 'CIPPDbItem' -tenant $TenantFilter ` - -message "Added $($State.TotalProcessed) items of type $Type$(if ($Count.IsPresent) { ' (count mode)' })" -sev Debug + -message "Added $($State.TotalProcessed) items of type $Type$(if ($Count.IsPresent) { ' (count mode)' })$(if ($Append.IsPresent) { ' (append mode)' })" -sev Debug } catch { Write-LogMessage -API 'CIPPDbItem' -tenant $TenantFilter ` @@ -191,7 +212,16 @@ function Add-CIPPDbItem { # Record count if AddCount was specified if ($AddCount.IsPresent -and $State.TotalProcessed -gt 0) { try { - Add-CIPPDbItem -TenantFilter $TenantFilter -Type $Type -InputObject $State.TotalProcessed -Count + $countParams = @{ + TenantFilter = $TenantFilter + Type = $Type + InputObject = $State.TotalProcessed + Count = $true + } + if ($Append.IsPresent) { + $countParams.Append = $true + } + Add-CIPPDbItem @countParams } catch { Write-LogMessage -API 'CIPPDbItem' -tenant $TenantFilter ` -message "Failed to record count for $Type : $($_.Exception.Message)" -sev Warning diff --git a/Modules/CIPPCore/Public/Add-CIPPPackagedApplication.ps1 b/Modules/CIPPCore/Public/Add-CIPPPackagedApplication.ps1 new file mode 100644 index 000000000000..0730a11e16a1 --- /dev/null +++ b/Modules/CIPPCore/Public/Add-CIPPPackagedApplication.ps1 @@ -0,0 +1,77 @@ +function Add-CIPPPackagedApplication { + <# + .SYNOPSIS + Adds a packaged Win32Lob application to Intune. + + .DESCRIPTION + Handles creation of Win32Lob apps with intunewin files and uploads the content. + + .PARAMETER AppBody + Hashtable or PSCustomObject containing the app configuration. + + .PARAMETER TenantFilter + Tenant ID or domain name for the Graph API call. + + .PARAMETER AppType + Type of app: 'Choco' or 'MSPApp'. + + .PARAMETER FilePath + Path to the intunewin file. + + .PARAMETER FileName + Name of the file from XML metadata. + + .PARAMETER UnencryptedSize + Unencrypted size of the file from XML metadata. + + .PARAMETER EncryptionInfo + Hashtable containing encryption information from XML. + + .PARAMETER DisplayName + Display name of the app for logging. + + .PARAMETER APIName + API name for logging (optional). + + .PARAMETER Headers + Request headers for logging (optional). + + .EXAMPLE + $AppBody = @{ '@odata.type' = '#microsoft.graph.win32LobApp'; displayName = 'My App' } + $EncryptionInfo = @{ EncryptionKey = '...'; MacKey = '...'; ... } + Add-CIPPPackagedApplication -AppBody $AppBody -TenantFilter 'contoso.com' -AppType 'Choco' -FilePath 'app.intunewin' -FileName 'app.intunewin' -UnencryptedSize 1024000 -EncryptionInfo $EncryptionInfo + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [object]$AppBody, + + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $true)] + [string]$FilePath, + + [Parameter(Mandatory = $true)] + [string]$FileName, + + [Parameter(Mandatory = $true)] + [int64]$UnencryptedSize, + + [Parameter(Mandatory = $true)] + [hashtable]$EncryptionInfo, + + [Parameter(Mandatory = $false)] + [string]$DisplayName + ) + + $BaseUri = 'https://graph.microsoft.com/beta/deviceAppManagement/mobileApps' + + # Create the Win32Lob app + $NewApp = New-GraphPostRequest -Uri $BaseUri -Body ($AppBody | ConvertTo-Json) -Type POST -tenantid $TenantFilter + + # Upload intunewin content + Add-CIPPWin32LobAppContent -AppId $NewApp.id -FilePath $FilePath -FileName $FileName -UnencryptedSize $UnencryptedSize -EncryptionInfo $EncryptionInfo -TenantFilter $TenantFilter -APIName $APIName -Headers $Headers + + return $NewApp +} diff --git a/Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 b/Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 new file mode 100644 index 000000000000..5ef92c5a997a --- /dev/null +++ b/Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 @@ -0,0 +1,215 @@ +function Add-CIPPW32ScriptApplication { + <# + .SYNOPSIS + Adds a Win32 app with PowerShell script installer to Intune using the standard Chocolatey package. + + .DESCRIPTION + Creates a Win32 app that uses the standard Chocolatey intunewin package but with custom PowerShell scripts. + Always uploads the same Choco package, but uses user-provided scripts for install/uninstall commands. + + .PARAMETER TenantFilter + Tenant ID or domain name for the Graph API call. + + .PARAMETER Properties + PSCustomObject containing all Win32 app properties: + - displayName (required): Display name of the app + - description: Description of the app + - publisher: Publisher name + - installScript (required): PowerShell install script content (plaintext) + - uninstallScript: PowerShell uninstall script content (plaintext) + - detectionPath (required): Full path to the file or folder to detect (e.g., 'C:\\Program Files\\MyApp') + - detectionFile: File name to detect (optional, for folder path detection) + - detectionType: 'exists', 'modifiedDate', 'createdDate', 'version', 'sizeInMB' (default: 'exists') + - check32BitOn64System: Boolean, check 32-bit registry/paths on 64-bit systems (default: false) + - runAsAccount: 'system' or 'user' (default: 'system') + - deviceRestartBehavior: 'allow', 'suppress', or 'force' (default: 'suppress') + + .EXAMPLE + $Properties = @{ + displayName = 'My Script App' + installScript = 'Write-Host "Installing..."' + detectionPath = 'C:\\Program Files\\MyApp' + detectionFile = 'app.exe' + } + Add-CIPPW32ScriptApplication -TenantFilter 'contoso.com' -Properties $Properties + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $true)] + [PSCustomObject]$Properties + ) + + # Get the standard Chocolatey package location (relative to function app root) + $IntuneWinFile = 'AddChocoApp\IntunePackage.intunewin' + $ChocoXmlFile = 'AddChocoApp\Choco.App.xml' + + if (-not (Test-Path $IntuneWinFile)) { + throw "Chocolatey IntunePackage.intunewin not found at: $IntuneWinFile (Current directory: $PWD)" + } + + if (-not (Test-Path $ChocoXmlFile)) { + throw "Choco.App.xml not found at: $ChocoXmlFile (Current directory: $PWD)" + } + + # Parse the Choco XML to get encryption info. We need a wrapper around the application and this is a tiny intune file, perfect for our purpose. + [xml]$ChocoXml = Get-Content $ChocoXmlFile + $EncryptionInfo = @{ + EncryptionKey = $ChocoXml.ApplicationInfo.EncryptionInfo.EncryptionKey + MacKey = $ChocoXml.ApplicationInfo.EncryptionInfo.MacKey + InitializationVector = $ChocoXml.ApplicationInfo.EncryptionInfo.InitializationVector + Mac = $ChocoXml.ApplicationInfo.EncryptionInfo.Mac + ProfileIdentifier = $ChocoXml.ApplicationInfo.EncryptionInfo.ProfileIdentifier + FileDigest = $ChocoXml.ApplicationInfo.EncryptionInfo.FileDigest + FileDigestAlgorithm = $ChocoXml.ApplicationInfo.EncryptionInfo.FileDigestAlgorithm + } + + $FileName = $ChocoXml.ApplicationInfo.FileName + $UnencryptedSize = [int64]$ChocoXml.ApplicationInfo.UnencryptedContentSize + + # Build detection rules + if ($Properties.detectionPath) { + # Determine if this is a file or folder detection + $DetectionRule = @{ + '@odata.type' = '#microsoft.graph.win32LobAppFileSystemDetection' + check32BitOn64System = if ($null -ne $Properties.check32BitOn64System) { [bool]$Properties.check32BitOn64System } else { $false } + detectionType = if ($Properties.detectionType) { $Properties.detectionType } else { 'exists' } + } + + if ($Properties.detectionFile) { + # File detection (path + file) + $DetectionRule['path'] = $Properties.detectionPath + $DetectionRule['fileOrFolderName'] = $Properties.detectionFile + } else { + # Folder/File detection (full path) + # Split the path into directory and file/folder name + $PathItem = Split-Path $Properties.detectionPath -Leaf + $ParentPath = Split-Path $Properties.detectionPath -Parent + + if ([string]::IsNullOrEmpty($ParentPath)) { + throw "Invalid detection path: $($Properties.detectionPath). Must be a full path." + } + + $DetectionRule['path'] = $ParentPath + $DetectionRule['fileOrFolderName'] = $PathItem + } + + $DetectionRules = @($DetectionRule) + } else { + # Default detection: Check for a marker file in ProgramData + $DetectionRules = @( + @{ + '@odata.type' = '#microsoft.graph.win32LobAppFileSystemDetection' + path = '%ProgramData%\CIPPApps' + fileOrFolderName = "$($Properties.displayName -replace '[^a-zA-Z0-9]', '_').txt" + check32BitOn64System = $false + detectionType = 'exists' + } + ) + } + + # Build the Win32 app body + $AppBody = @{ + '@odata.type' = '#microsoft.graph.win32LobApp' + displayName = $Properties.displayName + description = $Properties.description + publisher = if ($Properties.publisher) { $Properties.publisher } else { 'CIPP' } + fileName = $FileName + setupFilePath = 'N/A' + installCommandLine = 'powershell.exe -ExecutionPolicy Bypass -File install.ps1' + uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass -File uninstall.ps1' + minimumSupportedWindowsRelease = '1607' + detectionRules = $DetectionRules + returnCodes = @( + @{ returnCode = 0; type = 'success' } + @{ returnCode = 1707; type = 'success' } + @{ returnCode = 3010; type = 'softReboot' } + @{ returnCode = 1641; type = 'hardReboot' } + @{ returnCode = 1618; type = 'retry' } + ) + installExperience = @{ + '@odata.type' = 'microsoft.graph.win32LobAppInstallExperience' + runAsAccount = if ($Properties.runAsAccount) { $Properties.runAsAccount } else { 'system' } + deviceRestartBehavior = if ($Properties.deviceRestartBehavior) { $Properties.deviceRestartBehavior } else { 'suppress' } + } + } + + # Create the app first + $Baseuri = 'https://graph.microsoft.com/beta/deviceAppManagement/mobileApps' + $NewApp = New-GraphPostRequest -Uri $Baseuri -Body ($AppBody | ConvertTo-Json -Depth 10) -Type POST -tenantid $TenantFilter + Start-Sleep -Milliseconds 200 + + # Upload the Chocolatey intunewin content + Add-CIPPWin32LobAppContent -AppId $NewApp.id -FilePath $IntuneWinFile -FileName $FileName -UnencryptedSize $UnencryptedSize -EncryptionInfo $EncryptionInfo -TenantFilter $TenantFilter + + # Upload PowerShell scripts via the scripts endpoint (newer method) + $InstallScriptId = $null + $UninstallScriptId = $null + + if ($Properties.installScript) { + $InstallScriptContent = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Properties.installScript)) + $InstallScriptBody = @{ + '@odata.type' = '#microsoft.graph.win32LobAppInstallPowerShellScript' + displayName = 'install.ps1' + enforceSignatureCheck = $false + runAs32Bit = $false + content = $InstallScriptContent + } | ConvertTo-Json + + $InstallScriptResponse = New-GraphPostRequest -Uri "$Baseuri/$($NewApp.id)/microsoft.graph.win32LobApp/contentVersions/1/scripts" -Body $InstallScriptBody -Type POST -tenantid $TenantFilter + $InstallScriptId = $InstallScriptResponse.id + + # Wait for script to be committed + do { + $ScriptState = New-GraphGetRequest -Uri "$Baseuri/$($NewApp.id)/microsoft.graph.win32LobApp/contentVersions/1/scripts/$InstallScriptId" -tenantid $TenantFilter + if ($ScriptState.state -like '*fail*') { + throw "Failed to commit install script: $($ScriptState.state)" + } + Start-Sleep -Milliseconds 300 + } while ($ScriptState.state -eq 'commitPending') + } + + if ($Properties.uninstallScript) { + $UninstallScriptContent = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Properties.uninstallScript)) + $UninstallScriptBody = @{ + '@odata.type' = '#microsoft.graph.win32LobAppUninstallPowerShellScript' + displayName = 'uninstall.ps1' + enforceSignatureCheck = $false + runAs32Bit = $false + content = $UninstallScriptContent + } | ConvertTo-Json + + $UninstallScriptResponse = New-GraphPostRequest -Uri "$Baseuri/$($NewApp.id)/microsoft.graph.win32LobApp/contentVersions/1/scripts" -Body $UninstallScriptBody -Type POST -tenantid $TenantFilter + $UninstallScriptId = $UninstallScriptResponse.id + + # Wait for script to be committed + do { + $ScriptState = New-GraphGetRequest -Uri "$Baseuri/$($NewApp.id)/microsoft.graph.win32LobApp/contentVersions/1/scripts/$UninstallScriptId" -tenantid $TenantFilter + if ($ScriptState.state -like '*fail*') { + throw "Failed to commit uninstall script: $($ScriptState.state)" + } + Start-Sleep -Milliseconds 300 + } while ($ScriptState.state -eq 'commitPending') + } + + # Build final commit body with active script references + $CommitBody = @{ + '@odata.type' = '#microsoft.graph.win32LobApp' + committedContentVersion = '1' + } + + if ($InstallScriptId) { + $CommitBody['activeInstallScript'] = @{ targetId = $InstallScriptId } + } + + if ($UninstallScriptId) { + $CommitBody['activeUninstallScript'] = @{ targetId = $UninstallScriptId } + } + + # Commit the app with script references + $null = New-GraphPostRequest -Uri "$Baseuri/$($NewApp.id)" -tenantid $TenantFilter -Body ($CommitBody | ConvertTo-Json -Depth 10) -Type PATCH + + return $NewApp +} diff --git a/Modules/CIPPCore/Public/Add-CIPPWin32LobAppContent.ps1 b/Modules/CIPPCore/Public/Add-CIPPWin32LobAppContent.ps1 new file mode 100644 index 000000000000..1b238870e622 --- /dev/null +++ b/Modules/CIPPCore/Public/Add-CIPPWin32LobAppContent.ps1 @@ -0,0 +1,152 @@ +function Add-CIPPWin32LobAppContent { + <# + .SYNOPSIS + Uploads intunewin file content to a Win32Lob app in Intune. + + .DESCRIPTION + This function handles the complete process of uploading an intunewin file to a Win32Lob app: + 1. Creates a content version file entry + 2. Waits for Azure Storage URI + 3. Uploads the file to Azure Storage in chunks + 4. Commits the file with encryption info + 5. Finalizes the content version + + .PARAMETER AppId + The ID of the Win32Lob app to upload content to. + + .PARAMETER FilePath + Path to the intunewin file to upload. + + .PARAMETER FileName + Name of the file (from XML metadata). + + .PARAMETER UnencryptedSize + Unencrypted size of the file (from XML metadata). + + .PARAMETER EncryptionInfo + Hashtable containing encryption information from XML: + - EncryptionKey + - MacKey + - InitializationVector + - Mac + - ProfileIdentifier + - FileDigest + - FileDigestAlgorithm + + .PARAMETER TenantFilter + Tenant ID or domain name for the Graph API call. + + .PARAMETER APIName + API name for logging (optional). + + .PARAMETER Headers + Request headers for logging (optional). + + .EXAMPLE + $EncryptionInfo = @{ + EncryptionKey = '...' + MacKey = '...' + InitializationVector = '...' + Mac = '...' + ProfileIdentifier = 'ProfileVersion1' + FileDigest = '...' + FileDigestAlgorithm = 'SHA256' + } + Add-CIPPWin32LobAppContent -AppId '12345' -FilePath 'C:\app.intunewin' -FileName 'app.intunewin' -UnencryptedSize 1024000 -EncryptionInfo $EncryptionInfo -TenantFilter 'contoso.com' + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$AppId, + + [Parameter(Mandatory = $true)] + [string]$FilePath, + + [Parameter(Mandatory = $true)] + [string]$FileName, + + [Parameter(Mandatory = $true)] + [int64]$UnencryptedSize, + + [Parameter(Mandatory = $true)] + [hashtable]$EncryptionInfo, + + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $false)] + [string]$APIName = 'AppUpload', + + [Parameter(Mandatory = $false)] + [hashtable]$Headers + ) + + $BaseUri = 'https://graph.microsoft.com/beta/deviceAppManagement/mobileApps' + $FileInfo = Get-Item $FilePath + + # Create content version file entry + $ContentBody = ConvertTo-Json @{ + name = $FileName + size = $UnencryptedSize + sizeEncrypted = [int64]$FileInfo.Length + } + + $ContentReq = New-GraphPostRequest -Uri "$BaseUri/$AppId/microsoft.graph.win32lobapp/contentVersions/1/files/" -Body $ContentBody -Type POST -tenantid $TenantFilter + + # Wait for Azure Storage URI + do { + $AzFileUri = New-GraphGetRequest -Uri "$BaseUri/$AppId/microsoft.graph.win32lobapp/contentVersions/1/files/$($ContentReq.id)" -tenantid $TenantFilter + if ($AzFileUri.uploadState -like '*fail*') { + throw "Failed to get Azure Storage URI. Upload state: $($AzFileUri.uploadState)" + } + Start-Sleep -Milliseconds 300 + } while ($null -eq $AzFileUri.AzureStorageUri) + + if ($Headers) { + Write-LogMessage -Headers $Headers -API $APIName -message "Uploading file to $($AzFileUri.azureStorageUri)" -Sev 'Info' -tenant $TenantFilter + } else { + Write-Host "Uploading file to $($AzFileUri.azureStorageUri)" + } + + # Upload file to Azure Storage in chunks + $chunkSizeInBytes = 4mb + [byte[]]$bytes = [System.IO.File]::ReadAllBytes($FileInfo.FullName) + $chunks = [Math]::Ceiling($bytes.Length / $chunkSizeInBytes) + $id = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($chunks.ToString('0000'))) + # For anyone that reads this, The maximum chunk size is 100MB for blob storage, so we can upload it as one part and just give it the single ID. Easy :) + $null = Invoke-RestMethod -Uri "$($AzFileUri.azureStorageUri)&comp=block&blockid=$id" -Method Put -Headers @{'x-ms-blob-type' = 'BlockBlob' } -InFile $FilePath -ContentType 'application/octet-stream' + $null = Invoke-RestMethod -Uri "$($AzFileUri.azureStorageUri)&comp=blocklist" -Method Put -Body "$id" -ContentType 'application/xml' + + # Commit the file with encryption info + $EncBody = @{ + fileEncryptionInfo = @{ + encryptionKey = $EncryptionInfo.EncryptionKey + macKey = $EncryptionInfo.MacKey + initializationVector = $EncryptionInfo.InitializationVector + mac = $EncryptionInfo.Mac + profileIdentifier = $EncryptionInfo.ProfileIdentifier + fileDigest = $EncryptionInfo.FileDigest + fileDigestAlgorithm = $EncryptionInfo.FileDigestAlgorithm + } + } | ConvertTo-Json + + $null = New-GraphPostRequest -Uri "$BaseUri/$AppId/microsoft.graph.win32lobapp/contentVersions/1/files/$($ContentReq.id)/commit" -Body $EncBody -Type POST -tenantid $TenantFilter + + # Wait for commit to complete + do { + $CommitStateReq = New-GraphGetRequest -Uri "$BaseUri/$AppId/microsoft.graph.win32lobapp/contentVersions/1/files/$($ContentReq.id)" -tenantid $TenantFilter + if ($CommitStateReq.uploadState -like '*fail*') { + $errorMsg = "Commit failed. Upload state: $($CommitStateReq.uploadState)" + if ($Headers) { + Write-LogMessage -Headers $Headers -API $APIName -message $errorMsg -Sev 'Warning' -tenant $TenantFilter + } + throw $errorMsg + } + Start-Sleep -Milliseconds 300 + } while ($CommitStateReq.uploadState -eq 'commitFilePending') + + # Finalize content version + $null = New-GraphPostRequest -Uri "$BaseUri/$AppId" -tenantid $TenantFilter -Body '{"@odata.type":"#microsoft.graph.win32lobapp","committedContentVersion":"1"}' -type PATCH + + return $true +} diff --git a/Modules/CIPPCore/Public/Add-CIPPWinGetApp.ps1 b/Modules/CIPPCore/Public/Add-CIPPWinGetApp.ps1 new file mode 100644 index 000000000000..df30b2db096c --- /dev/null +++ b/Modules/CIPPCore/Public/Add-CIPPWinGetApp.ps1 @@ -0,0 +1,24 @@ +function Add-CIPPWinGetApp { + <# + .SYNOPSIS + Creates a WinGet app in Intune. + + .DESCRIPTION + Creates a new WinGet app using the provided app body. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [object]$AppBody, + + [Parameter(Mandatory = $true)] + [string]$TenantFilter + ) + + $BaseUri = 'https://graph.microsoft.com/beta/deviceAppManagement/mobileApps' + + # Create the WinGet app + $NewApp = New-GraphPostRequest -Uri $BaseUri -Body ($AppBody | ConvertTo-Json -Compress) -Type POST -tenantid $TenantFilter + + return $NewApp +} diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertAdminPassword.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertAdminPassword.ps1 index 27e911fe98b0..b401c18b83b2 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertAdminPassword.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertAdminPassword.ps1 @@ -13,14 +13,33 @@ function Get-CIPPAlertAdminPassword { ) try { $TenantId = (Get-Tenants | Where-Object -Property defaultDomainName -EQ $TenantFilter).customerId - $AlertData = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/roleManagement/directory/roleAssignments?`$filter=roleDefinitionId eq '62e90394-69f5-4237-9190-012177145e10'&`$expand=principal" -tenantid $($TenantFilter) | Where-Object { ($_.principalOrganizationId -EQ $TenantId) -and ($_.principal.'@odata.type' -eq '#microsoft.graph.user') } | ForEach-Object { - $LastChanges = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/users/$($_.principalId)?`$select=UserPrincipalName,lastPasswordChangeDateTime" -tenant $($TenantFilter) - if ($LastChanges.LastPasswordChangeDateTime -gt (Get-Date).AddDays(-1)) { - $LastChanges | Select-Object -Property UserPrincipalName, lastPasswordChangeDateTime + + # Get role assignments without expanding principal to avoid rate limiting + $RoleAssignments = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/roleManagement/directory/roleAssignments?`$filter=roleDefinitionId eq '62e90394-69f5-4237-9190-012177145e10'" -tenantid $($TenantFilter) | Where-Object { $_.principalOrganizationId -EQ $TenantId } + + # Build bulk requests for each principalId + $UserRequests = $RoleAssignments | ForEach-Object { + [PSCustomObject]@{ + id = $_.principalId + method = 'GET' + url = "users/$($_.principalId)?`$select=id,UserPrincipalName,lastPasswordChangeDateTime" } } + + # Make bulk call to get user information + if ($UserRequests) { + $BulkResults = New-GraphBulkRequest -Requests @($UserRequests) -tenantid $TenantFilter + + # Filter users with recent password changes and sort to prevent duplicate alerts + $AlertData = $BulkResults | Where-Object { $_.status -eq 200 -and $_.body.lastPasswordChangeDateTime -gt (Get-Date).AddDays(-1) } | ForEach-Object { + $_.body | Select-Object -Property UserPrincipalName, lastPasswordChangeDateTime + } | Sort-Object UserPrincipalName + } else { + $AlertData = @() + } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Could not get admin password changes for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Could not get admin password changes for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderMalware.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderMalware.ps1 index 252b5abe1b78..8ac96f34286f 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderMalware.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderMalware.ps1 @@ -30,6 +30,6 @@ function Get-CIPPAlertDefenderMalware { Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Could not get malware data for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Could not get malware data for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderStatus.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderStatus.ps1 index defef38677a1..44ae39bfc7f1 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderStatus.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderStatus.ps1 @@ -29,6 +29,6 @@ function Get-CIPPAlertDefenderStatus { Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Could not get defender status for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Could not get defender status for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDepTokenExpiry.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDepTokenExpiry.ps1 index 23004aa5c307..afc731eefb89 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDepTokenExpiry.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDepTokenExpiry.ps1 @@ -26,6 +26,6 @@ function Get-CIPPAlertDepTokenExpiry { } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Failed to check Apple Device Enrollment Program token expiry for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Failed to check Apple Device Enrollment Program token expiry for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDeviceCompliance.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDeviceCompliance.ps1 index ebdf7ee55be8..0ea7bd38fe5c 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDeviceCompliance.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDeviceCompliance.ps1 @@ -15,6 +15,6 @@ function Get-CIPPAlertDeviceCompliance { $AlertData = New-GraphGETRequest -uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=complianceState eq 'noncompliant'&`$select=id,deviceName,managedDeviceOwnerType,complianceState,lastSyncDateTime&`$top=999" -tenantid $TenantFilter Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Could not get compliance state for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Could not get compliance state for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertEntraConnectSyncStatus.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertEntraConnectSyncStatus.ps1 index 31ebb125ad83..6dd99ceeee8e 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertEntraConnectSyncStatus.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertEntraConnectSyncStatus.ps1 @@ -36,8 +36,6 @@ function Get-CIPPAlertEntraConnectSyncStatus { } } } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Could not get Entra Connect Sync Status for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -LogData (Get-CippException -Exception $_) - Write-Information "Could not get Entra Connect Sync Status for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" - Write-Information $_.PositionMessage + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Could not get Entra Connect Sync Status for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error -LogData (Get-CippException -Exception $_) } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertGlobalAdminAllowList.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertGlobalAdminAllowList.ps1 index 82ae1ebc1cfb..31a082b0e714 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertGlobalAdminAllowList.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertGlobalAdminAllowList.ps1 @@ -79,6 +79,6 @@ function Get-CIPPAlertGlobalAdminAllowList { Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } } catch { - Write-AlertMessage -tenant $TenantFilter -message "Failed to check approved Global Admins: $(Get-NormalizedError -message $_.Exception.Message)" + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Failed to check approved Global Admins: $(Get-NormalizedError -message $_.Exception.Message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertGroupMembershipChange.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertGroupMembershipChange.ps1 new file mode 100644 index 000000000000..79336a3eed63 --- /dev/null +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertGroupMembershipChange.ps1 @@ -0,0 +1,47 @@ +function Get-CIPPAlertGroupMembershipChange { + <# + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $false)] + [Alias('input')] + $InputValue, + $TenantFilter + ) + + try { + $MonitoredGroups = $InputValue -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ } + if (!$MonitoredGroups) { return $true } + + $OneHourAgo = (Get-Date).AddHours(-3).ToString('yyyy-MM-ddTHH:mm:ssZ') + $AuditLogs = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/auditLogs/directoryAudits?`$filter=activityDateTime ge $OneHourAgo and (activityDisplayName eq 'Add member to group' or activityDisplayName eq 'Remove member from group')" -tenantid $TenantFilter + + $AlertData = foreach ($Log in $AuditLogs) { + $Member = ($Log.targetResources | Where-Object { $_.type -in @('User', 'ServicePrincipal') })[0] + $GroupProp = ($Member.modifiedProperties | Where-Object { $_.displayName -eq 'Group.DisplayName' }) + $GroupDisplayName = (($GroupProp.newValue ?? $GroupProp.oldValue) -replace '"', '') + if (!$GroupDisplayName -or !($MonitoredGroups | Where-Object { $GroupDisplayName -like $_ })) { continue } + + $InitiatedBy = if ($Log.initiatedBy.user) { $Log.initiatedBy.user.userPrincipalName } else { $Log.initiatedBy.app.displayName } + $Action = if ($Log.activityDisplayName -eq 'Add member to group') { 'added to' } else { 'removed from' } + + [PSCustomObject]@{ + Message = "$($Member.userPrincipalName ?? $Member.displayName) was $Action group '$GroupDisplayName' by $InitiatedBy" + GroupName = $GroupDisplayName + MemberName = $Member.userPrincipalName ?? $Member.displayName + Action = $Log.activityDisplayName + InitiatedBy = $InitiatedBy + ActivityTime = $Log.activityDateTime + Tenant = $TenantFilter + } + } + + if ($AlertData) { + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + } + } catch { + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Could not check group membership changes for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error + } +} diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveGuestUsers.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveGuestUsers.ps1 index 839a0af97e37..195b5d51c31c 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveGuestUsers.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveGuestUsers.ps1 @@ -84,6 +84,6 @@ function Get-CIPPAlertInactiveGuestUsers { Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch {} } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Failed to check inactive guest users for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Failed to check inactive guest users for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveLicensedUsers.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveLicensedUsers.ps1 index 09288d3fee13..d7cdd091a2b2 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveLicensedUsers.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveLicensedUsers.ps1 @@ -85,6 +85,6 @@ function Get-CIPPAlertInactiveLicensedUsers { Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch {} } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Failed to check inactive users with licenses for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Failed to check inactive users with licenses for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveUsers.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveUsers.ps1 index 037f37e501d5..0eef10e4991e 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveUsers.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveUsers.ps1 @@ -80,6 +80,6 @@ function Get-CIPPAlertInactiveUsers { Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch {} } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Failed to check inactive users with licenses for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Failed to check inactive users with licenses for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1 index 684f6bd0fc87..965170516e28 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1 @@ -90,7 +90,7 @@ function Get-CIPPAlertIntunePolicyConflicts { } } } catch { - Write-AlertMessage -tenant $TenantFilter -message "Failed to query Intune policy states: $(Get-NormalizedError -message $_.Exception.Message)" + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Failed to query Intune policy states: $(Get-NormalizedError -message $_.Exception.Message)" -sev Error } } @@ -117,7 +117,7 @@ function Get-CIPPAlertIntunePolicyConflicts { } } } catch { - Write-AlertMessage -tenant $TenantFilter -message "Failed to query Intune application states: $(Get-NormalizedError -message $_.Exception.Message)" + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Failed to query Intune application states: $(Get-NormalizedError -message $_.Exception.Message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertLowDomainScore.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertLowDomainScore.ps1 index e5284435bcdc..674648585221 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertLowDomainScore.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertLowDomainScore.ps1 @@ -13,7 +13,7 @@ function Get-CIPPAlertLowDomainScore { ) $DomainData = Get-CIPPDomainAnalyser -TenantFilter $TenantFilter - $LowScoreDomains = $DomainData | Where-Object { $_.ScorePercentage -lt $InputValue -and $_.ScorePercentage -ne '' } | ForEach-Object { + $LowScoreDomains = $DomainData | Where-Object { $_.ScorePercentage -lt $InputValue -and $_.ScorePercentage -ne '' -and $_.Domain -notlike '*.onmicrosoft.com' -and $_.Domain -notlike '*.mail.onmicrosoft.com' } | ForEach-Object { [PSCustomObject]@{ Message = "$($_.Domain): Domain security score is $($_.ScorePercentage)%, which is below the threshold of $InputValue%. Issues: $($_.ScoreExplanation)" Domain = $_.Domain diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertLowTenantAlignment.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertLowTenantAlignment.ps1 index bc8c5a04672d..52de70f3b0a5 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertLowTenantAlignment.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertLowTenantAlignment.ps1 @@ -27,7 +27,7 @@ function Get-CIPPAlertLowTenantAlignment { $AlignmentData = Get-CIPPTenantAlignment -TenantFilter $TenantFilter if (-not $AlignmentData) { - Write-AlertMessage -tenant $TenantFilter -message "No alignment data found for tenant $TenantFilter. This may indicate no standards templates are configured or applied to this tenant." + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "No alignment data found for tenant $TenantFilter. This may indicate no standards templates are configured or applied to this tenant." -sev Warning return } @@ -47,6 +47,6 @@ function Get-CIPPAlertLowTenantAlignment { } } catch { - Write-AlertMessage -tenant $TenantFilter -message "Could not get tenant alignment data for $TenantFilter`: $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Could not get tenant alignment data for $TenantFilter`: $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewMFADevice.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewMFADevice.ps1 new file mode 100644 index 000000000000..9208d700d4dc --- /dev/null +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewMFADevice.ps1 @@ -0,0 +1,41 @@ +function Get-CIPPAlertNewMFADevice { + <# + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $false)] + [Alias('input')] + $InputValue, + $TenantFilter + ) + + try { + $OneHourAgo = (Get-Date).AddHours(-1).ToString('yyyy-MM-ddTHH:mm:ssZ') + + $AuditLogs = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/auditLogs/directoryAudits?`$filter=activityDateTime ge $OneHourAgo and (activityDisplayName eq 'User registered security info' or activityDisplayName eq 'User deleted security info')" -tenantid $TenantFilter + $AlertData = foreach ($Log in $AuditLogs) { + if ($Log.activityDisplayName -eq 'User registered security info') { + $User = $Log.targetResources[0].userPrincipalName + if (-not $User) { $User = $Log.initiatedBy.user.userPrincipalName } + + [PSCustomObject]@{ + Message = "New MFA method registered: $User" + User = $User + DisplayName = $Log.targetResources[0].displayName + Activity = $Log.activityDisplayName + ActivityTime = $Log.activityDateTime + Tenant = $TenantFilter + } + } + } + + if ($AlertData) { + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + } + + } catch { + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Could not check for new MFA devices for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error + } +} diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewRiskyUsers.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewRiskyUsers.ps1 index c0691da7fe0d..9686bd134f87 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewRiskyUsers.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewRiskyUsers.ps1 @@ -14,7 +14,7 @@ function Get-CIPPAlertNewRiskyUsers { # Check if tenant has P2 capabilities $Capabilities = Get-CIPPTenantCapabilities -TenantFilter $TenantFilter if (-not ($Capabilities.AAD_PREMIUM_P2 -eq $true)) { - Write-AlertMessage -tenant $($TenantFilter) -message 'Tenant does not have Azure AD Premium P2 licensing required for risky users detection' + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message 'Tenant does not have Azure AD Premium P2 licensing required for risky users detection' -sev Warning return } @@ -69,6 +69,6 @@ function Get-CIPPAlertNewRiskyUsers { } } } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Could not get risky users for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Could not get risky users for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewRole.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewRole.ps1 index 68167632b5b3..c8cae3616484 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewRole.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewRole.ps1 @@ -43,6 +43,6 @@ function Get-CIPPAlertNewRole { Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Could not get get role changes for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Could not get get role changes for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNoCAConfig.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNoCAConfig.ps1 index 48ae1dabff57..24055d1b606e 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNoCAConfig.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNoCAConfig.ps1 @@ -30,7 +30,7 @@ function Get-CIPPAlertNoCAConfig { } } } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Conditional Access Config Alert: Error occurred: $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Conditional Access Config Alert: Error occurred: $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertOnedriveQuota.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertOnedriveQuota.ps1 index b39ea94d9a48..b2e786bc9286 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertOnedriveQuota.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertOnedriveQuota.ps1 @@ -19,7 +19,7 @@ function Get-CIPPAlertOneDriveQuota { } } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message - Write-AlertMessage -tenant $($TenantFilter) -message "OneDrive quota Alert: Unable to get OneDrive usage: Error occurred: $ErrorMessage" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "OneDrive quota Alert: Unable to get OneDrive usage: Error occurred: $ErrorMessage" -sev Error return } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertOverusedLicenses.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertOverusedLicenses.ps1 index 69e4f0254a84..f0b87d48ac7c 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertOverusedLicenses.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertOverusedLicenses.ps1 @@ -39,6 +39,6 @@ function Get-CIPPAlertOverusedLicenses { } } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Overused Licenses Alert Error occurred: $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Overused Licenses Alert Error occurred: $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertQuarantineReleaseRequests.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertQuarantineReleaseRequests.ps1 index f0ca6d528e40..b20096d59020 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertQuarantineReleaseRequests.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertQuarantineReleaseRequests.ps1 @@ -14,7 +14,7 @@ #Add rerun protection: This Monitor can only run once every hour. $Rerun = Test-CIPPRerun -TenantFilter $TenantFilter -Type 'ExchangeMonitor' -API 'Get-CIPPAlertQuarantineReleaseRequests' if ($Rerun) { - return $true + return } $HasLicense = Test-CIPPStandardLicense -StandardName 'QuarantineReleaseRequests' -TenantFilter $TenantFilter -RequiredCapabilities @( 'EXCHANGE_S_STANDARD', @@ -25,11 +25,17 @@ ) if (-not $HasLicense) { - return $true + return } try { - $RequestedReleases = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-QuarantineMessage' -cmdParams @{ PageSize = 1000; ReleaseStatus = 'Requested'; StartReceivedDate = (Get-Date).AddHours(-6) } -ErrorAction Stop | Select-Object -ExcludeProperty *data.type* + $cmdParams = @{ + PageSize = 1000 + ReleaseStatus = 'Requested' + StartReceivedDate = (Get-Date).AddHours(-6) + EndReceivedDate = (Get-Date).AddHours(0) + } + $RequestedReleases = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-QuarantineMessage' -cmdParams $cmdParams -ErrorAction Stop | Select-Object -ExcludeProperty *data.type* | Sort-Object -Property ReceivedTime if ($RequestedReleases) { # Get the CIPP URL for the Quarantine link @@ -56,6 +62,6 @@ Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } } catch { - Write-AlertMessage -tenant $TenantFilter -message "QuarantineReleaseRequests: $(Get-NormalizedError -message $_.Exception.Message)" + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "QuarantineReleaseRequests: $(Get-NormalizedError -message $_.Exception.Message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertReportOnlyCA.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertReportOnlyCA.ps1 index 6205c9b6ac40..2ce381def049 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertReportOnlyCA.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertReportOnlyCA.ps1 @@ -36,7 +36,7 @@ function Get-CIPPAlertReportOnlyCA { } } } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Report-Only CA Alert: Error occurred: $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Report-Only CA Alert: Error occurred: $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSecDefaultsDisabled.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSecDefaultsDisabled.ps1 index 104cf17e03fa..a8055e2c6261 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSecDefaultsDisabled.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSecDefaultsDisabled.ps1 @@ -30,6 +30,6 @@ function Get-CIPPAlertSecDefaultsDisabled { } } } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Security Defaults Disabled Alert: Error occurred: $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Security Defaults Disabled Alert: Error occurred: $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSecureScore.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSecureScore.ps1 index 4bf49b56bbc7..a48a163d8532 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSecureScore.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSecureScore.ps1 @@ -41,6 +41,6 @@ function Get-CippAlertSecureScore { } Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $SecureScoreResult -PartitionKey SecureScore } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Could not get Secure Score for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Could not get Secure Score for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSoftDeletedMailboxes.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSoftDeletedMailboxes.ps1 index f446ec2e9161..b8ea70697c90 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSoftDeletedMailboxes.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSoftDeletedMailboxes.ps1 @@ -24,6 +24,6 @@ function Get-CIPPAlertSoftDeletedMailboxes { Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Failed to check for soft deleted mailboxes in $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Failed to check for soft deleted mailboxes in $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertStaleEntraDevices.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertStaleEntraDevices.ps1 index 29043c3288fd..f44bcd016905 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertStaleEntraDevices.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertStaleEntraDevices.ps1 @@ -78,6 +78,6 @@ function Get-CIPPAlertStaleEntraDevices { Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch {} } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Failed to check inactive guest users for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Failed to check inactive guest users for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertUnusedLicenses.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertUnusedLicenses.ps1 index 4db4e400eb1e..7f470e07d9a1 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertUnusedLicenses.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertUnusedLicenses.ps1 @@ -35,6 +35,6 @@ function Get-CIPPAlertUnusedLicenses { } Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Unused Licenses Alert Error occurred: $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Unused Licenses Alert Error occurred: $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertVulnerabilities.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertVulnerabilities.ps1 index f6cb1d37f0b8..7b6c374eecf7 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertVulnerabilities.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertVulnerabilities.ps1 @@ -81,7 +81,6 @@ function Get-CIPPAlertVulnerabilities { DaysOld = $DaysOld HoursOld = $HoursOld AffectedDeviceCount = $Group.Count - AffectedDevices = $AffectedDevices SoftwareName = $FirstVuln.softwareName SoftwareVendor = $FirstVuln.softwareVendor SoftwareVersion = $FirstVuln.softwareVersion @@ -90,6 +89,7 @@ function Get-CIPPAlertVulnerabilities { RecommendedUpdate = $FirstVuln.recommendedSecurityUpdate RecommendedUpdateId = $FirstVuln.recommendedSecurityUpdateId RecommendedUpdateUrl = $FirstVuln.recommendedSecurityUpdateUrl + AffectedDevices = $AffectedDevices Tenant = $TenantFilter } $AlertData.Add($VulnerabilityAlert) diff --git a/Modules/CIPPCore/Public/Authentication/New-CIPPAPIConfig.ps1 b/Modules/CIPPCore/Public/Authentication/New-CIPPAPIConfig.ps1 index 948d2a17f1fa..0ddd57a18fd2 100644 --- a/Modules/CIPPCore/Public/Authentication/New-CIPPAPIConfig.ps1 +++ b/Modules/CIPPCore/Public/Authentication/New-CIPPAPIConfig.ps1 @@ -65,6 +65,14 @@ function New-CIPPAPIConfig { Write-Information $CreateBody $Step = 'Creating Application' $APIApp = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/v1.0/applications' -AsApp $true -NoAuthCheck $true -type POST -body $CreateBody + + try { + $PolicyUpdate = Update-AppManagementPolicy -ApplicationId $APIApp.appId + Write-Information $PolicyUpdate.PolicyAction + } catch { + Write-Information "Failed to update app management policy: $($_.Exception.Message)" + } + Write-Information 'Creating password' $Step = 'Creating Application Password' $APIPassword = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/applications/$($APIApp.id)/addPassword" -AsApp $true -NoAuthCheck $true -type POST -body "{`"passwordCredential`":{`"displayName`":`"Generated by API Setup`"}}" -maxRetries 3 diff --git a/Modules/CIPPCore/Public/Authentication/Test-IpInRange.ps1 b/Modules/CIPPCore/Public/Authentication/Test-IpInRange.ps1 index 2279b20ce110..f7103fbca239 100644 --- a/Modules/CIPPCore/Public/Authentication/Test-IpInRange.ps1 +++ b/Modules/CIPPCore/Public/Authentication/Test-IpInRange.ps1 @@ -31,7 +31,8 @@ function Test-IpInRange { $IP = [System.Net.IPAddress]::Parse($IPAddress) $rangeParts = $Range -split '/' $networkAddr = [System.Net.IPAddress]::Parse($rangeParts[0]) - $prefix = [int]$rangeParts[1] + $maxBits = if ($networkAddr.AddressFamily -eq 'InterNetworkV6') { 128 } else { 32 } + $prefix = if ($rangeParts.Count -gt 1) { [int]$rangeParts[1] } else { $maxBits } if ($networkAddr.AddressFamily -ne $IP.AddressFamily) { return $false @@ -39,7 +40,6 @@ function Test-IpInRange { $ipBig = ConvertIpToBigInteger $IP $netBig = ConvertIpToBigInteger $networkAddr - $maxBits = if ($networkAddr.AddressFamily -eq 'InterNetworkV6') { 128 } else { 32 } $shift = $maxBits - $prefix $mask = [System.Numerics.BigInteger]::Pow(2, $shift) - [System.Numerics.BigInteger]::One $invertedMask = [System.Numerics.BigInteger]::MinusOne -bxor $mask diff --git a/Modules/CIPPCore/Public/Clear-CIPPImmutableId.ps1 b/Modules/CIPPCore/Public/Clear-CIPPImmutableId.ps1 index 60255008687e..4f4f11e1ad2e 100644 --- a/Modules/CIPPCore/Public/Clear-CIPPImmutableId.ps1 +++ b/Modules/CIPPCore/Public/Clear-CIPPImmutableId.ps1 @@ -3,14 +3,69 @@ function Clear-CIPPImmutableId { param ( $TenantFilter, $UserID, + $Username, # Optional - used for better logging and scheduling messages + $User, # Optional - if provided, will check sync status and schedule if needed $Headers, $APIName = 'Clear Immutable ID' ) try { + # If User object is provided, check if we need to schedule instead of clearing immediately + if ($User) { + # User has ImmutableID but is not synced from on-premises - safe to clear immediately + if ($User.onPremisesSyncEnabled -ne $true -and ![string]::IsNullOrEmpty($User.onPremisesImmutableId)) { + $DisplayName = $Username ?? $UserID + Write-LogMessage -Message "User $DisplayName has an ImmutableID set but is not synced from on-premises. Proceeding to clear the ImmutableID." -TenantFilter $TenantFilter -Severity 'Warning' -APIName $APIName -headers $Headers + # Continue to clear below + } + # User is synced from on-premises - must schedule for after deletion + elseif ($User.onPremisesSyncEnabled -eq $true -and ![string]::IsNullOrEmpty($User.onPremisesImmutableId)) { + $DisplayName = $Username ?? $UserID + Write-LogMessage -Message "User $DisplayName is synced from on-premises. Scheduling an Immutable ID clear for when the user account has been soft deleted." -TenantFilter $TenantFilter -Severity 'Warning' -APIName $APIName -headers $Headers + + $ScheduledTask = @{ + TenantFilter = $TenantFilter + Name = "Clear Immutable ID: $DisplayName" + Command = @{ value = 'Clear-CIPPImmutableID' } + Parameters = [pscustomobject]@{ + UserID = $UserID + TenantFilter = $TenantFilter + APIName = $APIName + } + Trigger = @{ + Type = 'DeltaQuery' + DeltaResource = 'users' + ResourceFilter = @($UserID) + EventType = 'deleted' + UseConditions = $false + ExecutePerResource = $true + ExecutionMode = 'once' + } + ScheduledTime = [int64](([datetime]::UtcNow).AddMinutes(5) - (Get-Date '1/1/1970')).TotalSeconds + Recurrence = '15m' + PostExecution = @{ + Webhook = $false + Email = $false + PSA = $false + } + } + Add-CIPPScheduledTask -Task $ScheduledTask -hidden $false -DisallowDuplicateName $true + return 'Scheduled Immutable ID clear task for when the user account is no longer synced in the on-premises directory.' + } + # User has no ImmutableID or is already clear + else { + $DisplayName = $Username ?? $UserID + $Result = "User $DisplayName does not have an ImmutableID set or it is already cleared." + Write-LogMessage -headers $Headers -API $APIName -message $Result -sev Info -tenant $TenantFilter + return $Result + } + } + + # Perform the actual clear operation try { - $User = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$UserID" -tenantid $TenantFilter -ErrorAction SilentlyContinue + $UserObj = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$UserID" -tenantid $TenantFilter -ErrorAction SilentlyContinue } catch { + # User might be deleted, try to restore it $DeletedUser = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/directory/deletedItems/$UserID" -tenantid $TenantFilter if ($DeletedUser.id) { # Restore deleted user object @@ -22,12 +77,14 @@ function Clear-CIPPImmutableId { $Body = [pscustomobject]@{ onPremisesImmutableId = $null } $Body = ConvertTo-Json -InputObject $Body -Depth 5 -Compress $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/users/$UserID" -tenantid $TenantFilter -type PATCH -body $Body - $Result = "Successfully cleared immutable ID for user $UserID" + $DisplayName = $Username ?? $UserID + $Result = "Successfully cleared immutable ID for user $DisplayName" Write-LogMessage -headers $Headers -API $APIName -message $Result -sev Info -tenant $TenantFilter return $Result } catch { $ErrorMessage = Get-CippException -Exception $_ - $Result = "Failed to clear immutable ID for $($UserID). Error: $($ErrorMessage.NormalizedError)" + $DisplayName = $Username ?? $UserID + $Result = "Failed to clear immutable ID for $DisplayName. Error: $($ErrorMessage.NormalizedError)" Write-LogMessage -headers $Headers -API $APIName -message $Result -sev Error -tenant $TenantFilter -LogData $ErrorMessage throw $Result } diff --git a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Applications/Push-UploadApplication.ps1 b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Applications/Push-UploadApplication.ps1 index 5aae136f1ba4..b643716861a1 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Applications/Push-UploadApplication.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Applications/Push-UploadApplication.ps1 @@ -14,118 +14,166 @@ function Push-UploadApplication { $CippRoot = (Get-Item $ModuleRoot).Parent.Parent Set-Location $CippRoot - $ChocoApp = (Get-CIPPAzDataTableEntity @Table -filter $Filter).JSON | ConvertFrom-Json - $intuneBody = $ChocoApp.IntuneBody - $tenants = if ($ChocoApp.tenant -eq 'AllTenants') { + $AppConfig = (Get-CIPPAzDataTableEntity @Table -filter $Filter).JSON | ConvertFrom-Json + $intuneBody = $AppConfig.IntuneBody + $tenants = if ($AppConfig.tenant -eq 'AllTenants') { (Get-Tenants -IncludeErrors).defaultDomainName } else { - $ChocoApp.tenant - } - if ($ChocoApp.type -eq 'MSPApp') { - [xml]$Intunexml = Get-Content "AddMSPApp\$($ChocoApp.MSPAppName).app.xml" - $intunewinFilesize = (Get-Item "AddMSPApp\$($ChocoApp.MSPAppName).intunewin") - $Infile = "AddMSPApp\$($ChocoApp.MSPAppName).intunewin" - } else { - [xml]$Intunexml = Get-Content 'AddChocoApp\Choco.App.xml' - $intunewinFilesize = (Get-Item 'AddChocoApp\IntunePackage.intunewin') - $Infile = "AddChocoApp\$($intunexml.ApplicationInfo.FileName)" - } - $assignTo = $ChocoApp.assignTo - $AssignToIntent = $ChocoApp.InstallationIntent - $Baseuri = 'https://graph.microsoft.com/beta/deviceAppManagement/mobileApps' - $ContentBody = ConvertTo-Json @{ - name = $intunexml.ApplicationInfo.FileName - size = [int64]$intunexml.ApplicationInfo.UnencryptedContentSize - sizeEncrypted = [int64]($intunewinFilesize).length + $AppConfig.tenant } + $assignTo = $AppConfig.assignTo + $AssignToIntent = $AppConfig.InstallationIntent $ClearRow = Get-CIPPAzDataTableEntity @Table -Filter $Filter - $RemoveCacheFile = if ($ChocoApp.tenant -ne 'AllTenants') { - Remove-AzDataTableEntity -Force @Table -Entity $clearRow + if ($AppConfig.tenant -ne 'AllTenants') { + $null = Remove-AzDataTableEntity -Force @Table -Entity $clearRow } else { $Table.Force = $true - Add-CIPPAzDataTableEntity @Table -Entity @{ - JSON = "$($ChocoApp | ConvertTo-Json)" + $null = Add-CIPPAzDataTableEntity @Table -Entity @{ + JSON = "$($AppConfig | ConvertTo-Json)" RowKey = "$($ClearRow.RowKey)" PartitionKey = 'apps' status = 'Deployed' } } - $EncBody = @{ - fileEncryptionInfo = @{ - encryptionKey = $intunexml.ApplicationInfo.EncryptionInfo.EncryptionKey - macKey = $intunexml.ApplicationInfo.EncryptionInfo.MacKey - initializationVector = $intunexml.ApplicationInfo.EncryptionInfo.InitializationVector - mac = $intunexml.ApplicationInfo.EncryptionInfo.Mac - profileIdentifier = $intunexml.ApplicationInfo.EncryptionInfo.ProfileIdentifier - fileDigest = $intunexml.ApplicationInfo.EncryptionInfo.FileDigest - fileDigestAlgorithm = $intunexml.ApplicationInfo.EncryptionInfo.FileDigestAlgorithm - } - } | ConvertTo-Json + # Determine app type (default to 'Choco' if not specified) + $AppType = if ($AppConfig.type) { $AppConfig.type } else { 'Choco' } + + # Load files based on app type (only for types that need them) + $Intunexml = $null + $Infile = $null + if ($AppType -eq 'MSPApp') { + [xml]$Intunexml = Get-Content "AddMSPApp\$($AppConfig.MSPAppName).app.xml" + $Infile = "AddMSPApp\$($AppConfig.MSPAppName).intunewin" + } elseif ($AppType -in @('Choco', 'Win32ScriptApp')) { + [xml]$Intunexml = Get-Content 'AddChocoApp\Choco.App.xml' + $Infile = "AddChocoApp\$($Intunexml.ApplicationInfo.FileName)" + } + + + $baseuri = 'https://graph.microsoft.com/beta/deviceAppManagement/mobileApps' foreach ($tenant in $tenants) { try { - $ApplicationList = New-GraphGetRequest -Uri $baseuri -tenantid $tenant | Where-Object { $_.DisplayName -eq $ChocoApp.Applicationname -and ($_.'@odata.type' -eq '#microsoft.graph.win32LobApp' -or $_.'@odata.type' -eq '#microsoft.graph.winGetApp') } + # Check if app already exists + $ApplicationList = New-GraphGetRequest -Uri $baseuri -tenantid $tenant | Where-Object { $_.DisplayName -eq $AppConfig.Applicationname -and ($_.'@odata.type' -eq '#microsoft.graph.win32LobApp' -or $_.'@odata.type' -eq '#microsoft.graph.winGetApp') } if ($ApplicationList.displayname.count -ge 1) { - Write-LogMessage -api 'AppUpload' -tenant $tenant -message "$($ChocoApp.Applicationname) exists. Skipping this application" -Sev 'Info' + Write-LogMessage -api 'AppUpload' -tenant $tenant -message "$($AppConfig.Applicationname) exists. Skipping this application" -Sev 'Info' continue } - if ($ChocoApp.type -eq 'WinGet') { - Write-Host 'Winget!' - Write-Host ($intuneBody | ConvertTo-Json -Compress) - $NewApp = New-GraphPostRequest -Uri $baseuri -Body ($intuneBody | ConvertTo-Json -Compress) -Type POST -tenantid $tenant - Start-Sleep -Milliseconds 200 - Write-LogMessage -api 'AppUpload' -tenant $tenant -message "$($ChocoApp.Applicationname) uploaded as WinGet app." -Sev 'Info' - if ($AssignTo -ne 'On') { - $intent = if ($AssignToIntent) { 'Uninstall' } else { 'Required' } - Set-CIPPAssignedApplication -ApplicationId $NewApp.Id -Intent $intent -TenantFilter $tenant -groupName "$AssignTo" -AppType 'WinGet' + + # Route to appropriate handler based on app type + $NewApp = $null + switch ($AppType) { + 'WinGet' { + $NewApp = Add-CIPPWinGetApp -AppBody $intuneBody -TenantFilter $tenant } - Write-LogMessage -api 'AppUpload' -tenant $tenant -message "$($ChocoApp.Applicationname) Successfully created" -Sev 'Info' - continue - } else { - $NewApp = New-GraphPostRequest -Uri $baseuri -Body ($intuneBody | ConvertTo-Json) -Type POST -tenantid $tenant + 'Choco' { + # Prepare encryption info from XML + $EncryptionInfo = @{ + EncryptionKey = $Intunexml.ApplicationInfo.EncryptionInfo.EncryptionKey + MacKey = $Intunexml.ApplicationInfo.EncryptionInfo.MacKey + InitializationVector = $Intunexml.ApplicationInfo.EncryptionInfo.InitializationVector + Mac = $Intunexml.ApplicationInfo.EncryptionInfo.Mac + ProfileIdentifier = $Intunexml.ApplicationInfo.EncryptionInfo.ProfileIdentifier + FileDigest = $Intunexml.ApplicationInfo.EncryptionInfo.FileDigest + FileDigestAlgorithm = $Intunexml.ApplicationInfo.EncryptionInfo.FileDigestAlgorithm + } - } - $ContentReq = New-GraphPostRequest -Uri "$($BaseURI)/$($NewApp.id)/microsoft.graph.win32lobapp/contentVersions/1/files/" -Body $ContentBody -Type POST -tenantid $tenant - do { - $AzFileUri = New-graphGetRequest -Uri "$($BaseURI)/$($NewApp.id)/microsoft.graph.win32lobapp/contentVersions/1/files/$($ContentReq.id)" -tenantid $tenant - if ($AZfileuri.uploadState -like '*fail*') { break } - Start-Sleep -Milliseconds 300 - } while ($AzFileUri.AzureStorageUri -eq $null) - Write-Host "Uploading file to $($AzFileUri.azureStorageUri)" - Write-Host "Complete AZ file uri data: $($AzFileUri | ConvertTo-Json -Depth 10)" - $chunkSizeInBytes = 4mb - [byte[]]$bytes = [System.IO.File]::ReadAllBytes($($intunewinFilesize.fullname)) - $chunks = [Math]::Ceiling($bytes.Length / $chunkSizeInBytes) - $id = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($chunks.ToString('0000'))) - #For anyone that reads this, The maximum chunk size is 100MB for blob storage, so we can upload it as one part and just give it the single ID. Easy :) - $Upload = Invoke-RestMethod -Uri "$($AzFileUri.azureStorageUri)&comp=block&blockid=$id" -Method Put -Headers @{'x-ms-blob-type' = 'BlockBlob' } -InFile $inFile -ContentType 'application/octet-stream' - Write-Host "Upload data: $($Upload | ConvertTo-Json -Depth 10)" - $ConfirmUpload = Invoke-RestMethod -Uri "$($AzFileUri.azureStorageUri)&comp=blocklist" -Method Put -Body "$id" -ContentType 'application/xml' - Write-Host "Confirm Upload data: $($ConfirmUpload | ConvertTo-Json -Depth 10)" - $CommitReq = New-graphPostRequest -Uri "$($BaseURI)/$($NewApp.id)/microsoft.graph.win32lobapp/contentVersions/1/files/$($ContentReq.id)/commit" -Body $EncBody -Type POST -tenantid $tenant - Write-Host "Commit Request: $($CommitReq | ConvertTo-Json -Depth 10)" - - do { - $CommitStateReq = New-graphGetRequest -Uri "$($BaseURI)/$($NewApp.id)/microsoft.graph.win32lobapp/contentVersions/1/files/$($ContentReq.id)" -tenantid $tenant - Write-Host "Commit State Request: $($CommitStateReq | ConvertTo-Json -Depth 10)" - if ($CommitStateReq.uploadState -like '*fail*') { - Write-LogMessage -api 'AppUpload' -tenant $tenant -message "$($ChocoApp.Applicationname) Commit failed. Please check if app uploaded succesful" -Sev 'Warning' - break + # Build parameters dynamically + $Params = @{ + AppBody = $intuneBody + TenantFilter = $tenant + FilePath = $Infile + FileName = $Intunexml.ApplicationInfo.FileName + UnencryptedSize = [int64]$Intunexml.ApplicationInfo.UnencryptedContentSize + EncryptionInfo = $EncryptionInfo + } + if ($AppConfig.Applicationname) { $Params.DisplayName = $AppConfig.Applicationname } + + $NewApp = Add-CIPPPackagedApplication @Params + } + 'MSPApp' { + # Prepare encryption info from XML + $EncryptionInfo = @{ + EncryptionKey = $Intunexml.ApplicationInfo.EncryptionInfo.EncryptionKey + MacKey = $Intunexml.ApplicationInfo.EncryptionInfo.MacKey + InitializationVector = $Intunexml.ApplicationInfo.EncryptionInfo.InitializationVector + Mac = $Intunexml.ApplicationInfo.EncryptionInfo.Mac + ProfileIdentifier = $Intunexml.ApplicationInfo.EncryptionInfo.ProfileIdentifier + FileDigest = $Intunexml.ApplicationInfo.EncryptionInfo.FileDigest + FileDigestAlgorithm = $Intunexml.ApplicationInfo.EncryptionInfo.FileDigestAlgorithm + } + + # Build parameters dynamically + $Params = @{ + AppBody = $intuneBody + TenantFilter = $tenant + FilePath = $Infile + FileName = $Intunexml.ApplicationInfo.FileName + UnencryptedSize = [int64]$Intunexml.ApplicationInfo.UnencryptedContentSize + EncryptionInfo = $EncryptionInfo + } + if ($AppConfig.Applicationname) { $Params.DisplayName = $AppConfig.Applicationname } + + $NewApp = Add-CIPPPackagedApplication @Params + } + 'Win32ScriptApp' { + # Prepare encryption info from XML + $EncryptionInfo = @{ + EncryptionKey = $Intunexml.ApplicationInfo.EncryptionInfo.EncryptionKey + MacKey = $Intunexml.ApplicationInfo.EncryptionInfo.MacKey + InitializationVector = $Intunexml.ApplicationInfo.EncryptionInfo.InitializationVector + Mac = $Intunexml.ApplicationInfo.EncryptionInfo.Mac + ProfileIdentifier = $Intunexml.ApplicationInfo.EncryptionInfo.ProfileIdentifier + FileDigest = $Intunexml.ApplicationInfo.EncryptionInfo.FileDigest + FileDigestAlgorithm = $Intunexml.ApplicationInfo.EncryptionInfo.FileDigestAlgorithm + } + + # Build properties dynamically + $Properties = @{ + displayName = $AppConfig.Applicationname + installScript = $AppConfig.installScript + } + + # A few of these are probably mandatory + if ($AppConfig.description) { $Properties['description'] = $AppConfig.description } + if ($AppConfig.publisher) { $Properties['publisher'] = $AppConfig.publisher } + if ($AppConfig.uninstallScript) { $Properties['uninstallScript'] = $AppConfig.uninstallScript } + if ($AppConfig.detectionPath) { $Properties['detectionPath'] = $AppConfig.detectionPath } + if ($AppConfig.detectionFile) { $Properties['detectionFile'] = $AppConfig.detectionFile } + if ($AppConfig.runAsAccount) { $Properties['runAsAccount'] = $AppConfig.runAsAccount } + if ($AppConfig.deviceRestartBehavior) { $Properties['deviceRestartBehavior'] = $AppConfig.deviceRestartBehavior } + if ($null -ne $AppConfig.runAs32Bit) { $Properties['runAs32Bit'] = $AppConfig.runAs32Bit } + if ($null -ne $AppConfig.enforceSignatureCheck) { $Properties['enforceSignatureCheck'] = $AppConfig.enforceSignatureCheck } + + $NewApp = Add-CIPPW32ScriptApplication -TenantFilter $tenant -Properties ([PSCustomObject]$Properties) + } + 'WinGetNew' { + # I think we don't need a separate WinGetNew type, just use WinGet? } - Start-Sleep -Milliseconds 300 - } while ($CommitStateReq.uploadState -eq 'commitFilePending') - $CommitFinalizeReq = New-graphPostRequest -Uri "$($BaseURI)/$($NewApp.id)" -tenantid $tenant -Body '{"@odata.type":"#microsoft.graph.win32lobapp","committedContentVersion":"1"}' -type PATCH - Write-Host "Commit Finalize Request: $($CommitFinalizeReq | ConvertTo-Json -Depth 10)" - Write-LogMessage -api 'AppUpload' -tenant $tenant -message "Added Application $($ChocoApp.Applicationname)" -Sev 'Info' - if ($AssignTo -ne 'On') { - $intent = if ($AssignToIntent) { 'Uninstall' } else { 'Required' } - Set-CIPPAssignedApplication -ApplicationId $NewApp.Id -Intent $intent -TenantFilter $tenant -groupName "$AssignTo" -AppType 'Win32Lob' + default { + throw "Unsupported app type: $($AppConfig.type)" + } + } + # Log success and assign app if requested + if ($NewApp) { + Write-LogMessage -api 'AppUpload' -tenant $tenant -message "$($AppConfig.Applicationname) Successfully created" -Sev 'Info' + + if ($assignTo -and $assignTo -ne 'On') { + $intent = if ($AssignToIntent) { 'Uninstall' } else { 'Required' } + $AppTypeForAssignment = switch ($AppType) { + 'WinGet' { 'WinGet' } + 'WinGetNew' { 'WinGet' } + default { 'Win32Lob' } + } + Start-Sleep -Milliseconds 200 + Set-CIPPAssignedApplication -ApplicationId $NewApp.Id -TenantFilter $tenant -groupName $assignTo -Intent $intent -AppType $AppTypeForAssignment -APIName 'AppUpload' + } } - Write-LogMessage -api 'AppUpload' -tenant $tenant -message 'Successfully added Application' -Sev 'Info' } catch { - "Failed to add Application for $($Tenant): $($_.Exception.Message)" - Write-LogMessage -api 'AppUpload' -tenant $tenant -message "Failed adding Application $($ChocoApp.Applicationname). Error: $($_.Exception.Message)" -LogData (Get-CippException -Exception $_) -Sev 'Error' + "Failed to add Application for $tenant : $($_.Exception.Message)" + Write-LogMessage -api 'AppUpload' -tenant $tenant -message "Failed adding Application $($AppConfig.Applicationname). Error: $($_.Exception.Message)" -LogData (Get-CippException -Exception $_) -Sev 'Error' continue } } diff --git a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Domain Analyser/Push-DomainAnalyserTenant.ps1 b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Domain Analyser/Push-DomainAnalyserTenant.ps1 index 43444b4a4101..f3ac78cf719a 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Domain Analyser/Push-DomainAnalyserTenant.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Domain Analyser/Push-DomainAnalyserTenant.ps1 @@ -5,7 +5,7 @@ function Push-DomainAnalyserTenant { #> param($Item) - $Tenant = Get-Tenants -IncludeAll | Where-Object { $_.customerId -eq $Item.customerId } | Select-Object -First 1 + $Tenant = Get-Tenants -TenantFilter $Item.customerId $DomainTable = Get-CippTable -tablename 'Domains' if ($Tenant.Excluded -eq $true) { @@ -20,6 +20,14 @@ function Push-DomainAnalyserTenant { return } else { try { + # Get domains from cached database instead of making Graph API calls + $Domains = New-CIPPDbRequest -TenantFilter $Tenant.defaultDomainName -Type 'Domains' + + if (-not $Domains) { + Write-LogMessage -API 'DomainAnalyser' -tenant $Tenant.defaultDomainName -tenantid $Tenant.customerId -message 'No cached domain data found. Domain analysis will be skipped until data collection completes.' -sev Info + return + } + # Remove domains that are not wanted, and used for cloud signature services. Same exclusions also found in Invoke-CIPPStandardAddDKIM $ExclusionDomains = @( '*.microsoftonline.com' @@ -35,7 +43,7 @@ function Push-DomainAnalyserTenant { '*.ucconnect.co.uk' '*.teams-sbc.dk' ) - $Domains = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/domains' -tenantid $Tenant.customerId | Where-Object { $_.isVerified -eq $true } | ForEach-Object { + $Domains = $Domains | Where-Object { $_.isVerified -eq $true } | ForEach-Object { $Domain = $_ foreach ($ExclusionDomain in $ExclusionDomains) { if ($Domain.id -like $ExclusionDomain) { diff --git a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 index 6bdf8f889ddf..907f5ad3de9d 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 @@ -50,9 +50,6 @@ function Push-CIPPDBCacheData { 'SecureScore' 'PIMSettings' 'Domains' - 'RoleEligibilitySchedules' - 'RoleManagementPolicies' - 'RoleAssignmentScheduleInstances' 'B2BManagementPolicy' 'AuthenticationFlowsPolicy' 'DeviceRegistrationPolicy' @@ -130,13 +127,16 @@ function Push-CIPPDBCacheData { } #endregion Conditional Access Licensed - #region Azure AD Premium P2 - Identity Protection features + #region Azure AD Premium P2 - Identity Protection/PIM features if ($AzureADPremiumP2Capable) { $P2CacheFunctions = @( 'RiskyUsers' 'RiskyServicePrincipals' 'ServicePrincipalRiskDetections' 'RiskDetections' + 'RoleEligibilitySchedules' + 'RoleAssignmentSchedules' + 'RoleManagementPolicies' ) foreach ($CacheFunction in $P2CacheFunctions) { $Batch.Add(@{ @@ -158,6 +158,7 @@ function Push-CIPPDBCacheData { 'IntunePolicies' 'ManagedDeviceEncryptionStates' 'IntuneAppProtectionPolicies' + 'DetectedApps' ) foreach ($CacheFunction in $IntuneCacheFunctions) { $Batch.Add(@{ diff --git a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPOffboardingComplete.ps1 b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPOffboardingComplete.ps1 new file mode 100644 index 000000000000..8d86e31a27a3 --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPOffboardingComplete.ps1 @@ -0,0 +1,120 @@ +function Push-CIPPOffboardingComplete { + <# + .SYNOPSIS + Post-execution handler for offboarding orchestration completion + + .DESCRIPTION + Updates the scheduled task state when offboarding completes + + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param($Item) + + $TaskInfo = $Item.Parameters.TaskInfo + $TenantFilter = $Item.Parameters.TenantFilter + $Username = $Item.Parameters.Username + $Results = $Item.Results # Results come from orchestrator, not Parameters + + try { + Write-Information "Completing offboarding orchestration for $Username in tenant $TenantFilter" + Write-Information "Raw results from orchestrator: $($Results | ConvertTo-Json -Depth 10)" + + # Flatten nested arrays from orchestrator results + # Activity functions may return arrays like [result, "status message"] + $FlattenedResults = @( + foreach ($BatchResult in $Results) { + if ($BatchResult -is [array] -and $BatchResult.Count -gt 0) { + Write-Information "Result is array with $($BatchResult.Count) elements, extracting elements" + # Output all elements from the array + foreach ($element in $BatchResult) { + if ($null -ne $element -and $element -ne '') { + $element + } + } + } elseif ($null -ne $BatchResult -and $BatchResult -ne '') { + # Single item - output it + $BatchResult + } + } + ) + + # Process results in the same way as Push-ExecScheduledCommand + if ($FlattenedResults.Count -eq 0) { + $ProcessedResults = "Offboarding completed successfully for $Username" + } else { + Write-Information "Processing $($FlattenedResults.Count) flattened results: $($FlattenedResults | ConvertTo-Json -Depth 10)" + + # Normalize results format + if ($FlattenedResults -is [string]) { + $ProcessedResults = @{ Results = $FlattenedResults } + } elseif ($FlattenedResults -is [array]) { + # Filter and process string or resultText items + $StringResults = $FlattenedResults | Where-Object { $_ -is [string] -or $_.resultText -is [string] } + if ($StringResults) { + $ProcessedResults = $StringResults | ForEach-Object { + $Message = if ($_ -is [string]) { $_ } else { $_.resultText } + @{ Results = $Message } + } + } else { + # Keep structured results as-is + $ProcessedResults = $FlattenedResults + } + } else { + $ProcessedResults = $FlattenedResults + } + } + + Write-Information "Results after processing: $($ProcessedResults | ConvertTo-Json -Depth 10)" + + # Prepare results for storage + if ($ProcessedResults -is [string]) { + $StoredResults = $ProcessedResults + } else { + $ProcessedResults = $ProcessedResults | Select-Object * -ExcludeProperty RowKey, PartitionKey + $StoredResults = $ProcessedResults | ConvertTo-Json -Compress -Depth 20 | Out-String + } + + if ($TaskInfo) { + # Update scheduled task to completed state + $Table = Get-CippTable -tablename 'ScheduledTasks' + $currentUnixTime = [int64](([datetime]::UtcNow) - (Get-Date '1/1/1970')).TotalSeconds + + # Check if results are too large and need separate storage + if ($StoredResults.Length -gt 64000) { + Write-Information 'Results exceed 64KB limit. Storing in ScheduledTaskResults table.' + $TaskResultsTable = Get-CippTable -tablename 'ScheduledTaskResults' + $TaskResults = @{ + PartitionKey = $TaskInfo.RowKey + RowKey = $TenantFilter + Results = [string](ConvertTo-Json -Compress -Depth 20 $ProcessedResults) + } + $null = Add-CIPPAzDataTableEntity @TaskResultsTable -Entity $TaskResults -Force + $StoredResults = @{ Results = 'Offboarding completed, details are available in the More Info pane' } | ConvertTo-Json -Compress + } + + $null = Update-AzDataTableEntity -Force @Table -Entity @{ + PartitionKey = $TaskInfo.PartitionKey + RowKey = $TaskInfo.RowKey + Results = "$StoredResults" + ExecutedTime = "$currentUnixTime" + TaskState = 'Completed' + } + + Write-LogMessage -API 'Offboarding' -tenant $TenantFilter -message "Offboarding completed successfully for $Username" -sev Info + + # Send post-execution alerts if configured + if ($TaskInfo.PostExecution -and $ProcessedResults) { + Send-CIPPScheduledTaskAlert -Results $ProcessedResults -TaskInfo $TaskInfo -TenantFilter $TenantFilter + } + } + + 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 + throw $ErrorMsg + } +} diff --git a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPOffboardingTask.ps1 b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPOffboardingTask.ps1 new file mode 100644 index 000000000000..7f0243f4d9c9 --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPOffboardingTask.ps1 @@ -0,0 +1,38 @@ +function Push-CIPPOffboardingTask { + <# + .SYNOPSIS + Generic wrapper to execute individual offboarding task cmdlets + + .DESCRIPTION + Executes the specified cmdlet with the provided parameters as part of user offboarding + + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param($Item) + + $Cmdlet = $Item.Cmdlet + $Parameters = $Item.Parameters | ConvertTo-Json -Depth 5 | ConvertFrom-Json -AsHashtable + + try { + Write-Information "Executing offboarding cmdlet: $Cmdlet" + + # Check if cmdlet exists + $CmdletInfo = Get-Command -Name $Cmdlet -ErrorAction SilentlyContinue + if (-not $CmdletInfo) { + throw "Cmdlet $Cmdlet does not exist" + } + + # Execute the cmdlet with splatting + $Result = & $Cmdlet @Parameters + + Write-Information "Completed $Cmdlet successfully" + return $Result + + } catch { + $ErrorMsg = "Failed to execute $Cmdlet : $($_.Exception.Message)" + Write-Information $ErrorMsg + return $ErrorMsg + } +} diff --git a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-ExecJITAdminListAllTenants.ps1 b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-ExecJITAdminListAllTenants.ps1 index 0f14c76645ab..2a4ec142805d 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-ExecJITAdminListAllTenants.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-ExecJITAdminListAllTenants.ps1 @@ -13,27 +13,26 @@ function Push-ExecJITAdminListAllTenants { # Get schema extensions $Schema = Get-CIPPSchemaExtensions | Where-Object { $_.id -match '_cippUser' } | Select-Object -First 1 - # Query users with JIT Admin enabled - $Query = @{ - TenantFilter = $DomainName # Use $DomainName for the current tenant - Endpoint = 'users' - Parameters = @{ - '$count' = 'true' - '$select' = "id,accountEnabled,displayName,userPrincipalName,$($Schema.id)" - '$filter' = "$($Schema.id)/jitAdminEnabled eq true or $($Schema.id)/jitAdminEnabled eq false" # Fetches both states to cache current status - } - } - $Users = Get-GraphRequestList @Query | Where-Object { $_.id } + # Query users with JIT Admin enabled using bulk request + $BulkRequests = [System.Collections.Generic.List[object]]::new() + $BulkRequests.Add(@{ + id = 'users' + method = 'GET' + url = "users?`$count=true&`$select=id,accountEnabled,displayName,userPrincipalName,$($Schema.id)&`$filter=$($Schema.id)/jitAdminEnabled eq true or $($Schema.id)/jitAdminEnabled eq false&`$top=999" + }) + + $BulkResults = New-GraphBulkRequest -tenantid $DomainName -Requests $BulkRequests + $Users = ($BulkResults | Where-Object { $_.id -eq 'users' }).body.value | Where-Object { $_.id } if ($Users) { # Get role memberships - $BulkRequests = $Users | ForEach-Object { @( - @{ - id = $_.id + $BulkRequests.Clear() + foreach ($User in $Users) { + $BulkRequests.Add(@{ + id = $User.id method = 'GET' - url = "users/$($_.id)/memberOf/microsoft.graph.directoryRole/?`$select=id,displayName" - } - ) + url = "users/$($User.id)/memberOf/microsoft.graph.directoryRole/?`$select=id,displayName" + }) } # Ensure $BulkRequests is not empty or null before making the bulk request if ($BulkRequests -and $BulkRequests.Count -gt 0) { diff --git a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-ExecOnboardTenantQueue.ps1 b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-ExecOnboardTenantQueue.ps1 index 23ca76c1adeb..b3d6805ff988 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-ExecOnboardTenantQueue.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-ExecOnboardTenantQueue.ps1 @@ -47,7 +47,10 @@ function Push-ExecOnboardTenantQueue { @{ Name = 'SharePoint Administrator'; Id = 'f28a1f50-f6e7-4571-818b-6a12f2af6b6c' }, @{ Name = 'Authentication Policy Administrator'; Id = '0526716b-113d-4c15-b2c8-68e3c22b9f80' }, @{ Name = 'Privileged Role Administrator'; Id = 'e8611ab8-c189-46e8-94e1-60213ab1f814' }, - @{ Name = 'Privileged Authentication Administrator'; Id = '7be44c8a-adaf-4e2a-84d6-ab2649e08a13' } + @{ Name = 'Privileged Authentication Administrator'; Id = '7be44c8a-adaf-4e2a-84d6-ab2649e08a13' }, + @{ Name = 'Billing Administrator'; Id = 'b0f54661-2d74-4c50-afa3-1ec803f12efe'; Optional = $true }, + @{ Name = 'Global Reader'; Id = 'f2ef992c-3afb-46b9-b7cf-a126ee74c451'; Optional = $true }, + @{ Name = 'Domain Name Administrator'; Id = '8329153b-31d0-4727-b945-745eb3bc5f31'; Optional = $true } ) if ($OnboardingSteps.Step1.Status -ne 'succeeded') { @@ -99,14 +102,16 @@ function Push-ExecOnboardTenantQueue { } if (($MissingRoles | Measure-Object).Count -gt 0) { $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = 'Missing roles for relationship' }) - if ($Item.IgnoreMissingRoles -ne $true) { + $RequiredMissingRoles = $ExpectedRoles | Where-Object { $_.Optional -ne $true -and $MissingRoles -contains $_.Name } + if ($Item.IgnoreMissingRoles -ne $true -and ($RequiredMissingRoles | Measure-Object).Count -gt 0) { + $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = "Missing the following required roles: $($MissingRoles -join ', ')" }) $TenantOnboarding.Status = 'failed' $OnboardingSteps.Step2.Status = 'failed' $OnboardingSteps.Step2.Message = "Your GDAP relationship is missing the following roles: $($MissingRoles -join ', ')" } else { $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = 'Ignoring missing roles' }) $OnboardingSteps.Step2.Status = 'succeeded' - $OnboardingSteps.Step2.Message = 'Your GDAP relationship is missing some roles, but the onboarding will continue' + $OnboardingSteps.Step2.Message = "Your GDAP relationship is missing some roles, but the onboarding will continue. Missing roles: $($MissingRoles -join ', ')" } } else { $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = 'Required roles found' }) @@ -121,6 +126,7 @@ function Push-ExecOnboardTenantQueue { if ($OnboardingSteps.Step2.Status -eq 'succeeded') { $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = 'Checking group mapping' }) $AccessAssignments = New-GraphGetRequest -Uri "https://graph.microsoft.com/beta/tenantRelationships/delegatedAdminRelationships/$Id/accessAssignments" + $AccessAssignments = $AccessAssignments | Where-Object { $_.status -notin @('deleted', 'deleting') } if ($AccessAssignments.id -and $Item.AutoMapRoles -ne $true) { $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = 'Groups mapped' }) $OnboardingSteps.Step3.Status = 'succeeded' @@ -223,6 +229,7 @@ function Push-ExecOnboardTenantQueue { do { $AccessAssignments = New-GraphGetRequest -Uri "https://graph.microsoft.com/beta/tenantRelationships/delegatedAdminRelationships/$Id/accessAssignments" + $AccessAssignments = $AccessAssignments | Where-Object { $_.status -notin @('deleted', 'deleting') } Start-Sleep -Seconds 15 } while ($AccessAssignments.status -contains 'pending' -and (Get-Date) -lt $Start.AddMinutes(8)) @@ -231,19 +238,58 @@ function Push-ExecOnboardTenantQueue { $OnboardingSteps.Step3.Status = 'succeeded' $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = 'Checking for missing groups for SAM user' }) - $SamUserId = (New-GraphGetRequest -uri "https://graph.microsoft.com/beta/me?`$select=id" -NoAuthCheck $true).id - $CurrentMemberships = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/me/transitiveMemberOf?`$select=id,displayName" -NoAuthCheck $true - $ExpectedCippRoles = $Item.Roles | Where-Object { $_.roleDefinitionId -in $ExpectedRoles.roleDefinitionId } + $BulkRequests = @( + @{ + id = 'samUserId' + method = 'GET' + url = "/me?`$select=id" + }, + @{ + id = 'currentMemberships' + method = 'GET' + url = "/me/transitiveMemberOf?`$select=id,displayName" + } + ) + $BulkResults = New-GraphBulkRequest -Requests $BulkRequests -NoAuthCheck $true + $SamUserId = ($BulkResults | Where-Object { $_.id -eq 'samUserId' }).body.id + $CurrentMemberships = ($BulkResults | Where-Object { $_.id -eq 'currentMemberships' }).body.value + $ExpectedCippRoles = $Item.Roles | Where-Object { $_.roleDefinitionId -in $ExpectedRoles.Id } + + # Build bulk requests for missing group memberships + $GroupMembershipRequests = [System.Collections.Generic.List[object]]::new() + $GroupMembershipLogs = [System.Collections.Generic.List[object]]::new() + foreach ($Role in $ExpectedCippRoles) { if ($CurrentMemberships.id -notcontains $Role.GroupId) { - $PostBody = @{ - '@odata.id' = 'https://graph.microsoft.com/v1.0/directoryObjects/{0}' -f $SamUserId - } | ConvertTo-Json -Compress - try { - New-GraphPostRequest -uri "https://graph.microsoft.com/beta/groups/$($Role.GroupId)/members/`$ref" -body $PostBody -AsApp $true -NoAuthCheck $true - $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = "Added SAM user to $($Role.GroupName)" }) - } catch { - $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = "Failed to add SAM user to $($Role.GroupName) - $($_.Exception.Message)" }) + $GroupMembershipRequests.Add(@{ + id = "addSamUser-$($Role.GroupId)" + method = 'POST' + url = "groups/$($Role.GroupId)/members/`$ref" + body = @{ + '@odata.id' = 'https://graph.microsoft.com/v1.0/directoryObjects/{0}' -f $SamUserId + } + headers = @{ + 'Content-Type' = 'application/json' + } + }) + $GroupMembershipLogs.Add(@{ + id = "addSamUser-$($Role.GroupId)" + GroupName = $Role.GroupName + }) + } + } + + # Execute bulk group membership additions if any are needed + if ($GroupMembershipRequests.Count -gt 0) { + $GroupMembershipResults = New-GraphBulkRequest -Requests $GroupMembershipRequests -AsApp $true -NoAuthCheck $true + + foreach ($LogEntry in $GroupMembershipLogs) { + $Result = $GroupMembershipResults | Where-Object { $_.id -eq $LogEntry.id } + if ($Result.status -match '^2[0-9]+') { + $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = "Added SAM user to $($LogEntry.GroupName)" }) + } else { + $ErrorMessage = if ($Result.body.error.message) { $Result.body.error.message } else { 'Unknown error' } + $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = "Failed to add SAM user to $($LogEntry.GroupName) - $ErrorMessage" }) } } } diff --git a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 index ccc7249ed798..a7744b0a1af0 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 @@ -7,6 +7,9 @@ function Push-ExecScheduledCommand { $item = $Item | ConvertTo-Json -Depth 100 | ConvertFrom-Json Write-Information "We are going to be running a scheduled task: $($Item.TaskInfo | ConvertTo-Json -Depth 10)" + # Define orchestrator-based commands that handle their own post-execution and state updates + $OrchestratorBasedCommands = @('Invoke-CIPPOffboardingJob') + # Initialize AsyncLocal storage for thread-safe per-invocation context if (-not $script:CippScheduledTaskIdStorage) { $script:CippScheduledTaskIdStorage = [System.Threading.AsyncLocal[string]]::new() @@ -225,6 +228,12 @@ function Push-ExecScheduledCommand { try { if (-not $Trigger.ExecutePerResource) { try { + # For orchestrator-based commands, add TaskInfo to enable post-execution updates + if ($Item.Command -eq 'Invoke-CIPPOffboardingJob') { + Write-Information 'Adding TaskInfo to command parameters for orchestrator-based offboarding' + $commandParameters['TaskInfo'] = $task + } + Write-Information "Starting task: $($Item.Command) for tenant: $Tenant with parameters: $($commandParameters | ConvertTo-Json)" $results = & $Item.Command @commandParameters } catch { @@ -308,45 +317,25 @@ function Push-ExecScheduledCommand { } Write-LogMessage -API 'Scheduler_UserTasks' -tenant $Tenant -tenantid $TenantInfo.customerId -message "Failed to execute task $($task.Name): $errorMessage" -sev Error -LogData (Get-CippExceptionData -Exception $_.Exception) } - Write-Information 'Sending task results to target. Updating the task state.' - - if ($Results) { - $TableDesign = '' - $FinalResults = if ($results -is [array] -and $results[0] -is [string]) { $Results | ConvertTo-Html -Fragment -Property @{ l = 'Text'; e = { $_ } } } else { $Results | ConvertTo-Html -Fragment } - $HTML = $FinalResults -replace '', "This alert is for tenant $Tenant.

$TableDesign
" | Out-String - - # Add alert comment if available - if ($task.AlertComment) { - if ($task.AlertComment -match '%resultcount%') { - $resultCount = if ($Results -is [array]) { $Results.Count } else { 1 } - $task.AlertComment = $task.AlertComment -replace '%resultcount%', "$resultCount" - } - $task.AlertComment = Get-CIPPTextReplacement -Text $task.AlertComment -TenantFilter $Tenant - $HTML += "

Alert Information

$($task.AlertComment)

" - } - $title = "$TaskType - $Tenant - $($task.Name)$(if ($task.Reference) { " - Reference: $($task.Reference)" })" - Write-Information 'Scheduler: Sending the results to the target.' - Write-Information "The content of results is: $Results" - switch -wildcard ($task.PostExecution) { - '*psa*' { Send-CIPPAlert -Type 'psa' -Title $title -HTMLContent $HTML -TenantFilter $Tenant } - '*email*' { Send-CIPPAlert -Type 'email' -Title $title -HTMLContent $HTML -TenantFilter $Tenant } - '*webhook*' { - $Webhook = [PSCustomObject]@{ - 'tenantId' = $TenantInfo.customerId - 'Tenant' = $Tenant - 'TaskInfo' = $Item.TaskInfo - 'Results' = $Results - 'AlertComment' = $task.AlertComment - } - Send-CIPPAlert -Type 'webhook' -Title $title -TenantFilter $Tenant -JSONContent $($Webhook | ConvertTo-Json -Depth 20) - } - } + # For orchestrator-based commands, skip post-execution alerts as they will be handled by the orchestrator's post-execution function + if ($Results -and $Item.Command -notin $OrchestratorBasedCommands) { + Write-Information "Sending task results to post execution target(s): $($Task.PostExecution -join ', ')." + Send-CIPPScheduledTaskAlert -Results $Results -TaskInfo $task -TenantFilter $Tenant -TaskType $TaskType } - Write-Information 'Sent the results to the target. Updating the task state.' try { - if ($task.Recurrence -eq '0' -or [string]::IsNullOrEmpty($task.Recurrence) -or $Trigger.ExecutionMode.value -eq 'once' -or $Trigger.ExecutionMode -eq 'once') { + # For orchestrator-based commands, skip task state update as it will be handled by post-execution + if ($Item.Command -in $OrchestratorBasedCommands) { + Write-Information "Command $($Item.Command) is orchestrator-based. Skipping task state update - will be handled by post-execution." + # Update task state to 'Running' to indicate orchestration is in progress + Update-AzDataTableEntity -Force @Table -Entity @{ + PartitionKey = $task.PartitionKey + RowKey = $task.RowKey + Results = 'Orchestration in progress' + TaskState = 'Processing' + } + } elseif ($task.Recurrence -eq '0' -or [string]::IsNullOrEmpty($task.Recurrence) -or $Trigger.ExecutionMode.value -eq 'once' -or $Trigger.ExecutionMode -eq 'once') { Write-Information 'Recurrence empty or 0. Task is not recurring. Setting task state to completed.' Update-AzDataTableEntity -Force @Table -Entity @{ PartitionKey = $task.PartitionKey diff --git a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandard.ps1 b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandard.ps1 index e3944c21a4d2..0b70aa838235 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandard.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandard.ps1 @@ -95,9 +95,7 @@ function Push-CIPPStandard { $metadata['CATemplateId'] = $Item.Settings.TemplateList.value } - Measure-CippTask -TaskName $Standard -EventName 'CIPP.StandardCompleted' -Metadata $metadata -Script { - & $FunctionName -Tenant $Item.Tenant -Settings $Settings -ErrorAction Stop - } + & $FunctionName -Tenant $Item.Tenant -Settings $Settings -ErrorAction Stop $result = 'Success' Write-Information "Standard $($Standard) completed for tenant $($Tenant)" diff --git a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandardsList.ps1 b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandardsList.ps1 index 01acec8da950..c0af2f9c5467 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandardsList.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandardsList.ps1 @@ -180,6 +180,38 @@ function Push-CIPPStandardsList { } } + $CAStandardFound = ($ComputedStandards.Keys.Where({ $_ -like '*ConditionalAccessTemplate*' }, 'First').Count -gt 0) + if ($CAStandardFound) { + $TestResult = Test-CIPPStandardLicense -StandardName 'ConditionalAccessTemplate_general' -TenantFilter $TenantFilter -RequiredCapabilities @('AAD_PREMIUM', 'AAD_PREMIUM_P2') + if (-not $TestResult) { + $CAKeys = @($ComputedStandards.Keys | Where-Object { $_ -like '*ConditionalAccessTemplate*' }) + $BulkFields = [System.Collections.Generic.List[object]]::new() + foreach ($Key in $CAKeys) { + $TemplateKey = ($Key -split '\|', 2)[1] + if ($TemplateKey) { + $BulkFields.Add([PSCustomObject]@{ + FieldName = "standards.ConditionalAccessTemplate.$TemplateKey" + FieldValue = 'This tenant does not have the required license for this standard.' + }) + } + [void]$ComputedStandards.Remove($Key) + } + if ($BulkFields.Count -gt 0) { + Set-CIPPStandardsCompareField -TenantFilter $TenantFilter -BulkFields $BulkFields + } + + Write-Information "Removed ConditionalAccessTemplate standards for $TenantFilter - missing required license" + } else { + # License valid - update CIPPDB cache with latest CA information before we run so that standards have the most up to date info + try { + Write-Information "Updating CIPPDB cache for Conditional Access policies for $TenantFilter" + Set-CIPPDBCacheConditionalAccessPolicies -TenantFilter $TenantFilter + } catch { + Write-Warning "Failed to update CA cache for $TenantFilter : $($_.Exception.Message)" + } + } + } + Write-Host "Returning $($ComputedStandards.Count) standards for tenant $TenantFilter after filtering." # Return filtered standards $FilteredStandards = $ComputedStandards.Values | ForEach-Object { diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCreateDefaultGroups.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCreateDefaultGroups.ps1 index bd2a682429ac..4fc1b2f5575c 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCreateDefaultGroups.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCreateDefaultGroups.ps1 @@ -16,7 +16,7 @@ function Invoke-ExecCreateDefaultGroups { $Table = Get-CippTable -tablename 'TenantGroups' $Results = [System.Collections.Generic.List[object]]::new() $ExistingGroups = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'TenantGroup' and Type eq 'dynamic'" - $DefaultGroups = '[{"PartitionKey":"TenantGroup","RowKey":"369d985e-0fba-48f9-844f-9f793b10a12c","Description":"This group does not have a license for intune, nor a license for Entra ID Premium","Description@type":null,"DynamicRules":"[{\"property\":\"availableServicePlan\",\"operator\":\"notIn\",\"value\":[{\"label\":\"Microsoft Intune\",\"value\":\"INTUNE_A\",\"id\":\"c1ec4a95-1f05-45b3-a911-aa3fa01094f5\"}]},{\"property\":\"availableServicePlan\",\"operator\":\"notIn\",\"value\":[{\"label\":\"Microsoft Entra ID P1\",\"value\":\"AAD_PREMIUM\",\"id\":\"41781fb2-bc02-4b7c-bd55-b576c07bb09d\"}]}]","DynamicRules@type":null,"GroupType":"dynamic","GroupType@type":null,"RuleLogic":"and","RuleLogic@type":null,"Name":"Not Intune and Entra Premium Capable","Name@type":null},{"PartitionKey":"TenantGroup","RowKey":"4dbca08b-7dc5-4e0f-bc25-14a90c8e0941","Description":"This group has atleast one Business Premium License available","Description@type":null,"DynamicRules":"[{\"property\":\"availableLicense\",\"operator\":\"in\",\"value\":[{\"label\":\"Microsoft 365 Business Premium\",\"value\":\"SPB\"}]},{\"property\":\"availableLicense\",\"operator\":\"in\",\"value\":[{\"label\":\"Microsoft 365 Business Premium (no Teams)\",\"value\":\"Microsoft_365_ Business_ Premium_(no Teams)\"}]},{\"property\":\"availableLicense\",\"operator\":\"in\",\"value\":[{\"label\":\"Microsoft 365 Business Premium Donation\",\"value\":\"Microsoft_365_Business_Premium_Donation_(Non_Profit_Pricing)\"}]},{\"property\":\"availableLicense\",\"operator\":\"in\",\"value\":[{\"label\":\"Microsoft 365 Business Premium EEA (no Teams)\",\"value\":\"Office_365_w\/o_Teams_Bundle_Business_Premium\"}]}]","DynamicRules@type":null,"GroupType":"dynamic","GroupType@type":null,"RuleLogic":"or","RuleLogic@type":null,"Name":"Business Premium License available","Name@type":null},{"PartitionKey":"TenantGroup","RowKey":"703c0e69-84a8-4dcf-a1c2-4986d2ccc850","Description":"This group does have a license for Entra Premium but does not have a license for Intune","Description@type":null,"DynamicRules":"[{\"property\":\"availableServicePlan\",\"operator\":\"in\",\"value\":[{\"label\":\"Microsoft Entra ID P1\",\"value\":\"AAD_PREMIUM\",\"id\":\"41781fb2-bc02-4b7c-bd55-b576c07bb09d\"}]},{\"property\":\"availableServicePlan\",\"operator\":\"notIn\",\"value\":[{\"label\":\"Microsoft Intune\",\"value\":\"INTUNE_A\",\"id\":\"c1ec4a95-1f05-45b3-a911-aa3fa01094f5\"}]}]","DynamicRules@type":null,"GroupType":"dynamic","GroupType@type":null,"RuleLogic":"and","RuleLogic@type":null,"Name":"Entra Premium Capable, Not Intune Capable","Name@type":null},{"PartitionKey":"TenantGroup","RowKey":"c1dadbc0-f0b4-448c-a2e6-e1938ba102e0","Description":"This group has Intune and Entra ID Premium available","Description@type":null,"DynamicRules":"{\"property\":\"availableServicePlan\",\"operator\":\"in\",\"value\":[{\"label\":\"Microsoft Intune\",\"value\":\"INTUNE_A\"},{\"label\":\"Microsoft Entra ID P1\",\"value\":\"AAD_PREMIUM\"}]}","DynamicRules@type":null,"GroupType":"dynamic","GroupType@type":null,"RuleLogic":"and","RuleLogic@type":null,"Name":"Entra ID Premium and Intune Capable","Name@type":null}]' | ConvertFrom-Json + $DefaultGroups = '[{"PartitionKey":"TenantGroup","RowKey":"369d985e-0fba-48f9-844f-9f793b10a12c","Description":"This group does not have a license for intune, nor a license for Entra ID Premium","Description@type":null,"DynamicRules":"[{\"property\":\"availableServicePlan\",\"operator\":\"notIn\",\"value\":[{\"label\":\"Microsoft Intune\",\"value\":\"INTUNE_A\",\"id\":\"c1ec4a95-1f05-45b3-a911-aa3fa01094f5\"}]},{\"property\":\"availableServicePlan\",\"operator\":\"notIn\",\"value\":[{\"label\":\"Microsoft Entra ID P1\",\"value\":\"AAD_PREMIUM\",\"id\":\"41781fb2-bc02-4b7c-bd55-b576c07bb09d\"}]}]","DynamicRules@type":null,"GroupType":"dynamic","GroupType@type":null,"RuleLogic":"and","RuleLogic@type":null,"Name":"Not Intune and Entra Premium Capable","Name@type":null},{"PartitionKey":"TenantGroup","RowKey":"4dbca08b-7dc5-4e0f-bc25-14a90c8e0941","Description":"This group has atleast one Business Premium License available","DynamicRules":"{\"property\":\"availableLicense\",\"operator\":\"in\",\"value\":[{\"label\":\"Microsoft 365 Business Premium\",\"value\":\"SPB\",\"guid\":\"cbdc14ab-d96c-4c30-b9f4-6ada7cdc1d46\"},{\"label\":\"Microsoft 365 Business Premium (no Teams)\",\"value\":\"Microsoft_365_ Business_ Premium_(no Teams)\",\"guid\":\"00e1ec7b-e4a3-40d1-9441-b69b597ab222\"},{\"label\":\"Microsoft 365 Business Premium Donation\",\"value\":\"Microsoft_365_Business_Premium_Donation_(Non_Profit_Pricing)\",\"guid\":\"24c35284-d768-4e53-84d9-b7ae73dddf69\"},{\"label\":\"Microsoft 365 Business Premium EEA (no Teams)\",\"value\":\"Office_365_w/o_Teams_Bundle_Business_Premium\",\"guid\":\"a3f586b6-8cce-4d9b-99d6-55238397f77a\"}]}","GroupType":"dynamic","Name":"Business Premium License available","RuleLogic":"or"},{"PartitionKey":"TenantGroup","RowKey":"703c0e69-84a8-4dcf-a1c2-4986d2ccc850","Description":"This group does have a license for Entra Premium but does not have a license for Intune","Description@type":null,"DynamicRules":"[{\"property\":\"availableServicePlan\",\"operator\":\"in\",\"value\":[{\"label\":\"Microsoft Entra ID P1\",\"value\":\"AAD_PREMIUM\",\"id\":\"41781fb2-bc02-4b7c-bd55-b576c07bb09d\"}]},{\"property\":\"availableServicePlan\",\"operator\":\"notIn\",\"value\":[{\"label\":\"Microsoft Intune\",\"value\":\"INTUNE_A\",\"id\":\"c1ec4a95-1f05-45b3-a911-aa3fa01094f5\"}]}]","DynamicRules@type":null,"GroupType":"dynamic","GroupType@type":null,"RuleLogic":"and","RuleLogic@type":null,"Name":"Entra Premium Capable, Not Intune Capable","Name@type":null},{"PartitionKey":"TenantGroup","RowKey":"c1dadbc0-f0b4-448c-a2e6-e1938ba102e0","Description":"This group has Intune and Entra ID Premium available","Description@type":null,"DynamicRules":"{\"property\":\"availableServicePlan\",\"operator\":\"in\",\"value\":[{\"label\":\"Microsoft Intune\",\"value\":\"INTUNE_A\"},{\"label\":\"Microsoft Entra ID P1\",\"value\":\"AAD_PREMIUM\"}]}","DynamicRules@type":null,"GroupType":"dynamic","GroupType@type":null,"RuleLogic":"and","RuleLogic@type":null,"Name":"Entra ID Premium and Intune Capable","Name@type":null}]' | ConvertFrom-Json foreach ($Group in $DefaultGroups) { diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecGDAPTrace.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecGDAPTrace.ps1 new file mode 100644 index 000000000000..d138555690a5 --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecGDAPTrace.ps1 @@ -0,0 +1,782 @@ +function Invoke-ExecAccessTest { + <# + .SYNOPSIS + Tests the complete GDAP (Granular Delegated Admin Privileges) access path for a user. + + This function traces the access path from customer tenant → GDAP relationships → mapped security groups → user, + checking all 15 standard GDAP roles. It verifies whether a SAM user in the partner tenant has access to each + role through direct or nested group memberships across all active GDAP relationships for a customer tenant. + + The function returns a role-centric view showing: + - For each of the 15 GDAP roles: whether it's assigned, whether the user has access, and the complete path + - Complete traceability: Role → Relationship → Group → User (including nested group paths) + - Broken path detection: identifies roles assigned but user not a member of the required groups + + The output is structured as JSON suitable for diagram visualization, showing the complete access chain + regardless of which relationship provides each role. + + Very boilerplate AI code. Needs some simplification and cleanup. + Ridiculous amount of comments to explain the logic so I don't have to explain it to Claude on the frontend. - rvd + + .DESCRIPTION + GDAP Access Path Testing: + 1. Validates input parameters (TenantFilter and UPN) + 2. Retrieves customer tenant information + 3. Gets all active GDAP relationships for the customer tenant + 4. Locates the UPN in the partner tenant + 5. Gets user's transitive group memberships (handles nested groups automatically) + 6. For each GDAP relationship: + - Retrieves all access assignments (mapped security groups) + - For each group: checks user membership (direct or nested) and traces the path + - Maps roles to relationships and groups + 7. For each of the 15 GDAP roles: + - Finds all relationships/groups that have this role assigned + - Checks if user is a member of any group with this role + - Builds complete access path showing how user gets the role (if they do) + 8. Returns comprehensive JSON with role-centric view and complete path traces + + .FUNCTIONALITY + Entrypoint + .ROLE + CIPP.AppSettings.Read + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + # Initialize API logging + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + Write-LogMessage -Headers $Headers -API $APIName -message 'Accessed this API' -Sev 'Debug' + + # Extract query parameters + # TenantFilter: The customer tenant ID or domain name to test access for + # UPN: The User Principal Name of the SAM user in the partner tenant whose access we're testing + $TenantFilter = $Request.Query.TenantFilter + $UPN = $Request.Query.UPN + + # Validate required input parameters + if ([string]::IsNullOrWhiteSpace($TenantFilter)) { + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Error = 'TenantFilter is required' } + } + } + + if ([string]::IsNullOrWhiteSpace($UPN)) { + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Error = 'UPN is required' } + } + } + + try { + # ============================================================================ + # STEP 1: Define all 15 standard GDAP roles + # ============================================================================ + # These are the roles that should be available through GDAP relationships. + # Each role has a unique roleDefinitionId (GUID) that Microsoft Graph uses + # to identify the role. We'll check if the user has access to each of these + # roles through any GDAP relationship, regardless of which relationship provides it. + # + # Note: The roleDefinitionId is the template ID used in Azure AD role definitions. + # These IDs are consistent across all tenants and are used in GDAP access assignments. + # ============================================================================ + + # Get these from the repo in future -rvd + $AllGDAPRoles = @( + @{ Name = 'Application Administrator'; Id = '9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3'; Description = 'Can create and manage all applications, service principals, app registration, enterprise apps, consent requests. Cannot manage directory roles, security groups.' }, + @{ Name = 'Authentication Policy Administrator'; Id = '0526716b-113d-4c15-b2c8-68e3c22b9f80'; Description = 'Configures authentication methods policy, MFA settings, manages Password Protection settings, creates/manages verifiable credentials, Azure support tickets. Restrictions on updating sensitive properties, deleting/restoring users, legacy MFA settings.' }, + @{ Name = 'Billing Administrator'; Id = 'b0f54661-2d74-4c50-afa3-1ec803f12efe'; Description = 'Can perform common billing related tasks like updating payment information.' }, + @{ Name = 'Cloud App Security Administrator'; Id = '892c5842-a9a6-463a-8041-72aa08ca3cf6'; Description = 'Manages all aspects of the Defender for Cloud App Security in Azure AD, including policies, alerts, and related configurations.' }, + @{ Name = 'Cloud Device Administrator'; Id = '7698a772-787b-4ac8-901f-60d6b08affd2'; Description = 'Enables, disables, deletes devices in Azure AD, reads Windows 10 BitLocker keys. Does not grant permissions to manage other properties on the device.' }, + @{ Name = 'Domain Name Administrator'; Id = '8329153a-20ed-4bf8-aa37-81242c6e8e01'; Description = 'Can manage domain names in cloud and on-premises.' }, + @{ Name = 'Exchange Administrator'; Id = '29232cdf-9323-42fd-ade2-1d097af3e4de'; Description = 'Manages all aspects of Exchange Online, including mailboxes, permissions, connectivity, and related settings. Limited access to related Exchange settings in Azure AD.' }, + @{ Name = 'Global Reader'; Id = 'f2ef992c-3afb-46b9-b7cf-a126ee74c451'; Description = 'Can read everything that a Global Administrator can but not update anything.' }, + @{ Name = 'Intune Administrator'; Id = '3a2c62db-5318-420d-8d74-23affee5d9d5'; Description = 'Manages all aspects of Intune, including all related resources, policies, configurations, and tasks.' }, + @{ Name = 'Privileged Authentication Administrator'; Id = '7be44c8a-adaf-4e2a-84d6-ab2649e08a13'; Description = 'Sets/resets authentication methods for all users (admin or non-admin), deletes/restores any users. Manages support tickets in Azure and Microsoft 365. Restrictions on managing per-user MFA in legacy MFA portal.' }, + @{ Name = 'Privileged Role Administrator'; Id = 'e8611ab8-c189-46e8-94e1-60213ab1f814'; Description = 'Manages role assignments in Azure AD, Azure AD Privileged Identity Management, creates/manages groups, manages all aspects of Privileged Identity Management, administrative units. Allows managing assignments for all Azure AD roles including Global Administrator.' }, + @{ Name = 'Security Administrator'; Id = '194ae4cb-b126-40b2-bd5b-6091b380977d'; Description = 'Can read security information and reports, and manages security-related features, including identity protection, security policies, device management, and threat management in Azure AD and Office 365.' }, + @{ Name = 'SharePoint Administrator'; Id = 'f28a1f50-f6e7-4571-818b-6a12f2af6b6c'; Description = 'Manages all aspects of SharePoint Online, Microsoft 365 groups, support tickets, service health. Scoped permissions for Microsoft Intune, SharePoint, and OneDrive resources.' }, + @{ Name = 'Teams Administrator'; Id = '69091246-20e8-4a56-aa4d-066075b2a7a8'; Description = 'Manages all aspects of Microsoft Teams, including telephony, messaging, meetings, teams, Microsoft 365 groups, support tickets, and service health.' }, + @{ Name = 'User Administrator'; Id = 'fe930be7-5e62-47db-91af-98c3a49a38b1'; Description = 'Manages all aspects of users, groups, registration, and resets passwords for limited admins. Cannot manage security-related policies or other configuration objects.' } + ) + + # ============================================================================ + # STEP 2: Get customer tenant information + # ============================================================================ + # The TenantFilter can be either a tenant ID (GUID) or a domain name. + # Get-Tenants will resolve it and return the tenant object with customerId and displayName. + # ============================================================================ + $Tenant = Get-Tenants -TenantFilter $TenantFilter + if (-not $Tenant) { + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::NotFound + Body = @{ Error = "Tenant not found: $TenantFilter" } + } + } + + $CustomerTenantId = $Tenant.customerId + $CustomerTenantName = $Tenant.displayName + + # ============================================================================ + # STEP 3: Get all active GDAP relationships for the customer tenant + # ============================================================================ + # GDAP relationships are created in the partner tenant and link to customer tenants. + # We query from the partner tenant perspective ($env:TenantID) and filter for: + # - status eq 'active': Only relationships that are currently active + # - customer/tenantId eq '$CustomerTenantId': Only relationships for this specific customer + # + # A tenant can have multiple GDAP relationships, each potentially with different roles. + # We need to check all of them to see which roles are available through which relationships. + # ============================================================================ + $BaseUri = 'https://graph.microsoft.com/beta/tenantRelationships/delegatedAdminRelationships' + $FilterValue = "status eq 'active' and customer/tenantId eq '$CustomerTenantId'" + $RelationshipsUri = "$($BaseUri)?`$filter=$($FilterValue)" + $Relationships = New-GraphGetRequest -uri $RelationshipsUri -tenantid $env:TenantID -NoAuthCheck $true + + # If no active relationships exist, return early with an informative message + if (-not $Relationships -or $Relationships.Count -eq 0) { + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @{ + tenantId = $CustomerTenantId + tenantName = $CustomerTenantName + relationships = @() + error = "No active GDAP relationships found for tenant $CustomerTenantName" + } + } + } + + # ============================================================================ + # STEP 4: Get the SAM user in the partner tenant + # ============================================================================ + # The UPN provided is for a user in the PARTNER tenant (not the customer tenant). + # This is the SAM (Service Account Manager) user whose access we're testing. + # The user must be in the partner tenant because GDAP groups are in the partner tenant. + # + # We try two methods: + # 1. Filter query: More efficient if it works + # 2. Direct lookup: Fallback if filter query doesn't return results + # ============================================================================ + $User = $null + try { + # Filter didn't work, try direct lookup by UPN (works if UPN is unique identifier) + $User = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$UPN" -tenantid $env:TenantID -NoAuthCheck $true + } catch { + Write-LogMessage -Headers $Headers -API $APIName -message "Could not find user $UPN in partner tenant: $($_.Exception.Message)" -Sev 'Warning' + } + + # If user not found, return error + if (-not $User) { + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::NotFound + Body = @{ + tenantId = $CustomerTenantId + tenantName = $CustomerTenantName + relationships = @() + error = "User $UPN not found in partner tenant" + } + } + } + + $UserId = $User.id + $UserDisplayName = $User.displayName + + # ============================================================================ + # STEP 5: Get user's transitive group memberships + # ============================================================================ + # This is a critical step. We use transitiveMemberOf which automatically handles + # nested groups at any depth. This means: + # - If user is directly in Group A, they're included + # - If user is in Group B, and Group B is in Group A, they're included + # - If user is in Group C, Group C is in Group B, Group B is in Group A, they're included + # - And so on for any depth of nesting + # + # We build a hashtable (dictionary) for O(1) lookup performance when checking + # if the user is a member of a specific group later. + # + # We filter for only groups (@odata.type = '#microsoft.graph.group') because + # transitiveMemberOf can also return role assignments, which we don't need here. + # ============================================================================ + $UserGroupMemberships = @{} + try { + # Use AsApp=true to get all memberships regardless of current user context + $PartnerUserMemberships = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$UserId/transitiveMemberOf?`$select=id,displayName" -tenantid $env:TenantID -NoAuthCheck $true -AsApp $true -ErrorAction SilentlyContinue + if ($PartnerUserMemberships) { + foreach ($Membership in $PartnerUserMemberships) { + # Only include groups, not role assignments + if ($Membership.'@odata.type' -eq '#microsoft.graph.group') { + # Store in hashtable for fast lookup: key = groupId, value = membership object + $UserGroupMemberships[$Membership.id] = $Membership + } + } + } + } catch { + Write-LogMessage -Headers $Headers -API $APIName -message "Could not get user group memberships: $($_.Exception.Message)" -Sev 'Warning' + } + + # ============================================================================ + # STEP 6: Collect all relationships, groups, and build role mapping + # ============================================================================ + # We need to: + # 1. For each relationship, get all access assignments (mapped groups) + # 2. Collect all unique group IDs from all assignments + # 3. Batch fetch all groups at once (more efficient than individual calls) + # 4. For each group, check if user is a member and trace the path + # 5. Build a map from roleId -> list of relationships/groups that have that role + # + # This allows us to later check each of the 15 roles and see: + # - Which relationships have this role + # - Which groups in those relationships have this role + # - Whether the user is a member of any of those groups + # ============================================================================ + $AllRelationshipData = [System.Collections.Generic.List[object]]::new() + # This map will store: roleId -> list of {relationship, group} objects that have this role assigned + $RoleToRelationshipsMap = @{} + # This map will store: roleId -> list of relationships that have this role available (but may not be assigned) + $RoleToAvailableRelationshipsMap = @{} + + # ======================================================================== + # PHASE 1: Collect all access assignments and extract unique group IDs + # ======================================================================== + # First, we'll collect all access assignments from all relationships + # and extract the unique group IDs. Then we'll fetch all groups in batch. + # Also track which roles are available in each relationship. + # ======================================================================== + $AllAccessAssignments = [System.Collections.Generic.List[object]]::new() + $RelationshipAssignmentMap = @{} # Maps relationshipId -> list of assignments + + foreach ($Relationship in $Relationships) { + $RelationshipId = $Relationship.id + $RelationshipName = $Relationship.displayName + $RelationshipStatus = $Relationship.status + + # Track roles available in this relationship (from accessDetails.unifiedRoles) + if ($Relationship.accessDetails -and $Relationship.accessDetails.unifiedRoles) { + foreach ($Role in $Relationship.accessDetails.unifiedRoles) { + $RoleId = $Role.roleDefinitionId + if ($RoleId) { + if (-not $RoleToAvailableRelationshipsMap.ContainsKey($RoleId)) { + $RoleToAvailableRelationshipsMap[$RoleId] = [System.Collections.Generic.List[object]]::new() + } + $RoleToAvailableRelationshipsMap[$RoleId].Add([PSCustomObject]@{ + relationshipId = $RelationshipId + relationshipName = $RelationshipName + relationshipStatus = $RelationshipStatus + }) + } + } + } + + # Get access assignments (mapped security groups) for this relationship + $AccessAssignments = @() + try { + $AccessAssignments = New-GraphGetRequest -Uri "https://graph.microsoft.com/beta/tenantRelationships/delegatedAdminRelationships/$RelationshipId/accessAssignments" -tenantid $env:TenantID -NoAuthCheck $true + + # Handle case where response might be a single object instead of array + if ($AccessAssignments -and -not ($AccessAssignments -is [System.Array])) { + $AccessAssignments = @($AccessAssignments) + } + + Write-LogMessage -Headers $Headers -API $APIName -message "Retrieved $($AccessAssignments.Count) access assignments for relationship ${RelationshipName}" -Sev 'Debug' + + # Store assignments for this relationship + $RelationshipAssignmentMap[$RelationshipId] = @{ + Relationship = $Relationship + Assignments = $AccessAssignments + } + + # Add to master list + foreach ($Assignment in $AccessAssignments) { + $AllAccessAssignments.Add(@{ + RelationshipId = $RelationshipId + RelationshipName = $RelationshipName + RelationshipStatus = $RelationshipStatus + Assignment = $Assignment + }) + } + } catch { + Write-LogMessage -Headers $Headers -API $APIName -message "Could not get access assignments for relationship ${RelationshipName}: $($_.Exception.Message)" -Sev 'Warning' + } + } + + # Extract all unique group IDs from all assignments + $AllGroupIds = [System.Collections.Generic.HashSet[string]]::new() + foreach ($AssignmentData in $AllAccessAssignments) { + $Assignment = $AssignmentData.Assignment + $GroupId = $null + + # Extract group ID from assignment + if ($Assignment.accessContainer) { + $GroupId = $Assignment.accessContainer.accessContainerId + } elseif ($Assignment.value -and $Assignment.value.accessContainer) { + $GroupId = $Assignment.value.accessContainer.accessContainerId + } + + if ($GroupId -and -not [string]::IsNullOrWhiteSpace($GroupId)) { + [void]$AllGroupIds.Add($GroupId) + } + } + + Write-LogMessage -Headers $Headers -API $APIName -message "Found $($AllGroupIds.Count) unique groups across all relationships" -Sev 'Debug' + + # ======================================================================== + # PHASE 2: Fetch all groups at once and filter in memory + # ======================================================================== + # Fetch all groups in a single request, then create a lookup dictionary + # for fast in-memory filtering when processing assignments + # ======================================================================== + $GroupLookup = @{} # Maps groupId -> group object + + try { + # Fetch all groups at once (similar to Set-CIPPDBCacheGroups) + $AllGroups = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$top=999&$select=id,displayName' -tenantid $env:TenantID -NoAuthCheck $true -AsApp $true + + # Handle case where response might be a single object instead of array + if ($AllGroups -and -not ($AllGroups -is [System.Array])) { + $AllGroups = @($AllGroups) + } + + # Build lookup dictionary for O(1) access + foreach ($Group in $AllGroups) { + if ($Group.id) { + $GroupLookup[$Group.id] = $Group + } + } + + Write-LogMessage -Headers $Headers -API $APIName -message "Fetched $($AllGroups.Count) total groups, $($GroupLookup.Count) in lookup" -Sev 'Debug' + } catch { + Write-LogMessage -Headers $Headers -API $APIName -message "Could not fetch all groups: $($_.Exception.Message). Will use fallback for missing groups." -Sev 'Warning' + } + + # ======================================================================== + # PHASE 3: Process all assignments using the group lookup + # ======================================================================== + # Now that we have all groups, process each relationship's assignments + # ======================================================================== + foreach ($Relationship in $Relationships) { + $RelationshipId = $Relationship.id + $RelationshipName = $Relationship.displayName + $RelationshipStatus = $Relationship.status + + # Get assignments for this relationship + if (-not $RelationshipAssignmentMap.ContainsKey($RelationshipId)) { + # No assignments for this relationship, create empty groups list + $AllRelationshipData.Add([PSCustomObject]@{ + relationshipId = $RelationshipId + relationshipName = $RelationshipName + relationshipStatus = $RelationshipStatus + customerTenantId = $Relationship.customer.tenantId + customerTenantName = $Relationship.customer.displayName + groups = @() + }) + continue + } + + $AccessAssignments = $RelationshipAssignmentMap[$RelationshipId].Assignments + $RelationshipGroups = [System.Collections.Generic.List[object]]::new() + + Write-LogMessage -Headers $Headers -API $APIName -message "Processing $($AccessAssignments.Count) access assignments for relationship ${RelationshipName}" -Sev 'Debug' + + foreach ($Assignment in $AccessAssignments) { + # Extract the security group ID and roles from the assignment + $GroupId = $null + if ($Assignment.accessContainer) { + $GroupId = $Assignment.accessContainer.accessContainerId + } elseif ($Assignment.value -and $Assignment.value.accessContainer) { + $GroupId = $Assignment.value.accessContainer.accessContainerId + $Assignment = $Assignment.value + } else { + Write-LogMessage -Headers $Headers -API $APIName -message "Access assignment missing accessContainer: $($Assignment | ConvertTo-Json -Compress)" -Sev 'Warning' + continue + } + + if ([string]::IsNullOrWhiteSpace($GroupId)) { + Write-LogMessage -Headers $Headers -API $APIName -message "Access assignment has empty accessContainerId: $($Assignment | ConvertTo-Json -Compress)" -Sev 'Warning' + continue + } + + # Extract roles - handle both direct and nested structures + $Roles = $null + if ($Assignment.accessDetails -and $Assignment.accessDetails.unifiedRoles) { + $Roles = $Assignment.accessDetails.unifiedRoles + } elseif ($Assignment.unifiedRoles) { + $Roles = $Assignment.unifiedRoles + } + + if (-not $Roles -or $Roles.Count -eq 0) { + Write-LogMessage -Headers $Headers -API $APIName -message "Access assignment for group $GroupId has no roles assigned" -Sev 'Warning' + $Roles = @() + } + + # Get group from lookup (already fetched all groups at once) + $Group = $null + if ($GroupLookup.ContainsKey($GroupId)) { + $Group = $GroupLookup[$GroupId] + } else { + # Fallback: create minimal group object if not in lookup + # This can happen if the group was deleted or doesn't exist + $Group = [PSCustomObject]@{ + id = $GroupId + displayName = "Unknown Group ($GroupId)" + } + Write-LogMessage -Headers $Headers -API $APIName -message "Group $GroupId not found in lookup, using fallback" -Sev 'Warning' + } + + # Process the assignment even if group lookup failed - we still have the group ID and roles + if ($Group) { + # ================================================================ + # Check if user is a member of this group (direct or nested) + # ================================================================ + # We already have the user's transitive memberships, so we can + # quickly check if they're a member using our hashtable lookup. + # This is O(1) performance. + # ================================================================ + $IsMember = $UserGroupMemberships.ContainsKey($GroupId) + $MembershipPath = @() + $IsPathComplete = $false + + if ($IsMember) { + # ============================================================ + # User IS a member (either direct or nested) + # ============================================================ + # We know from transitiveMemberOf that the user is a member, + # but we need to determine if it's direct or nested, and if + # nested, try to find the path through intermediate groups. + # ============================================================ + $IsPathComplete = $true + # Start with assumption of direct membership + $MembershipPath = @( + @{ + groupId = $GroupId + groupName = $Group.displayName + membershipType = 'direct' + } + ) + + # ============================================================ + # Determine if membership is direct or nested + # ============================================================ + # We check the direct members of the group to see if the user + # is directly in it. If not, they must be nested (through + # another group that's a member of this group). + # ============================================================ + try { + # Get direct members of the target group + $DirectMembers = New-GraphGetRequest -Uri "https://graph.microsoft.com/beta/groups/$GroupId/members?`$select=id,displayName,userPrincipalName" -tenantid $env:TenantID -NoAuthCheck $true -AsApp $true + $IsDirectMember = $DirectMembers.value | Where-Object { $_.id -eq $UserId } + + if (-not $IsDirectMember) { + # ==================================================== + # User is nested - find the path through nested groups + # ==================================================== + # The user is not directly in this group, so they must + # be in a group that's a member of this group. + # We try to find which of the user's direct groups + # are members of this target group. + # ==================================================== + $MembershipPath[0].membershipType = 'nested' + + # Get groups the user is directly in (not nested) + $UserDirectGroups = New-GraphGetRequest -Uri "https://graph.microsoft.com/beta/users/$UserId/memberOf?`$select=id,displayName" -tenantid $env:TenantID -NoAuthCheck $true -AsApp $true -ErrorAction SilentlyContinue + if ($UserDirectGroups) { + $NestedGroups = @() + # Check each of the user's direct groups + foreach ($UserGroup in $UserDirectGroups) { + if ($UserGroup.'@odata.type' -eq '#microsoft.graph.group') { + try { + # Check if this user's direct group is a member of the target group + $GroupMembers = New-GraphGetRequest -Uri "https://graph.microsoft.com/beta/groups/$GroupId/members?`$select=id" -tenantid $env:TenantID -NoAuthCheck $true -AsApp $true -ErrorAction SilentlyContinue + if ($GroupMembers.value | Where-Object { $_.id -eq $UserGroup.id }) { + # Found it! This is the intermediate group + $NestedGroups += @{ + groupId = $UserGroup.id + groupName = $UserGroup.displayName + membershipType = 'direct' # User is direct member of this intermediate group + } + } + } catch { + # Skip if we can't check (permissions issue, etc.) + } + } + } + if ($NestedGroups.Count -gt 0) { + # Build the complete path: User → Intermediate Group → Target Group + # Add the target group to complete the path + $NestedGroups += @{ + groupId = $GroupId + groupName = $Group.displayName + membershipType = 'nested' # Intermediate group is nested in target group + } + $MembershipPath = $NestedGroups + } + } + } + # If IsDirectMember is true, membershipPath already shows 'direct' - we're done + } catch { + # If we can't check direct members (permissions, API error), assume nested + # This is a safe assumption - we know they're a member somehow + $MembershipPath[0].membershipType = 'nested' + } + } else { + # ============================================================ + # User is NOT a member of this group + # ============================================================ + # The group exists and has roles assigned, but the user isn't + # a member. This represents a broken path - the role is assigned + # but the user can't access it. + # ============================================================ + # Check if the group has any members at all (for diagnostic purposes) + $GroupHasMembers = $false + try { + $GroupMembers = New-GraphGetRequest -Uri "https://graph.microsoft.com/beta/groups/$GroupId/members?`$top=1" -tenantid $env:TenantID -NoAuthCheck $true -AsApp $true -ErrorAction SilentlyContinue + $GroupHasMembers = $GroupMembers.value.Count -gt 0 + } catch { + $GroupHasMembers = $false + } + + # Record the broken path + $MembershipPath = @( + @{ + groupId = $GroupId + groupName = $Group.displayName + membershipType = 'not_member' + groupHasMembers = $GroupHasMembers # Helps diagnose if group is empty + } + ) + } + + # ================================================================ + # Store group data for this relationship + # ================================================================ + # We store all the information about this group including: + # - Whether user is a member + # - The membership path (direct/nested/not_member) + # - All roles assigned to this group + # ================================================================ + $GroupData = [PSCustomObject]@{ + groupId = $GroupId + groupName = $Group.displayName + roles = $Roles # Array of role objects with roleDefinitionId + isMember = $IsMember + isPathComplete = $IsPathComplete # True if user can access this group + membershipPath = $MembershipPath # The path showing how user gets access (or why they don't) + assignmentStatus = $Assignment.status # Status of the access assignment + } + + $RelationshipGroups.Add($GroupData) + Write-LogMessage -Headers $Headers -API $APIName -message "Processed group $GroupDisplayName ($GroupId) with $($Roles.Count) roles for relationship ${RelationshipName}" -Sev 'Debug' + + # ================================================================ + # Map each role to this relationship/group combination + # ================================================================ + # This builds our role-to-relationships map that we'll use later + # to check each of the 15 GDAP roles. For each role, we'll know: + # - Which relationships have it + # - Which groups in those relationships have it + # - Whether the user is a member of those groups + # ================================================================ + if ($Roles -and $Roles.Count -gt 0) { + foreach ($Role in $Roles) { + # Handle both direct role objects and role objects with roleDefinitionId property + $RoleId = $null + if ($Role.roleDefinitionId) { + $RoleId = $Role.roleDefinitionId + } elseif ($Role -is [string]) { + $RoleId = $Role + } else { + Write-LogMessage -Headers $Headers -API $APIName -message "Role object missing roleDefinitionId: $($Role | ConvertTo-Json -Compress)" -Sev 'Warning' + continue + } + + if ([string]::IsNullOrWhiteSpace($RoleId)) { + Write-LogMessage -Headers $Headers -API $APIName -message "Role has empty roleDefinitionId for group $GroupId" -Sev 'Warning' + continue + } + + # Initialize list for this role if we haven't seen it before + if (-not $RoleToRelationshipsMap.ContainsKey($RoleId)) { + $RoleToRelationshipsMap[$RoleId] = [System.Collections.Generic.List[object]]::new() + } + # Add this relationship/group combination to the role's list + $RoleToRelationshipsMap[$RoleId].Add([PSCustomObject]@{ + relationshipId = $RelationshipId + relationshipName = $RelationshipName + relationshipStatus = $RelationshipStatus + groupId = $GroupId + groupName = $Group.displayName + groupData = $GroupData # Full group data including membership info + }) + } + } + } + } + + # Store relationship data for reference + $AllRelationshipData.Add([PSCustomObject]@{ + relationshipId = $RelationshipId + relationshipName = $RelationshipName + relationshipStatus = $RelationshipStatus + customerTenantId = $Relationship.customer.tenantId + customerTenantName = $Relationship.customer.displayName + groups = $RelationshipGroups + }) + } + + # ============================================================================ + # STEP 7: Trace each of the 15 GDAP roles to the user + # ============================================================================ + # This is the core logic - for each of the 15 standard GDAP roles, we: + # 1. Find all relationships/groups that have this role assigned + # 2. Check if the user is a member of any of those groups + # 3. Build the complete access path showing how the user gets the role (if they do) + # 4. Identify broken paths (role assigned but user not a member) + # + # The result is a role-centric view where each role shows: + # - Whether it's assigned in any relationship + # - Whether the user has access to it + # - All relationships/groups that have it + # - The complete path from role to user (if access exists) + # ============================================================================ + $RoleTraces = [System.Collections.Generic.List[object]]::new() + + # Check each of the 15 standard GDAP roles + foreach ($GDAPRole in $AllGDAPRoles) { + $RoleId = $GDAPRole.Id + $RoleName = $GDAPRole.Name + $RoleDescription = $GDAPRole.Description + + # ======================================================================== + # Find all relationships/groups that have this role assigned + # ======================================================================== + # We use the RoleToRelationshipsMap we built earlier. For each role, + # this map contains all relationship/group combinations that have + # this role assigned. + # ======================================================================== + $RelationshipsWithRole = @() + $UserHasAccess = $false + $AccessPaths = [System.Collections.Generic.List[object]]::new() + + if ($RoleToRelationshipsMap.ContainsKey($RoleId)) { + # This role exists in at least one relationship + foreach ($RoleRelationship in $RoleToRelationshipsMap[$RoleId]) { + $GroupData = $RoleRelationship.groupData + + # Record all relationships/groups that have this role (for reference) + $RelationshipsWithRole += [PSCustomObject]@{ + relationshipId = $RoleRelationship.relationshipId + relationshipName = $RoleRelationship.relationshipName + relationshipStatus = $RoleRelationship.relationshipStatus + groupId = $RoleRelationship.groupId + groupName = $RoleRelationship.groupName + isUserMember = $GroupData.isMember # Whether user is in this group + membershipPath = $GroupData.membershipPath # How user gets access (or why they don't) + } + + # ================================================================ + # Check if user has access through this group + # ================================================================ + # If the user is a member of this group (direct or nested), + # they have access to this role. We only need ONE path where + # the user is a member - if they're in any group with this role, + # they have access. + # ================================================================ + if ($GroupData.isMember) { + $UserHasAccess = $true + # Record the access path for this role + $AccessPaths.Add([PSCustomObject]@{ + relationshipId = $RoleRelationship.relationshipId + relationshipName = $RoleRelationship.relationshipName + groupId = $RoleRelationship.groupId + groupName = $RoleRelationship.groupName + membershipPath = $GroupData.membershipPath # Shows: User → Group (or User → Intermediate → Group) + }) + } + } + } + + # ======================================================================== + # Build the role trace object + # ======================================================================== + # This contains all information about this role: + # - roleExistsInRelationship: Role is available in at least one relationship (may not be assigned to any group) + # - isAssigned: Role is assigned to at least one group (must exist in relationship first) + # - isUserHasAccess: User is a member of at least one group with this role + # - relationshipsWithRole: All relationships/groups that have this role assigned + # - relationshipsWithRoleAvailable: All relationships where this role is available (but may not be assigned) + # - accessPaths: Only the paths where user actually has access (if any) + # ======================================================================== + $RoleExistsInRelationship = $RoleToAvailableRelationshipsMap.ContainsKey($RoleId) + $IsAssigned = $RelationshipsWithRole.Count -gt 0 + + # Get relationships where role is available but may not be assigned + $RelationshipsWithRoleAvailable = @() + if ($RoleToAvailableRelationshipsMap.ContainsKey($RoleId)) { + $RelationshipsWithRoleAvailable = $RoleToAvailableRelationshipsMap[$RoleId] + } + + $RoleTraces.Add([PSCustomObject]@{ + roleName = $RoleName + roleId = $RoleId + roleDescription = $RoleDescription + roleExistsInRelationship = $RoleExistsInRelationship # Role exists in at least one relationship + isAssigned = $IsAssigned # Role is assigned to at least one group + isUserHasAccess = $UserHasAccess + relationshipsWithRole = $RelationshipsWithRole # All places this role is assigned to groups + relationshipsWithRoleAvailable = $RelationshipsWithRoleAvailable # All relationships where role is available + accessPaths = $AccessPaths # Only paths where user has access + }) + } + + # ============================================================================ + # STEP 8: Build final result structure - role-centric view + # ============================================================================ + # The output is structured to be role-centric, making it easy to: + # - See which of the 15 roles the user has access to + # - See which roles are missing + # - See the complete path for each role (if access exists) + # - Identify broken paths (roles assigned but user not a member) + # + # The JSON structure is designed for diagram visualization, showing the + # complete chain: Role → Relationship → Group → User (with nested groups) + # ============================================================================ + + # Calculate summary statistics + $RolesWithAccess = ($RoleTraces | Where-Object { $_.isUserHasAccess -eq $true }).Count + $RolesAssignedButNoAccess = ($RoleTraces | Where-Object { ($_.isAssigned -eq $true) -and ($_.isUserHasAccess -eq $false) }).Count + $RolesInRelationshipButNotAssigned = ($RoleTraces | Where-Object { ($_.roleExistsInRelationship -eq $true) -and ($_.isAssigned -eq $false) }).Count + $RolesNotInAnyRelationship = ($RoleTraces | Where-Object { $_.roleExistsInRelationship -eq $false }).Count + + # Build the results object with role-centric view + $Results = [PSCustomObject]@{ + tenantId = $CustomerTenantId + tenantName = $CustomerTenantName + userUPN = $UPN + userId = $UserId + userDisplayName = $UserDisplayName + roles = $RoleTraces + relationships = $AllRelationshipData + summary = [PSCustomObject]@{ + totalRelationships = $Relationships.Count + totalRoles = $AllGDAPRoles.Count + rolesWithAccess = $RolesWithAccess + rolesAssignedButNoAccess = $RolesAssignedButNoAccess + rolesInRelationshipButNotAssigned = $RolesInRelationshipButNotAssigned + rolesNotInAnyRelationship = $RolesNotInAnyRelationship + } + } + + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = $Results + } + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -Headers $Headers -API $APIName -message "Failed to test GDAP access path: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::InternalServerError + Body = @{ Error = $ErrorMessage.NormalizedError } + } + } +} diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecLogRetentionConfig.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecLogRetentionConfig.ps1 new file mode 100644 index 000000000000..2f8cb0168ec7 --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecLogRetentionConfig.ps1 @@ -0,0 +1,61 @@ +function Invoke-ExecLogRetentionConfig { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + CIPP.AppSettings.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + $Table = Get-CIPPTable -TableName Config + $Filter = "PartitionKey eq 'LogRetention' and RowKey eq 'Settings'" + + $results = try { + if ($Request.Query.List) { + $RetentionSettings = Get-CIPPAzDataTableEntity @Table -Filter $Filter + if (!$RetentionSettings) { + # Return default values if not set + @{ + RetentionDays = 90 + } + } else { + @{ + RetentionDays = [int]$RetentionSettings.RetentionDays + } + } + } else { + $RetentionDays = [int]$Request.Body.RetentionDays + + # Validate minimum value + if ($RetentionDays -lt 7) { + throw 'Retention days must be at least 7 days' + } + + # Validate maximum value + if ($RetentionDays -gt 365) { + throw 'Retention days must be at most 365 days' + } + + $RetentionConfig = @{ + 'RetentionDays' = $RetentionDays + 'PartitionKey' = 'LogRetention' + 'RowKey' = 'Settings' + } + + Add-CIPPAzDataTableEntity @Table -Entity $RetentionConfig -Force | Out-Null + Write-LogMessage -headers $Request.Headers -API $Request.Params.CIPPEndpoint -message "Set log retention to $RetentionDays days" -Sev 'Info' + "Successfully set log retention to $RetentionDays days" + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Request.Headers -API $Request.Params.CIPPEndpoint -message "Failed to set log retention configuration: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + "Failed to set configuration: $($ErrorMessage.NormalizedError)" + } + + $body = [pscustomobject]@{'Results' = $Results } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = $body + }) +} diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 index 3e00555ce247..202d6e4dc182 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 @@ -68,6 +68,15 @@ function Invoke-ExecCreateSAMApp { } } until ($attempt -gt 3) } + + try { + $AppPolicyStatus = Update-AppManagementPolicy + 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 if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-AddContactTemplates.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-AddContactTemplates.ps1 index af9b208dded7..afdc1d1b1d98 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-AddContactTemplates.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-AddContactTemplates.ps1 @@ -3,7 +3,7 @@ Function Invoke-AddContactTemplates { .FUNCTIONALITY Entrypoint,AnyTenant .ROLE - Exchange.ReadWrite + Exchange.Contact.ReadWrite #> [CmdletBinding()] param($Request, $TriggerMetadata) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-EditContactTemplates.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-EditContactTemplates.ps1 index 8edcaf294af2..87faed97031a 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-EditContactTemplates.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-EditContactTemplates.ps1 @@ -3,7 +3,7 @@ Function Invoke-EditContactTemplates { .FUNCTIONALITY Entrypoint,AnyTenant .ROLE - Exchange.ReadWrite + Exchange.Contact.ReadWrite #> [CmdletBinding()] param($Request, $TriggerMetadata) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-RemoveContactTemplates.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-RemoveContactTemplates.ps1 index de4e79bf2b70..43b43dff8917 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-RemoveContactTemplates.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-RemoveContactTemplates.ps1 @@ -3,7 +3,7 @@ function Invoke-RemoveContactTemplates { .FUNCTIONALITY Entrypoint,AnyTenant .ROLE - Exchange.ReadWrite + Exchange.Contact.ReadWrite #> [CmdletBinding()] param($Request, $TriggerMetadata) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecEditMailboxPermissions.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecEditMailboxPermissions.ps1 index ee36d68eb6b2..00d696892b7c 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecEditMailboxPermissions.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecEditMailboxPermissions.ps1 @@ -1,4 +1,4 @@ -Function Invoke-ExecEditMailboxPermissions { +function Invoke-ExecEditMailboxPermissions { <# .FUNCTIONALITY Entrypoint @@ -23,6 +23,9 @@ Function Invoke-ExecEditMailboxPermissions { $MailboxPerms = New-ExoRequest -Anchor $username -tenantid $Tenantfilter -cmdlet 'Remove-mailboxpermission' -cmdParams @{Identity = $userid; user = $RemoveUser; accessRights = @('FullAccess'); } $results.add("Removed $($removeuser) from $($username) Shared Mailbox permissions") Write-LogMessage -headers $Request.Headers -API $APINAME-message "Removed $($RemoveUser) from $($username) Shared Mailbox permission" -Sev 'Info' -tenant $TenantFilter + + # Sync cache + Sync-CIPPMailboxPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $username -User $RemoveUser -PermissionType 'FullAccess' -Action 'Remove' } catch { Write-LogMessage -headers $Request.Headers -API $APINAME-message "Could not remove mailbox permissions for $($removeuser) on $($username)" -Sev 'Error' -tenant $TenantFilter $results.add("Could not remove $($removeuser) shared mailbox permissions for $($username). Error: $($_.Exception.Message)") @@ -36,6 +39,9 @@ Function Invoke-ExecEditMailboxPermissions { $results.add( "Granted $($UserAutomap) access to $($username) Mailbox with automapping") Write-LogMessage -headers $Request.Headers -API $APINAME-message "Granted $($UserAutomap) access to $($username) Mailbox with automapping" -Sev 'Info' -tenant $TenantFilter + # Sync cache + Sync-CIPPMailboxPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $username -User $UserAutomap -PermissionType 'FullAccess' -Action 'Add' + } catch { Write-LogMessage -headers $Request.Headers -API $APINAME-message "Could not add mailbox permissions for $($UserAutomap) on $($username)" -Sev 'Error' -tenant $TenantFilter $results.add( "Could not add $($UserAutomap) shared mailbox permissions for $($username). Error: $($_.Exception.Message)") @@ -48,6 +54,9 @@ Function Invoke-ExecEditMailboxPermissions { $MailboxPerms = New-ExoRequest -Anchor $username -tenantid $Tenantfilter -cmdlet 'Add-MailboxPermission' -cmdParams @{Identity = $userid; user = $UserNoAutomap; accessRights = @('FullAccess'); automapping = $false } $results.add( "Granted $UserNoAutomap access to $($username) Mailbox without automapping") Write-LogMessage -headers $Request.Headers -API $APINAME-message "Granted $UserNoAutomap access to $($username) Mailbox without automapping" -Sev 'Info' -tenant $TenantFilter + + # Sync cache + Sync-CIPPMailboxPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $username -User $UserNoAutomap -PermissionType 'FullAccess' -Action 'Add' } catch { Write-LogMessage -headers $Request.Headers -API $APINAME-message "Could not add mailbox permissions for $($UserNoAutomap) on $($username)" -Sev 'Error' -tenant $TenantFilter $results.add("Could not add $($UserNoAutomap) shared mailbox permissions for $($username). Error: $($_.Exception.Message)") @@ -61,6 +70,9 @@ Function Invoke-ExecEditMailboxPermissions { $MailboxPerms = New-ExoRequest -Anchor $username -tenantid $Tenantfilter -cmdlet 'Add-RecipientPermission' -cmdParams @{Identity = $userid; Trustee = $UserSendAs; accessRights = @('SendAs') } $results.add( "Granted $UserSendAs access to $($username) with Send As permissions") Write-LogMessage -headers $Request.Headers -API $APINAME-message "Granted $UserSendAs access to $($username) with Send As permissions" -Sev 'Info' -tenant $TenantFilter + + # Sync cache + Sync-CIPPMailboxPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $username -User $UserSendAs -PermissionType 'SendAs' -Action 'Add' } catch { Write-LogMessage -headers $Request.Headers -API $APINAME-message "Could not add mailbox permissions for $($UserSendAs) on $($username)" -Sev 'Error' -tenant $TenantFilter $results.add("Could not add $($UserSendAs) send-as permissions for $($username). Error: $($_.Exception.Message)") @@ -74,6 +86,9 @@ Function Invoke-ExecEditMailboxPermissions { $MailboxPerms = New-ExoRequest -Anchor $username -tenantid $Tenantfilter -cmdlet 'Remove-RecipientPermission' -cmdParams @{Identity = $userid; Trustee = $UserSendAs; accessRights = @('SendAs') } $results.add( "Removed $UserSendAs from $($username) with Send As permissions") Write-LogMessage -headers $Request.Headers -API $APINAME-message "Removed $UserSendAs from $($username) with Send As permissions" -Sev 'Info' -tenant $TenantFilter + + # Sync cache + Sync-CIPPMailboxPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $username -User $UserSendAs -PermissionType 'SendAs' -Action 'Remove' } catch { Write-LogMessage -headers $Request.Headers -API $APINAME-message "Could not remove mailbox permissions for $($UserSendAs) on $($username)" -Sev 'Error' -tenant $TenantFilter $results.add("Could not remove $($UserSendAs) send-as permissions for $($username). Error: $($_.Exception.Message)") diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantine.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantine.ps1 index d785b015f940..7780f90588eb 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantine.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantine.ps1 @@ -16,8 +16,9 @@ function Invoke-ListMailQuarantine { } else { $Table = Get-CIPPTable -TableName cacheQuarantineMessages $PartitionKey = 'QuarantineMessage' - $Filter = "PartitionKey eq '$PartitionKey'" - $Rows = Get-CIPPAzDataTableEntity @Table -filter $Filter | Where-Object -Property Timestamp -GT (Get-Date).AddMinutes(-30) + $30MinutesAgo = (Get-Date).AddMinutes(-30).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + $Filter = "PartitionKey eq '$PartitionKey' and Timestamp gt datetime'$30MinutesAgo'" + $Rows = Get-CIPPAzDataTableEntity @Table -filter $Filter $QueueReference = '{0}-{1}' -f $TenantFilter, $PartitionKey $RunningQueue = Invoke-ListCippQueue -Reference $QueueReference | Where-Object { $_.Status -notmatch 'Completed' -and $_.Status -notmatch 'Failed' } # If a queue is running, we will not start a new one diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddChocoApp.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddChocoApp.ps1 index 246ee7071f45..de8bdb8a5172 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddChocoApp.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddChocoApp.ps1 @@ -1,7 +1,7 @@ -Function Invoke-AddChocoApp { +function Invoke-AddChocoApp { <# .FUNCTIONALITY - Entrypoint + Entrypoint,AnyTenant .ROLE Endpoint.Application.ReadWrite #> @@ -30,7 +30,9 @@ Function Invoke-AddChocoApp { $intuneBody.detectionRules[0].path = "$($ENV:SystemDrive)\programdata\chocolatey\lib" $intuneBody.detectionRules[0].fileOrFolderName = "$($ChocoApp.PackageName)" - $Tenants = $Request.Body.selectedTenants.defaultDomainName + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + $Tenants = ($Request.Body.selectedTenants | Where-Object { $AllowedTenants -contains $_.customerId -or $AllowedTenants -contains 'AllTenants' }).defaultDomainName + $Results = foreach ($Tenant in $Tenants) { try { # Apply CIPP text replacement for tenant-specific variables diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddMSPApp.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddMSPApp.ps1 index bdbc7f7dba1f..fe750f11e6fb 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddMSPApp.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddMSPApp.ps1 @@ -1,7 +1,7 @@ function Invoke-AddMSPApp { <# .FUNCTIONALITY - Entrypoint + Entrypoint,AnyTenant .ROLE Endpoint.Application.ReadWrite #> @@ -17,7 +17,8 @@ function Invoke-AddMSPApp { $intuneBody = Get-Content "AddMSPApp\$($RMMApp.RMMName.value).app.json" | ConvertFrom-Json $intuneBody.displayName = $RMMApp.DisplayName - $Tenants = $Request.Body.selectedTenants + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + $Tenants = $Request.Body.selectedTenants | Where-Object { $AllowedTenants -contains $_.customerId -or $AllowedTenants -contains 'AllTenants' } $Results = foreach ($Tenant in $Tenants) { $InstallParams = [PSCustomObject]$RMMApp.params switch ($RMMApp.RMMName.value) { diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddOfficeApp.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddOfficeApp.ps1 index 97d65b678542..ff880318819f 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddOfficeApp.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddOfficeApp.ps1 @@ -1,15 +1,15 @@ function Invoke-AddOfficeApp { <# .FUNCTIONALITY - Entrypoint + 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 # Input bindings are passed in via param block. - $Tenants = $Request.Body.selectedTenants.defaultDomainName $Headers = $Request.Headers $APIName = $Request.Params.CIPPEndpoint if ('AllTenants' -in $Tenants) { $Tenants = (Get-Tenants).defaultDomainName } diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddStoreApp.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddStoreApp.ps1 index af6eb44c1be7..90d4c94285ea 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddStoreApp.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddStoreApp.ps1 @@ -1,7 +1,7 @@ function Invoke-AddStoreApp { <# .FUNCTIONALITY - Entrypoint + Entrypoint,AnyTenant .ROLE Endpoint.Application.ReadWrite #> @@ -26,8 +26,8 @@ function Invoke-AddStoreApp { 'runAsAccount' = 'system' } } - - $Tenants = $Request.body.selectedTenants.defaultDomainName + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + $Tenants = ($Request.Body.selectedTenants | Where-Object { $AllowedTenants -contains $_.customerId -or $AllowedTenants -contains 'AllTenants' }).defaultDomainName $Results = foreach ($Tenant in $Tenants) { try { $CompleteObject = [PSCustomObject]@{ diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddWin32ScriptApp.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddWin32ScriptApp.ps1 new file mode 100644 index 000000000000..bdb797cbdd88 --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddWin32ScriptApp.ps1 @@ -0,0 +1,80 @@ +function Invoke-AddWin32ScriptApp { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + Endpoint.Application.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + $Win32ScriptApp = $Request.Body + $AssignTo = $Win32ScriptApp.AssignTo -eq 'customGroup' ? $Win32ScriptApp.CustomGroup : $Win32ScriptApp.AssignTo + + # Validate required fields + if ([string]::IsNullOrEmpty($Win32ScriptApp.ApplicationName) -and [string]::IsNullOrEmpty($Win32ScriptApp.applicationName)) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = @('Application name is required') } + }) + } + + if ([string]::IsNullOrEmpty($Win32ScriptApp.installScript)) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = @('Install script is required') } + }) + } + + # Use whichever case was provided + $AppName = if ($Win32ScriptApp.ApplicationName) { $Win32ScriptApp.ApplicationName } else { $Win32ScriptApp.applicationName } + + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + $Tenants = ($Request.Body.selectedTenants | Where-Object { $AllowedTenants -contains $_.customerId -or $AllowedTenants -contains 'AllTenants' }).defaultDomainName + + $Results = foreach ($Tenant in $Tenants) { + try { + $CompleteObject = [PSCustomObject]@{ + tenant = $Tenant + Applicationname = $AppName + assignTo = $AssignTo + InstallationIntent = $Win32ScriptApp.InstallationIntent + type = 'Win32ScriptApp' + description = $Win32ScriptApp.description + publisher = $Win32ScriptApp.publisher + installScript = $Win32ScriptApp.installScript + uninstallScript = $Win32ScriptApp.uninstallScript + detectionPath = $Win32ScriptApp.detectionPath + detectionFile = $Win32ScriptApp.detectionFile + runAsAccount = if ($Win32ScriptApp.InstallAsSystem) { 'system' } else { 'user' } + deviceRestartBehavior = if ($Win32ScriptApp.DisableRestart) { 'suppress' } else { 'allow' } + runAs32Bit = [bool]$Win32ScriptApp.runAs32Bit + enforceSignatureCheck = [bool]$Win32ScriptApp.enforceSignatureCheck + } | ConvertTo-Json -Depth 15 + + $Table = Get-CippTable -tablename 'apps' + $Table.Force = $true + Add-CIPPAzDataTableEntity @Table -Entity @{ + JSON = "$CompleteObject" + RowKey = "$((New-Guid).GUID)" + PartitionKey = 'apps' + status = 'Not Deployed yet' + } + "Successfully added Win32 Script App for $($Tenant) to queue." + Write-LogMessage -headers $Headers -API $APIName -tenant $Tenant -message "Successfully added Win32 Script App $AppName to queue" -Sev 'Info' + } catch { + Write-LogMessage -headers $Headers -API $APIName -tenant $Tenant -message "Failed to add Win32 Script App $AppName to queue. Error: $($_.Exception.Message)" -Sev 'Error' + "Failed to add Win32 Script App for $($Tenant) to queue: $($_.Exception.Message)" + } + } + + $body = [PSCustomObject]@{ 'Results' = $Results } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = $body + }) +} diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecSyncVPP.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecSyncVPP.ps1 index 2dc3f4ed534a..d94b31cad906 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecSyncVPP.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecSyncVPP.ps1 @@ -9,9 +9,8 @@ function Invoke-ExecSyncVPP { param($Request, $TriggerMetadata) $APIName = $Request.Params.CIPPEndpoint $Headers = $Request.Headers - Write-LogMessage -Headers $Headers -API $APIName -message 'Accessed this API' -Sev Debug - $TenantFilter = $Request.Body.tenantFilter ?? $Request.Query.tenantFilter + $TenantFilter = $Request.Body.tenantFilter try { # Get all VPP tokens and sync them $VppTokens = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceAppManagement/vppTokens' -tenantid $TenantFilter | Where-Object { $_.state -eq 'valid' } diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddPolicy.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddPolicy.ps1 index 92dfb409d341..1c633f205e49 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddPolicy.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddPolicy.ps1 @@ -17,6 +17,15 @@ function Invoke-AddPolicy { $description = $Request.Body.Description $AssignTo = if ($Request.Body.AssignTo -ne 'on') { $Request.Body.AssignTo } $ExcludeGroup = $Request.Body.excludeGroup + $AssignmentFilterSelection = $Request.Body.AssignmentFilterName ?? $Request.Body.assignmentFilter + $AssignmentFilterType = $Request.Body.AssignmentFilterType ?? $Request.Body.assignmentFilterType + $AssignmentFilterName = switch ($AssignmentFilterSelection) { + { $_ -is [string] } { $_; break } + { $_ -and $_.PSObject.Properties['value'] } { $_.value; break } + { $_ -and $_.PSObject.Properties['displayName'] } { $_.displayName; break } + { $_ -and $_.PSObject.Properties['label'] } { $_.label; break } + default { $null } + } $Request.Body.customGroup ? ($AssignTo = $Request.Body.customGroup) : $null $RawJSON = $Request.Body.RAWJson @@ -70,6 +79,12 @@ function Invoke-AddPolicy { Headers = $Headers APIName = $APIName } + + if (-not [string]::IsNullOrWhiteSpace($AssignmentFilterName)) { + $params.AssignmentFilterName = $AssignmentFilterName + $params.AssignmentFilterType = [string]::IsNullOrWhiteSpace($AssignmentFilterType) ? 'include' : $AssignmentFilterType + } + Set-CIPPIntunePolicy @params } catch { "$($_.Exception.Message)" diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecSyncDEP.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecSyncDEP.ps1 new file mode 100644 index 000000000000..5672f6a984ee --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecSyncDEP.ps1 @@ -0,0 +1,50 @@ +function Invoke-ExecSyncDEP { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Endpoint.MEM.ReadWrite + .DESCRIPTION + Syncs devices from Apple Business Manager to Intune + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + $TenantFilter = $Request.Body.tenantFilter + try { + $DepOnboardingSettings = @(New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/depOnboardingSettings' -tenantid $TenantFilter) + + if ($null -eq $DepOnboardingSettings -or $DepOnboardingSettings.Count -eq 0) { + $Result = 'No Apple Business Manager connections found' + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Info + } else { + $SyncCount = 0 + foreach ($DepSetting in $DepOnboardingSettings) { + if ($DepSetting.id) { + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/deviceManagement/depOnboardingSettings/$($DepSetting.id)/syncWithAppleDeviceEnrollmentProgram" -tenantid $TenantFilter + $SyncCount++ + } + } + if ($SyncCount -eq 0) { + $Result = 'No Apple Business Manager connections found' + } else { + $Result = "Successfully started device sync for $SyncCount Apple Business Manager connection$(if ($SyncCount -gt 1) { 's' })" + } + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Info + } + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = 'Failed to start Apple Business Manager device sync' + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::Forbidden + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ Results = $Result } + }) + +} diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListDefenderState.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListDefenderState.ps1 index e03874e4bac6..4ca3690502b5 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListDefenderState.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListDefenderState.ps1 @@ -9,16 +9,29 @@ Function Invoke-ListDefenderState { param($Request, $TriggerMetadata) $StatusCode = [HttpStatusCode]::OK - - # Interact with query parameters or the body of the request. $TenantFilter = $Request.Query.TenantFilter + $DeviceID = $Request.Query.DeviceID + try { - $GraphRequest = New-GraphGetRequest -tenantid $TenantFilter -uri "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$expand=windowsProtectionState&`$select=id,deviceName,deviceType,operatingSystem,windowsProtectionState" + # If DeviceID is provided, get Defender state for that specific device + if ($DeviceID) { + $GraphRequest = New-GraphGetRequest -tenantid $TenantFilter -uri "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$($DeviceID)?`$expand=windowsProtectionState&`$select=id,deviceName,deviceType,operatingSystem,windowsProtectionState" + } + # If no DeviceID is provided, get Defender state for all devices + else { + $GraphRequest = New-GraphGetRequest -tenantid $TenantFilter -uri "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$expand=windowsProtectionState&`$select=id,deviceName,deviceType,operatingSystem,windowsProtectionState" + } + + # Ensure we return an array even if single device + if ($GraphRequest -and -not ($GraphRequest -is [array])) { + $GraphRequest = @($GraphRequest) + } + $StatusCode = [HttpStatusCode]::OK } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message - $StatusCode = [HttpStatusCode]::Forbidden + $StatusCode = [HttpStatusCode]::OK $GraphRequest = "$($ErrorMessage)" } return ([HttpResponseContext]@{ diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListDefenderTVM.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListDefenderTVM.ps1 index 3d6a59a25d46..ad2065be4e42 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListDefenderTVM.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListDefenderTVM.ps1 @@ -25,8 +25,8 @@ function Invoke-ListDefenderTVM { # Add all properties from the group with appropriate processing foreach ($property in $allProperties) { if ($property -eq 'deviceName') { - # Special handling for deviceName - join with comma - $obj['affectedDevices'] = ($cve.group.$property -join ', ') + # Special handling for deviceName - create array of objects + $obj['affectedDevices'] = @($cve.group.$property | ForEach-Object { @{ $property = $_ } }) } else { # For all other properties, get unique values $obj[$property] = ($cve.group.$property | Sort-Object -Unique) | Select-Object -First 1 diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUser.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUser.ps1 index 5fce41b7da5b..d00439f6d36c 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUser.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUser.ps1 @@ -75,8 +75,10 @@ function Invoke-AddUser { 'User' = $CreationResults.User } } catch { + $ErrorMessage = $_.TargetObject.Results -join ' ' + $ErrorMessage = [string]::IsNullOrWhiteSpace($ErrorMessage) ? $_.Exception.Message : $ErrorMessage $body = [pscustomobject] @{ - 'Results' = @("$($_.Exception.Message)") + 'Results' = @($ErrorMessage) } $StatusCode = [HttpStatusCode]::InternalServerError } diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-CIPPOffboardingJob.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-CIPPOffboardingJob.ps1 index 8a9a94c0cbd3..6da71f0a98bf 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-CIPPOffboardingJob.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-CIPPOffboardingJob.ps1 @@ -6,224 +6,308 @@ function Invoke-CIPPOffboardingJob { [switch]$RunScheduled, $Options, $APIName = 'Offboard user', - $Headers + $Headers, + $TaskInfo ) - if ($Options -is [string]) { - $Options = $Options | ConvertFrom-Json - } - $User = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$($Username)?`$select=id,displayName,onPremisesSyncEnabled,onPremisesImmutableId" -tenantid $TenantFilter - $UserID = $User.id - $DisplayName = $User.displayName - Write-Host "Running offboarding job for $Username with options: $($Options | ConvertTo-Json -Depth 10)" - $Return = switch ($Options) { - { $_.ConvertToShared -eq $true } { - try { - Set-CIPPMailboxType -Headers $Headers -tenantFilter $TenantFilter -userid $UserID -username $Username -MailboxType 'Shared' -APIName $APIName - } catch { - $_.Exception.Message - } + + try { + if ($Options -is [string]) { + $Options = $Options | ConvertFrom-Json } - { $_.RevokeSessions -eq $true } { - try { - Revoke-CIPPSessions -tenantFilter $TenantFilter -username $Username -userid $UserID -Headers $Headers -APIName $APIName - } catch { - $_.Exception.Message + + Write-Information "Starting offboarding job for $Username in tenant $TenantFilter" + Write-LogMessage -API 'Offboarding' -tenant $TenantFilter -message "Starting offboarding orchestration for user $Username" -sev Info + + # Get user information needed for various tasks + $User = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$($Username)?`$select=id,displayName,onPremisesSyncEnabled,onPremisesImmutableId" -tenantid $TenantFilter + $UserID = $User.id + $DisplayName = $User.displayName + + # 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 + $TaskOrder = @( + @{ + Condition = { $Options.RevokeSessions -eq $true } + Cmdlet = 'Revoke-CIPPSessions' + Parameters = @{ + tenantFilter = $TenantFilter + username = $Username + userid = $UserID + APIName = $APIName + } } - } - { $_.ResetPass -eq $true } { - try { - Set-CIPPResetPassword -tenantFilter $TenantFilter -DisplayName $DisplayName -UserID $username -Headers $Headers -APIName $APIName - } catch { - $_.Exception.Message + @{ + Condition = { $Options.ResetPass -eq $true } + Cmdlet = 'Set-CIPPResetPassword' + Parameters = @{ + tenantFilter = $TenantFilter + DisplayName = $DisplayName + UserID = $Username + APIName = $APIName + } } - } - { $_.RemoveGroups -eq $true } { - Remove-CIPPGroups -userid $UserID -tenantFilter $TenantFilter -Headers $Headers -APIName $APIName -Username $Username - } - { $_.HideFromGAL -eq $true } { - try { - Set-CIPPHideFromGAL -tenantFilter $TenantFilter -UserID $username -HideFromGAL $true -Headers $Headers -APIName $APIName - } catch { - $_.Exception.Message + @{ + Condition = { $Options.DisableSignIn -eq $true } + Cmdlet = 'Set-CIPPSignInState' + Parameters = @{ + TenantFilter = $TenantFilter + userid = $Username + AccountEnabled = $false + APIName = $APIName + } } - } - { $_.DisableSignIn -eq $true } { - try { - Set-CIPPSignInState -TenantFilter $TenantFilter -userid $username -AccountEnabled $false -Headers $Headers -APIName $APIName - } catch { - $_.Exception.Message + @{ + Condition = { $Options.HideFromGAL -eq $true } + Cmdlet = 'Set-CIPPHideFromGAL' + Parameters = @{ + tenantFilter = $TenantFilter + UserID = $Username + hidefromgal = $true + APIName = $APIName + } } - } - { $_.OnedriveAccess } { - $Options.OnedriveAccess | ForEach-Object { - try { - Set-CIPPSharePointPerms -tenantFilter $TenantFilter -userid $username -OnedriveAccessUser $_.value -Headers $Headers -APIName $APIName - } catch { - $_.Exception.Message + @{ + Condition = { $Options.RemoveGroups -eq $true } + Cmdlet = 'Remove-CIPPGroups' + Parameters = @{ + userid = $UserID + tenantFilter = $TenantFilter + APIName = $APIName + Username = $Username } } - } - { $_.AccessNoAutomap } { - $Options.AccessNoAutomap | ForEach-Object { - try { - Set-CIPPMailboxAccess -tenantFilter $TenantFilter -userid $username -AccessUser $_.value -Automap $false -AccessRights @('FullAccess') -Headers $Headers -APIName $APIName - } catch { - $_.Exception.Message + @{ + Condition = { $Options.RemoveRules -eq $true } + Cmdlet = 'Remove-CIPPMailboxRule' + Parameters = @{ + userid = $UserID + username = $Username + tenantFilter = $TenantFilter + APIName = $APIName + RemoveAllRules = $true } } - } - { $_.AccessAutomap } { - $Options.AccessAutomap | ForEach-Object { - try { - Set-CIPPMailboxAccess -tenantFilter $TenantFilter -userid $username -AccessUser $_.value -Automap $true -AccessRights @('FullAccess') -Headers $Headers -APIName $APIName - } catch { - $_.Exception.Message + @{ + Condition = { $Options.RemoveMobile -eq $true } + Cmdlet = 'Remove-CIPPMobileDevice' + Parameters = @{ + userid = $UserID + username = $Username + tenantFilter = $TenantFilter + APIName = $APIName } } - } - { $_.OOO } { - try { - Set-CIPPOutOfOffice -tenantFilter $TenantFilter -UserID $username -InternalMessage $Options.OOO -ExternalMessage $Options.OOO -Headers $Headers -APIName $APIName -state 'Enabled' - } catch { - $_.Exception.Message + @{ + Condition = { $Options.removeCalendarInvites -eq $true } + Cmdlet = 'Remove-CIPPCalendarInvites' + Parameters = @{ + UserID = $UserID + Username = $Username + TenantFilter = $TenantFilter + APIName = $APIName + } } - } - { $_.forward } { - if (!$Options.KeepCopy) { - try { - Set-CIPPForwarding -userid $userid -username $username -tenantFilter $TenantFilter -Forward $Options.forward.value -Headers $Headers -APIName $APIName - } catch { - $_.Exception.Message + @{ + Condition = { ![string]::IsNullOrEmpty($Options.OOO) } + Cmdlet = 'Set-CIPPOutOfOffice' + Parameters = @{ + tenantFilter = $TenantFilter + UserID = $Username + InternalMessage = $Options.OOO + ExternalMessage = $Options.OOO + APIName = $APIName + state = 'Enabled' } - } else { - $KeepCopy = [boolean]$Options.KeepCopy - try { - Set-CIPPForwarding -userid $userid -username $username -tenantFilter $TenantFilter -Forward $Options.forward.value -KeepCopy $KeepCopy -Headers $Headers -APIName $APIName - } catch { - $_.Exception.Message + } + @{ + Condition = { ![string]::IsNullOrEmpty($Options.forward) } + Cmdlet = 'Set-CIPPForwarding' + Parameters = @{ + userid = $UserID + username = $Username + tenantFilter = $TenantFilter + Forward = $Options.forward.value + KeepCopy = [bool]$Options.KeepCopy + APIName = $APIName } } - } - { $_.disableForwarding } { - try { - Set-CIPPForwarding -userid $userid -username $username -tenantFilter $TenantFilter -Disable $true -Headers $Headers -APIName $APIName - } catch { - $_.Exception.Message + @{ + Condition = { $Options.disableForwarding -eq $true } + Cmdlet = 'Set-CIPPForwarding' + Parameters = @{ + userid = $UserID + username = $Username + tenantFilter = $TenantFilter + Disable = $true + APIName = $APIName + } } - } - { $_.RemoveTeamsPhoneDID } { - try { - Remove-CIPPUserTeamsPhoneDIDs -userid $userid -username $username -tenantFilter $TenantFilter -Headers $Headers -APIName $APIName - } catch { - $_.Exception.Message + @{ + Condition = { ![string]::IsNullOrEmpty($Options.OnedriveAccess) } + Cmdlet = 'Set-CIPPSharePointPerms' + Parameters = @{ + tenantFilter = $TenantFilter + userid = $Username + OnedriveAccessUser = $Options.OnedriveAccess + APIName = $APIName + } } - } - { $_.RemoveLicenses -eq $true } { - Remove-CIPPLicense -userid $userid -username $Username -tenantFilter $TenantFilter -Headers $Headers -APIName $APIName -Schedule - } - { $_.DeleteUser -eq $true } { - try { - Remove-CIPPUser -UserID $userid -Username $Username -TenantFilter $TenantFilter -Headers $Headers -APIName $APIName - } catch { - $_.Exception.Message + @{ + Condition = { ![string]::IsNullOrEmpty($Options.AccessNoAutomap) } + Cmdlet = 'Set-CIPPMailboxAccess' + Parameters = @{ + tenantFilter = $TenantFilter + userid = $Username + AccessUser = $Options.AccessNoAutomap + Automap = $false + AccessRights = @('FullAccess') + APIName = $APIName + } } - } - { $_.RemoveRules -eq $true } { - Write-Host "Removing rules for $username" - try { - Remove-CIPPMailboxRule -userid $userid -username $Username -tenantFilter $TenantFilter -Headers $Headers -APIName $APIName -RemoveAllRules - } catch { - $_.Exception.Message + @{ + Condition = { ![string]::IsNullOrEmpty($Options.AccessAutomap) } + Cmdlet = 'Set-CIPPMailboxAccess' + Parameters = @{ + tenantFilter = $TenantFilter + userid = $Username + AccessUser = $Options.AccessAutomap + Automap = $true + AccessRights = @('FullAccess') + APIName = $APIName + } } - } - { $_.RemoveMobile -eq $true } { - try { - Remove-CIPPMobileDevice -userid $userid -username $Username -tenantFilter $TenantFilter -Headers $Headers -APIName $APIName - } catch { - $_.Exception.Message + @{ + Condition = { $Options.removePermissions -eq $true } + Cmdlet = 'Remove-CIPPMailboxPermissions' + Parameters = @{ + AccessUser = $Username + TenantFilter = $TenantFilter + UseCache = $true + APIName = $APIName + } } - } - { $_.removeCalendarInvites -eq $true } { - try { - Remove-CIPPCalendarInvites -UserID $userid -Username $Username -TenantFilter $TenantFilter -Headers $Headers -APIName $APIName - } catch { - $_.Exception.Message + @{ + Condition = { $Options.removeCalendarPermissions -eq $true } + Cmdlet = 'Remove-CIPPCalendarPermissions' + Parameters = @{ + UserToRemove = $Username + TenantFilter = $TenantFilter + UseCache = $true + APIName = $APIName + } } - } - { $_.removePermissions } { - if ($RunScheduled) { - Remove-CIPPMailboxPermissions -PermissionsLevel @('FullAccess', 'SendAs', 'SendOnBehalf') -userid 'AllUsers' -AccessUser $UserName -TenantFilter $TenantFilter -APIName $APINAME -Headers $Headers + @{ + Condition = { $Options.ConvertToShared -eq $true } + Cmdlet = 'Set-CIPPMailboxType' + Parameters = @{ + tenantFilter = $TenantFilter + userid = $UserID + username = $Username + MailboxType = 'Shared' + APIName = $APIName + } + } + @{ + Condition = { $Options.RemoveMFADevices -eq $true } + Cmdlet = 'Remove-CIPPUserMFA' + Parameters = @{ + UserPrincipalName = $Username + TenantFilter = $TenantFilter + } + } + @{ + Condition = { $Options.RemoveTeamsPhoneDID -eq $true } + Cmdlet = 'Remove-CIPPUserTeamsPhoneDIDs' + Parameters = @{ + userid = $UserID + username = $Username + tenantFilter = $TenantFilter + APIName = $APIName + } + } + @{ + Condition = { $Options.RemoveLicenses -eq $true } + Cmdlet = 'Remove-CIPPLicense' + Parameters = @{ + userid = $UserID + username = $Username + tenantFilter = $TenantFilter + APIName = $APIName + Schedule = $true + } + } + @{ + Condition = { $Options.ClearImmutableId -eq $true } + Cmdlet = 'Clear-CIPPImmutableID' + Parameters = @{ + UserID = $UserID + Username = $Username + TenantFilter = $TenantFilter + User = $User + APIName = $APIName + } + } + @{ + Condition = { $Options.DeleteUser -eq $true } + Cmdlet = 'Remove-CIPPUser' + Parameters = @{ + UserID = $UserID + Username = $Username + TenantFilter = $TenantFilter + APIName = $APIName + } + } + ) - } else { - $Queue = New-CippQueueEntry -Name "Offboarding - Mailbox Permissions: $Username" -TotalTasks 1 - $InputObject = [PSCustomObject]@{ - Batch = @( - [PSCustomObject]@{ - 'FunctionName' = 'ExecOffboardingMailboxPermissions' - 'TenantFilter' = $TenantFilter - 'User' = $Username - 'Headers' = $Headers - 'APINAME' = $APINAME - 'QueueId' = $Queue.RowKey - } - ) - OrchestratorName = "OffboardingMailboxPermissions_$Username" - SkipLog = $true - } - $null = Start-NewOrchestration -FunctionName CIPPOrchestrator -InputObject ($InputObject | ConvertTo-Json -Depth 10) - "Removal of permissions queued. This task will run in the background and send it's results to the logbook." + # Build batch from selected tasks + foreach ($Task in $TaskOrder) { + if (& $Task.Condition) { + $Batch.Add(@{ + FunctionName = 'CIPPOffboardingTask' + Cmdlet = $Task.Cmdlet + Parameters = $Task.Parameters + }) } } - { $_.RemoveMFADevices -eq $true } { - try { - Remove-CIPPUserMFA -UserPrincipalName $Username -TenantFilter $TenantFilter -Headers $Headers - } catch { - $_.Exception.Message - } + + if ($Batch.Count -eq 0) { + Write-LogMessage -API 'Offboarding' -tenant $TenantFilter -message "No offboarding tasks selected for user $Username" -sev Warning + return "No offboarding tasks were selected for $Username" + } + + Write-Information "Built batch of $($Batch.Count) offboarding tasks for $Username" + + # Start orchestration + $InputObject = [PSCustomObject]@{ + OrchestratorName = "OffboardingUser_$($Username)_$TenantFilter" + Batch = @($Batch) + SkipLog = $true + DurableMode = 'Sequence' } - { $_.ClearImmutableId -eq $true } { - if ($User.onPremisesSyncEnabled -ne $true -and ![string]::IsNullOrEmpty($User.onPremisesImmutableId)) { - Write-LogMessage -Message "User $Username has an ImmutableID set but is not synced from on-premises. Proceeding to clear the ImmutableID." -TenantFilter $TenantFilter -Severity 'Warning' -APIName $APIName -Headers $Headers - try { - Clear-CIPPImmutableID -UserID $userid -TenantFilter $TenantFilter -Headers $Headers -APIName $APIName - } catch { - $_.Exception.Message - } - } elseif ($User.onPremisesSyncEnabled -eq $true -and ![string]::IsNullOrEmpty($User.onPremisesImmutableId)) { - Write-LogMessage -Message "User $Username is synced from on-premises. Scheduling an Immutable ID clear for when the user account has been soft deleted." -TenantFilter $TenantFilter -Severity 'Error' -APIName $APIName -Headers $Headers - 'Scheduling Immutable ID clear task for when the user account is no longer synced in the on-premises directory.' - $ScheduledTask = @{ - TenantFilter = $TenantFilter - Name = "Clear Immutable ID: $Username" - Command = @{ - value = 'Clear-CIPPImmutableID' - } - Parameters = [pscustomobject]@{ - userid = $userid - APIName = $APIName - Headers = $Headers - } - Trigger = @{ - Type = 'DeltaQuery' - DeltaResource = 'users' - ResourceFilter = @($UserID) - EventType = 'deleted' - UseConditions = $false - ExecutePerResource = $true - ExecutionMode = 'once' - } - ScheduledTime = [int64](([datetime]::UtcNow).AddMinutes(5) - (Get-Date '1/1/1970')).TotalSeconds - Recurrence = '15m' - PostExecution = @{ - Webhook = $false - Email = $false - PSA = $false - } - } - Add-CIPPScheduledTask -Task $ScheduledTask -hidden $false + + # Add post-execution handler if TaskInfo is provided (from scheduled task) + if ($TaskInfo) { + $InputObject | Add-Member -NotePropertyName PostExecution -NotePropertyValue @{ + FunctionName = 'CIPPOffboardingComplete' + Parameters = @{ + TaskInfo = $TaskInfo + TenantFilter = $TenantFilter + Username = $Username + } } } - } - return $Return + $InstanceId = Start-NewOrchestration -FunctionName 'CIPPOrchestrator' -InputObject ($InputObject | ConvertTo-Json -Depth 10 -Compress) + Write-Information "Started offboarding job for $Username with ID = '$InstanceId'" + Write-LogMessage -API 'Offboarding' -tenant $TenantFilter -message "Started offboarding job for $Username with $($Batch.Count) tasks. Instance ID: $InstanceId" -sev Info + + return "Offboarding job started for $Username with $($Batch.Count) tasks" + + } 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 + throw $ErrorMessage + } } diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecSendPush.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecSendPush.ps1 index 93d534a2a37e..f04ecb0608fc 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecSendPush.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecSendPush.ps1 @@ -51,6 +51,12 @@ function Invoke-ExecSendPush { $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 + Write-Information $PolicyUpdate.PolicyAction + } catch { + Write-Information "Failed to update app management policy: $($_.Exception.Message)" + } $PassReqBody = @{ 'passwordCredential' = @{ diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITAdmin.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITAdmin.ps1 index 705e4b205258..954760a7a464 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITAdmin.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITAdmin.ps1 @@ -17,23 +17,23 @@ if ($TenantFilter -ne 'AllTenants') { # Single tenant logic - $Query = @{ - TenantFilter = $TenantFilter - Endpoint = 'users' - Parameters = @{ - '$count' = 'true' - '$select' = "id,accountEnabled,displayName,userPrincipalName,$($Schema.id)" - '$filter' = "$($Schema.id)/jitAdminEnabled eq true or $($Schema.id)/jitAdminEnabled eq false" - } - } - $Users = Get-GraphRequestList @Query | Where-Object { $_.id } - $BulkRequests = $Users | ForEach-Object { @( - @{ - id = $_.id + $BulkRequests = [System.Collections.Generic.List[object]]::new() + $BulkRequests.Add(@{ + id = 'users' + method = 'GET' + url = "users?`$count=true&`$select=id,accountEnabled,displayName,userPrincipalName,$($Schema.id)&`$filter=$($Schema.id)/jitAdminEnabled eq true or $($Schema.id)/jitAdminEnabled eq false&`$top=999" + }) + + $BulkResults = New-GraphBulkRequest -tenantid $TenantFilter -Requests $BulkRequests + $Users = ($BulkResults | Where-Object { $_.id -eq 'users' }).body.value | Where-Object { $_.id } + + $BulkRequests.Clear() + foreach ($User in $Users) { + $BulkRequests.Add(@{ + id = $User.id method = 'GET' - url = "users/$($_.id)/memberOf/microsoft.graph.directoryRole/?`$select=id,displayName" - } - ) + url = "users/$($User.id)/memberOf/microsoft.graph.directoryRole/?`$select=id,displayName" + }) } $RoleResults = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($BulkRequests) # Write-Information ($RoleResults | ConvertTo-Json -Depth 10 ) @@ -54,7 +54,7 @@ } # Write-Information ($Results | ConvertTo-Json -Depth 10) - $Metadata = [PSCustomObject]@{Parameters = $Query.Parameters } + $Metadata = [PSCustomObject]@{Method = 'BulkRequest' } } else { # AllTenants logic $Results = [System.Collections.Generic.List[object]]::new() diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserMailboxDetails.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserMailboxDetails.ps1 index 636639f32296..a0336ea3f1f9 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserMailboxDetails.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserMailboxDetails.ps1 @@ -155,7 +155,6 @@ function Invoke-ListUserMailboxDetails { # First try users array $matchedUser = $usernames | Where-Object { $_.id -eq $rawAddress -or - $_.displayName -eq $rawAddress -or $_.mailNickname -eq $rawAddress } @@ -166,7 +165,8 @@ function Invoke-ListUserMailboxDetails { try { # Escape single quotes in the filter value $escapedAddress = $rawAddress -replace "'", "''" - $filterQuery = "displayName eq '$escapedAddress' or mailNickname eq '$escapedAddress'" + $escapedNickname = $rawAddress -replace "'", "''" -replace ' ', '' + $filterQuery = "displayName eq '$escapedAddress' or mailNickname eq '$escapedNickname'" $contactUri = "https://graph.microsoft.com/beta/contacts?`$filter=$filterQuery&`$select=displayName,mail,mailNickname" $matchedContacts = New-GraphGetRequest -tenantid $TenantFilter -uri $contactUri diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddAlert.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddAlert.ps1 index a683b153c508..df6818654545 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddAlert.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddAlert.ps1 @@ -1,4 +1,4 @@ -Function Invoke-AddAlert { +function Invoke-AddAlert { <# .FUNCTIONALITY Entrypoint @@ -27,6 +27,7 @@ Function Invoke-AddAlert { $WebhookTable = Get-CippTable -TableName 'WebhookRules' Add-CIPPAzDataTableEntity @WebhookTable -Entity $CompleteObject -Force $Results = "Added Audit Log Alert for $($Tenants.count) tenants. It may take up to four hours before Microsoft starts delivering these alerts." + Write-LogMessage -API 'AddAlert' -message $Results -sev Info -LogData $CompleteObject -headers $Request.Headers return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::OK diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecAddGDAPRole.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecAddGDAPRole.ps1 index 65380b19933c..dcf555008f41 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecAddGDAPRole.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecAddGDAPRole.ps1 @@ -71,7 +71,10 @@ function Invoke-ExecAddGDAPRole { @{ 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 = '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 diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ExecCommunityRepo.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ExecCommunityRepo.ps1 index ffe14702ce63..fd7feed3c243 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ExecCommunityRepo.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ExecCommunityRepo.ps1 @@ -178,10 +178,10 @@ function Invoke-ExecCommunityRepo { (Get-GitHubFileContents -FullName $FullName -Branch $Branch -Path $Location.path).content | ConvertFrom-Json } } - Import-CommunityTemplate -Template $Content -SHA $Template.sha -MigrationTable $MigrationTable -LocationData $LocationData + $ImportResult = Import-CommunityTemplate -Template $Content -SHA $Template.sha -MigrationTable $MigrationTable -LocationData $LocationData -Source $FullName $Results = @{ - resultText = 'Template imported' + resultText = $ImportResult ?? 'Template imported' state = 'success' } } catch { diff --git a/Modules/CIPPCore/Public/Entrypoints/Invoke-ExecUniversalSearchV2.ps1 b/Modules/CIPPCore/Public/Entrypoints/Invoke-ExecUniversalSearchV2.ps1 index 25cb9e964f4c..df21429f9605 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Invoke-ExecUniversalSearchV2.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Invoke-ExecUniversalSearchV2.ps1 @@ -8,11 +8,24 @@ function Invoke-ExecUniversalSearchV2 { [CmdletBinding()] param($Request, $TriggerMetadata) - $TenantFilter = $Request.Query.tenantFilter $SearchTerms = $Request.Query.searchTerms $Limit = if ($Request.Query.limit) { [int]$Request.Query.limit } else { 10 } + $Type = if ($Request.Query.type) { $Request.Query.type } else { 'Users' } - $Results = Search-CIPPDbData -TenantFilter $TenantFilter -SearchTerms $SearchTerms -Types 'Users' -Limit $Limit + # Always search all tenants - do not pass TenantFilter parameter + switch ($Type) { + 'Users' { + $Results = Search-CIPPDbData -SearchTerms $SearchTerms -Types 'Users' -Limit $Limit -Properties 'id', 'userPrincipalName', 'displayName' + } + 'Groups' { + $Results = Search-CIPPDbData -SearchTerms $SearchTerms -Types 'Groups' -Limit $Limit -Properties 'id', 'displayName', 'mail', 'mailEnabled', 'securityEnabled', 'groupTypes', 'description' + } + default { + $Results = Search-CIPPDbData -SearchTerms $SearchTerms -Types 'Users' -Limit $Limit -Properties 'id', 'userPrincipalName', 'displayName' + } + } + + Write-Information "Results: $($Results | ConvertTo-Json -Depth 10)" return [HttpResponseContext]@{ StatusCode = [HttpStatusCode]::OK diff --git a/Modules/CIPPCore/Public/Entrypoints/Invoke-ListDetectedAppDevices.ps1 b/Modules/CIPPCore/Public/Entrypoints/Invoke-ListDetectedAppDevices.ps1 new file mode 100644 index 000000000000..e394db309281 --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/Invoke-ListDetectedAppDevices.ps1 @@ -0,0 +1,40 @@ +function Invoke-ListDetectedAppDevices { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Identity.Device.Read + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter + $AppID = $Request.Query.AppID + + # Get managed devices where a specific detected app is installed + # Uses deviceManagement/detectedApps/{id}/managedDevices endpoint + + try { + if (-not $AppID) { + throw "AppID parameter is required" + } + + $GraphRequest = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceManagement/detectedApps/$AppID/managedDevices" -Tenantid $TenantFilter + + # Ensure we return an array even if null + if ($null -eq $GraphRequest) { + $GraphRequest = @() + } + + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + $StatusCode = [HttpStatusCode]::OK + $GraphRequest = $ErrorMessage + } + + return [HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @($GraphRequest) + } +} diff --git a/Modules/CIPPCore/Public/Entrypoints/Invoke-ListDetectedApps.ps1 b/Modules/CIPPCore/Public/Entrypoints/Invoke-ListDetectedApps.ps1 new file mode 100644 index 000000000000..0a8a754c271a --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/Invoke-ListDetectedApps.ps1 @@ -0,0 +1,78 @@ +function Invoke-ListDetectedApps { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Identity.Device.Read + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter + $DeviceID = $Request.Query.DeviceID + $IncludeDevices = $Request.Query.includeDevices + + # This is all about the deviceManagement/detectedApps endpoint + # We need to get the detected apps for a given device or the entire tenant + # If no device ID is provided, we need to get the detected apps for the entire tenant + # If a device ID is provided, we need to get the detected apps for the device + # deviceManagement/detectedApps for the entire tenant, or deviceManagement/managedDevices/$DeviceID/detectedApps for the device + # If includeDevices is true, we can use deviceManagement/detectedApps/{id}/managedDevices to get devices where each app is installed + + try { + # If DeviceID is provided, get detected apps for that device + if ($DeviceID) { + $GraphRequest = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$DeviceID/detectedApps" -Tenantid $TenantFilter + } + # If no device ID is provided, get detected apps for the entire tenant + else { + $GraphRequest = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceManagement/detectedApps" -Tenantid $TenantFilter + } + + # Ensure we return an array even if null + if ($null -eq $GraphRequest) { + $GraphRequest = @() + } + + # If includeDevices is requested and we have detected apps, fetch devices for each app + if ($IncludeDevices -and $GraphRequest -and ($GraphRequest | Measure-Object).Count -gt 0) { + # Build bulk requests to get devices for each detected app + $BulkRequests = [System.Collections.Generic.List[object]]::new() + foreach ($App in $GraphRequest) { + if ($App.id) { + $BulkRequests.Add(@{ + id = $App.id + method = 'GET' + url = "deviceManagement/detectedApps('$($App.id)')/managedDevices" + }) + } + } + + if ($BulkRequests.Count -gt 0) { + $BulkResults = New-GraphBulkRequest -Requests $BulkRequests -tenantid $TenantFilter + + # Merge device information back into each detected app + $GraphRequest = foreach ($App in $GraphRequest) { + $Devices = Get-GraphBulkResultByID -Results $BulkResults -ID $App.id -Value + if ($Devices) { + $App | Add-Member -NotePropertyName 'managedDevices' -NotePropertyValue $Devices -Force + } else { + $App | Add-Member -NotePropertyName 'managedDevices' -NotePropertyValue @() -Force + } + $App + } + } + } + + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + $StatusCode = [HttpStatusCode]::OK + $GraphRequest = $ErrorMessage + } + + return [HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @($GraphRequest) + } +} diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPProcessorQueue.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPProcessorQueue.ps1 index 4e34dc09f102..cbc065c4f15c 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPProcessorQueue.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPProcessorQueue.ps1 @@ -30,7 +30,7 @@ function Start-CIPPProcessorQueue { TriggerType = 'ProcessorQueue' QueueRowKey = $QueueItem.RowKey } - + # Add parameters info if available if ($Parameters.Count -gt 0) { $metadata['ParameterCount'] = $Parameters.Count @@ -42,11 +42,11 @@ function Start-CIPPProcessorQueue { $metadata['Tenant'] = $Parameters.TenantFilter } } - + # Wrap function execution with telemetry - Measure-CippTask -TaskName $FunctionName -Metadata $metadata -Script { - Invoke-Command -ScriptBlock { & $FunctionName @Parameters } - } + + Invoke-Command -ScriptBlock { & $FunctionName @Parameters } + } catch { Write-Warning "Failed to run function $($FunctionName). Error: $($_.Exception.Message)" } diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-LogRetentionCleanup.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-LogRetentionCleanup.ps1 new file mode 100644 index 000000000000..19ad42a6c5ad --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-LogRetentionCleanup.ps1 @@ -0,0 +1,66 @@ +function Start-LogRetentionCleanup { + <# + .SYNOPSIS + Start the Log Retention Cleanup Timer + .DESCRIPTION + This function cleans up old CIPP logs based on the retention policy + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param() + + try { + # Get retention settings + $ConfigTable = Get-CippTable -tablename Config + $Filter = "PartitionKey eq 'LogRetention' and RowKey eq 'Settings'" + $RetentionSettings = Get-CIPPAzDataTableEntity @ConfigTable -Filter $Filter + + # Default to 90 days if not set + $RetentionDays = if ($RetentionSettings.RetentionDays) { + [int]$RetentionSettings.RetentionDays + } else { + 90 + } + + # Ensure minimum retention of 7 days + if ($RetentionDays -lt 7) { + $RetentionDays = 7 + } + + # Ensure maximum retention of 365 days + if ($RetentionDays -gt 365) { + $RetentionDays = 365 + } + + Write-Host "Starting log cleanup with retention of $RetentionDays days" + + # Calculate cutoff date + $CutoffDate = (Get-Date).AddDays(-$RetentionDays).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + + $DeletedCount = 0 + + # Clean up CIPP Logs + if ($PSCmdlet.ShouldProcess('CippLogs', 'Cleaning up old logs')) { + $CippLogsTable = Get-CippTable -tablename 'CippLogs' + $CutoffFilter = "Timestamp lt datetime'$CutoffDate'" + + # Fetch all old log entries + $OldLogs = Get-AzDataTableEntity @CippLogsTable -Filter $CutoffFilter -Property @('PartitionKey', 'RowKey', 'ETag') + + if ($OldLogs) { + Remove-AzDataTableEntity @CippLogsTable -Entity $OldLogs -Force + $DeletedCount = ($OldLogs | Measure-Object).Count + Write-LogMessage -API 'LogRetentionCleanup' -message "Deleted $DeletedCount old log entries (retention: $RetentionDays days)" -Sev 'Info' + Write-Host "Deleted $DeletedCount old log entries" + } else { + Write-Host 'No old logs found' + } + } + + Write-LogMessage -API 'LogRetentionCleanup' -message "Log cleanup completed. Total logs deleted: $DeletedCount (retention: $RetentionDays days)" -Sev 'Info' + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'LogRetentionCleanup' -message "Failed to run log cleanup: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + throw + } +} diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-TableCleanup.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-TableCleanup.ps1 index 74f620e133e1..771e9d66363c 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-TableCleanup.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-TableCleanup.ps1 @@ -75,6 +75,16 @@ function Start-TableCleanup { Property = @('PartitionKey', 'RowKey', 'ETag') } } + @{ + FunctionName = 'TableCleanupTask' + Type = 'CleanupRule' + TableName = 'cacheQuarantineMessages' + DataTableProps = @{ + Filter = "PartitionKey eq 'QuarantineMessage' and Timestamp lt datetime'$((Get-Date).AddDays(-1).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 416318bb4438..037203bb9952 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 @@ -39,12 +39,21 @@ function Start-UpdateTokensTimer { # Check application secret expiration for $env:ApplicationId and generate a new application secret if expiration is within 30 days. try { $AppId = $env:ApplicationID - $PasswordCredentials = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/applications(appId='$AppId')?`$select=id,passwordCredentials" -NoAuthCheck $true -AsApp $true -ErrorAction Stop + $AppRegistration = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/applications(appId='$AppId')?`$select=id,passwordCredentials,servicePrincipalLockConfiguration" -NoAuthCheck $true -AsApp $true -ErrorAction Stop # sort by latest expiration date and get the first one - $LastPasswordCredential = $PasswordCredentials.passwordCredentials | Sort-Object -Property endDateTime -Descending | Select-Object -First 1 + $LastPasswordCredential = $AppRegistration.passwordCredentials | Sort-Object -Property endDateTime -Descending | Select-Object -First 1 + + try { + $AppPolicyStatus = Update-AppManagementPolicy + Write-Information $AppPolicyStatus.PolicyAction + } catch { + Write-Warning "Error updating app management policy $($_.Exception.Message)." + Write-Information ($_.InvocationInfo.PositionMessage) + } + if ($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/$($PasswordCredentials.id)/addPassword" -Body '{"passwordCredential":{"displayName":"UpdateTokens"}}' -NoAuthCheck $true -AsApp $true -ErrorAction Stop + $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)." } else { Write-Information "Application secret for $AppId is valid until $($LastPasswordCredential.endDateTime). No need to generate a new application secret." @@ -77,6 +86,20 @@ function Start-UpdateTokensTimer { } else { Write-Information "No expired application secrets found for $AppId." } + + if (!$AppRegistration.servicePrincipalLockConfiguration.isEnabled) { + Write-Warning "Service principal lock configuration is not enabled for $AppId" + $Body = @{ + servicePrincipalLockConfiguration = @{ + isEnabled = $true + allProperties = $true + } + } | ConvertTo-Json + New-GraphPOSTRequest -type PATCH -uri "https://graph.microsoft.com/v1.0/applications/$($AppRegistration.id)" -Body $Body -NoAuthCheck $true -AsApp $true -ErrorAction Stop + Write-Information "Service principal lock configuration has been enabled for application $AppId." + Write-LogMessage -API 'Update Tokens' -message "Service principal lock configuration has been enabled for application $AppId." -sev 'Info' + } + } catch { Write-Warning "Error updating application secret $($_.Exception.Message)." Write-Information ($_.InvocationInfo.PositionMessage) diff --git a/Modules/CIPPCore/Public/Functions/Remove-EmptyArrays.ps1 b/Modules/CIPPCore/Public/Functions/Remove-EmptyArrays.ps1 new file mode 100644 index 000000000000..85726be4607a --- /dev/null +++ b/Modules/CIPPCore/Public/Functions/Remove-EmptyArrays.ps1 @@ -0,0 +1,50 @@ +function Remove-EmptyArrays { + <# + .SYNOPSIS + Recursively removes empty arrays and null properties from objects + .DESCRIPTION + This function recursively traverses an object (Array, Hashtable, or PSCustomObject) and removes: + - Empty arrays + - Null properties + The function modifies the object in place. + .PARAMETER Object + The object to process (can be Array, Hashtable, or PSCustomObject) + .FUNCTIONALITY + Internal + .EXAMPLE + $obj = @{ items = @(); name = "test"; value = $null } + Remove-EmptyArrays -Object $obj + .EXAMPLE + $obj = [PSCustomObject]@{ items = @(); name = "test" } + Remove-EmptyArrays -Object $obj + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [object]$Object + ) + + if ($Object -is [Array]) { + foreach ($Item in $Object) { + Remove-EmptyArrays -Object $Item + } + } elseif ($Object -is [HashTable]) { + foreach ($Key in @($Object.get_Keys())) { + if ($Object[$Key] -is [Array] -and $Object[$Key].get_Count() -eq 0) { + $Object.Remove($Key) + } else { + Remove-EmptyArrays -Object $Object[$Key] + } + } + } elseif ($Object -is [PSCustomObject]) { + foreach ($Name in @($Object.PSObject.Properties.Name)) { + if ($Object.$Name -is [Array] -and $Object.$Name.get_Count() -eq 0) { + $Object.PSObject.Properties.Remove($Name) + } elseif ($null -eq $Object.$Name) { + $Object.PSObject.Properties.Remove($Name) + } else { + Remove-EmptyArrays -Object $Object.$Name + } + } + } +} diff --git a/Modules/CIPPCore/Public/Functions/Test-IsGuid.ps1 b/Modules/CIPPCore/Public/Functions/Test-IsGuid.ps1 new file mode 100644 index 000000000000..3beda62ec9cf --- /dev/null +++ b/Modules/CIPPCore/Public/Functions/Test-IsGuid.ps1 @@ -0,0 +1,23 @@ +function Test-IsGuid { + <# + .SYNOPSIS + Tests if a string is a valid GUID + .DESCRIPTION + This function checks if a string can be parsed as a valid GUID using .NET's Guid.TryParse method. + .PARAMETER String + The string to test for GUID format + .FUNCTIONALITY + Internal + .EXAMPLE + Test-IsGuid -String "123e4567-e89b-12d3-a456-426614174000" + .EXAMPLE + Test-IsGuid -String "not-a-guid" + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$String + ) + + return [guid]::TryParse($String, [ref][guid]::Empty) +} diff --git a/Modules/CIPPCore/Public/Get-CIPPCalendarPermissionReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPCalendarPermissionReport.ps1 index 4f7bd038fea4..b8a014d50656 100644 --- a/Modules/CIPPCore/Public/Get-CIPPCalendarPermissionReport.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPCalendarPermissionReport.ps1 @@ -31,8 +31,6 @@ function Get-CIPPCalendarPermissionReport { ) try { - Write-LogMessage -API 'CalendarPermissionReport' -tenant $TenantFilter -message 'Generating calendar permission report' -sev Info - # Handle AllTenants if ($TenantFilter -eq 'AllTenants') { # Get all tenants that have calendar data @@ -185,6 +183,7 @@ function Get-CIPPCalendarPermissionReport { Calendar = $_.MailboxDisplayName CalendarUPN = $_.MailboxUPN AccessRights = $_.AccessRights + FolderName = $_.FolderName } }) @@ -209,6 +208,7 @@ function Get-CIPPCalendarPermissionReport { [PSCustomObject]@{ User = $_.User AccessRights = $_.AccessRights + FolderName = $_.FolderName } }) @@ -216,6 +216,7 @@ function Get-CIPPCalendarPermissionReport { CalendarUPN = $CalendarUPN CalendarDisplayName = $CalendarInfo.MailboxDisplayName CalendarType = $CalendarInfo.MailboxType + FolderName = $CalendarInfo.FolderName PermissionCount = $_.Count Permissions = $PermissionDetails Tenant = $TenantFilter diff --git a/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 b/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 index f6cb38f81a04..2396a749d20e 100644 --- a/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 @@ -104,28 +104,44 @@ function Get-CIPPDrift { $standardDescription = $null #if the $ComparisonItem.StandardName contains *IntuneTemplate*, then it's an Intune policy deviation, and we need to grab the correct displayname from the template table if ($ComparisonItem.StandardName -like '*IntuneTemplate*') { - $CompareGuid = $ComparisonItem.StandardName.Split('.') | Select-Object -Last 1 - Write-Verbose "Extracted Intune GUID: $CompareGuid from $($ComparisonItem.StandardName)" - $Template = $AllIntuneTemplates | Where-Object { $_.GUID -eq "$CompareGuid" } - if ($Template) { - $displayName = $Template.displayName - $standardDescription = $Template.description - Write-Verbose "Found Intune template: $displayName" + # Extract GUID from format like: standards.IntuneTemplate.{GUID}.IntuneTemplate.json + # Split by '.' and find the element that looks like a GUID (contains hyphens and is 36 chars) + $Parts = $ComparisonItem.StandardName.Split('.') + $CompareGuid = $Parts | Where-Object { $_ -match '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' } | Select-Object -First 1 + + if ($CompareGuid) { + Write-Verbose "Extracted Intune GUID: $CompareGuid from $($ComparisonItem.StandardName)" + $Template = $AllIntuneTemplates | Where-Object { $_.GUID -match "$CompareGuid" } + if ($Template) { + $displayName = $Template.displayName + $standardDescription = $Template.description + Write-Verbose "Found Intune template: $displayName" + } else { + Write-Warning "Intune template not found for GUID: $CompareGuid" + } } else { - Write-Warning "Intune template not found for GUID: $CompareGuid" + Write-Verbose "No valid GUID found in: $($ComparisonItem.StandardName)" } } # Handle Conditional Access templates if ($ComparisonItem.StandardName -like '*ConditionalAccessTemplate*') { - $CompareGuid = $ComparisonItem.StandardName.Split('.') | Select-Object -Last 1 - Write-Verbose "Extracted CA GUID: $CompareGuid from $($ComparisonItem.StandardName)" - $Template = $AllCATemplates | Where-Object { $_.GUID -eq "$CompareGuid" } - if ($Template) { - $displayName = $Template.displayName - $standardDescription = $Template.description - Write-Verbose "Found CA template: $displayName" + # Extract GUID from format like: standards.ConditionalAccessTemplate.{GUID}.CATemplate.json + # Split by '.' and find the element that looks like a GUID (contains hyphens and is 36 chars) + $Parts = $ComparisonItem.StandardName.Split('.') + $CompareGuid = $Parts | Where-Object { $_ -match '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' } | Select-Object -First 1 + + if ($CompareGuid) { + Write-Verbose "Extracted CA GUID: $CompareGuid from $($ComparisonItem.StandardName)" + $Template = $AllCATemplates | Where-Object { $_.GUID -match "$CompareGuid" } + if ($Template) { + $displayName = $Template.displayName + $standardDescription = $Template.description + Write-Verbose "Found CA template: $displayName" + } else { + Write-Warning "CA template not found for GUID: $CompareGuid" + } } else { - Write-Warning "CA template not found for GUID: $CompareGuid" + Write-Verbose "No valid GUID found in: $($ComparisonItem.StandardName)" } } $reason = if ($ExistingDriftStates.ContainsKey($ComparisonItem.StandardName)) { $ExistingDriftStates[$ComparisonItem.StandardName].Reason } diff --git a/Modules/CIPPCore/Public/Get-CIPPIntunePolicy.ps1 b/Modules/CIPPCore/Public/Get-CIPPIntunePolicy.ps1 index ba0cbda8cfaa..9d9143b63151 100644 --- a/Modules/CIPPCore/Public/Get-CIPPIntunePolicy.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPIntunePolicy.ps1 @@ -35,8 +35,8 @@ function Get-CIPPIntunePolicy { $iOSPolicies = ($BulkResults | Where-Object { $_.id -eq 'iOSPolicies' }).body.value if ($DisplayName) { - $androidPolicy = $androidPolicies | Where-Object -Property displayName -EQ $DisplayName - $iOSPolicy = $iOSPolicies | Where-Object -Property displayName -EQ $DisplayName + $androidPolicy = $androidPolicies | Where-Object -Property displayName -EQ $DisplayName | Sort-Object -Property lastModifiedDateTime -Descending | Select-Object -First 1 + $iOSPolicy = $iOSPolicies | Where-Object -Property displayName -EQ $DisplayName | Sort-Object -Property lastModifiedDateTime -Descending | Select-Object -First 1 # Return the matching policy (Android or iOS) - using full data from bulk request if ($androidPolicy) { @@ -92,7 +92,7 @@ function Get-CIPPIntunePolicy { if ($DisplayName) { $policies = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $tenantFilter - $policy = $policies | Where-Object -Property displayName -EQ $DisplayName + $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)')?`$expand=scheduledActionsForRule(`$expand=scheduledActionConfigurations)" -tenantid $tenantFilter $policyJson = ConvertTo-Json -InputObject $policyDetails -Depth 100 -Compress @@ -122,7 +122,7 @@ function Get-CIPPIntunePolicy { if ($DisplayName) { $policies = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $tenantFilter - $policy = $policies | Where-Object -Property displayName -EQ $DisplayName + $policy = $policies | Where-Object -Property displayName -EQ $DisplayName | Sort-Object -Property lastModifiedDateTime -Descending | Select-Object -First 1 if ($policy) { $definitionValues = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL('$($policy.id)')/definitionValues" -tenantid $tenantFilter $policy | Add-Member -MemberType NoteProperty -Name 'definitionValues' -Value $definitionValues -Force @@ -237,7 +237,7 @@ function Get-CIPPIntunePolicy { if ($DisplayName) { $policies = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $tenantFilter - $policy = $policies | Where-Object -Property displayName -EQ $DisplayName + $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 id, lastModifiedDateTime, '@odata.context', 'ScopeTagIds', 'supportsScopeTags', 'createdDateTime' @@ -270,7 +270,7 @@ function Get-CIPPIntunePolicy { if ($DisplayName) { $policies = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $tenantFilter - $policy = $policies | Where-Object -Property Name -EQ $DisplayName + $policy = $policies | Where-Object -Property Name -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)')?`$expand=settings" -tenantid $tenantFilter $policyDetails = $policyDetails | Select-Object name, description, settings, platforms, technologies, templateReference @@ -303,7 +303,7 @@ function Get-CIPPIntunePolicy { if ($DisplayName) { $policies = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $tenantFilter - $policy = $policies | Where-Object -Property displayName -EQ $DisplayName + $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 id, lastModifiedDateTime, '@odata.context', 'ScopeTagIds', 'supportsScopeTags', 'createdDateTime' @@ -336,7 +336,7 @@ function Get-CIPPIntunePolicy { if ($DisplayName) { $policies = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $tenantFilter - $policy = $policies | Where-Object -Property displayName -EQ $DisplayName + $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 id, lastModifiedDateTime, '@odata.context', 'ScopeTagIds', 'supportsScopeTags', 'createdDateTime' @@ -369,7 +369,7 @@ function Get-CIPPIntunePolicy { if ($DisplayName) { $policies = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $tenantFilter - $policy = $policies | Where-Object -Property displayName -EQ $DisplayName + $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 id, lastModifiedDateTime, '@odata.context', 'ScopeTagIds', 'supportsScopeTags', 'createdDateTime' @@ -402,7 +402,7 @@ function Get-CIPPIntunePolicy { if ($DisplayName) { $policies = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $tenantFilter - $policy = $policies | Where-Object -Property displayName -EQ $DisplayName + $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 id, lastModifiedDateTime, '@odata.context', 'ScopeTagIds', 'supportsScopeTags', 'createdDateTime' diff --git a/Modules/CIPPCore/Public/Get-CIPPLAPSPassword.ps1 b/Modules/CIPPCore/Public/Get-CIPPLAPSPassword.ps1 index a6718f332a10..b0cd9c6534a0 100644 --- a/Modules/CIPPCore/Public/Get-CIPPLAPSPassword.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPLAPSPassword.ps1 @@ -9,7 +9,7 @@ function Get-CIPPLapsPassword { ) try { - $GraphRequest = (New-GraphGetRequest -noauthcheck $true -uri "https://graph.microsoft.com/beta/directory/deviceLocalCredentials/$($device)?`$select=credentials" -tenantid $TenantFilter).credentials | Select-Object -First 1 | ForEach-Object { + $GraphRequest = (New-GraphGetRequest -NoAuthCheck $true -uri "https://graph.microsoft.com/beta/directory/deviceLocalCredentials/$($device)?`$select=credentials" -tenantid $TenantFilter).credentials | Select-Object -First 1 | ForEach-Object { $PlainText = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($_.passwordBase64)) $date = $_.BackupDateTime [PSCustomObject]@{ diff --git a/Modules/CIPPCore/Public/Get-CIPPLicenseOverview.ps1 b/Modules/CIPPCore/Public/Get-CIPPLicenseOverview.ps1 index bfa4b519de08..752cf71f8cc7 100644 --- a/Modules/CIPPCore/Public/Get-CIPPLicenseOverview.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPLicenseOverview.ps1 @@ -63,7 +63,7 @@ function Get-CIPPLicenseOverview { $ExcludedSkuList = Get-CIPPAzDataTableEntity @LicenseTable } - $AllLicensedUsers = @(($Results | Where-Object { $_.id -eq 'licensedUsers' }).body.value) + $AllLicensedUsers = @(($Results | Where-Object { $_.id -eq 'licensedUsers' }).body.value) | Sort-Object -Property displayName $UsersBySku = @{} foreach ($User in $AllLicensedUsers) { if (-not $User.assignedLicenses) { continue } # Skip users with no assigned licenses. Should not happens as the filter is applied, but just in case @@ -84,7 +84,7 @@ function Get-CIPPLicenseOverview { } - $AllLicensedGroups = @(($Results | Where-Object { $_.id -eq 'licensedGroups' }).body.value) + $AllLicensedGroups = @(($Results | Where-Object { $_.id -eq 'licensedGroups' }).body.value) | Sort-Object -Property displayName $GroupsBySku = @{} foreach ($Group in $AllLicensedGroups) { if (-not $Group.assignedLicenses) { continue } @@ -156,5 +156,5 @@ function Get-CIPPLicenseOverview { } } } - return $GraphRequest + return ($GraphRequest | Sort-Object -Property License) } diff --git a/Modules/CIPPCore/Public/Get-CIPPMailboxPermissionReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPMailboxPermissionReport.ps1 index 389dad4ddd1d..c27b00f532fb 100644 --- a/Modules/CIPPCore/Public/Get-CIPPMailboxPermissionReport.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPMailboxPermissionReport.ps1 @@ -31,7 +31,7 @@ function Get-CIPPMailboxPermissionReport { ) try { - Write-LogMessage -API 'MailboxPermissionReport' -tenant $TenantFilter -message 'Generating mailbox permission report' -sev Info + Write-LogMessage -API 'MailboxPermissionReport' -tenant $TenantFilter -message 'Generating mailbox permission report' -sev Debug # Handle AllTenants if ($TenantFilter -eq 'AllTenants') { diff --git a/Modules/CIPPCore/Public/Get-CIPPReusableSettingsFromPolicy.ps1 b/Modules/CIPPCore/Public/Get-CIPPReusableSettingsFromPolicy.ps1 index 8e331a484f00..f7a8e8c32ce2 100644 --- a/Modules/CIPPCore/Public/Get-CIPPReusableSettingsFromPolicy.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPReusableSettingsFromPolicy.ps1 @@ -98,12 +98,26 @@ function Get-CIPPReusableSettingsFromPolicy { foreach ($settingId in $referencedReusableIds) { try { $setting = New-GraphGETRequest -Uri "https://graph.microsoft.com/beta/deviceManagement/reusablePolicySettings/$settingId" -tenantid $Tenant - if (-not $setting) { + if ($null -eq $setting) { Write-LogMessage -headers $Headers -API $APIName -message "Reusable setting $settingId not returned from Graph" -Sev 'Warn' continue } - $settingDisplayName = $setting.displayName + # Normalize Graph SDK objects into PSCustomObject to ensure cleanup works consistently + $settingNormalized = [ordered]@{} + foreach ($prop in $setting.PSObject.Properties) { + $settingNormalized[$prop.Name] = $prop.Value + } + + if ($settingNormalized.Count -eq 0) { + foreach ($prop in $setting.GetType().GetProperties()) { + $settingNormalized[$prop.Name] = $prop.GetValue($setting) + } + } + + $settingNormalized = $settingNormalized | ConvertTo-Json -Depth 100 -Compress | ConvertFrom-Json -Depth 100 + + $settingDisplayName = $setting.displayName ?? $settingNormalized.displayName if (-not $settingDisplayName) { Write-LogMessage -headers $Headers -API $APIName -message "Reusable setting $settingId missing displayName" -Sev 'Warn' continue @@ -112,9 +126,10 @@ function Get-CIPPReusableSettingsFromPolicy { $matchedTemplate = $existingReusableByName[$settingDisplayName] $templateGuid = $matchedTemplate.RowKey + $cleanSetting = Remove-CIPPReusableSettingMetadata -InputObject $settingNormalized + $sanitizedJson = $cleanSetting | ConvertTo-Json -Depth 100 -Compress + if (-not $templateGuid) { - $cleanSetting = Remove-CIPPReusableSettingMetadata -InputObject $setting - $sanitizedJson = $cleanSetting | ConvertTo-Json -Depth 100 -Compress $templateGuid = (New-Guid).Guid $reusableEntity = [pscustomobject]@{ DisplayName = $settingDisplayName @@ -139,7 +154,46 @@ function Get-CIPPReusableSettingsFromPolicy { Write-LogMessage -headers $Headers -API $APIName -message "Created reusable setting template $templateGuid for '$settingDisplayName'" -Sev 'Info' } else { - Write-LogMessage -headers $Headers -API $APIName -message "Reusing existing reusable setting template $templateGuid for '$settingDisplayName'" -Sev 'Info' + $existingRawJson = $matchedTemplate.RawJSON + if (-not $existingRawJson) { + $existingParsed = $matchedTemplate.JSON | ConvertFrom-Json -ErrorAction SilentlyContinue + $existingRawJson = $existingParsed.RawJSON + } + + $requiresNormalization = $false + if ($existingRawJson -and $existingRawJson -match '"children"\s*:\s*null') { + $requiresNormalization = $true + } + + if ($requiresNormalization) { + $reusableEntity = [pscustomobject]@{ + DisplayName = $settingDisplayName + Description = $setting.description + RawJSON = $sanitizedJson + GUID = $templateGuid + } | ConvertTo-Json -Depth 100 -Compress + + Add-CIPPAzDataTableEntity @templatesTableForAdd -Entity @{ + JSON = "$reusableEntity" + RowKey = "$templateGuid" + PartitionKey = 'IntuneReusableSettingTemplate' + GUID = "$templateGuid" + DisplayName = $settingDisplayName + Description = $setting.description + RawJSON = "$sanitizedJson" + } + + $existingReusableByName[$settingDisplayName] = [pscustomobject]@{ + RowKey = $templateGuid + DisplayName = $settingDisplayName + JSON = $reusableEntity + RawJSON = $sanitizedJson + } + + Write-LogMessage -headers $Headers -API $APIName -message "Normalized reusable setting template $templateGuid for '$settingDisplayName'" -Sev 'Info' + } else { + Write-LogMessage -headers $Headers -API $APIName -message "Reusing existing reusable setting template $templateGuid for '$settingDisplayName'" -Sev 'Info' + } } $result.ReusableSettings.Add([pscustomobject]@{ diff --git a/Modules/CIPPCore/Public/GraphHelper/New-CIPPGraphRetry.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-CIPPGraphRetry.ps1 new file mode 100644 index 000000000000..eae86faad9d5 --- /dev/null +++ b/Modules/CIPPCore/Public/GraphHelper/New-CIPPGraphRetry.ps1 @@ -0,0 +1,94 @@ +function New-CIPPGraphRetry { + <# + .SYNOPSIS + Retries a failed Graph API request + .DESCRIPTION + This function is called by scheduled tasks when a Graph API request has exhausted retries. + It attempts to execute the request again with the original parameters. + .PARAMETER uri + The Graph API URI to call + .PARAMETER tenantid + The tenant ID for the request + .PARAMETER type + The HTTP method (POST, PATCH, DELETE, etc.) + .PARAMETER body + The request body + .PARAMETER scope + Optional OAuth scope + .PARAMETER AsApp + Whether to use application authentication + .PARAMETER NoAuthCheck + Whether to skip authorization check + .PARAMETER skipTokenCache + Whether to skip token cache + .PARAMETER AddedHeaders + Additional headers to include + .PARAMETER contentType + Content type for the request + .PARAMETER IgnoreErrors + Whether to ignore HTTP errors + .PARAMETER returnHeaders + Whether to return response headers + .PARAMETER maxRetries + Maximum number of retries + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$uri, + + [Parameter(Mandatory = $true)] + [string]$tenantid, + + [Parameter(Mandatory = $true)] + [string]$type, + + [string]$body, + [string]$scope, + [switch]$AsApp, + [switch]$NoAuthCheck, + [switch]$skipTokenCache, + [hashtable]$AddedHeaders, + [string]$contentType, + [bool]$IgnoreErrors, + [bool]$returnHeaders, + [int]$maxRetries = 3 + ) + + Write-Information "Retrying Graph API request for URI: $uri | Tenant: $tenantid" + + try { + # Build the parameter splat for New-GraphPOSTRequest + $GraphParams = @{ + uri = $uri + tenantid = $tenantid + type = $type + body = $body + maxRetries = $maxRetries + ScheduleRetry = $false # Do NOT schedule again if this retry fails + } + + # Add optional parameters if they were provided + if ($scope) { $GraphParams.scope = $scope } + if ($AsApp) { $GraphParams.AsApp = $AsApp } + if ($NoAuthCheck) { $GraphParams.NoAuthCheck = $NoAuthCheck } + if ($skipTokenCache) { $GraphParams.skipTokenCache = $skipTokenCache } + if ($AddedHeaders) { $GraphParams.AddedHeaders = $AddedHeaders } + if ($contentType) { $GraphParams.contentType = $contentType } + if ($IgnoreErrors) { $GraphParams.IgnoreErrors = $IgnoreErrors } + if ($returnHeaders) { $GraphParams.returnHeaders = $returnHeaders } + + # Execute the Graph request + $Result = New-GraphPOSTRequest @GraphParams + + Write-LogMessage -API 'GraphRetry' -message "Successfully retried Graph request for URI: $uri | Tenant: $tenantid" -Sev 'Info' -tenant $tenantid + + return $Result + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + Write-LogMessage -API 'GraphRetry' -message "Failed to retry Graph request for URI: $uri | Tenant: $tenantid. Error: $ErrorMessage" -Sev 'Error' -tenant $tenantid + throw $ErrorMessage + } +} diff --git a/Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 index ec43778f3748..23b0e59d3dc7 100644 --- a/Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 @@ -160,10 +160,15 @@ function New-GraphGetRequest { $WaitTime = [int]$RetryAfterHeader Write-Warning "Rate limited (429). Waiting $WaitTime seconds before retry. Attempt $($RetryCount + 1) of $MaxRetries" $ShouldRetry = $true + } else { + # If no Retry-After header, use exponential backoff with jitter + $WaitTime = Get-Random -Minimum 1.1 -Maximum 4.1 # Random sleep between 1-4 seconds + Write-Warning "Rate limited (429) with no Retry-After header. Waiting $WaitTime seconds before retry. Attempt $($RetryCount + 1) of $MaxRetries. Headers: $(($HttpResponseDetails.Headers | ConvertTo-Json -Compress))" + $ShouldRetry = $true } } # Check for "Resource temporarily unavailable" - elseif ($Message -like '*Resource temporarily unavailable*') { + elseif ($Message -like '*Resource temporarily unavailable*' -or $Message -like '*Too many requests*') { if ($RetryCount -lt $MaxRetries) { $WaitTime = Get-Random -Minimum 1.1 -Maximum 3.1 # Random sleep between 1-2 seconds Write-Warning "Resource temporarily unavailable. Waiting $WaitTime seconds before retry. Attempt $($RetryCount + 1) of $MaxRetries" diff --git a/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 index ebc8a4efc2dd..d2ac97fb8839 100644 --- a/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 @@ -17,7 +17,8 @@ function New-GraphPOSTRequest { $contentType, $IgnoreErrors = $false, $returnHeaders = $false, - $maxRetries = 1 + $maxRetries = 3, + $ScheduleRetry = $false ) if ($NoAuthCheck -or (Get-AuthorisedRequest -Uri $uri -TenantID $tenantid)) { @@ -38,26 +39,96 @@ function New-GraphPOSTRequest { $body = Get-CIPPTextReplacement -TenantFilter $tenantid -Text $body -EscapeForJson - $x = 0 + $RetryCount = 0 + $RequestSuccessful = $false do { try { - Write-Information "$($type.ToUpper()) [ $uri ] | tenant: $tenantid | attempt: $($x + 1) of $maxRetries" - $success = $false + Write-Information "$($type.ToUpper()) [ $uri ] | tenant: $tenantid | attempt: $($RetryCount + 1) of $maxRetries" $ReturnedData = (Invoke-RestMethod -Uri $($uri) -Method $TYPE -Body $body -Headers $headers -ContentType $contentType -SkipHttpErrorCheck:$IgnoreErrors -ResponseHeadersVariable responseHeaders) - $success = $true + $RequestSuccessful = $true } catch { - + $ShouldRetry = $false + $WaitTime = 0 $Message = if ($_.ErrorDetails.Message) { Get-NormalizedError -Message $_.ErrorDetails.Message } else { $_.Exception.message } - $x++ - Start-Sleep -Seconds (2 * $x) + + # Check for 429 Too Many Requests + if ($_.Exception.Response.StatusCode -eq 429) { + $RetryAfterHeader = $_.Exception.Response.Headers['Retry-After'] + if ($RetryAfterHeader) { + $WaitTime = [int]$RetryAfterHeader + Write-Warning "Rate limited (429). Waiting $WaitTime seconds before retry. Attempt $($RetryCount + 1) of $maxRetries" + $ShouldRetry = $true + } + } + # Check for "Resource temporarily unavailable" + elseif ($Message -like '*Resource temporarily unavailable*' -or $Message -like '*Too many requests*') { + $WaitTime = Get-Random -Minimum 1.1 -Maximum 3.1 + Write-Warning "Resource temporarily unavailable. Waiting $WaitTime seconds before retry. Attempt $($RetryCount + 1) of $maxRetries" + $ShouldRetry = $true + } + + if ($ShouldRetry) { + $RetryCount++ + if ($RetryCount -lt $maxRetries) { + Start-Sleep -Seconds $WaitTime + } + } else { + # Not a retryable error, exit immediately + break + } } - } while (($x -lt $maxRetries) -and ($success -eq $false)) + } while (-not $RequestSuccessful -and $RetryCount -lt $maxRetries) + + if (($RequestSuccessful -eq $false) -and $ScheduleRetry -eq $true -and $ShouldRetry -eq $true) { + #Create a scheduled task to retry the task later, when there is less pressure on the system, but only if ScheduledRetry is true. + try { + $TaskId = (New-Guid).Guid.ToString() + + # Prepare parameters for the retry + $RetryParameters = @{ + uri = $uri + tenantid = $tenantid + type = $type + body = $body + } + + # Add optional parameters if they were provided + if ($scope) { $RetryParameters.scope = $scope } + if ($AsApp) { $RetryParameters.AsApp = $AsApp } + if ($NoAuthCheck) { $RetryParameters.NoAuthCheck = $NoAuthCheck } + if ($skipTokenCache) { $RetryParameters.skipTokenCache = $skipTokenCache } + if ($AddedHeaders) { $RetryParameters.AddedHeaders = $AddedHeaders } + if ($contentType) { $RetryParameters.contentType = $contentType } + if ($IgnoreErrors) { $RetryParameters.IgnoreErrors = $IgnoreErrors } + if ($returnHeaders) { $RetryParameters.ReturnHeaders = $returnHeaders } + if ($maxRetries) { $RetryParameters.maxRetries = $maxRetries } + + # Create the scheduled task object + $TaskObject = [PSCustomObject]@{ + TenantFilter = $tenantid + Name = "Graph API Retry - $($uri -replace 'https://graph.microsoft.com/(beta|v1.0)/', '')" + Command = [PSCustomObject]@{ value = 'New-CIPPGraphRetry' } + Parameters = $RetryParameters + ScheduledTime = [int64](([datetime]::UtcNow.AddMinutes(15)) - (Get-Date '1/1/1970')).TotalSeconds + Recurrence = '0' + PostExecution = @{} + Reference = "GraphRetry-$TaskId" + } + + # Add the scheduled task (hidden = system task) + $null = Add-CIPPScheduledTask -Task $TaskObject -Hidden $true + + return @{Result = "Scheduled job with id $TaskId as Graph API was too busy to respond. Check the job status in the scheduler." } + } catch { + Write-Warning "Failed to schedule retry task: $($_.Exception.Message)" + } + } - if ($success -eq $false) { + if ($RequestSuccessful -eq $false) { throw $Message } diff --git a/Modules/CIPPCore/Public/GraphHelper/Update-AppManagementPolicy.ps1 b/Modules/CIPPCore/Public/GraphHelper/Update-AppManagementPolicy.ps1 new file mode 100644 index 000000000000..1c5e20ae81df --- /dev/null +++ b/Modules/CIPPCore/Public/GraphHelper/Update-AppManagementPolicy.ps1 @@ -0,0 +1,240 @@ +function Update-AppManagementPolicy { + <# + .SYNOPSIS + Check and update app management policies for credential restrictions + + .DESCRIPTION + Retrieves tenant default app management policy and app management policies to check if + passwordCredential or keyCredential creation is restricted. If the default policy blocks + credential addition and the targeted app doesn't have an exemption, creates or updates a policy + to allow the app to manage credentials. + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $TenantFilter = $env:TenantID, + $ApplicationId = $env:ApplicationID + ) + + try { + # Create bulk request to fetch both policies at once + $Requests = @( + @{ + id = 'defaultPolicy' + method = 'GET' + url = '/policies/defaultAppManagementPolicy' + } + @{ + id = 'appPolicies' + method = 'GET' + url = '/policies/appManagementPolicies' + } + @{ + id = 'appRegistration' + method = 'GET' + url = "applications(appId='$ApplicationId')?`$select=id,appId,displayName" + } + ) + + # Execute bulk request + $Results = New-GraphBulkRequest -Requests $Requests -NoAuthCheck $true -asapp $true -tenantid $TenantFilter + + # Parse results + $DefaultPolicy = ($Results | Where-Object { $_.id -eq 'defaultPolicy' }).body + $AppPolicies = ($Results | Where-Object { $_.id -eq 'appPolicies' }).body.value + $CIPPApp = ($Results | Where-Object { $_.id -eq 'appRegistration' }).body + + # Check if CIPP-SAM app is targeted by any policies + $CIPPAppTargeted = $false + $CIPPAppPolicyId = $null + if ($AppPolicies -and $ApplicationId) { + # Build bulk requests to get appliesTo for each policy + $AppliesToRequests = @($AppPolicies | ForEach-Object { + @{ + id = $_.id + method = 'GET' + url = "/policies/appManagementPolicies/$($_.id)/appliesTo" + } + }) + + if ($AppliesToRequests.Count -gt 0) { + $AppliesToResults = New-GraphBulkRequest -Requests $AppliesToRequests -NoAuthCheck $true -asapp $true -tenantid $TenantFilter + + # Find which policy (if any) targets the app + $CIPPPolicyResult = $AppliesToResults | Where-Object { $_.body.value.appId -contains $ApplicationId } | Select-Object -First 1 + if ($CIPPPolicyResult) { + $CIPPAppTargeted = $true + $CIPPAppPolicyId = $CIPPPolicyResult.id + } + } + } + + # Check for credential restrictions across all policies + $PasswordAdditionBlocked = $false + $SymmetricKeyAdditionBlocked = $false + $AsymmetricKeyAdditionBlocked = $false + $PasswordLifetimeRestricted = $false + $KeyLifetimeRestricted = $false + + # Helper function to check restrictions in a policy + function Test-PolicyRestrictions { + param($Policy, [switch]$IsDefaultPolicy) + + # Default policy has applicationRestrictions, app-specific policies have restrictions + $pwdCreds = if ($IsDefaultPolicy) { $Policy.applicationRestrictions.passwordCredentials } else { $Policy.restrictions.passwordCredentials } + $keyCreds = if ($IsDefaultPolicy) { $Policy.applicationRestrictions.keyCredentials } else { $Policy.restrictions.keyCredentials } + + if ($pwdCreds) { + foreach ($restriction in $pwdCreds | Where-Object { $_.state -eq 'enabled' }) { + switch ($restriction.restrictionType) { + 'passwordAddition' { $PasswordAdditionBlocked = $true } + 'symmetricKeyAddition' { $SymmetricKeyAdditionBlocked = $true } + 'passwordLifetime' { $PasswordLifetimeRestricted = $true } + 'symmetricKeyLifetime' { $PasswordLifetimeRestricted = $true } + } + } + } + + if ($keyCreds) { + foreach ($restriction in $keyCreds | Where-Object { $_.state -eq 'enabled' }) { + switch ($restriction.restrictionType) { + 'asymmetricKeyLifetime' { $KeyLifetimeRestricted = $true } + 'trustedCertificateAuthority' { $AsymmetricKeyAdditionBlocked = $true } + } + } + } + } + + # Check default policy (uses applicationRestrictions structure) + if ($DefaultPolicy) { + Test-PolicyRestrictions -Policy $DefaultPolicy -IsDefaultPolicy + } + + # Check app-specific policies (use restrictions structure) + if ($AppPolicies) { + foreach ($AppPolicy in $AppPolicies | Where-Object { $_.isEnabled -eq $true }) { + Test-PolicyRestrictions -Policy $AppPolicy + } + } + + # Determine if default policy blocks credential addition + $DefaultPolicyBlocksCredentials = $false + if ($DefaultPolicy.applicationRestrictions.passwordCredentials) { + $DefaultPolicyBlocksCredentials = ($DefaultPolicy.applicationRestrictions.passwordCredentials | Where-Object { $_.restrictionType -in @('passwordAddition', 'symmetricKeyAddition') -and $_.state -eq 'enabled' }).Count -gt 0 + } + + # If default policy blocks credentials and CIPP app doesn't have an exemption, create/update policy + $PolicyAction = $null + if ($DefaultPolicyBlocksCredentials -and $CIPPApp) { + # Check if a CIPP-SAM Exemption Policy already exists + $ExistingExemptionPolicy = $AppPolicies | Where-Object { $_.displayName -eq 'CIPP Exemption Policy' } | Select-Object -First 1 + + # Check if CIPP app has a policy that allows credentials + $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) { + $CIPPHasExemption = -not ($CIPPPolicy.restrictions.passwordCredentials | Where-Object { $_.restrictionType -in @('passwordAddition', 'symmetricKeyAddition') -and $_.state -eq 'enabled' }) + } else { + # No password restrictions means it allows credentials + $CIPPHasExemption = $true + } + } + + if (-not $CIPPHasExemption) { + # Need to create or update a policy for CIPP + try { + # Define policy structure with disabled restrictions + $PolicyBody = @{ + displayName = 'CIPP Exemption Policy' + description = '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 = @() + } + } + + if ($CIPPAppPolicyId) { + # Update existing policy that's already assigned to the app + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/policies/appManagementPolicies/$CIPPAppPolicyId" -type PATCH -body ($PolicyBody | ConvertTo-Json -Depth 10) -asapp $true -NoAuthCheck $true -tenantid $TenantFilter + $PolicyAction = "Updated existing policy $CIPPAppPolicyId to allow credentials" + } elseif ($ExistingExemptionPolicy) { + # Exemption policy exists but not assigned to app - update and assign it + $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 + + if ($CIPPApp.id) { + # Assign existing policy to CIPP-SAM application + $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 + $PolicyAction = "Updated and assigned existing policy $($ExistingExemptionPolicy.id) to CIPP-SAM" + $CIPPAppPolicyId = $ExistingExemptionPolicy.id + $CIPPAppTargeted = $true + } else { + $PolicyAction = "Updated policy $($ExistingExemptionPolicy.id) but failed to assign: CIPP application not found" + } + } else { + # Create new policy and assign to CIPP-SAM app + $CreatedPolicy = New-GraphPostRequest -uri 'https://graph.microsoft.com/v1.0/policies/appManagementPolicies' -type POST -body ($PolicyBody | ConvertTo-Json -Depth 10) -asapp $true -NoAuthCheck $true + + if ($CIPPApp.id) { + # Assign policy to CIPP-SAM application 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 + $PolicyAction = "Created new policy $($CreatedPolicy.id) and assigned to CIPP-SAM" + $CIPPAppPolicyId = $CreatedPolicy.id + $CIPPAppTargeted = $true + } else { + $PolicyAction = "Created new policy $($CreatedPolicy.id) but failed to assign: CIPP application not found" + } + } + } catch { + $PolicyAction = "Failed to update policy: $($_.Exception.Message)" + } + } else { + $PolicyAction = 'CIPP-SAM app is already exempt from credential restrictions. No action needed.' + } + } + + # Build result object + $PolicyInfo = [PSCustomObject]@{ + DefaultPolicy = $DefaultPolicy + AppPolicies = $AppPolicies + CIPPAppTargeted = $CIPPAppTargeted + CIPPAppPolicyId = $CIPPAppPolicyId + CIPPHasExemption = $CIPPHasExemption + PolicyAction = $PolicyAction + PasswordAdditionBlocked = $PasswordAdditionBlocked + SymmetricKeyAdditionBlocked = $SymmetricKeyAdditionBlocked + PasswordLifetimeRestricted = $PasswordLifetimeRestricted + KeyLifetimeRestricted = $KeyLifetimeRestricted + AnyCredentialCreationRestricted = $PasswordAdditionBlocked -or $SymmetricKeyAdditionBlocked + PolicyCount = if ($AppPolicies) { $AppPolicies.Count } else { 0 } + EnabledPolicyCount = if ($AppPolicies) { ($AppPolicies | Where-Object { $_.isEnabled -eq $true }).Count } else { 0 } + } + + return $PolicyInfo + + } catch { + Write-Warning "Failed to retrieve app management policies: $($_.Exception.Message)" + return $null + } +} diff --git a/Modules/CIPPCore/Public/New-CIPPBackup.ps1 b/Modules/CIPPCore/Public/New-CIPPBackup.ps1 index b3f8409ade3e..f5760c802330 100644 --- a/Modules/CIPPCore/Public/New-CIPPBackup.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPBackup.ps1 @@ -54,6 +54,8 @@ function New-CIPPBackup { 'WebhookRules' 'ScheduledTasks' 'TenantProperties' + 'TenantGroups' + 'TenantGroupMembers' ) $CSVfile = foreach ($CSVTable in $BackupTables) { $Table = Get-CippTable -tablename $CSVTable @@ -145,6 +147,7 @@ function New-CIPPBackup { } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -headers $Headers -API $APINAME -message "Blob upload failed: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + return [pscustomobject]@{'Results' = "Blob Upload failed: $($ErrorMessage.NormalizedError)" } } # Write table entity pointing to blob resource diff --git a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 index 1f908ff4e0f2..76fba584da6d 100644 --- a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 @@ -10,47 +10,26 @@ function New-CIPPCAPolicy { $DisableSD = $false, $CreateGroups = $false, $APIName = 'Create CA Policy', - $Headers + $Headers, + $PreloadedCAPolicies = $null, + $PreloadedLocations = $null ) - function Remove-EmptyArrays ($Object) { - if ($Object -is [Array]) { - foreach ($Item in $Object) { Remove-EmptyArrays $Item } - } elseif ($Object -is [HashTable]) { - foreach ($Key in @($Object.get_Keys())) { - if ($Object[$Key] -is [Array] -and $Object[$Key].get_Count() -eq 0) { - $Object.Remove($Key) - } else { Remove-EmptyArrays $Object[$Key] } - } - } elseif ($Object -is [PSCustomObject]) { - foreach ($Name in @($Object.psobject.properties.Name)) { - if ($Object.$Name -is [Array] -and $Object.$Name.get_Count() -eq 0) { - $Object.PSObject.Properties.Remove($Name) - } elseif ($null -eq $object.$name) { - $Object.PSObject.Properties.Remove($Name) - } else { Remove-EmptyArrays $Object.$Name } - } - } - } - # Function to check if a string is a GUID - function Test-IsGuid($string) { - return [guid]::tryparse($string, [ref][guid]::Empty) - } # Helper function to replace group display names with GUIDs - function Replace-GroupNameWithId { + function Convert-GroupNameToId { param($TenantFilter, $groupNames, $CreateGroups, $GroupTemplates) $GroupIds = [System.Collections.Generic.List[string]]::new() $groupNames | ForEach-Object { - if (Test-IsGuid $_) { - Write-LogMessage -Headers $Headers -API 'Create CA Policy' -message "Already GUID, no need to replace: $_" -Sev 'Debug' + if (Test-IsGuid -String $_) { + Write-LogMessage -Headers $Headers -API $APIName -message "Already GUID, no need to replace: $_" -Sev 'Debug' $GroupIds.Add($_) # it's a GUID, so we keep it } else { $groupId = ($groups | Where-Object -Property displayName -EQ $_).id # it's a display name, so we get the group ID if ($groupId) { foreach ($gid in $groupId) { Write-Warning "Replaced group name $_ with ID $gid" - $null = Write-LogMessage -Headers $Headers -API 'Create CA Policy' -message "Replaced group name $_ with ID $gid" -Sev 'Debug' + $null = Write-LogMessage -Headers $Headers -API $APIName -message "Replaced group name $_ with ID $gid" -Sev 'Debug' $GroupIds.Add($gid) # add the ID to the list } } elseif ($CreateGroups) { @@ -58,7 +37,7 @@ function New-CIPPCAPolicy { if ($GroupTemplates.displayName -eq $_) { Write-Information "Creating group from template for $_" $GroupTemplate = $GroupTemplates | Where-Object -Property displayName -EQ $_ - $NewGroup = New-CIPPGroup -GroupObject $GroupTemplate -TenantFilter $TenantFilter -APIName 'New-CIPPCAPolicy' + $NewGroup = New-CIPPGroup -GroupObject $GroupTemplate -TenantFilter $TenantFilter -APIName $APIName $GroupIds.Add($NewGroup.GroupId) } else { Write-Information "No template found, creating security group for $_" @@ -72,7 +51,7 @@ function New-CIPPCAPolicy { username = $username securityEnabled = $true } - $NewGroup = New-CIPPGroup -GroupObject $GroupObject -TenantFilter $TenantFilter -APIName 'New-CIPPCAPolicy' + $NewGroup = New-CIPPGroup -GroupObject $GroupObject -TenantFilter $TenantFilter -APIName $APIName $GroupIds.Add($NewGroup.GroupId) } } else { @@ -83,20 +62,20 @@ function New-CIPPCAPolicy { return $GroupIds } - function Replace-UserNameWithId { + function Convert-UserNameToId { param($userNames) $UserIds = [System.Collections.Generic.List[string]]::new() $userNames | ForEach-Object { - if (Test-IsGuid $_) { - Write-LogMessage -Headers $Headers -API 'Create CA Policy' -message "Already GUID, no need to replace: $_" -Sev 'Debug' + if (Test-IsGuid -String $_) { + Write-LogMessage -Headers $Headers -API $APIName -message "Already GUID, no need to replace: $_" -Sev 'Debug' $UserIds.Add($_) # it's a GUID, so we keep it } else { $userId = ($users | Where-Object -Property displayName -EQ $_).id # it's a display name, so we get the user ID if ($userId) { foreach ($uid in $userId) { Write-Warning "Replaced user name $_ with ID $uid" - $null = Write-LogMessage -Headers $Headers -API 'Create CA Policy' -message "Replaced user name $_ with ID $uid" -Sev 'Debug' + $null = Write-LogMessage -Headers $Headers -API $APIName -message "Replaced user name $_ with ID $uid" -Sev 'Debug' $UserIds.Add($uid) # add the ID to the list } } else { @@ -107,13 +86,19 @@ function New-CIPPCAPolicy { return $UserIds } - $displayname = ($RawJSON | ConvertFrom-Json).Displayname + $displayName = ($RawJSON | ConvertFrom-Json).displayName $JSONobj = $RawJSON | ConvertFrom-Json | Select-Object * -ExcludeProperty ID, GUID, *time* - Remove-EmptyArrays $JSONobj + Remove-EmptyArrays -Object $JSONobj #Remove context as it does not belong in the payload. try { - $JSONobj.grantControls.PSObject.Properties.Remove('authenticationStrength@odata.context') + if ($JSONobj.grantControls) { + try { + $JSONobj.grantControls.PSObject.Properties.Remove('authenticationStrength@odata.context') + } catch { + #did not need to remove because didn't exist. + } + } $JSONobj.templateId ? $JSONobj.PSObject.Properties.Remove('templateId') : $null if ($JSONobj.conditions.users.excludeGuestsOrExternalUsers.externalTenants.Members) { $JSONobj.conditions.users.excludeGuestsOrExternalUsers.externalTenants.PSObject.Properties.Remove('@odata.context') @@ -122,28 +107,88 @@ function New-CIPPCAPolicy { $JSONobj.state = $State } } catch { - # no issues here. + $ErrorMessage = Get-CippException -Exception $_ + Write-Information "Error cleaning JSON properties: $($ErrorMessage | ConvertTo-Json -Depth 10 -Compress)" } - #If Grant Controls contains authenticationstrength, create these and then replace the id + # Execute all required GET requests ONCE at the beginning to avoid rate limiting + Write-Information 'Fetching required resources from Graph API...' + + # Get existing CA policies once (or use preloaded ones) + if ($PreloadedCAPolicies) { + Write-Information 'Using preloaded CA policies' + $AllExistingPolicies = $PreloadedCAPolicies + } else { + try { + Write-Information 'Fetching existing CA policies...' + $AllExistingPolicies = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/policies?$top=999' -tenantid $TenantFilter -asApp $true + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-Information "Error fetching existing policies: $($ErrorMessage | ConvertTo-Json -Depth 10 -Compress)" + throw "Failed to fetch existing CA policies: $($ErrorMessage.NormalizedError)" + } + } + + # Get named locations once if needed + $AllNamedLocations = $null + if ($JSONobj.LocationInfo) { + if ($PreloadedLocations) { + Write-Information 'Using preloaded named locations' + $AllNamedLocations = $PreloadedLocations + } else { + try { + Write-Information 'Fetching all named locations...' + $AllNamedLocations = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations?$top=999' -tenantid $TenantFilter -asApp $true + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-Information "Error fetching named locations: $($ErrorMessage | ConvertTo-Json -Depth 10 -Compress)" + throw "Failed to fetch named locations: $($ErrorMessage.NormalizedError)" + } + } + } + + # Get authentication strength policies once if needed + $AllAuthStrengthPolicies = $null if ($JSONobj.GrantControls.authenticationStrength.policyType -eq 'custom' -or $JSONobj.GrantControls.authenticationStrength.policyType -eq 'BuiltIn') { - $ExistingStrength = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationStrength/policies/' -tenantid $TenantFilter -asApp $true | Where-Object -Property displayName -EQ $JSONobj.GrantControls.authenticationStrength.displayName + try { + Write-Information 'Fetching authentication strength policies...' + $AllAuthStrengthPolicies = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationStrength/policies/' -tenantid $TenantFilter -asApp $true + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-Information "Error fetching authentication strength policies: $($ErrorMessage | ConvertTo-Json -Depth 10 -Compress)" + throw "Failed to fetch authentication strength policies: $($ErrorMessage.NormalizedError)" + } + } + + # Get service principals once if needed + $AllServicePrincipals = $null + if (($JSONobj.conditions.applications.includeApplications -and $JSONobj.conditions.applications.includeApplications -notcontains 'All') -or ($JSONobj.conditions.applications.excludeApplications -and $JSONobj.conditions.applications.excludeApplications -notcontains 'All')) { + try { + Write-Information 'Fetching all service principals...' + $AllServicePrincipals = New-GraphGETRequest -uri 'https://graph.microsoft.com/v1.0/servicePrincipals?$select=appId&$top=999' -tenantid $TenantFilter -asApp $true + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-Information "Error fetching service principals: $($ErrorMessage | ConvertTo-Json -Depth 10 -Compress)" + throw "Failed to fetch service principals: $($ErrorMessage.NormalizedError)" + } + } + + #If Grant Controls contains authenticationStrength, create these and then replace the id + if ($JSONobj.GrantControls.authenticationStrength.policyType -eq 'custom' -or $JSONobj.GrantControls.authenticationStrength.policyType -eq 'BuiltIn') { + $ExistingStrength = $AllAuthStrengthPolicies | Where-Object -Property displayName -EQ $JSONobj.GrantControls.authenticationStrength.displayName if ($ExistingStrength) { $JSONobj.GrantControls.authenticationStrength = @{ id = $ExistingStrength.id } } else { $Body = ConvertTo-Json -InputObject $JSONobj.GrantControls.authenticationStrength - $GraphRequest = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationStrength/policies' -body $body -Type POST -tenantid $tenantfilter -asApp $true + $GraphRequest = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationStrength/policies' -body $body -Type POST -tenantid $TenantFilter -asApp $true -ScheduleRetry $true $JSONobj.GrantControls.authenticationStrength = @{ id = $ExistingStrength.id } - Write-LogMessage -Headers $Headers -API $APINAME -message "Created new Authentication Strength Policy: $($JSONobj.GrantControls.authenticationStrength.displayName)" -Sev 'Info' + Write-LogMessage -Headers $Headers -API $APIName -message "Created new Authentication Strength Policy: $($JSONobj.GrantControls.authenticationStrength.displayName)" -Sev 'Info' } } #if we have excluded or included applications, we need to remove any appIds that do not have a service principal in the tenant - - if (($JSONobj.conditions.applications.includeApplications -and $JSONobj.conditions.applications.includeApplications -notcontains 'All') -or ($JSONobj.conditions.applications.excludeApplications -and $JSONobj.conditions.applications.excludeApplications -notcontains 'All')) { - $AllServicePrincipals = New-GraphGETRequest -uri 'https://graph.microsoft.com/v1.0/servicePrincipals?$select=appId' -tenantid $TenantFilter -asApp $true - + if ($AllServicePrincipals) { $ReservedApplicationNames = @('none', 'All', 'Office365', 'MicrosoftAdminPortals') if ($JSONobj.conditions.applications.excludeApplications -and $JSONobj.conditions.applications.excludeApplications -notcontains 'All') { @@ -171,22 +216,24 @@ function New-CIPPCAPolicy { if (!$locations) { continue } foreach ($location in $locations) { if (!$location.displayName) { continue } - $CheckExisting = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations' -tenantid $TenantFilter -asApp $true - if ($Location.displayName -in $CheckExisting.displayName) { - $ExistingLocation = $CheckExisting | Where-Object -Property displayName -EQ $Location.displayName + # Use cached named locations instead of fetching each time + if ($Location.displayName -in $AllNamedLocations.displayName) { + $ExistingLocation = $AllNamedLocations | Where-Object -Property displayName -EQ $Location.displayName if ($Overwrite) { $LocationUpdate = $location | Select-Object * -ExcludeProperty id Remove-ODataProperties -Object $LocationUpdate $Body = ConvertTo-Json -InputObject $LocationUpdate -Depth 10 try { - $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations/$($ExistingLocation.id)" -body $body -Type PATCH -tenantid $tenantfilter -asApp $true - Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APINAME -message "Updated existing Named Location: $($location.displayName)" -Sev 'Info' + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations/$($ExistingLocation.id)" -body $body -Type PATCH -tenantid $TenantFilter -asApp $true -ScheduleRetry $true + Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APIName -message "Updated existing Named Location: $($location.displayName)" -Sev 'Info' } catch { - Write-Warning "Failed to update location $($location.displayName): $_" - Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APINAME -message "Failed to update existing Named Location: $($location.displayName). Error: $_" -Sev 'Error' + $ErrorMessage = Get-CippException -Exception $_ + Write-Information "Error updating named location: $($ErrorMessage | ConvertTo-Json -Depth 10 -Compress)" + Write-Warning "Failed to update location $($location.displayName): $($ErrorMessage.NormalizedError)" + Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APIName -message "Failed to update existing Named Location: $($location.displayName). Error: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage } } else { - Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APINAME -message "Matched a CA policy with the existing Named Location: $($location.displayName)" -Sev 'Info' + Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APIName -message "Matched a CA policy with the existing Named Location: $($location.displayName)" -Sev 'Info' } [pscustomobject]@{ id = $ExistingLocation.id @@ -195,19 +242,38 @@ function New-CIPPCAPolicy { } } else { if ($location.countriesAndRegions) { $location.countriesAndRegions = @($location.countriesAndRegions) } - $location | Select-Object * -ExcludeProperty id - Remove-ODataProperties -Object $location - $Body = ConvertTo-Json -InputObject $Location - $GraphRequest = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations' -body $body -Type POST -tenantid $tenantfilter -asApp $true - $retryCount = 0 - do { - Write-Host "Checking for location $($GraphRequest.id) attempt $retryCount. $TenantFilter" - $LocationRequest = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations' -tenantid $tenantfilter -asApp $true | Where-Object -Property id -EQ $GraphRequest.id - Write-Host "LocationRequest: $($LocationRequest.id)" - Start-Sleep -Seconds 2 - $retryCount++ - } while ((!$LocationRequest -or !$LocationRequest.id) -and ($retryCount -lt 5)) - Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APINAME -message "Created new Named Location: $($location.displayName)" -Sev 'Info' + $LocationBody = $location | Select-Object * -ExcludeProperty id + Remove-ODataProperties -Object $LocationBody + $Body = ConvertTo-Json -InputObject $LocationBody + try { + $GraphRequest = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations' -body $body -Type POST -tenantid $TenantFilter -asApp $true + Write-Information "Created named location with ID: $($GraphRequest.id)" + # Wait for location to be available - reduced retry count and increased delay + $retryCount = 0 + $MaxRetryCount = 5 + $LocationRequest = $null + do { + Write-Information "Verifying location $($GraphRequest.id) exists, attempt $($retryCount + 1)/$MaxRetryCount" + Start-Sleep -Seconds 3 + try { + # Get specific location by ID instead of all locations + $LocationRequest = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations/$($GraphRequest.id)" -tenantid $TenantFilter -asApp $true -ErrorAction Stop + Write-Information "Location verified: $($LocationRequest.id)" + } catch { + Write-Information 'Location not yet available, will retry...' + } + $retryCount++ + } while ((!$LocationRequest -or !$LocationRequest.id) -and ($retryCount -lt $MaxRetryCount)) + + if (!$LocationRequest -or !$LocationRequest.id) { + Write-Warning "Location created but could not verify availability after $MaxRetryCount attempts. Proceeding anyway." + } + Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APIName -message "Created new Named Location: $($location.displayName)" -Sev 'Info' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-Information "Error creating named location: $($ErrorMessage | ConvertTo-Json -Depth 10 -Compress)" + throw "Failed to create named location $($location.displayName): $($ErrorMessage.NormalizedError)" + } [pscustomobject]@{ id = $GraphRequest.id name = $GraphRequest.displayName @@ -218,25 +284,27 @@ function New-CIPPCAPolicy { Write-Information 'Location Lookup Table:' Write-Information ($LocationLookupTable | ConvertTo-Json -Depth 10) - foreach ($location in $JSONobj.conditions.locations.includeLocations) { - if ($null -eq $location) { continue } - $lookup = $LocationLookupTable | Where-Object { $_.name -eq $location -or $_.displayName -eq $location -or $_.templateId -eq $location } - if (!$lookup) { continue } - Write-Information "Replacing named location - $location" - $index = [array]::IndexOf($JSONobj.conditions.locations.includeLocations, $location) - if ($lookup.id) { - $JSONobj.conditions.locations.includeLocations[$index] = $lookup.id + if ($LocationLookupTable -and $JSONobj.conditions.locations) { + foreach ($location in $JSONobj.conditions.locations.includeLocations) { + if ($null -eq $location) { continue } + $lookup = $LocationLookupTable | Where-Object { $_.name -eq $location -or $_.displayName -eq $location -or $_.templateId -eq $location } + if (!$lookup) { continue } + Write-Information "Replacing named location - $location" + $index = [array]::IndexOf($JSONobj.conditions.locations.includeLocations, $location) + if ($lookup.id) { + $JSONobj.conditions.locations.includeLocations[$index] = $lookup.id + } } - } - foreach ($location in $JSONobj.conditions.locations.excludeLocations) { - if ($null -eq $location) { continue } - $lookup = $LocationLookupTable | Where-Object { $_.name -eq $location -or $_.displayName -eq $location -or $_.templateId -eq $location } - if (!$lookup) { continue } - Write-Information "Replacing named location - $location" - $index = [array]::IndexOf($JSONobj.conditions.locations.excludeLocations, $location) - if ($lookup.id) { - $JSONobj.conditions.locations.excludeLocations[$index] = $lookup.id + foreach ($location in $JSONobj.conditions.locations.excludeLocations) { + if ($null -eq $location) { continue } + $lookup = $LocationLookupTable | Where-Object { $_.name -eq $location -or $_.displayName -eq $location -or $_.templateId -eq $location } + if (!$lookup) { continue } + Write-Information "Replacing named location - $location" + $index = [array]::IndexOf($JSONobj.conditions.locations.excludeLocations, $location) + if ($lookup.id) { + $JSONobj.conditions.locations.excludeLocations[$index] = $lookup.id + } } } switch ($ReplacePattern) { @@ -280,18 +348,19 @@ function New-CIPPCAPolicy { foreach ($userType in 'includeUsers', 'excludeUsers') { if ($JSONobj.conditions.users.PSObject.Properties.Name -contains $userType -and $JSONobj.conditions.users.$userType -notin 'All', 'None', 'GuestOrExternalUsers') { - $JSONobj.conditions.users.$userType = @(Replace-UserNameWithId -userNames $JSONobj.conditions.users.$userType) + $JSONobj.conditions.users.$userType = @(Convert-UserNameToId -userNames $JSONobj.conditions.users.$userType) } } # Check the included and excluded groups foreach ($groupType in 'includeGroups', 'excludeGroups') { if ($JSONobj.conditions.users.PSObject.Properties.Name -contains $groupType) { - $JSONobj.conditions.users.$groupType = @(Replace-GroupNameWithId -groupNames $JSONobj.conditions.users.$groupType -CreateGroups $CreateGroups -TenantFilter $TenantFilter -GroupTemplates $GroupTemplates) + $JSONobj.conditions.users.$groupType = @(Convert-GroupNameToId -groupNames $JSONobj.conditions.users.$groupType -CreateGroups $CreateGroups -TenantFilter $TenantFilter -GroupTemplates $GroupTemplates) } } } catch { $ErrorMessage = Get-CippException -Exception $_ + Write-Information "Error replacing displayNames: $($ErrorMessage | ConvertTo-Json -Depth 10 -Compress)" Write-LogMessage -API 'Standards' -tenant $TenantFilter -message "Failed to replace displayNames for conditional access rule $($JSONobj.displayName). Error: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage throw "Failed to replace displayNames for conditional access rule $($JSONobj.displayName): $($ErrorMessage.NormalizedError)" } @@ -322,11 +391,12 @@ function New-CIPPCAPolicy { #Send request to disable security defaults. $body = '{ "isEnabled": false }' try { - $null = New-GraphPostRequest -tenantid $TenantFilter -Uri 'https://graph.microsoft.com/beta/policies/identitySecurityDefaultsEnforcementPolicy' -Type patch -Body $body -asApp $true -ContentType 'application/json' - Write-LogMessage -Headers $Headers -API 'Create CA Policy' -tenant $TenantFilter -message "Disabled Security Defaults for tenant $($TenantFilter)" -Sev 'Info' + $null = New-GraphPostRequest -tenantid $TenantFilter -Uri 'https://graph.microsoft.com/beta/policies/identitySecurityDefaultsEnforcementPolicy' -Type patch -Body $body -asApp $true + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message "Disabled Security Defaults for tenant $($TenantFilter)" -Sev 'Info' Start-Sleep 3 } catch { $ErrorMessage = Get-CippException -Exception $_ + Write-Information "Error disabling security defaults: $($ErrorMessage | ConvertTo-Json -Depth 10 -Compress)" Write-Information "Failed to disable security defaults for tenant $($TenantFilter): $($ErrorMessage.NormalizedError)" } } @@ -334,10 +404,25 @@ function New-CIPPCAPolicy { Write-Information $RawJSON try { Write-Information 'Checking for existing policies' - $CheckExisting = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/policies' -tenantid $TenantFilter -asApp $true | Where-Object -Property displayName -EQ $displayname + # Use cached policies from the beginning + $CheckExisting = $AllExistingPolicies | Where-Object -Property displayName -EQ $displayName + + # Handle multiple policies with the same name (should not happen but does) + if ($CheckExisting -is [Array] -and $CheckExisting.Count -gt 1) { + Write-Warning "Found $($CheckExisting.Count) policies with display name '$displayName'. IDs: $($CheckExisting.id -join ', '). Using the first one." + $CheckExisting = $CheckExisting[0] + } + if ($CheckExisting) { + Write-Information "Found existing policy: displayName=$($CheckExisting.displayName), id=$($CheckExisting.id)" + + # Validate the ID before proceeding + if ([string]::IsNullOrWhiteSpace($CheckExisting.id)) { + Write-Information "ERROR: Policy found but ID is null/empty. Full object: $($CheckExisting | ConvertTo-Json -Depth 5 -Compress)" + throw "Found existing policy '$displayName' but ID is null or empty. This may indicate an API issue." + } if ($Overwrite -ne $true) { - throw "Conditional Access Policy with Display Name $($Displayname) Already exists" + throw "Conditional Access Policy with Display Name $($displayName) Already exists" return $false } else { if ($State -eq 'donotchange') { @@ -347,7 +432,7 @@ function New-CIPPCAPolicy { # Preserve any exclusion groups named "Vacation Exclusion - " from existing policy try { $ExistingVacationGroup = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/groups?`$filter=startsWith(displayName,'Vacation Exclusion')&`$select=id,displayName&`$top=999&`$count=true" -ComplexFilter -tenantid $TenantFilter -asApp $true | - Where-Object { $CheckExisting.conditions.users.excludeGroups -contains $_.id } + Where-Object { $CheckExisting.conditions.users.excludeGroups -contains $_.id } if ($ExistingVacationGroup) { if (-not ($JSONobj.conditions.users.PSObject.Properties.Name -contains 'excludeGroups')) { $JSONobj.conditions.users | Add-Member -NotePropertyName 'excludeGroups' -NotePropertyValue @() -Force @@ -366,29 +451,35 @@ function New-CIPPCAPolicy { $RawJSON = ConvertTo-Json -InputObject $JSONobj -Depth 10 -Compress } } catch { - Write-Information "Failed to preserve vacation exclusion group: $($_.Exception.Message)" + $ErrorMessage = Get-CippException -Exception $_ + Write-Information "Error preserving vacation exclusion group: $($ErrorMessage | ConvertTo-Json -Depth 10 -Compress)" + Write-Information "Failed to preserve vacation exclusion group: $($ErrorMessage.NormalizedError)" } Write-Information "overwriting $($CheckExisting.id)" - $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/identity/conditionalAccess/policies/$($CheckExisting.id)" -tenantid $tenantfilter -type PATCH -body $RawJSON -asApp $true - Write-LogMessage -Headers $Headers -API 'Create CA Policy' -tenant $($Tenant) -message "Updated Conditional Access Policy $($JSONobj.Displayname) to the template standard." -Sev 'Info' - return "Updated policy $displayname for $tenantfilter" + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/identity/conditionalAccess/policies/$($CheckExisting.id)" -tenantid $TenantFilter -type PATCH -body $RawJSON -asApp $true -ScheduleRetry $true + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message "Updated Conditional Access Policy $($JSONobj.displayName) to the template standard." -Sev 'Info' + return "Updated policy $($JSONobj.displayName) for $TenantFilter" } } else { Write-Information 'Creating new policy' if ($JSOObj.GrantControls.authenticationStrength.policyType -or $JSONobj.$JSONobj.LocationInfo) { Start-Sleep 3 } - $null = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/policies' -tenantid $tenantfilter -type POST -body $RawJSON -asApp $true - Write-LogMessage -Headers $Headers -API 'Create CA Policy' -tenant $($Tenant) -message "Added Conditional Access Policy $($JSONobj.Displayname)" -Sev 'Info' - return "Created policy $displayname for $tenantfilter" + $null = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/policies' -tenantid $TenantFilter -type POST -body $RawJSON -asApp $true -ScheduleRetry $true + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message "Added Conditional Access Policy $($JSONobj.displayName)" -Sev 'Info' + return "Created policy $($JSONobj.displayName) for $TenantFilter" } } catch { $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -API 'Standards' -tenant $TenantFilter -message "Failed to create or update conditional access rule $($JSONobj.displayName): $($ErrorMessage.NormalizedError) " -sev 'Error' -LogData $ErrorMessage + $Result = "Failed to create or update conditional access rule $($JSONobj.displayName): $($ErrorMessage.NormalizedError)" + + # Full error details for debugging + Write-Information "Full error details: $($ErrorMessage | ConvertTo-Json -Depth 10 -Compress)" + Write-Information "Position: $($_.InvocationInfo.PositionMessage)" + Write-Information "Policy JSON: $($JSONobj | ConvertTo-Json -Depth 10 -Compress)" - Write-Warning "Failed to create or update conditional access rule $($JSONobj.displayName): $($ErrorMessage.NormalizedError)" - Write-Information $_.InvocationInfo.PositionMessage - Write-Information ($JSONobj | ConvertTo-Json -Depth 10) - throw "Failed to create or update conditional access rule $($JSONobj.displayName): $($ErrorMessage.NormalizedError)" + Write-LogMessage -API $APIName -tenant $TenantFilter -message $Result -sev 'Error' -LogData $ErrorMessage + Write-Warning $Result + throw $Result } } diff --git a/Modules/CIPPCore/Public/New-CIPPCATemplate.ps1 b/Modules/CIPPCore/Public/New-CIPPCATemplate.ps1 index 060b91863208..6103d5ad945c 100644 --- a/Modules/CIPPCore/Public/New-CIPPCATemplate.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPCATemplate.ps1 @@ -6,7 +6,8 @@ function New-CIPPCATemplate { $APIName = 'Add CIPP CA Template', $Headers, $preloadedUsers, - $preloadedGroups + $preloadedGroups, + $preloadedLocations ) $JSON = ([pscustomobject]$JSON) | ForEach-Object { @@ -17,11 +18,6 @@ function New-CIPPCATemplate { Write-Information "Processing CA Template for tenant $TenantFilter" Write-Information ($JSON | ConvertTo-Json -Depth 10) - # Function to check if a string is a GUID - function Test-IsGuid($string) { - return [guid]::tryparse($string, [ref][guid]::Empty) - } - if ($preloadedUsers) { $users = $preloadedUsers } else { @@ -34,8 +30,12 @@ function New-CIPPCATemplate { } $namedLocations = $null - if ($JSON.conditions.locations.includeLocations -or $JSON.conditions.locations.excludeLocations) { - $namedLocations = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations' -tenantid $TenantFilter + if ($preloadedLocations) { + $namedLocations = $preloadedLocations + } else { + if ($JSON.conditions.locations.includeLocations -or $JSON.conditions.locations.excludeLocations) { + $namedLocations = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations?$top=999' -tenantid $TenantFilter + } } $AllLocations = [system.collections.generic.list[object]]::new() @@ -46,7 +46,9 @@ function New-CIPPCATemplate { $null = if ($locationinfo) { $includelocations.add($locationinfo.displayName) } else { $includelocations.add($location) } $locationinfo } - if ($includelocations) { $JSON.conditions.locations.includeLocations = $includelocations } + if ($includelocations) { + $JSON.conditions.locations | Add-Member -NotePropertyName 'includeLocations' -NotePropertyValue $includelocations -Force + } $excludelocations = [system.collections.generic.list[object]]::new() $ExcludeJSON = foreach ($Location in $JSON.conditions.locations.excludeLocations) { @@ -55,7 +57,9 @@ function New-CIPPCATemplate { $locationinfo } - if ($excludelocations) { $JSON.conditions.locations.excludeLocations = $excludelocations } + if ($excludelocations) { + $JSON.conditions.locations | Add-Member -NotePropertyName 'excludeLocations' -NotePropertyValue $excludelocations -Force + } # Check if conditions.users exists and is a PSCustomObject (not an array) before accessing properties $hasConditionsUsers = $null -ne $JSON.conditions.users # Explicitly exclude array types - arrays have properties but we can't set custom properties on them @@ -85,7 +89,7 @@ function New-CIPPCATemplate { if ($isPSCustomObject -and $null -ne $JSON.conditions.users.includeGroups) { $JSON.conditions.users.includeGroups = @($JSON.conditions.users.includeGroups | ForEach-Object { $originalID = $_ - if ($_ -in 'All', 'None', 'GuestOrExternalUsers' -or -not (Test-IsGuid $_)) { return $_ } + if ($_ -in 'All', 'None', 'GuestOrExternalUsers' -or -not (Test-IsGuid -String $_)) { return $_ } $match = $groups | Where-Object { $_.id -eq $originalID } if ($match) { $match.displayName } else { $originalID } }) @@ -93,7 +97,7 @@ function New-CIPPCATemplate { if ($isPSCustomObject -and $null -ne $JSON.conditions.users.excludeGroups) { $JSON.conditions.users.excludeGroups = @($JSON.conditions.users.excludeGroups | ForEach-Object { $originalID = $_ - if ($_ -in 'All', 'None', 'GuestOrExternalUsers' -or -not (Test-IsGuid $_)) { return $_ } + if ($_ -in 'All', 'None', 'GuestOrExternalUsers' -or -not (Test-IsGuid -String $_)) { return $_ } $match = $groups | Where-Object { $_.id -eq $originalID } if ($match) { $match.displayName } else { $originalID } }) diff --git a/Modules/CIPPCore/Public/New-CIPPRestoreTask.ps1 b/Modules/CIPPCore/Public/New-CIPPRestoreTask.ps1 index aec6db68de03..d750ce6cdea9 100644 --- a/Modules/CIPPCore/Public/New-CIPPRestoreTask.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPRestoreTask.ps1 @@ -200,8 +200,10 @@ function New-CIPPRestoreTask { $backupGroups = if ($BackupData.groups -is [string]) { $BackupData.groups | ConvertFrom-Json } else { $BackupData.groups } $Groups = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$top=999' -tenantid $TenantFilter $BackupGroups | ForEach-Object { + try { - $JSON = $_ | ConvertTo-Json -Depth 100 -Compress + $CleanObj = Clean-GraphObject $_ + $JSON = $CleanObj | ConvertTo-Json -Depth 100 -Compress $DisplayName = $_.displayName if ($overwrite) { if ($_.id -in $Groups.id) { diff --git a/Modules/CIPPCore/Public/New-CIPPTemplateRun.ps1 b/Modules/CIPPCore/Public/New-CIPPTemplateRun.ps1 index 76ff020eb9ef..530a45c29fe5 100644 --- a/Modules/CIPPCore/Public/New-CIPPTemplateRun.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPTemplateRun.ps1 @@ -64,8 +64,11 @@ function New-CIPPTemplateRun { if (!$ExistingTemplate -or $UpdateNeeded) { $Template = (Get-GitHubFileContents -FullName $TemplateSettings.templateRepo.value -Branch $TemplateSettings.templateRepoBranch.value -Path $File.path).content | ConvertFrom-Json - Import-CommunityTemplate -Template $Template -SHA $File.sha -MigrationTable $MigrationTable -LocationData $LocationData -Source $TemplateSettings.templateRepo.value - if ($UpdateNeeded) { + $ImportResult = Import-CommunityTemplate -Template $Template -SHA $File.sha -MigrationTable $MigrationTable -LocationData $LocationData -Source $TemplateSettings.templateRepo.value + if ($ImportResult) { + Write-Information $ImportResult + $ImportResult + } elseif ($UpdateNeeded) { Write-Information "Template $($File.name) needs to be updated as the SHA is different" "Template $($File.name) updated" } else { diff --git a/Modules/CIPPCore/Public/Remove-CIPPCalendarPermissions.ps1 b/Modules/CIPPCore/Public/Remove-CIPPCalendarPermissions.ps1 new file mode 100644 index 000000000000..402eca0145b9 --- /dev/null +++ b/Modules/CIPPCore/Public/Remove-CIPPCalendarPermissions.ps1 @@ -0,0 +1,177 @@ +function Remove-CIPPCalendarPermissions { + <# + .SYNOPSIS + Remove calendar permissions for a specific user + + .DESCRIPTION + Removes calendar folder permissions for a user from specified calendars or all calendars they have access to + + .PARAMETER UserToRemove + The user whose calendar access should be removed + + .PARAMETER CalendarIdentity + Optional. Specific calendar identity (e.g., "mailbox@domain.com:\Calendar"). If not provided, will query from cache. + + .PARAMETER FolderName + Optional. Folder name (defaults to "Calendar"). Used with CalendarIdentity or when querying from cache. + + .PARAMETER TenantFilter + The tenant to operate on + + .PARAMETER UseCache + If specified, will query cached calendar permissions to find all calendars the user has access to + + .PARAMETER APIName + API name for logging (defaults to 'Remove Calendar Permissions') + + .PARAMETER Headers + Headers for logging + + .EXAMPLE + Remove-CIPPCalendarPermissions -UserToRemove 'user@domain.com' -CalendarIdentity 'mailbox@domain.com:\Calendar' -TenantFilter 'contoso.com' + + .EXAMPLE + Remove-CIPPCalendarPermissions -UserToRemove 'user@domain.com' -TenantFilter 'contoso.com' -UseCache + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string]$UserToRemove, + + [Parameter(Mandatory = $false)] + [string]$CalendarIdentity, + + [Parameter(Mandatory = $false)] + [string]$FolderName = 'Calendar', + + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $false)] + [switch]$UseCache, + + [Parameter(Mandatory = $false)] + [string]$APIName = 'Remove Calendar Permissions', + + [Parameter(Mandatory = $false)] + $Headers + ) + + try { + $Results = [System.Collections.Generic.List[string]]::new() + + if ($UseCache) { + # Get all calendars this user has access to from cache + try { + # Resolve user to display name if a UPN was provided + # Calendar permissions use display names, not UPNs + $UserToMatch = $UserToRemove + if ($UserToRemove -match '@') { + # Try to get display name from mailbox cache + $MailboxItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'Mailboxes' | Where-Object { $_.RowKey -ne 'Mailboxes-Count' } + foreach ($Item in $MailboxItems) { + $Mailbox = $Item.Data | ConvertFrom-Json + if ($Mailbox.UPN -eq $UserToRemove -or $Mailbox.primarySmtpAddress -eq $UserToRemove) { + $UserToMatch = $Mailbox.displayName + Write-Information "Resolved $UserToRemove to display name: $UserToMatch" -InformationAction Continue + break + } + } + } + + $CalendarPermissions = Get-CIPPCalendarPermissionReport -TenantFilter $TenantFilter -ByUser | Where-Object { $_.User -eq $UserToMatch } + + if (-not $CalendarPermissions -or $CalendarPermissions.Permissions.Count -eq 0) { + $Message = "No calendar permissions found for $UserToRemove in cached data" + Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev 'Info' -tenant $TenantFilter + return $Message + } + + # Remove from each calendar + foreach ($CalPermEntry in $CalendarPermissions.Permissions) { + try { + $Folder = if ($CalPermEntry.FolderName) { $CalPermEntry.FolderName } else { 'Calendar' } + $CalIdentity = "$($CalPermEntry.CalendarUPN):\$Folder" + + $RemovalResult = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-MailboxFolderPermission' -cmdParams @{ + Identity = $CalIdentity + User = $UserToMatch + } -UseSystemMailbox $true + + # Sync cache regardless of whether permission existed in Exchange + # Cache sync uses flexible matching so it will find and remove the entry + Sync-CIPPCalendarPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $CalPermEntry.CalendarUPN -FolderName $Folder -User $UserToMatch -Action 'Remove' + + $SuccessMsg = "Removed $UserToRemove from calendar $CalIdentity" + Write-LogMessage -headers $Headers -API $APIName -message $SuccessMsg -Sev 'Info' -tenant $TenantFilter + $Results.Add($SuccessMsg) + } catch { + # Sync cache even on error (permission might not exist) + try { + Sync-CIPPCalendarPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $CalPermEntry.CalendarUPN -FolderName $Folder -User $UserToMatch -Action 'Remove' + } catch { + Write-Verbose "Failed to sync cache: $_" + } + + $ErrorMsg = "Failed to remove $UserToRemove from calendar $($CalPermEntry.CalendarUPN): $($_.Exception.Message)" + Write-LogMessage -headers $Headers -API $APIName -message $ErrorMsg -Sev 'Warning' -tenant $TenantFilter + $Results.Add($ErrorMsg) + } + } + + $SummaryMsg = "Processed $($CalendarPermissions.CalendarCount) calendar(s) - removed $($Results.Count) permission(s)" + Write-LogMessage -headers $Headers -API $APIName -message $SummaryMsg -Sev 'Info' -tenant $TenantFilter + return $Results + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -message "Failed to query calendar permissions from cache: $($ErrorMessage.NormalizedError)" -Sev 'Error' -tenant $TenantFilter -LogData $ErrorMessage + throw "Failed to query calendar permissions from cache: $($ErrorMessage.NormalizedError)" + } + } else { + # Remove from specific calendar + if ([string]::IsNullOrEmpty($CalendarIdentity)) { + throw 'CalendarIdentity is required when not using cache' + } + + try { + $RemovalResult = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-MailboxFolderPermission' -cmdParams @{ + Identity = $CalendarIdentity + User = $UserToRemove + } -UseSystemMailbox $true + + # Sync cache - extract mailbox UPN from identity + $MailboxUPN = if ($CalendarIdentity -match '^([^:]+):') { $Matches[1] } else { $CalendarIdentity } + $Folder = if ($CalendarIdentity -match ':\\(.+)$') { $Matches[1] } else { $FolderName } + + # Sync cache regardless of whether permission existed in Exchange + # Cache sync uses flexible matching so it will find and remove the entry + Sync-CIPPCalendarPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $MailboxUPN -FolderName $Folder -User $UserToRemove -Action 'Remove' + + $SuccessMsg = "Removed $UserToRemove from calendar $CalendarIdentity" + Write-LogMessage -headers $Headers -API $APIName -message $SuccessMsg -Sev 'Info' -tenant $TenantFilter + return $SuccessMsg + + } catch { + # Sync cache even on error (permission might not exist) + $MailboxUPN = if ($CalendarIdentity -match '^([^:]+):') { $Matches[1] } else { $CalendarIdentity } + $Folder = if ($CalendarIdentity -match ':\\(.+)$') { $Matches[1] } else { $FolderName } + + try { + Sync-CIPPCalendarPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $MailboxUPN -FolderName $Folder -User $UserToRemove -Action 'Remove' + } catch { + Write-Verbose "Failed to sync cache: $_" + } + + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -message "Failed to remove calendar permission: $($ErrorMessage.NormalizedError)" -Sev 'Error' -tenant $TenantFilter -LogData $ErrorMessage + throw "Failed to remove calendar permission: $($ErrorMessage.NormalizedError)" + } + } + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -message "Could not remove calendar permissions for $UserToRemove. Error: $($ErrorMessage.NormalizedError)" -Sev 'Error' -tenant $TenantFilter -LogData $ErrorMessage + return "Could not remove calendar permissions for $UserToRemove. Error: $($ErrorMessage.NormalizedError)" + } +} diff --git a/Modules/CIPPCore/Public/Remove-CIPPLicense.ps1 b/Modules/CIPPCore/Public/Remove-CIPPLicense.ps1 index 3b18b54b0678..be55ac1b73c1 100644 --- a/Modules/CIPPCore/Public/Remove-CIPPLicense.ps1 +++ b/Modules/CIPPCore/Public/Remove-CIPPLicense.ps1 @@ -29,7 +29,7 @@ function Remove-CIPPLicense { PSA = $false } } - Add-CIPPScheduledTask -Task $ScheduledTask -hidden $false + Add-CIPPScheduledTask -Task $ScheduledTask -hidden $false -DisallowDuplicateName $true return "Scheduled license removal for $username" } else { try { diff --git a/Modules/CIPPCore/Public/Remove-CIPPMailboxPermissions.ps1 b/Modules/CIPPCore/Public/Remove-CIPPMailboxPermissions.ps1 index dc934b29088d..0b8927896be9 100644 --- a/Modules/CIPPCore/Public/Remove-CIPPMailboxPermissions.ps1 +++ b/Modules/CIPPCore/Public/Remove-CIPPMailboxPermissions.ps1 @@ -5,12 +5,54 @@ function Remove-CIPPMailboxPermissions { $AccessUser, $TenantFilter, $PermissionsLevel, + + [Parameter(Mandatory = $false)] + [switch]$UseCache, + $APIName = 'Manage Shared Mailbox Access', $Headers ) try { - if ($userid -eq 'AllUsers') { + if ($UseCache.IsPresent) { + # Use cached permission report to find all mailboxes the user has access to + + Write-Information "Accessing cached mailbox permissions for $AccessUser in tenant $TenantFilter" -InformationAction Continue + Write-LogMessage -headers $Headers -API $APIName -message "Removing mailbox permissions for $AccessUser using cached permission report" -Sev 'Info' -tenant $TenantFilter + + $UserPermissions = Get-CIPPMailboxPermissionReport -TenantFilter $TenantFilter -ByUser | Where-Object { $_.User -eq $AccessUser } + + if (-not $UserPermissions -or $UserPermissions.Permissions.Count -eq 0) { + $Message = "No mailbox permissions found for $AccessUser in cached data" + Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev 'Info' -tenant $TenantFilter + return $Message + } + + $Results = [System.Collections.Generic.List[string]]::new() + + # Loop through each mailbox and remove permissions + foreach ($PermissionEntry in $UserPermissions.Permissions) { + $MailboxUPN = $PermissionEntry.MailboxUPN + $AccessRights = $PermissionEntry.AccessRights -split ', ' + + try { + # Recursively call this function without UseCache + $Result = Remove-CIPPMailboxPermissions -userid $MailboxUPN -AccessUser $AccessUser -TenantFilter $TenantFilter -PermissionsLevel $AccessRights -APIName $APIName -Headers $Headers + if ($Result) { + $Results.Add($Result) + } + } catch { + $ErrorMsg = "Failed to remove permissions from $MailboxUPN for $AccessUser : $($_.Exception.Message)" + Write-LogMessage -headers $Headers -API $APIName -message $ErrorMsg -Sev 'Warning' -tenant $TenantFilter + $Results.Add($ErrorMsg) + } + } + + $SummaryMsg = "Processed $($UserPermissions.MailboxCount) mailbox(es) - removed $($Results.Count) permission(s)" + Write-LogMessage -headers $Headers -API $APIName -message $SummaryMsg -Sev 'Info' -tenant $TenantFilter + return $Results + + } elseif ($userid -eq 'AllUsers') { $Mailboxes = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-Mailbox' -Select UserPrincipalName $Mailboxes | ForEach-Object -Parallel { Import-Module '.\Modules\AzBobbyTables' @@ -20,19 +62,28 @@ function Remove-CIPPMailboxPermissions { } -ThrottleLimit 10 } else { $Results = $PermissionsLevel | ForEach-Object { + Write-Information "Removing $($_) permissions for $AccessUser on mailbox $userid" -InformationAction Continue switch ($_) { 'SendOnBehalf' { $MailboxPerms = New-ExoRequest -Anchor $UserId -tenantid $Tenantfilter -cmdlet 'Set-Mailbox' -cmdParams @{Identity = $userid; GrantSendonBehalfTo = @{'@odata.type' = '#Exchange.GenericHashTable'; remove = $AccessUser }; } if ($MailboxPerms -notlike '*completed successfully but no settings of*') { Write-LogMessage -headers $Headers -API $APIName -message "Removed SendOnBehalf permissions for $($AccessUser) from $($userid)'s mailbox." -Sev 'Info' -tenant $TenantFilter + # Note: SendOnBehalf not cached as separate permission "Removed SendOnBehalf permissions for $($AccessUser) from $($userid)'s mailbox." } } 'SendAS' { $MailboxPerms = New-ExoRequest -Anchor $userId -tenantid $Tenantfilter -cmdlet 'Remove-RecipientPermission' -cmdParams @{Identity = $userid; Trustee = $AccessUser; accessRights = @('SendAs') } + + # Sync cache regardless of whether permission existed + Sync-CIPPMailboxPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $userid -User $AccessUser -PermissionType 'SendAs' -Action 'Remove' + if ($MailboxPerms -notlike "*because the ACE isn't present*") { Write-LogMessage -headers $Headers -API $APIName -message "Removed SendAs permissions for $($AccessUser) from $($userid)'s mailbox." -Sev 'Info' -tenant $TenantFilter "Removed SendAs permissions for $($AccessUser) from $($userid)'s mailbox." + } else { + Write-LogMessage -headers $Headers -API $APIName -message "SendAs permissions for $($AccessUser) on $($userid)'s mailbox were already removed or don't exist." -Sev 'Info' -tenant $TenantFilter + "SendAs permissions for $($AccessUser) on $($userid)'s mailbox were already removed or don't exist." } } 'FullAccess' { @@ -49,9 +100,15 @@ function Remove-CIPPMailboxPermissions { } $permissions = New-ExoRequest @ExoRequest + # Sync cache regardless of whether permission existed + Sync-CIPPMailboxPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $userid -User $AccessUser -PermissionType 'FullAccess' -Action 'Remove' + if ($permissions -notlike "*because the ACE doesn't exist on the object.*") { Write-LogMessage -headers $Headers -API $APIName -message "Removed FullAccess permissions for $($AccessUser) from $($userid)'s mailbox." -Sev 'Info' -tenant $TenantFilter "Removed FullAccess permissions for $($AccessUser) from $($userid)'s mailbox." + } else { + Write-LogMessage -headers $Headers -API $APIName -message "FullAccess permissions for $($AccessUser) on $($userid)'s mailbox were already removed or don't exist." -Sev 'Info' -tenant $TenantFilter + "FullAccess permissions for $($AccessUser) on $($userid)'s mailbox were already removed or don't exist." } } } diff --git a/Modules/CIPPCore/Public/Remove-CIPPReusableSettingMetadata.ps1 b/Modules/CIPPCore/Public/Remove-CIPPReusableSettingMetadata.ps1 index 3a6fcba20866..07ba8b6da4ad 100644 --- a/Modules/CIPPCore/Public/Remove-CIPPReusableSettingMetadata.ps1 +++ b/Modules/CIPPCore/Public/Remove-CIPPReusableSettingMetadata.ps1 @@ -1,23 +1,113 @@ function Remove-CIPPReusableSettingMetadata { param($InputObject) - if ($null -eq $InputObject) { return $null } + $metadataFields = @( + 'id', + 'createdDateTime', + 'lastModifiedDateTime', + 'version', + '@odata.context', + '@odata.etag', + 'referencingConfigurationPolicyCount', + 'settingInstanceTemplateReference', + 'settingValueTemplateReference', + 'auditRuleInformation' + ) - if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) { - $cleanArray = [System.Collections.Generic.List[object]]::new() - foreach ($item in $InputObject) { $cleanArray.Add((Remove-CIPPReusableSettingMetadata -InputObject $item)) } - return $cleanArray - } + function Normalize-Object { + param($Value) + + if ($null -eq $Value) { return $null } - if ($InputObject -is [psobject]) { - $output = [ordered]@{} - foreach ($prop in $InputObject.PSObject.Properties) { - if ($null -eq $prop.Value) { continue } - if ($prop.Name -in @('id','createdDateTime','lastModifiedDateTime','version','@odata.context','@odata.etag','referencingConfigurationPolicyCount','settingInstanceTemplateReference','settingValueTemplateReference','auditRuleInformation')) { continue } - $output[$prop.Name] = Remove-CIPPReusableSettingMetadata -InputObject $prop.Value + function Test-IsCollection { + param($Candidate) + return ( + $Candidate -is [System.Collections.IEnumerable] -and + $Candidate -isnot [string] -and + ( + $Candidate -is [System.Array] -or + $Candidate -is [System.Collections.IList] -or + $Candidate -is [System.Collections.ICollection] + ) + ) } - return [pscustomobject]$output + + function Normalize-Entries { + param($Entries) + + $output = [ordered]@{} + foreach ($entry in $Entries) { + $name = $entry.Name + $item = $entry.Value + + if ($name -ieq 'children') { + if ($null -eq $item) { + $output[$name] = @() + } elseif (Test-IsCollection -Candidate $item) { + $output[$name] = Normalize-Object -Value $item + } else { + $output[$name] = @(Normalize-Object -Value $item) + } + continue + } + + if ($name -ieq 'groupSettingCollectionValue') { + if ($null -eq $item) { + $output[$name] = @() + continue + } + + if (Test-IsCollection -Candidate $item) { + $output[$name] = Normalize-Object -Value $item + } else { + $output[$name] = @(Normalize-Object -Value $item) + } + continue + } + + if ($null -eq $item) { continue } + if ($name -in $metadataFields) { continue } + $output[$name] = Normalize-Object -Value $item + } + + if ($output.Contains('children') -and -not (Test-IsCollection -Candidate $output['children'])) { + $output['children'] = @($output['children']) + } + + if ( + $output.Contains('groupSettingCollectionValue') -and + -not (Test-IsCollection -Candidate $output['groupSettingCollectionValue']) + ) { + $output['groupSettingCollectionValue'] = @($output['groupSettingCollectionValue']) + } + + return [pscustomobject]$output + } + + if ($Value -is [System.Collections.IDictionary]) { + $entries = foreach ($key in $Value.Keys) { + [pscustomobject]@{ Name = $key; Value = $Value[$key] } + } + return Normalize-Entries -Entries $entries + } + + if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { + $cleanArray = [System.Collections.Generic.List[object]]::new() + foreach ($entry in $Value) { + $cleanArray.Add((Normalize-Object -Value $entry)) + } + return $cleanArray + } + + if ($Value -is [psobject]) { + $entries = foreach ($prop in $Value.PSObject.Properties) { + [pscustomobject]@{ Name = $prop.Name; Value = $prop.Value } + } + return Normalize-Entries -Entries $entries + } + + return $Value } - return $InputObject + return Normalize-Object -Value $InputObject } diff --git a/Modules/CIPPCore/Public/Search-CIPPDbData.ps1 b/Modules/CIPPCore/Public/Search-CIPPDbData.ps1 index a782821fa989..1f60a59a4f2c 100644 --- a/Modules/CIPPCore/Public/Search-CIPPDbData.ps1 +++ b/Modules/CIPPCore/Public/Search-CIPPDbData.ps1 @@ -30,12 +30,22 @@ function Search-CIPPDbData { .PARAMETER Limit Maximum total number of results to return across all types. Default is unlimited (0) + .PARAMETER Properties + Array of property names to return for the searched types. If not specified, all properties are returned. + Applies to all types in the Types parameter. Only properties that exist in the data will be included. + + .PARAMETER UserProperties + [DEPRECATED] Use Properties parameter instead. Array of property names to return for Users type. + .EXAMPLE Search-CIPPDbData -TenantFilter 'contoso.onmicrosoft.com' -SearchTerms 'john.doe' -Types 'Users', 'Groups' .EXAMPLE Search-CIPPDbData -SearchTerms 'admin' -Types 'Users' + .EXAMPLE + Search-CIPPDbData -SearchTerms 'admin' -Types 'Users' -UserProperties 'id', 'displayName', 'userPrincipalName', 'mail' + .EXAMPLE Search-CIPPDbData -SearchTerms 'SecurityDefaults', 'ConditionalAccess' -Types 'ConditionalAccessPolicies', 'Organization' @@ -69,7 +79,13 @@ function Search-CIPPDbData { [int]$MaxResultsPerType = 0, [Parameter(Mandatory = $false)] - [int]$Limit = 0 + [int]$Limit = 0, + + [Parameter(Mandatory = $false)] + [string[]]$Properties, + + [Parameter(Mandatory = $false)] + [string[]]$UserProperties ) try { @@ -143,26 +159,91 @@ function Search-CIPPDbData { if ($IsMatch) { try { $Data = $Item.Data | ConvertFrom-Json - $ResultItem = [PSCustomObject]@{ - Tenant = $Item.PartitionKey - Type = $Type - RowKey = $Item.RowKey - Data = $Data - Timestamp = $Item.Timestamp + + # Determine which properties to use (Properties parameter takes precedence, fallback to UserProperties for backward compatibility) + $PropertiesToUse = if ($Properties -and $Properties.Count -gt 0) { + $Properties + } elseif ($Type -eq 'Users' -and $UserProperties -and $UserProperties.Count -gt 0) { + $UserProperties + } else { + $null } - $Results.Add($ResultItem) - $TypeResultCount++ - # Check total limit first - if ($Limit -gt 0 -and $Results.Count -ge $Limit) { - Write-Verbose "Reached total limit of $Limit results" - break typeLoop + # If properties are specified, verify match is in target properties + $IsVerifiedMatch = $true + if ($PropertiesToUse -and $PropertiesToUse.Count -gt 0) { + $IsVerifiedMatch = $false + + if ($MatchAll) { + # All search terms must match in target properties + $IsVerifiedMatch = $true + foreach ($SearchTerm in $SearchTerms) { + $SearchPattern = [regex]::Escape($SearchTerm) + $TermMatches = $false + foreach ($Property in $PropertiesToUse) { + if ($Data.PSObject.Properties.Name -contains $Property -and + $null -ne $Data.$Property -and + $Data.$Property.ToString() -match $SearchPattern) { + $TermMatches = $true + break + } + } + if (-not $TermMatches) { + $IsVerifiedMatch = $false + break + } + } + } else { + # Any search term can match in target properties + foreach ($SearchTerm in $SearchTerms) { + $SearchPattern = [regex]::Escape($SearchTerm) + foreach ($Property in $PropertiesToUse) { + if ($Data.PSObject.Properties.Name -contains $Property -and + $null -ne $Data.$Property -and + $Data.$Property.ToString() -match $SearchPattern) { + $IsVerifiedMatch = $true + break + } + } + if ($IsVerifiedMatch) { break } + } + } } - # Check max results per type - if ($MaxResultsPerType -gt 0 -and $TypeResultCount -ge $MaxResultsPerType) { - Write-Verbose "Reached max results per type ($MaxResultsPerType) for type '$Type'" - continue typeLoop + # Only add to results if verified (or no property filtering) + if ($IsVerifiedMatch) { + # Filter properties if specified + if ($PropertiesToUse -and $PropertiesToUse.Count -gt 0) { + $FilteredData = [PSCustomObject]@{} + foreach ($Property in $PropertiesToUse) { + if ($Data.PSObject.Properties.Name -contains $Property) { + $FilteredData | Add-Member -MemberType NoteProperty -Name $Property -Value $Data.$Property -Force + } + } + $Data = $FilteredData + } + + $ResultItem = [PSCustomObject]@{ + Tenant = $Item.PartitionKey + Type = $Type + RowKey = $Item.RowKey + Data = $Data + Timestamp = $Item.Timestamp + } + $Results.Add($ResultItem) + $TypeResultCount++ + + # Check total limit first (only for verified matches) + if ($Limit -gt 0 -and $Results.Count -ge $Limit) { + Write-Verbose "Reached total limit of $Limit results" + break typeLoop + } + + # Check max results per type (only for verified matches) + if ($MaxResultsPerType -gt 0 -and $TypeResultCount -ge $MaxResultsPerType) { + Write-Verbose "Reached max results per type ($MaxResultsPerType) for type '$Type'" + continue typeLoop + } } } catch { Write-Verbose "Failed to parse JSON for $($Item.RowKey): $($_.Exception.Message)" diff --git a/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 b/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 index 3451827a67d3..c26f0f2254e8 100644 --- a/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 +++ b/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 @@ -112,35 +112,41 @@ function Send-CIPPAlert { $Headers = $null } - $JSONBody = Get-CIPPTextReplacement -TenantFilter $TenantFilter -Text $JSONContent -EscapeForJson + $ReplacedContent = Get-CIPPTextReplacement -TenantFilter $TenantFilter -Text $JSONContent -EscapeForJson try { if (![string]::IsNullOrWhiteSpace($Config.webhook) -or ![string]::IsNullOrWhiteSpace($AltWebhook)) { if ($PSCmdlet.ShouldProcess($Config.webhook, 'Sending webhook')) { $webhook = if ($AltWebhook) { $AltWebhook } else { $Config.webhook } switch -wildcard ($webhook) { '*webhook.office.com*' { - $JSONBody = "{`"text`": `"You've setup your alert policies to be alerted whenever specific events happen. We've found some of these events in the log.

$JSONContent`"}" - Invoke-RestMethod -Uri $webhook -Method POST -ContentType 'Application/json' -Body $JSONBody + $TeamsBody = [PSCustomObject]@{ + text = "You've setup your alert policies to be alerted whenever specific events happen. We've found some of these events in the log.

$ReplacedContent" + } | ConvertTo-Json -Compress + Invoke-RestMethod -Uri $webhook -Method POST -ContentType 'Application/json' -Body $TeamsBody } '*discord.com*' { - $JSONBody = "{`"content`": `"You've setup your alert policies to be alerted whenever specific events happen. We've found some of these events in the log. $JSONContent`"}" - Invoke-RestMethod -Uri $webhook -Method POST -ContentType 'Application/json' -Body $JSONBody + $DiscordBody = [PSCustomObject]@{ + content = "You've setup your alert policies to be alerted whenever specific events happen. We've found some of these events in the log. ``````$ReplacedContent``````" + } | ConvertTo-Json -Compress + Invoke-RestMethod -Uri $webhook -Method POST -ContentType 'Application/json' -Body $DiscordBody } '*slack.com*' { $SlackBlocks = Get-SlackAlertBlocks -JSONBody $JSONContent if ($SlackBlocks.blocks) { - $JSONBody = $SlackBlocks | ConvertTo-Json -Depth 10 -Compress + $SlackBody = $SlackBlocks | ConvertTo-Json -Depth 10 -Compress } else { - $JSONBody = "{`"text`": `"You've setup your alert policies to be alerted whenever specific events happen. We've found some of these events in the log. $JSONContent`"}" + $SlackBody = [PSCustomObject]@{ + text = "You've setup your alert policies to be alerted whenever specific events happen. We've found some of these events in the log. ``````$ReplacedContent``````" + } | ConvertTo-Json -Compress } - Invoke-RestMethod -Uri $webhook -Method POST -ContentType 'Application/json' -Body $JSONBody + Invoke-RestMethod -Uri $webhook -Method POST -ContentType 'Application/json' -Body $SlackBody } default { $RestMethod = @{ Uri = $webhook Method = 'POST' ContentType = 'application/json' - Body = $JSONContent + Body = $ReplacedContent } if ($Headers) { $RestMethod['Headers'] = $Headers diff --git a/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 b/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 new file mode 100644 index 000000000000..1f8b163fe714 --- /dev/null +++ b/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 @@ -0,0 +1,100 @@ +function Send-CIPPScheduledTaskAlert { + <# + .SYNOPSIS + Send post-execution alerts for scheduled tasks + + .DESCRIPTION + Handles sending alerts (PSA, Email, Webhook) for scheduled task completion + + .PARAMETER Results + The results to send in the alert + + .PARAMETER TaskInfo + The task information from the ScheduledTasks table + + .PARAMETER TenantFilter + The tenant filter for the task + + .PARAMETER TaskType + The type of task (default: 'Scheduled Task') + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + $Results, + + [Parameter(Mandatory = $true)] + $TaskInfo, + + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $false)] + [string]$TaskType = 'Scheduled Task' + ) + + try { + Write-Information "Sending post-execution alerts for task $($TaskInfo.Name)" + + # Get tenant information + $TenantInfo = Get-Tenants -TenantFilter $TenantFilter + + # Build HTML with adaptive table styling + $TableDesign = '' + $FinalResults = if ($Results -is [array] -and $Results[0] -is [string]) { + $Results | ConvertTo-Html -Fragment -Property @{ l = 'Text'; e = { $_ } } + } else { + $Results | ConvertTo-Html -Fragment + } + $HTML = $FinalResults -replace '
', "This alert is for tenant $TenantFilter.

$TableDesign
" | Out-String + + # Add alert comment if available + if ($TaskInfo.AlertComment) { + $AlertComment = $TaskInfo.AlertComment + + # Replace %resultcount% variable + if ($AlertComment -match '%resultcount%') { + $resultCount = if ($Results -is [array]) { $Results.Count } else { 1 } + $AlertComment = $AlertComment -replace '%resultcount%', "$resultCount" + } + + # Replace other variables + $AlertComment = Get-CIPPTextReplacement -Text $AlertComment -TenantFilter $TenantFilter + $HTML += "

Alert Information

$AlertComment

" + } + + # Build title + $title = "$TaskType - $TenantFilter - $($TaskInfo.Name)" + if ($TaskInfo.Reference) { + $title += " - Reference: $($TaskInfo.Reference)" + } + + Write-Information 'Scheduler: Sending the results to configured targets.' + + # Send to configured alert targets + switch -wildcard ($TaskInfo.PostExecution) { + '*psa*' { + Send-CIPPAlert -Type 'psa' -Title $title -HTMLContent $HTML -TenantFilter $TenantFilter + } + '*email*' { + Send-CIPPAlert -Type 'email' -Title $title -HTMLContent $HTML -TenantFilter $TenantFilter + } + '*webhook*' { + $Webhook = [PSCustomObject]@{ + 'tenantId' = $TenantInfo.customerId + 'Tenant' = $TenantFilter + 'TaskInfo' = $TaskInfo + 'Results' = $Results + 'AlertComment' = $TaskInfo.AlertComment + } + Send-CIPPAlert -Type 'webhook' -Title $title -TenantFilter $TenantFilter -JSONContent $($Webhook | ConvertTo-Json -Depth 20) + } + } + + Write-Information "Successfully sent alerts for task $($TaskInfo.Name)" + + } 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 + } +} diff --git a/Modules/CIPPCore/Public/Set-CIPPCalendarPermission.ps1 b/Modules/CIPPCore/Public/Set-CIPPCalendarPermission.ps1 index 57d2c4695680..adba643d3f2b 100644 --- a/Modules/CIPPCore/Public/Set-CIPPCalendarPermission.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPCalendarPermission.ps1 @@ -38,6 +38,9 @@ function Set-CIPPCalendarPermission { $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-MailboxFolderPermission' -cmdParams @{Identity = "$($UserID):\$FolderName"; User = $RemoveAccess } $Result = "Successfully removed access for $LoggingName from calendar $($CalParam.Identity)" Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -sev Info + + # Sync cache + Sync-CIPPCalendarPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $UserID -FolderName $FolderName -User $RemoveAccess -Action 'Remove' } } else { if ($PSCmdlet.ShouldProcess("$UserID\$FolderName", "Set permissions for $LoggingName to $Permissions")) { @@ -54,6 +57,9 @@ function Set-CIPPCalendarPermission { $Result += ' A notification has been sent to the user.' } Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -sev Info + + # Sync cache + Sync-CIPPCalendarPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $UserID -FolderName $FolderName -User $UserToGetPermissions -Permissions $Permissions -Action 'Add' } } } catch { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheDetectedApps.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheDetectedApps.ps1 new file mode 100644 index 000000000000..2f209898efed --- /dev/null +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheDetectedApps.ps1 @@ -0,0 +1,75 @@ +function Set-CIPPDBCacheDetectedApps { + <# + .SYNOPSIS + Caches all detected apps for a tenant, including devices that have each app + + .PARAMETER TenantFilter + The tenant to cache detected apps 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 detected apps' -sev Debug + + # Fetch all detected apps for the tenant + $DetectedApps = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/detectedApps' -tenantid $TenantFilter + if (!$DetectedApps) { $DetectedApps = @() } + + if (($DetectedApps | Measure-Object).Count -eq 0) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'No detected apps found' -sev Debug + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DetectedApps' -Data @() + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DetectedApps' -Data @() -Count + return + } + + # Build bulk request for devices that have each detected app + $DeviceRequests = $DetectedApps | ForEach-Object { + if ($_.id) { + [PSCustomObject]@{ + id = $_.id + method = 'GET' + url = "deviceManagement/detectedApps('$($_.id)')/managedDevices" + } + } + } + + if ($DeviceRequests) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Fetching devices for $($DetectedApps.Count) detected apps" -sev Debug + $DeviceResults = New-GraphBulkRequest -Requests @($DeviceRequests) -tenantid $TenantFilter + + # Add devices to each detected app object + $DetectedAppsWithDevices = foreach ($App in $DetectedApps) { + $Devices = Get-GraphBulkResultByID -Results $DeviceResults -ID $App.id -Value + if ($Devices) { + $App | Add-Member -NotePropertyName 'managedDevices' -NotePropertyValue $Devices -Force + } else { + $App | Add-Member -NotePropertyName 'managedDevices' -NotePropertyValue @() -Force + } + $App + } + + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DetectedApps' -Data $DetectedAppsWithDevices + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DetectedApps' -Data $DetectedAppsWithDevices -Count + $DetectedApps = $null + $DetectedAppsWithDevices = $null + } else { + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DetectedApps' -Data $DetectedApps + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DetectedApps' -Data $DetectedApps -Count + $DetectedApps = $null + } + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached detected apps with devices successfully' -sev Debug + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter ` + -message "Failed to cache detected apps: $($_.Exception.Message)" -sev Error + } +} diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheManagedDevices.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheManagedDevices.ps1 index 509a136a4fec..5190fd72c24f 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheManagedDevices.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheManagedDevices.ps1 @@ -18,7 +18,7 @@ function Set-CIPPDBCacheManagedDevices { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching managed devices' -sev Debug - $ManagedDevices = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/managedDevices?$top=999&$select=id,deviceName,operatingSystem,osVersion,complianceState,managedDeviceOwnerType,enrolledDateTime,lastSyncDateTime' -tenantid $TenantFilter + $ManagedDevices = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/managedDevices?$top=999' -tenantid $TenantFilter if (!$ManagedDevices) { $ManagedDevices = @() } Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ManagedDevices' -Data $ManagedDevices Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ManagedDevices' -Data $ManagedDevices -Count diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheUsers.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheUsers.ps1 index d500fcbe16f2..e987260ab09b 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheUsers.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheUsers.ps1 @@ -19,9 +19,75 @@ function Set-CIPPDBCacheUsers { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching users' -sev Debug + $SignInLogsCapable = Test-CIPPStandardLicense -StandardName 'UserSignInLogsCapable' -TenantFilter $TenantFilter -RequiredCapabilities @('AAD_PREMIUM', 'AAD_PREMIUM_P2') -SkipLog + + # Base properties needed by tests, standards, reports, UI, and integrations (Hudu, NinjaOne) + $BaseSelect = @( + # Core identity + 'id' + 'displayName' + 'userPrincipalName' + 'givenName' + 'surname' + 'mailNickname' + + # Account status + 'accountEnabled' + 'userType' + 'isResourceAccount' + 'createdDateTime' + + # Security & policies + 'passwordPolicies' + 'perUserMfaState' + + # Contact information + 'mail' + 'otherMails' + 'mobilePhone' + 'businessPhones' + 'faxNumber' + 'proxyAddresses' + + # Location & organization + 'jobTitle' + 'department' + 'companyName' + 'officeLocation' + 'city' + 'state' + 'country' + 'postalCode' + 'streetAddress' + + # Settings + 'preferredLanguage' + 'usageLocation' + 'preferredDataLocation' + 'showInAddressList' + + # Licenses + 'assignedLicenses' + 'assignedPlans' + 'licenseAssignmentStates' + + # On-premises sync + 'onPremisesSyncEnabled' + 'onPremisesImmutableId' + 'onPremisesLastSyncDateTime' + 'onPremisesDistinguishedName' + ) + + if ($SignInLogsCapable) { + $Select = ($BaseSelect + 'signInActivity') -join ',' + $Top = 500 + } else { + $Select = $BaseSelect -join ',' + $Top = 999 + } + # Stream users directly from Graph API to batch processor - # Using $top=500 due to signInActivity limitation - New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/users?$top=500&$select=signInActivity' -tenantid $TenantFilter | + New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users?`$top=$Top&`$select=$Select&`$count=true" -ComplexFilter -tenantid $TenantFilter | Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Users' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached users successfully' -sev Debug diff --git a/Modules/CIPPCore/Public/Set-CIPPDefaultAPDeploymentProfile.ps1 b/Modules/CIPPCore/Public/Set-CIPPDefaultAPDeploymentProfile.ps1 index 818c2f97dfea..4a46e344d5e9 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDefaultAPDeploymentProfile.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDefaultAPDeploymentProfile.ps1 @@ -70,17 +70,22 @@ function Set-CIPPDefaultAPDeploymentProfile { } if ($AssignTo -eq $true) { - $AssignBody = '{"target":{"@odata.type":"#microsoft.graph.allDevicesAssignmentTarget"}}' - if ($PSCmdlet.ShouldProcess($AssignTo, "Assign Autopilot profile $DisplayName")) { - #Get assignments - $Assignments = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeploymentProfiles/$($GraphRequest.id)/assignments" -tenantid $TenantFilter - if (!$Assignments) { - $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeploymentProfiles/$($GraphRequest.id)/assignments" -tenantid $TenantFilter -type POST -body $AssignBody + try { + $AssignBody = '{"target":{"@odata.type":"#microsoft.graph.allDevicesAssignmentTarget"}}' + if ($PSCmdlet.ShouldProcess($AssignTo, "Assign Autopilot profile $DisplayName")) { + #Get assignments + $Assignments = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeploymentProfiles/$($GraphRequest.id)/assignments" -tenantid $TenantFilter + if (!$Assignments) { + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeploymentProfiles/$($GraphRequest.id)/assignments" -tenantid $TenantFilter -type POST -body $AssignBody + } + Write-LogMessage -Headers $User -API $APIName -tenant $TenantFilter -message "Assigned autopilot profile $($DisplayName) to $($AssignTo)" -Sev 'Info' } - Write-LogMessage -Headers $User -API $APIName -tenant $TenantFilter -message "Assigned autopilot profile $($DisplayName) to $AssignTo" -Sev 'Info' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -Headers $User -API $APIName -tenant $TenantFilter -message "Failed to assign Autopilot profile $($DisplayName) to $($AssignTo): $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage } } - "Successfully $($Type)ed profile for $TenantFilter" + "Successfully $($Type)ed profile for $($TenantFilter)" } catch { $ErrorMessage = Get-CippException -Exception $_ $Result = "Failed $($Type)ing Autopilot Profile $($DisplayName). Error: $($ErrorMessage.NormalizedError)" diff --git a/Modules/CIPPCore/Public/Set-CIPPMailboxAccess.ps1 b/Modules/CIPPCore/Public/Set-CIPPMailboxAccess.ps1 index c4ab09866086..b16d867d6f41 100644 --- a/Modules/CIPPCore/Public/Set-CIPPMailboxAccess.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPMailboxAccess.ps1 @@ -2,7 +2,7 @@ function Set-CIPPMailboxAccess { [CmdletBinding()] param ( $userid, - $AccessUser, + [array]$AccessUser, # Can be single value or array of users [bool]$Automap, $TenantFilter, $APIName = 'Manage Shared Mailbox Access', @@ -10,16 +10,33 @@ function Set-CIPPMailboxAccess { [array]$AccessRights ) - try { - $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Add-MailboxPermission' -cmdParams @{Identity = $userid; user = $AccessUser; AutoMapping = $Automap; accessRights = $AccessRights; InheritanceType = 'all' } -Anchor $userid + # Ensure AccessUser is always an array + if ($AccessUser -isnot [array]) { + $AccessUser = @($AccessUser) + } + + # Extract values if objects with .value property (from frontend) + $AccessUser = $AccessUser | ForEach-Object { + if ($_ -is [PSCustomObject] -and $_.value) { $_.value } else { $_ } + } - $Message = "Successfully added $($AccessUser) to $($userid) Shared Mailbox $($Automap ? 'with' : 'without') AutoMapping, with the following permissions: $AccessRights" - Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev 'Info' -tenant $TenantFilter - return $Message - } catch { - $ErrorMessage = Get-CippException -Exception $_ - $Message = "Failed to add mailbox permissions for $($AccessUser) on $($userid). Error: $($ErrorMessage.NormalizedError)" - Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev 'Error' -tenant $TenantFilter -LogData $ErrorMessage - throw $Message + $Results = [system.collections.generic.list[string]]::new() + + # Process each access user + foreach ($User in $AccessUser) { + try { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Add-MailboxPermission' -cmdParams @{Identity = $userid; user = $User; AutoMapping = $Automap; accessRights = $AccessRights; InheritanceType = 'all' } -Anchor $userid + + $Message = "Successfully added $($User) to $($userid) Shared Mailbox $($Automap ? 'with' : 'without') AutoMapping, with the following permissions: $AccessRights" + Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev 'Info' -tenant $TenantFilter + $Results.Add($Message) + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Message = "Failed to add mailbox permissions for $($User) on $($userid). Error: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev 'Error' -tenant $TenantFilter -LogData $ErrorMessage + $Results.Add($Message) + } } + + return $Results } diff --git a/Modules/CIPPCore/Public/Set-CIPPSharePointPerms.ps1 b/Modules/CIPPCore/Public/Set-CIPPSharePointPerms.ps1 index ffc4dbd72fd6..525ae41f1f39 100644 --- a/Modules/CIPPCore/Public/Set-CIPPSharePointPerms.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPSharePointPerms.ps1 @@ -2,19 +2,27 @@ function Set-CIPPSharePointPerms { [CmdletBinding()] param ( $UserId, # The UPN or ID of the users OneDrive we are changing permissions on - $OnedriveAccessUser, # The UPN of the user we are adding or removing permissions for + [array]$OnedriveAccessUser, # The UPN(s) of the user(s) we are adding or removing permissions for - can be single value or array $TenantFilter, $APIName = 'Manage SharePoint Owner', $RemovePermission, $Headers, $URL ) - if ($RemovePermission -eq $true) { - $SiteAdmin = 'false' - } else { - $SiteAdmin = 'true' + + # Ensure OnedriveAccessUser is always an array + if ($OnedriveAccessUser -isnot [array]) { + $OnedriveAccessUser = @($OnedriveAccessUser) + } + + # Extract values if objects with .value property (from frontend) + $OnedriveAccessUser = $OnedriveAccessUser | ForEach-Object { + if ($_ -is [PSCustomObject] -and $_.value) { $_.value } else { $_ } } + $SiteAdmin = if ($RemovePermission -eq $true) { 'false' } else { 'true' } + $Results = [system.collections.generic.list[string]]::new() + try { if (!$URL) { Write-Information 'No URL provided, getting URL from Graph' @@ -22,7 +30,11 @@ function Set-CIPPSharePointPerms { } $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter - $XML = @" + + # Process each access user + foreach ($AccessUser in $OnedriveAccessUser) { + try { + $XML = @" @@ -31,7 +43,7 @@ function Set-CIPPSharePointPerms { $URL - $OnedriveAccessUser + $AccessUser $SiteAdmin @@ -39,20 +51,29 @@ function Set-CIPPSharePointPerms { "@ - $request = New-GraphPostRequest -scope "$($SharePointInfo.AdminUrl)/.default" -tenantid $TenantFilter -Uri "$($SharePointInfo.AdminUrl)/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' - # Write-Host $($request) - if (!$request.ErrorInfo.ErrorMessage) { - $Message = "Successfully $($RemovePermission ? 'removed' : 'added') $($OnedriveAccessUser) as an owner of $URL" - Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev Info -tenant $TenantFilter - return $Message - } else { - $Message = "Failed to change access: $($request.ErrorInfo.ErrorMessage)" - Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev Error -tenant $TenantFilter - throw $Message + $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.ErrorInfo.ErrorMessage) { + $Message = "Successfully $($RemovePermission ? 'removed' : 'added') $($AccessUser) as an owner of $URL" + Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev Info -tenant $TenantFilter + $Results.Add($Message) + } else { + $Message = "Failed to change access for $($AccessUser): $($request.ErrorInfo.ErrorMessage)" + Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev Error -tenant $TenantFilter + $Results.Add($Message) + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Message = "Failed to change access for $($AccessUser) on $URL. Error: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev Error -tenant $TenantFilter -LogData $ErrorMessage + $Results.Add($Message) + } } + + return $Results } catch { $ErrorMessage = Get-CippException -Exception $_ - $Message = "Failed to set SharePoint permissions for $($OnedriveAccessUser) on $URL. Error: $($ErrorMessage.NormalizedError)" + $Message = "Failed to process SharePoint permissions. 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/Standards/Invoke-CIPPStandardAutopilotProfile.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardAutopilotProfile.ps1 index 508e56635b2e..d077b530922e 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardAutopilotProfile.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardAutopilotProfile.ps1 @@ -52,7 +52,7 @@ function Invoke-CIPPStandardAutopilotProfile { Where-Object { $_.displayName -eq $Settings.DisplayName } | Select-Object -Property displayName, description, deviceNameTemplate, locale, preprovisioningAllowed, hardwareHashExtractionEnabled, outOfBoxExperienceSetting - if ($Settings.NotLocalAdmin -eq $true) { $userType = 'Standard' } else { $userType = 'Administrator' } + if ($Settings.NotLocalAdmin -eq $true) { $userType = 'standard' } else { $userType = 'administrator' } if ($Settings.SelfDeployingMode -eq $true) { $DeploymentMode = 'shared' $Settings.AllowWhiteGlove = $false diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardAutopilotStatusPage.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardAutopilotStatusPage.ps1 index 1fcf83165723..6631b4809b5d 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardAutopilotStatusPage.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardAutopilotStatusPage.ps1 @@ -67,7 +67,18 @@ function Invoke-CIPPStandardAutopilotStatusPage { $StateIsCorrect = $false } - $CurrentValue = $CurrentConfig | Select-Object -Property id, displayName, priority, showInstallationProgress, blockDeviceSetupRetryByUser, allowDeviceResetOnInstallFailure, allowLogCollectionOnInstallFailure, customErrorMessage, installProgressTimeoutInMinutes, allowDeviceUseOnInstallFailure, trackInstallProgressForAutopilotOnly, installQualityUpdates + $CurrentValue = [PSCustomObject]@{ + installProgressTimeoutInMinutes = $CurrentConfig.installProgressTimeoutInMinutes + customErrorMessage = $CurrentConfig.customErrorMessage + showInstallationProgress = $CurrentConfig.showInstallationProgress + allowLogCollectionOnInstallFailure = $CurrentConfig.allowLogCollectionOnInstallFailure + trackInstallProgressForAutopilotOnly = $CurrentConfig.trackInstallProgressForAutopilotOnly + blockDeviceSetupRetryByUser = $CurrentConfig.blockDeviceSetupRetryByUser + installQualityUpdates = $CurrentConfig.installQualityUpdates + allowDeviceResetOnInstallFailure = $CurrentConfig.allowDeviceResetOnInstallFailure + allowDeviceUseOnInstallFailure = $CurrentConfig.allowDeviceUseOnInstallFailure + } + $ExpectedValue = [PSCustomObject]@{ installProgressTimeoutInMinutes = $Settings.TimeOutInMinutes customErrorMessage = $Settings.ErrorMessage diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardBranding.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardBranding.ps1 index a0efe5f4ea5b..d68230294b11 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardBranding.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardBranding.ps1 @@ -37,7 +37,7 @@ function Invoke-CIPPStandardBranding { param($Tenant, $Settings) - $TestResult = Test-CIPPStandardLicense -StandardName 'Branding' -TenantFilter $Tenant -RequiredCapabilities @('AAD_PREMIUM', 'AAD_PREMIUM_P2') + $TestResult = Test-CIPPStandardLicense -StandardName 'Branding' -TenantFilter $Tenant -RequiredCapabilities @('AAD_PREMIUM', 'AAD_PREMIUM_P2', 'OFFICE_BUSINESS') if ($TestResult -eq $false) { return $true diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 index 07da79a1fc70..85261df84091 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 @@ -33,20 +33,29 @@ function Invoke-CIPPStandardConditionalAccessTemplate { https://docs.cipp.app/user-documentation/tenant/standards/list-standards #> param($Tenant, $Settings) - ##$Rerun -Type Standard -Tenant $Tenant -Settings $Settings 'ConditionalAccess' - $Table = Get-CippTable -tablename 'templates' + + #Checking if the DB has been updated in the last 3h, if not, run an update before we run the standard, as CA policies are critical and we want to make sure we have the latest state before making changes or comparisons. + $LastDBUpdate = Get-CIPPDbItem -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' -CountsOnly + if ($LastDBUpdate -eq $null -or ($LastDBUpdate.Timestamp -lt (Get-Date).AddHours(-3) -or $LastDBUpdate.DataCount -eq 0)) { + Write-Information "DB last updated at $($LastDBUpdate.Timestamp). Updating DB before running standard, this is probably a manual run." + Set-CIPPDBCacheConditionalAccessPolicies -TenantFilter $Tenant + } else { + Write-Information "DB last updated at $($LastDBUpdate.Timestamp). No need to update before running standard." + } + + $TestResult = Test-CIPPStandardLicense -StandardName 'ConditionalAccessTemplate_general' -TenantFilter $Tenant -RequiredCapabilities @('AAD_PREMIUM', 'AAD_PREMIUM_P2') - $TestP2 = Test-CIPPStandardLicense -StandardName 'ConditionalAccessTemplate_p2' -TenantFilter $Tenant -RequiredCapabilities @('AAD_PREMIUM_P2') -SkipLog if ($TestResult -eq $false) { - #writing to each item that the license is not present. - foreach ($Template in $settings.TemplateList) { - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Template.value)" -FieldValue 'This tenant does not have the required license for this standard.' -Tenant $Tenant - } + Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue 'This tenant does not have the required license for this standard.' -Tenant $Tenant return $true } #we're done. + $Table = Get-CippTable -tablename 'templates' + try { - $AllCAPolicies = New-GraphGetRequest -Uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/policies?$top=999' -tenantid $Tenant -asApp $true + #Get from DB, as we just downloaded the latest before the standard runs. + $AllCAPolicies = New-CIPPDbRequest -TenantFilter $tenant -Type 'ConditionalAccessPolicies' + $PreloadedLocations = New-CIPPDbRequest -TenantFilter $tenant -Type 'NamedLocations' } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the ConditionalAccessTemplate state for $Tenant. Error: $ErrorMessage" -Sev Error @@ -54,73 +63,71 @@ function Invoke-CIPPStandardConditionalAccessTemplate { } if ($Settings.remediate -eq $true) { - foreach ($Setting in $Settings) { - try { - $Filter = "PartitionKey eq 'CATemplate' and RowKey eq '$($Setting.TemplateList.value)'" - $JSONObj = (Get-CippAzDataTableEntity @Table -Filter $Filter).JSON - $Policy = $JSONObj | ConvertFrom-Json - if ($Policy.conditions.userRiskLevels.count -gt 0 -or $Policy.conditions.signInRiskLevels.count -gt 0) { - if (!$TestP2) { - Write-Information "Skipping policy $($Policy.displayName) as it requires AAD Premium P2 license." - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Setting.value)" -FieldValue "Policy $($Policy.displayName) requires AAD Premium P2 license." -Tenant $Tenant - continue - } + try { + $Filter = "PartitionKey eq 'CATemplate' and RowKey eq '$($Settings.TemplateList.value)'" + $JSONObj = (Get-CippAzDataTableEntity @Table -Filter $Filter).JSON + $Policy = $JSONObj | ConvertFrom-Json + if ($Policy.conditions.userRiskLevels.count -gt 0 -or $Policy.conditions.signInRiskLevels.count -gt 0) { + $TestP2 = Test-CIPPStandardLicense -StandardName 'ConditionalAccessTemplate_p2' -TenantFilter $Tenant -RequiredCapabilities @('AAD_PREMIUM_P2') -SkipLog + if (!$TestP2) { + Write-Information "Skipping policy $($Policy.displayName) as it requires AAD Premium P2 license." + Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue "Policy $($Policy.displayName) requires AAD Premium P2 license." -Tenant $Tenant + return $true } - $NewCAPolicy = @{ - replacePattern = 'displayName' - TenantFilter = $Tenant - state = $Setting.state - RawJSON = $JSONObj - Overwrite = $true - APIName = 'Standards' - Headers = $Request.Headers - DisableSD = $Setting.DisableSD - CreateGroups = $Setting.CreateGroups ?? $false - } - - $null = New-CIPPCAPolicy @NewCAPolicy - } catch { - $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message - Write-LogMessage -API 'Standards' -tenant $tenant -message "Failed to create or update conditional access rule $($JSONObj.displayName). Error: $ErrorMessage" -sev 'Error' } + $NewCAPolicy = @{ + replacePattern = 'displayName' + TenantFilter = $Tenant + state = $Settings.state + RawJSON = $JSONObj + Overwrite = $true + APIName = 'Standards' + Headers = $Request.Headers + DisableSD = $Settings.DisableSD + CreateGroups = $Settings.CreateGroups ?? $false + PreloadedCAPolicies = $AllCAPolicies + PreloadedLocations = $PreloadedLocations + } + + $null = New-CIPPCAPolicy @NewCAPolicy + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + Write-LogMessage -API 'Standards' -tenant $tenant -message "Failed to create or update conditional access rule $($JSONObj.displayName). Error: $ErrorMessage" -sev 'Error' } } if ($Settings.report -eq $true -or $Settings.remediate -eq $true) { - $Filter = "PartitionKey eq 'CATemplate'" - $Policies = (Get-CippAzDataTableEntity @Table -Filter $Filter | Where-Object RowKey -In $Settings.TemplateList.value).JSON | ConvertFrom-Json -Depth 10 - $AllCAPolicies = New-GraphGetRequest -Uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/policies?$top=999' -tenantid $Tenant -asApp $true - #check if all groups.displayName are in the existingGroups, if not $fieldvalue should contain all missing groups, else it should be true. - $MissingPolicies = foreach ($Setting in $Settings.TemplateList) { - $policy = $Policies | Where-Object { $_.displayName -eq $Setting.label } - $CheckExististing = $AllCAPolicies | Where-Object -Property displayName -EQ $Setting.label - if (!$CheckExististing) { - if ($Setting.conditions.userRiskLevels.Count -gt 0 -or $Setting.conditions.signInRiskLevels.Count -gt 0) { - if (!$TestP2) { - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Setting.value)" -FieldValue "Policy $($Setting.label) requires AAD Premium P2 license." -Tenant $Tenant - } else { - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Setting.value)" -FieldValue "Policy $($Setting.label) is missing from this tenant." -Tenant $Tenant - } + $Filter = "PartitionKey eq 'CATemplate' and RowKey eq '$($Settings.TemplateList.value)'" + $Policy = (Get-CippAzDataTableEntity @Table -Filter $Filter).JSON | ConvertFrom-Json -Depth 10 + + $CheckExististing = $AllCAPolicies | Where-Object -Property displayName -EQ $Settings.TemplateList.label + if (!$CheckExististing) { + if ($Policy.conditions.userRiskLevels.Count -gt 0 -or $Policy.conditions.signInRiskLevels.Count -gt 0) { + $TestP2 = Test-CIPPStandardLicense -StandardName 'ConditionalAccessTemplate_p2' -TenantFilter $Tenant -RequiredCapabilities @('AAD_PREMIUM_P2') -SkipLog + if (!$TestP2) { + Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue "Policy $($Settings.TemplateList.label) requires AAD Premium P2 license." -Tenant $Tenant } else { - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Setting.value)" -FieldValue "Policy $($Setting.label) is missing from this tenant." -Tenant $Tenant + Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue "Policy $($Settings.TemplateList.label) is missing from this tenant." -Tenant $Tenant } } else { - $templateResult = New-CIPPCATemplate -TenantFilter $tenant -JSON $CheckExististing - $CompareObj = ConvertFrom-Json -ErrorAction SilentlyContinue -InputObject $templateResult - try { - $Compare = Compare-CIPPIntuneObject -ReferenceObject $policy -DifferenceObject $CompareObj -CompareType 'ca' - } catch { - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Error comparing CA policy: $($_.Exception.Message)" -sev Error - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Setting.value)" -FieldValue "Error comparing policy: $($_.Exception.Message)" -Tenant $Tenant - continue - } - if (!$Compare) { - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Setting.value)" -FieldValue $true -Tenant $Tenant - } else { - #this can still be prettified but is for later. - $ExpectedValue = @{ 'Differences' = @() } - $CurrentValue = @{ 'Differences' = $Compare } - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Setting.value)" -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant - } + Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue "Policy $($Settings.TemplateList.label) is missing from this tenant." -Tenant $Tenant + } + } else { + $templateResult = New-CIPPCATemplate -TenantFilter $tenant -JSON $CheckExististing -preloadedLocations $preloadedLocations + $CompareObj = ConvertFrom-Json -ErrorAction SilentlyContinue -InputObject $templateResult + try { + $Compare = Compare-CIPPIntuneObject -ReferenceObject $Policy -DifferenceObject $CompareObj -CompareType 'ca' + } catch { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Error comparing CA policy: $($_.Exception.Message)" -sev Error + Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue "Error comparing policy: $($_.Exception.Message)" -Tenant $Tenant + return + } + if (!$Compare) { + Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue $true -Tenant $Tenant + } else { + #this can still be prettified but is for later. + $ExpectedValue = @{ 'Differences' = @() } + $CurrentValue = @{ 'Differences' = $Compare } + Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant } } } diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDeployMailContact.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDeployMailContact.ps1 index 0bc606b7adbf..e264f723eb36 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDeployMailContact.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDeployMailContact.ps1 @@ -105,15 +105,15 @@ function Invoke-CIPPStandardDeployMailContact { $ContactData = @{ DisplayName = $Settings.DisplayName ExternalEmailAddress = $Settings.ExternalEmailAddress - FirstName = $Settings.FirstName - LastName = $Settings.LastName + FirstName = $Settings.FirstName ?? '' + LastName = $Settings.LastName ?? '' } $CurrentValue = $ExistingContact | Select-Object DisplayName, ExternalEmailAddress, FirstName, LastName $currentValue = @{ DisplayName = $ExistingContact.displayName ExternalEmailAddress = ($ExistingContact.ExternalEmailAddress -replace 'SMTP:', '') - FirstName = $ExistingContact.firstName - LastName = $ExistingContact.lastName + FirstName = $ExistingContact.firstName ?? '' + LastName = $ExistingContact.lastName ?? '' } Add-CIPPBPAField -FieldName 'DeployMailContact' -FieldValue $ReportData -StoreAs json -Tenant $Tenant Set-CIPPStandardsCompareField -FieldName 'standards.DeployMailContact' -CurrentValue $CurrentValue -ExpectedValue $ReportData -Tenant $Tenant diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardEnableLitigationHold.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardEnableLitigationHold.ps1 index 35f68f3350d9..54a45b6c08c5 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardEnableLitigationHold.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardEnableLitigationHold.ps1 @@ -59,12 +59,14 @@ function Invoke-CIPPStandardEnableLitigationHold { } } if ($null -ne $Settings.days) { - $params.CmdletInput.Parameters['LitigationHoldDuration'] = $Settings.days + $Days = [int]::TryParse($Settings.days, [ref]$null) ? $Settings.days : $null + if ($Days -gt 0 -or $Settings.days -eq 'Unlimited') { + $params.CmdletInput.Parameters['LitigationHoldDuration'] = $Settings.days + } } $params } - $BatchResults = New-ExoBulkRequest -tenantid $Tenant -cmdletArray @($Request) foreach ($Result in $BatchResults) { if ($Result.error) { diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardGroupTemplate.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardGroupTemplate.ps1 index ed52d13b0b4b..9eddfc36d59b 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardGroupTemplate.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardGroupTemplate.ps1 @@ -33,8 +33,6 @@ function Invoke-CIPPStandardGroupTemplate { $existingGroups = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/groups?$top=999&$select=id,displayName,description,membershipRule' -tenantid $tenant - $TestResult = Test-CIPPStandardLicense -StandardName 'GroupTemplate' -TenantFilter $Tenant -RequiredCapabilities @('EXCHANGE_S_STANDARD', 'EXCHANGE_S_ENTERPRISE', 'EXCHANGE_LITE') -SkipLog - $Settings.groupTemplate ? ($Settings | Add-Member -NotePropertyName 'TemplateList' -NotePropertyValue $Settings.groupTemplate) : $null $Table = Get-CippTable -tablename 'templates' @@ -64,9 +62,12 @@ function Invoke-CIPPStandardGroupTemplate { $ActionType = 'create' # Check if Exchange license is required for distribution groups - if ($groupobj.groupType -in @('distribution', 'dynamicdistribution') -and !$TestResult) { - Write-LogMessage -API 'Standards' -tenant $tenant -message "Cannot create group $($groupobj.displayname) as the tenant is not licensed for Exchange." -Sev 'Error' - continue + if ($groupobj.groupType -in @('distribution', 'dynamicdistribution')) { + $TestResult = Test-CIPPStandardLicense -StandardName 'GroupTemplate' -TenantFilter $Tenant -RequiredCapabilities @('EXCHANGE_S_STANDARD', 'EXCHANGE_S_ENTERPRISE', 'EXCHANGE_LITE') -SkipLog + if (!$TestResult) { + Write-LogMessage -API 'Standards' -tenant $tenant -message "Cannot create group $($groupobj.displayname) as the tenant is not licensed for Exchange." -Sev 'Error' + continue + } } # Use the centralized New-CIPPGroup function @@ -127,6 +128,7 @@ function Invoke-CIPPStandardGroupTemplate { } else { # Handle Exchange Online groups (Distribution, DynamicDistribution) + $TestResult = Test-CIPPStandardLicense -StandardName 'GroupTemplate' -TenantFilter $Tenant -RequiredCapabilities @('EXCHANGE_S_STANDARD', 'EXCHANGE_S_ENTERPRISE', 'EXCHANGE_LITE') -SkipLog if (!$TestResult) { Write-LogMessage -API 'Standards' -tenant $tenant -message "Cannot update group $($groupobj.displayName) as the tenant is not licensed for Exchange." -Sev 'Error' continue diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardOauthConsent.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardOauthConsent.ps1 index bf86be5f502e..18ab63baafb8 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardOauthConsent.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardOauthConsent.ps1 @@ -47,7 +47,7 @@ function Invoke-CIPPStandardOauthConsent { Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the OauthConsent state for $Tenant. Error: $ErrorMessage" -Sev Error return } - $StateIsCorrect = if ($State.permissionGrantPolicyIdsAssignedToDefaultUserRole -eq 'managePermissionGrantsForSelf.cipp-consent-policy') { $true } else { $false } + $StateIsCorrect = if ($State.permissionGrantPolicyIdsAssignedToDefaultUserRole -eq 'ManagePermissionGrantsForSelf.cipp-consent-policy') { $true } else { $false } if ($Settings.remediate -eq $true) { $AllowedAppIdsForTenant = $settings.AllowedApps -split ',' | ForEach-Object { $_.Trim() } @@ -77,8 +77,8 @@ function Invoke-CIPPStandardOauthConsent { "Could not add exclusions, probably already exist: $($_)" } - if ($State.permissionGrantPolicyIdsAssignedToDefaultUserRole -notin @('managePermissionGrantsForSelf.cipp-consent-policy')) { - New-GraphPostRequest -tenantid $tenant -Uri 'https://graph.microsoft.com/beta/policies/authorizationPolicy/authorizationPolicy' -Type PATCH -Body '{"permissionGrantPolicyIdsAssignedToDefaultUserRole":["managePermissionGrantsForSelf.cipp-consent-policy"]}' -ContentType 'application/json' + if ($State.permissionGrantPolicyIdsAssignedToDefaultUserRole -notin @('ManagePermissionGrantsForSelf.cipp-consent-policy')) { + New-GraphPostRequest -tenantid $tenant -Uri 'https://graph.microsoft.com/beta/policies/authorizationPolicy/authorizationPolicy' -Type PATCH -Body '{"permissionGrantPolicyIdsAssignedToDefaultUserRole":["ManagePermissionGrantsForSelf.cipp-consent-policy"]}' -ContentType 'application/json' } Write-LogMessage -API 'Standards' -tenant $tenant -message 'Application Consent Mode has been enabled.' -sev Info diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardOauthConsentLowSec.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardOauthConsentLowSec.ps1 index e28c87dc27e9..34d47a81ffee 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardOauthConsentLowSec.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardOauthConsentLowSec.ps1 @@ -49,7 +49,7 @@ function Invoke-CIPPStandardOauthConsentLowSec { $ConflictingStandard = $Standards | Where-Object -Property Standard -EQ 'OauthConsent' if ($Settings.remediate -eq $true) { - if ($State.permissionGrantPolicyIdsAssignedToDefaultUserRole -in @('managePermissionGrantsForSelf.microsoft-user-default-low')) { + if ($State.permissionGrantPolicyIdsAssignedToDefaultUserRole -in @('ManagePermissionGrantsForSelf.microsoft-user-default-low')) { Write-LogMessage -API 'Standards' -tenant $tenant -message 'Application Consent Mode(microsoft-user-default-low) is already enabled.' -sev Info } elseif ($ConflictingStandard -and $State.permissionGrantPolicyIdsAssignedToDefaultUserRole -contains 'ManagePermissionGrantsForSelf.cipp-consent-policy') { Write-LogMessage -API 'Standards' -tenant $tenant -message 'There is a conflicting OAuth Consent policy standard enabled for this tenant. Remove the Require admin consent for applications (Prevent OAuth phishing) standard from this tenant to apply the low security standard.' -sev Error @@ -60,7 +60,7 @@ function Invoke-CIPPStandardOauthConsentLowSec { Uri = 'https://graph.microsoft.com/beta/policies/authorizationPolicy/authorizationPolicy' Type = 'PATCH' Body = @{ - permissionGrantPolicyIdsAssignedToDefaultUserRole = @('managePermissionGrantsForSelf.microsoft-user-default-low') + permissionGrantPolicyIdsAssignedToDefaultUserRole = @('ManagePermissionGrantsForSelf.microsoft-user-default-low') } | ConvertTo-Json ContentType = 'application/json' } @@ -98,7 +98,7 @@ function Invoke-CIPPStandardOauthConsentLowSec { } if ($Settings.alert -eq $true) { - if ($State.permissionGrantPolicyIdsAssignedToDefaultUserRole -notin @('managePermissionGrantsForSelf.microsoft-user-default-low')) { + if ($State.permissionGrantPolicyIdsAssignedToDefaultUserRole -notin @('ManagePermissionGrantsForSelf.microsoft-user-default-low')) { Write-StandardsAlert -message 'Application Consent Mode(microsoft-user-default-low) is not enabled' -object $State -tenant $tenant -standardName 'OauthConsentLowSec' -standardId $Settings.standardId Write-LogMessage -API 'Standards' -tenant $tenant -message 'Application Consent Mode(microsoft-user-default-low) is not enabled.' -sev Info } else { diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardPasswordExpireDisabled.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardPasswordExpireDisabled.ps1 index 2caaa9e00f7f..fcf52e7bf4b8 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardPasswordExpireDisabled.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardPasswordExpireDisabled.ps1 @@ -43,8 +43,15 @@ function Invoke-CIPPStandardPasswordExpireDisabled { return } - $DomainsWithoutPassExpire = $GraphRequest | - Where-Object { $_.isVerified -eq $true -and $_.passwordValidityPeriodInDays -ne 2147483647 } + $DomainIds = @($GraphRequest.id) + $SubDomains = foreach ($id in $DomainIds) { + foreach ($parent in $DomainIds) { + if ($id -ne $parent -and $id.EndsWith(".$parent")) { + $id; break + } + } + } + $DomainsWithoutPassExpire = $GraphRequest | Where-Object { $_.isVerified -eq $true -and $_.passwordValidityPeriodInDays -ne 2147483647 -and $_.id -notin $SubDomains } if ($Settings.remediate -eq $true) { diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardPhishProtection.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardPhishProtection.ps1 index 8f79c8d5a850..0f3ca5cc94e4 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardPhishProtection.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardPhishProtection.ps1 @@ -34,13 +34,13 @@ function Invoke-CIPPStandardPhishProtection { param($Tenant, $Settings) - $TestResult = Test-CIPPStandardLicense -StandardName 'PhishProtection' -TenantFilter $Tenant -RequiredCapabilities @('AAD_PREMIUM', 'AAD_PREMIUM_P2') + $TestResult = Test-CIPPStandardLicense -StandardName 'PhishProtection' -TenantFilter $Tenant -RequiredCapabilities @('AAD_PREMIUM', 'AAD_PREMIUM_P2', 'OFFICE_BUSINESS') if ($TestResult -eq $false) { return $true } #we're done. - $TenantId = Get-Tenants | Where-Object -Property defaultDomainName -EQ $tenant + $TenantId = Get-Tenants | Where-Object -Property defaultDomainName -EQ $Tenant $Table = Get-CIPPTable -TableName Config $CippConfig = (Get-CIPPAzDataTableEntity @Table) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardSafeAttachmentPolicy.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardSafeAttachmentPolicy.ps1 index 79c6d309ee85..4a9d73a371d4 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardSafeAttachmentPolicy.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardSafeAttachmentPolicy.ps1 @@ -57,6 +57,14 @@ function Invoke-CIPPStandardSafeAttachmentPolicy { Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the SafeAttachmentPolicy state for $Tenant. Error: $ErrorMessage" -Sev Error return } + # Cache all Safe Attachment Rules to avoid duplicate API calls + try { + $AllSafeAttachmentRule = New-ExoRequest -tenantid $Tenant -cmdlet 'Get-SafeAttachmentRule' + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the SafeAttachmentRule state for $Tenant. Error: $ErrorMessage" -Sev Error + return + } # Use custom name if provided, otherwise use default for backward compatibility $PolicyName = if ($Settings.name) { $Settings.name } else { 'CIPP Default Safe Attachment Policy' } @@ -72,7 +80,7 @@ function Invoke-CIPPStandardSafeAttachmentPolicy { # Derive rule name from policy name, but check for old names for backward compatibility $DesiredRuleName = "$PolicyName Rule" $RuleList = @($DesiredRuleName, 'CIPP Default Safe Attachment Rule', 'CIPP Default Safe Attachment Policy') - $ExistingRule = New-ExoRequest -tenantid $Tenant -cmdlet 'Get-SafeAttachmentRule' | Where-Object -Property Name -In $RuleList | Select-Object -First 1 + $ExistingRule = $AllSafeAttachmentRule | Where-Object -Property Name -In $RuleList | Select-Object -First 1 if ($null -eq $ExistingRule.Name) { # No existing rule - use the derived name $RuleName = $DesiredRuleName @@ -94,7 +102,7 @@ function Invoke-CIPPStandardSafeAttachmentPolicy { $AcceptedDomains = New-ExoRequest -tenantid $Tenant -cmdlet 'Get-AcceptedDomain' - $RuleState = New-ExoRequest -tenantid $Tenant -cmdlet 'Get-SafeAttachmentRule' | + $RuleState = $AllSafeAttachmentRule | Where-Object -Property Name -EQ $RuleName | Select-Object Name, SafeAttachmentPolicy, Priority, RecipientDomainIs diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardSendFromAlias.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardSendFromAlias.ps1 index 8dc6a4af027b..94b377657fdc 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardSendFromAlias.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardSendFromAlias.ps1 @@ -5,9 +5,9 @@ function Invoke-CIPPStandardSendFromAlias { .COMPONENT (APIName) SendFromAlias .SYNOPSIS - (Label) Allow users to send from their alias addresses + (Label) Set Send from alias state .DESCRIPTION - (Helptext) Enables the ability for users to send from their alias addresses. + (Helptext) Enables or disables the ability for users to send from their alias addresses. (DocsDescription) Allows users to change the 'from' address to any set in their Azure AD Profile. .NOTES CAT @@ -16,6 +16,7 @@ function Invoke-CIPPStandardSendFromAlias { EXECUTIVETEXT Allows employees to send emails from their alternative email addresses (aliases) rather than just their primary address. This is useful for employees who manage multiple roles or departments, enabling them to send emails from the most appropriate address for the context. ADDEDCOMPONENT + {"type":"autoComplete","multiple":false,"creatable":false,"label":"Select value","name":"standards.SendFromAlias.state","options":[{"label":"Enabled","value":"true"},{"label":"Disabled","value":"false"}]} IMPACT Medium Impact ADDEDDATE @@ -40,43 +41,43 @@ function Invoke-CIPPStandardSendFromAlias { try { $CurrentInfo = (New-ExoRequest -tenantid $Tenant -cmdlet 'Get-OrganizationConfig').SendFromAliasEnabled } catch { - $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message - Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the SendFromAlias state for $Tenant. Error: $ErrorMessage" -Sev Error + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the SendFromAlias state for $Tenant. Error: $($ErrorMessage.NormalizedError)" -Sev Error -LogData $ErrorMessage return } + # Backwards compat: existing configs have no state (null) → default to 'true' (original behavior). For pre v10.1 + $state = $Settings.state.value ?? $Settings.state ?? 'true' + $WantedState = [System.Convert]::ToBoolean($state) + if ($Settings.remediate -eq $true) { - if ($CurrentInfo -ne $true) { + if ($CurrentInfo -ne $WantedState) { try { - New-ExoRequest -tenantid $Tenant -cmdlet 'Set-OrganizationConfig' -cmdParams @{ SendFromAliasEnabled = $true } - Write-LogMessage -API 'Standards' -tenant $tenant -message 'Send from alias enabled.' -sev Info - $CurrentInfo = $true + New-ExoRequest -tenantid $Tenant -cmdlet 'Set-OrganizationConfig' -cmdParams @{ SendFromAliasEnabled = $WantedState } + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Send from alias set to $state." -sev Info + $CurrentInfo = $WantedState } catch { - $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message - Write-LogMessage -API 'Standards' -tenant $tenant -message "Failed to enable send from alias. Error: $ErrorMessage" -sev Error + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to set send from alias to $state. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } else { - Write-LogMessage -API 'Standards' -tenant $tenant -message 'Send from alias is already enabled.' -sev Info + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Send from alias is already set to $state." -sev Info } } if ($Settings.alert -eq $true) { - if ($CurrentInfo -eq $true) { - Write-LogMessage -API 'Standards' -tenant $tenant -message 'Send from alias is enabled.' -sev Info + if ($CurrentInfo -eq $WantedState) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Send from alias is set to $state." -sev Info } else { - Write-StandardsAlert -message 'Send from alias is not enabled' -object $CurrentInfo -tenant $tenant -standardName 'SendFromAlias' -standardId $Settings.standardId - Write-LogMessage -API 'Standards' -tenant $tenant -message 'Send from alias is not enabled.' -sev Info + Write-StandardsAlert -message "Send from alias is not set to $state" -object $CurrentInfo -tenant $Tenant -standardName 'SendFromAlias' -standardId $Settings.standardId + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Send from alias is not set to $state." -sev Info } } if ($Settings.report -eq $true) { - Add-CIPPBPAField -FieldName 'SendFromAlias' -FieldValue $CurrentInfo -StoreAs bool -Tenant $tenant - $CurrentValue = @{ - SendFromAliasEnabled = $CurrentInfo - } - $ExpectedValue = @{ - SendFromAliasEnabled = $true - } - Set-CIPPStandardsCompareField -FieldName 'standards.SendFromAlias' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $tenant + Add-CIPPBPAField -FieldName 'SendFromAlias' -FieldValue $CurrentInfo -StoreAs bool -Tenant $Tenant + $CurrentValue = @{ SendFromAliasEnabled = $CurrentInfo } + $ExpectedValue = @{ SendFromAliasEnabled = $WantedState } + Set-CIPPStandardsCompareField -FieldName 'standards.SendFromAlias' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant } } diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardintuneBrandingProfile.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardintuneBrandingProfile.ps1 index 66ed49f285ec..ecac361e88e6 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardintuneBrandingProfile.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardintuneBrandingProfile.ps1 @@ -114,13 +114,13 @@ function Invoke-CIPPStandardintuneBrandingProfile { displayName = $CurrentState.displayName showLogo = $CurrentState.showLogo showDisplayNameNextToLogo = $CurrentState.showDisplayNameNextToLogo - contactITName = $CurrentState.contactITName - contactITPhoneNumber = $CurrentState.contactITPhoneNumber - contactITEmailAddress = $CurrentState.contactITEmailAddress - contactITNotes = $CurrentState.contactITNotes - onlineSupportSiteName = $CurrentState.onlineSupportSiteName - onlineSupportSiteUrl = $CurrentState.onlineSupportSiteUrl - privacyUrl = $CurrentState.privacyUrl + contactITName = $CurrentState.contactITName ?? '' + contactITPhoneNumber = $CurrentState.contactITPhoneNumber ?? '' + contactITEmailAddress = $CurrentState.contactITEmailAddress ?? '' + contactITNotes = $CurrentState.contactITNotes ?? '' + onlineSupportSiteName = $CurrentState.onlineSupportSiteName ?? '' + onlineSupportSiteUrl = $CurrentState.onlineSupportSiteUrl ?? '' + privacyUrl = $CurrentState.privacyUrl ?? '' } $ExpectedValue = @{ displayName = $Settings.displayName diff --git a/Modules/CIPPCore/Public/Sync-CIPPCalendarPermissionCache.ps1 b/Modules/CIPPCore/Public/Sync-CIPPCalendarPermissionCache.ps1 new file mode 100644 index 000000000000..fc59916b8fcc --- /dev/null +++ b/Modules/CIPPCore/Public/Sync-CIPPCalendarPermissionCache.ps1 @@ -0,0 +1,173 @@ +function Sync-CIPPCalendarPermissionCache { + <# + .SYNOPSIS + Synchronize calendar permission changes to the cached reporting database + + .DESCRIPTION + Updates the cached calendar permissions in the reporting database when permissions are + added or removed via CIPP, keeping the cache in sync with actual permissions. + + .PARAMETER TenantFilter + The tenant domain or GUID + + .PARAMETER MailboxIdentity + The mailbox identity (UPN or email) + + .PARAMETER FolderName + The calendar folder name + + .PARAMETER User + The user being granted or removed permissions + + .PARAMETER Permissions + The permission level being granted + + .PARAMETER Action + Whether to 'Add' or 'Remove' the permission + + .EXAMPLE + Sync-CIPPCalendarPermissionCache -TenantFilter 'contoso.com' -MailboxIdentity 'user@contoso.com' -FolderName 'Calendar' -User 'guest@contoso.com' -Permissions 'Editor' -Action 'Add' + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $true)] + [string]$MailboxIdentity, + + [Parameter(Mandatory = $true)] + [string]$FolderName, + + [Parameter(Mandatory = $true)] + [string]$User, + + [Parameter(Mandatory = $false)] + [string]$Permissions, + + [Parameter(Mandatory = $true)] + [ValidateSet('Add', 'Remove')] + [string]$Action + ) + + try { + $CalendarIdentity = "$MailboxIdentity`:\$FolderName" + + # Resolve user to display name if a UPN was provided + # Calendar permissions use display names, not UPNs + $UserToCache = $User + if ($User -match '@') { + # Try to get display name from mailbox cache + $MailboxItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'Mailboxes' | Where-Object { $_.RowKey -ne 'Mailboxes-Count' } + foreach ($Item in $MailboxItems) { + $Mailbox = $Item.Data | ConvertFrom-Json + if ($Mailbox.UPN -eq $User -or $Mailbox.primarySmtpAddress -eq $User) { + $UserToCache = $Mailbox.displayName + Write-Information "Resolved $User to display name: $UserToCache" -InformationAction Continue + break + } + } + } + + if ($Action -eq 'Add') { + # Create calendar permission object in the same format as cached permissions + $PermissionObject = [PSCustomObject]@{ + id = [guid]::NewGuid().ToString() + Identity = $CalendarIdentity + User = $UserToCache + AccessRights = $Permissions + FolderName = $FolderName + } + + # Add to cache using Append to not clear existing entries + $PermissionObject | Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CalendarPermissions' -Append + + Write-LogMessage -API 'CalendarPermissionCache' -tenant $TenantFilter ` + -message "Added calendar permission cache entry: $UserToCache on $CalendarIdentity with $Permissions" -sev Debug + + } else { + # Remove from cache - need to find the item by Identity and User combination + try { + $Table = Get-CippTable -tablename 'CippReportingDB' + + # Build mailbox lookup for flexible Identity matching (same as report function) + $MailboxItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'Mailboxes' | Where-Object { $_.RowKey -ne 'Mailboxes-Count' } + $MailboxLookup = @{} + $MailboxByIdLookup = @{} + $MailboxByExternalIdLookup = @{} + + foreach ($Item in $MailboxItems) { + $Mailbox = $Item.Data | ConvertFrom-Json + if ($Mailbox.UPN) { + $MailboxLookup[$Mailbox.UPN.ToLower()] = @{ + UPN = $Mailbox.UPN + Id = $Mailbox.Id + ExternalDirectoryObjectId = $Mailbox.ExternalDirectoryObjectId + } + } + if ($Mailbox.primarySmtpAddress) { + $MailboxLookup[$Mailbox.primarySmtpAddress.ToLower()] = @{ + UPN = if ($Mailbox.UPN) { $Mailbox.UPN } else { $Mailbox.primarySmtpAddress } + Id = $Mailbox.Id + ExternalDirectoryObjectId = $Mailbox.ExternalDirectoryObjectId + } + } + if ($Mailbox.Id) { + $MailboxByIdLookup[$Mailbox.Id] = $Mailbox.UPN + } + if ($Mailbox.ExternalDirectoryObjectId) { + $MailboxByExternalIdLookup[$Mailbox.ExternalDirectoryObjectId] = $Mailbox.UPN + } + } + + # Get all possible identifiers for the target mailbox + $TargetMailboxInfo = $MailboxLookup[$MailboxIdentity.ToLower()] + $PossibleIdentities = @($MailboxIdentity) + if ($TargetMailboxInfo) { + if ($TargetMailboxInfo.Id) { $PossibleIdentities += $TargetMailboxInfo.Id } + if ($TargetMailboxInfo.ExternalDirectoryObjectId) { $PossibleIdentities += $TargetMailboxInfo.ExternalDirectoryObjectId } + if ($TargetMailboxInfo.UPN) { $PossibleIdentities += $TargetMailboxInfo.UPN } + } + + # Build all possible calendar identities (combining each mailbox identifier with folder name) + $PossibleCalendarIdentities = $PossibleIdentities | ForEach-Object { "$_`:\$FolderName" } + + # Query for all CalendarPermissions for this tenant + $Filter = "PartitionKey eq '{0}' and RowKey ge 'CalendarPermissions-' and RowKey lt 'CalendarPermissions0'" -f $TenantFilter + $AllPermissions = Get-CIPPAzDataTableEntity @Table -Filter $Filter | Where-Object { $_.RowKey -ne 'CalendarPermissions-Count' } + + # Find the specific permission entry that matches + foreach ($CachedPerm in $AllPermissions) { + # Skip entries with null or empty Data + if ([string]::IsNullOrEmpty($CachedPerm.Data)) { + continue + } + + $PermData = $CachedPerm.Data | ConvertFrom-Json + + # Match on Identity (flexible) and User + if ($PossibleCalendarIdentities -contains $PermData.Identity -and $PermData.User -eq $User) { + + # Extract ItemId from RowKey (format: "Type-ItemId") + Write-Information "Removing calendar permission cache entry: $User on $CalendarIdentity (matched via $($PermData.Identity))" -InformationAction Continue + $ItemId = $CachedPerm.RowKey -replace '^CalendarPermissions-', '' + Remove-CIPPDbItem -TenantFilter $TenantFilter -Type 'CalendarPermissions' -ItemId $ItemId + + Write-Information "Removed calendar permission cache entry: $User on $CalendarIdentity" -InformationAction Continue + Write-LogMessage -API 'CalendarPermissionCache' -tenant $TenantFilter ` + -message "Removed calendar permission cache entry: $User on $CalendarIdentity" -sev Debug + break + } + } + } catch { + Write-LogMessage -API 'CalendarPermissionCache' -tenant $TenantFilter ` + -message "Failed to remove calendar permission cache entry: $($_.Exception.Message)" -sev Warning + Write-Information "Failed to remove calendar permission cache entry: $($_.Exception.Message)" -InformationAction Continue + } + } + } catch { + Write-LogMessage -API 'CalendarPermissionCache' -tenant $TenantFilter ` + -message "Failed to sync calendar permission cache: $($_.Exception.Message)" -sev Warning + # Don't throw - cache sync failures shouldn't break the main operation + } +} diff --git a/Modules/CIPPCore/Public/Sync-CIPPMailboxPermissionCache.ps1 b/Modules/CIPPCore/Public/Sync-CIPPMailboxPermissionCache.ps1 new file mode 100644 index 000000000000..59e0fa504ee3 --- /dev/null +++ b/Modules/CIPPCore/Public/Sync-CIPPMailboxPermissionCache.ps1 @@ -0,0 +1,157 @@ +function Sync-CIPPMailboxPermissionCache { + <# + .SYNOPSIS + Synchronize mailbox permission changes to the cached reporting database + + .DESCRIPTION + Updates the cached mailbox permissions in the reporting database when permissions are + added or removed via CIPP, keeping the cache in sync with actual permissions. + + .PARAMETER TenantFilter + The tenant domain or GUID + + .PARAMETER MailboxIdentity + The mailbox identity (UPN or email) + + .PARAMETER User + The user/trustee being granted or removed permissions + + .PARAMETER PermissionType + The type of permission: 'FullAccess', 'SendAs', or 'SendOnBehalf' + + .PARAMETER Action + Whether to 'Add' or 'Remove' the permission + + .EXAMPLE + Sync-CIPPMailboxPermissionCache -TenantFilter 'contoso.com' -MailboxIdentity 'mailbox@contoso.com' -User 'user@contoso.com' -PermissionType 'FullAccess' -Action 'Add' + + .EXAMPLE + Sync-CIPPMailboxPermissionCache -TenantFilter 'contoso.com' -MailboxIdentity 'mailbox@contoso.com' -User 'user@contoso.com' -PermissionType 'SendAs' -Action 'Remove' + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $true)] + [string]$MailboxIdentity, + + [Parameter(Mandatory = $true)] + [string]$User, + + [Parameter(Mandatory = $true)] + [ValidateSet('FullAccess', 'SendAs', 'SendOnBehalf')] + [string]$PermissionType, + + [Parameter(Mandatory = $true)] + [ValidateSet('Add', 'Remove')] + [string]$Action + ) + + try { + if ($Action -eq 'Add') { + # Create permission object in the same format as cached permissions + $PermissionObject = [PSCustomObject]@{ + id = [guid]::NewGuid().ToString() + Identity = $MailboxIdentity + User = $User + AccessRights = @($PermissionType) + IsInherited = $false + Deny = $false + } + + # Determine which type to use based on permission + $Type = if ($PermissionType -eq 'SendAs') { 'MailboxPermissions' } else { 'MailboxPermissions' } + + # Add to cache using Append to not clear existing entries + $PermissionObject | Add-CIPPDbItem -TenantFilter $TenantFilter -Type $Type -Append + + Write-LogMessage -API 'MailboxPermissionCache' -tenant $TenantFilter ` + -message "Added $PermissionType permission cache entry: $User on $MailboxIdentity" -sev Debug + + } else { + # Remove from cache - need to find the item by Identity and User combination + try { + $Table = Get-CippTable -tablename 'CippReportingDB' + + # Build mailbox lookup for flexible Identity matching (same as report function) + $MailboxItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'Mailboxes' | Where-Object { $_.RowKey -ne 'Mailboxes-Count' } + $MailboxLookup = @{} + $MailboxByIdLookup = @{} + $MailboxByExternalIdLookup = @{} + + foreach ($Item in $MailboxItems) { + $Mailbox = $Item.Data | ConvertFrom-Json + if ($Mailbox.UPN) { + $MailboxLookup[$Mailbox.UPN.ToLower()] = @{ + UPN = $Mailbox.UPN + Id = $Mailbox.Id + ExternalDirectoryObjectId = $Mailbox.ExternalDirectoryObjectId + } + } + if ($Mailbox.primarySmtpAddress) { + $MailboxLookup[$Mailbox.primarySmtpAddress.ToLower()] = @{ + UPN = if ($Mailbox.UPN) { $Mailbox.UPN } else { $Mailbox.primarySmtpAddress } + Id = $Mailbox.Id + ExternalDirectoryObjectId = $Mailbox.ExternalDirectoryObjectId + } + } + if ($Mailbox.Id) { + $MailboxByIdLookup[$Mailbox.Id] = $Mailbox.UPN + } + if ($Mailbox.ExternalDirectoryObjectId) { + $MailboxByExternalIdLookup[$Mailbox.ExternalDirectoryObjectId] = $Mailbox.UPN + } + } + + # Get all possible identifiers for the target mailbox + $TargetMailboxInfo = $MailboxLookup[$MailboxIdentity.ToLower()] + $PossibleIdentities = @($MailboxIdentity) + if ($TargetMailboxInfo) { + if ($TargetMailboxInfo.Id) { $PossibleIdentities += $TargetMailboxInfo.Id } + if ($TargetMailboxInfo.ExternalDirectoryObjectId) { $PossibleIdentities += $TargetMailboxInfo.ExternalDirectoryObjectId } + if ($TargetMailboxInfo.UPN) { $PossibleIdentities += $TargetMailboxInfo.UPN } + } + + # Query for all MailboxPermissions for this tenant + $Filter = "PartitionKey eq '{0}' and RowKey ge 'MailboxPermissions-' and RowKey lt 'MailboxPermissions0'" -f $TenantFilter + $AllPermissions = Get-CIPPAzDataTableEntity @Table -Filter $Filter | Where-Object { $_.RowKey -ne 'MailboxPermissions-Count' } + + # Find the specific permission entry that matches + foreach ($CachedPerm in $AllPermissions) { + # Skip entries with null or empty Data + if ([string]::IsNullOrEmpty($CachedPerm.Data)) { + continue + } + + $PermData = $CachedPerm.Data | ConvertFrom-Json + + # Match on Identity (flexible), User, and AccessRights + if ($PossibleIdentities -contains $PermData.Identity -and + $PermData.User -eq $User -and + $PermData.AccessRights -contains $PermissionType) { + + # Extract ItemId from RowKey (format: "Type-ItemId") + Write-Information "Removing $PermissionType permission cache entry: $User on $MailboxIdentity (matched via $($PermData.Identity))" -InformationAction Continue + $ItemId = $CachedPerm.RowKey -replace '^MailboxPermissions-', '' + Remove-CIPPDbItem -TenantFilter $TenantFilter -Type 'MailboxPermissions' -ItemId $ItemId + + Write-Information "Removed $PermissionType permission cache entry: $User on $MailboxIdentity" -InformationAction Continue + Write-LogMessage -API 'MailboxPermissionCache' -tenant $TenantFilter ` + -message "Removed $PermissionType permission cache entry: $User on $MailboxIdentity" -sev Debug + break + } + } + } catch { + Write-LogMessage -API 'MailboxPermissionCache' -tenant $TenantFilter ` + -message "Failed to remove permission cache entry: $($_.Exception.Message)" -sev Warning + Write-Information "Failed to remove permission cache entry: $($_.Exception.Message)" -InformationAction Continue + } + } + } catch { + Write-LogMessage -API 'MailboxPermissionCache' -tenant $TenantFilter ` + -message "Failed to sync permission cache: $($_.Exception.Message)" -sev Warning + # Don't throw - cache sync failures shouldn't break the main operation + Write-Information "Failed to sync permission cache: $($_.Exception.Message)" -InformationAction Continue + } +} diff --git a/Modules/CIPPCore/Public/Test-CIPPAccessTenant.ps1 b/Modules/CIPPCore/Public/Test-CIPPAccessTenant.ps1 index 4ca993cbc631..51dda90f7ff4 100644 --- a/Modules/CIPPCore/Public/Test-CIPPAccessTenant.ps1 +++ b/Modules/CIPPCore/Public/Test-CIPPAccessTenant.ps1 @@ -17,7 +17,10 @@ function Test-CIPPAccessTenant { @{ Name = 'SharePoint Administrator'; Id = 'f28a1f50-f6e7-4571-818b-6a12f2af6b6c' }, @{ Name = 'Authentication Policy Administrator'; Id = '0526716b-113d-4c15-b2c8-68e3c22b9f80' }, @{ Name = 'Privileged Role Administrator'; Id = 'e8611ab8-c189-46e8-94e1-60213ab1f814' }, - @{ Name = 'Privileged Authentication Administrator'; Id = '7be44c8a-adaf-4e2a-84d6-ab2649e08a13' } + @{ Name = 'Privileged Authentication Administrator'; Id = '7be44c8a-adaf-4e2a-84d6-ab2649e08a13' }, + @{ Name = 'Billing Administrator'; Id = 'b0f54661-2d74-4c50-afa3-1ec803f12efe'; Optional = $true }, + @{ Name = 'Global Reader'; Id = 'f2ef992c-3afb-46b9-b7cf-a126ee74c451'; Optional = $true }, + @{ Name = 'Domain Name Administrator'; Id = '8329153b-31d0-4727-b945-745eb3bc5f31'; Optional = $true } ) $TenantParams = @{ @@ -82,11 +85,11 @@ function Test-CIPPAccessTenant { if (!$Role) { $MissingRoles.Add( [PSCustomObject]@{ - Name = $RoleId.Name - Type = 'Tenant' + Name = $RoleId.Name + Type = 'Tenant' + Optional = $RoleId.Optional } ) - $AddedText = 'but missing GDAP roles' } else { $GDAPRoles.Add([PSCustomObject]@{ Role = $RoleId.Name @@ -95,6 +98,13 @@ function Test-CIPPAccessTenant { } } + $RequiredMissingRoles = $MissingRoles | Where-Object { $_.Optional -ne $true } + if (($RequiredMissingRoles | Measure-Object).Count -gt 0) { + $AddedText = 'but missing required GDAP roles' + } elseif (($MissingRoles | Measure-Object).Count -gt 0) { + $AddedText = 'but missing optional GDAP roles' + } + $GraphTest = "Successfully connected to Graph $($AddedText)" $GraphStatus = $true } catch { @@ -120,7 +130,7 @@ function Test-CIPPAccessTenant { $AvailableRoles = $RoleDefinitions | Where-Object -Property displayName -In $AllOrgManagementRoles | Select-Object -Property displayName, id, description Write-Information "Found $($AvailableRoles.Count) available Organization Management roles in Exchange" $MissingOrgMgmtRoles = $AvailableRoles | Where-Object { $OrgManagementRoles.Role -notcontains $_.displayName } - if (($MissingOrgMgmtRoles | Measure-Object).Count -gt 0) { + if (($MissingOrgMgmtRoles | Measure-Object).Count -ge 5) { $Results.OrgManagementRolesMissing = $MissingOrgMgmtRoles Write-Warning "Found $($MissingRoles.Count) missing Organization Management roles in Exchange" $ExchangeStatus = $false diff --git a/Modules/CIPPCore/Public/Tools/Import-CommunityTemplate.ps1 b/Modules/CIPPCore/Public/Tools/Import-CommunityTemplate.ps1 index d099f2a280f8..603df66a4c96 100644 --- a/Modules/CIPPCore/Public/Tools/Import-CommunityTemplate.ps1 +++ b/Modules/CIPPCore/Public/Tools/Import-CommunityTemplate.ps1 @@ -14,6 +14,7 @@ function Import-CommunityTemplate { ) $Table = Get-CippTable -TableName 'templates' + $StatusMessage = $null try { if ($Template.RowKey) { @@ -67,11 +68,20 @@ function Import-CommunityTemplate { $Template | Add-Member -MemberType NoteProperty -Name SHA -Value $SHA -Force $Template | Add-Member -MemberType NoteProperty -Name Source -Value $Source -Force Add-CIPPAzDataTableEntity @Table -Entity $Template -Force + + if ($Existing -and $Existing.SHA -ne $SHA) { + $StatusMessage = "Updated template '$($Template.RowKey)' from source '$Source' (SHA changed)." + } elseif ($Existing) { + $StatusMessage = "Template '$($Template.RowKey)' from source '$Source' is already up to date." + } else { + $StatusMessage = "Created template '$($Template.RowKey)' from source '$Source'." + } } else { $id = [guid]::NewGuid().ToString() if ($Template.mailNickname) { $Type = 'Group' } if ($Template.'@odata.type' -like '*conditionalAccessPolicy*') { $Type = 'ConditionalAccessPolicy' } Write-Host "The type is $Type" + switch -Wildcard ($Type) { '*Group*' { $RawJsonObj = [PSCustomObject]@{ @@ -82,12 +92,42 @@ function Import-CommunityTemplate { GUID = $id groupType = 'generic' } | ConvertTo-Json -Depth 100 + + # Check for duplicate template + $DuplicateFilter = "PartitionKey eq 'GroupTemplate'" + $ExistingTemplates = Get-CIPPAzDataTableEntity @Table -Filter $DuplicateFilter -ErrorAction SilentlyContinue + $Duplicate = $ExistingTemplates | Where-Object { + try { + $ExistingJSON = if (Test-Json $_.JSON -ErrorAction SilentlyContinue) { + $_.JSON | ConvertFrom-Json + } else { + $_.JSON + } + $ExistingJSON.Displayname -eq $Template.displayName -and $_.Source -eq $Source + } catch { + $false + } + } | Select-Object -First 1 + + if ($Duplicate -and $Duplicate.SHA -eq $SHA -and -not $Force) { + $StatusMessage = "Group template '$($Template.displayName)' from source '$Source' is already up to date. Skipping import." + Write-Information $StatusMessage + break + } + + if ($Duplicate) { + $StatusMessage = "Updating Group template '$($Template.displayName)' from source '$Source' (SHA changed)." + Write-Information $StatusMessage + } else { + $StatusMessage = "Created Group template '$($Template.displayName)' from source '$Source'." + } + $entity = @{ JSON = "$RawJsonObj" PartitionKey = 'GroupTemplate' SHA = $SHA - GUID = $id - RowKey = $id + GUID = if ($Duplicate) { $Duplicate.GUID } else { $id } + RowKey = if ($Duplicate) { $Duplicate.RowKey } else { $id } Source = $Source } Add-CIPPAzDataTableEntity @Table -Entity $entity -Force @@ -125,12 +165,41 @@ function Import-CommunityTemplate { } } + # Check for duplicate template + $DuplicateFilter = "PartitionKey eq 'CATemplate'" + $ExistingTemplates = Get-CIPPAzDataTableEntity @Table -Filter $DuplicateFilter -ErrorAction SilentlyContinue + $Duplicate = $ExistingTemplates | Where-Object { + try { + $ExistingJSON = if (Test-Json $_.JSON -ErrorAction SilentlyContinue) { + $_.JSON | ConvertFrom-Json + } else { + $_.JSON + } + $ExistingJSON.displayName -eq $Template.displayName -and $_.Source -eq $Source + } catch { + $false + } + } | Select-Object -First 1 + + if ($Duplicate -and $Duplicate.SHA -eq $SHA -and -not $Force) { + $StatusMessage = "Conditional Access template '$($Template.displayName)' from source '$Source' is already up to date. Skipping import." + Write-Information $StatusMessage + break + } + + if ($Duplicate) { + $StatusMessage = "Updating Conditional Access template '$($Template.displayName)' from source '$Source' (SHA changed)." + Write-Information $StatusMessage + } else { + $StatusMessage = "Created Conditional Access template '$($Template.displayName)' from source '$Source'." + } + $entity = @{ JSON = "$RawJson" PartitionKey = 'CATemplate' SHA = $SHA - GUID = $id - RowKey = $id + GUID = if ($Duplicate) { $Duplicate.GUID } else { $id } + RowKey = if ($Duplicate) { $Duplicate.RowKey } else { $id } Source = $Source } Write-Information "Final entity: $($entity | ConvertTo-Json -Depth 10)" @@ -153,20 +222,51 @@ function Import-CommunityTemplate { $RawJson = $RawJson | ConvertTo-Json -Depth 100 -Compress #create a new template + $DisplayName = $Template.displayName ?? $template.Name + $RawJsonObj = [PSCustomObject]@{ - Displayname = $Template.displayName ?? $template.Name + Displayname = $DisplayName Description = $Template.Description RAWJson = $RawJson Type = $URLName GUID = $id } | ConvertTo-Json -Depth 100 -Compress + # Check for duplicate template + $DuplicateFilter = "PartitionKey eq 'IntuneTemplate'" + $ExistingTemplates = Get-CIPPAzDataTableEntity @Table -Filter $DuplicateFilter -ErrorAction SilentlyContinue + $Duplicate = $ExistingTemplates | Where-Object { + try { + $ExistingJSON = if (Test-Json $_.JSON -ErrorAction SilentlyContinue) { + $_.JSON | ConvertFrom-Json + } else { + $_.JSON + } + $ExistingJSON.Displayname -eq $DisplayName -and $_.Source -eq $Source + } catch { + $false + } + } | Select-Object -First 1 + + if ($Duplicate -and $Duplicate.SHA -eq $SHA -and -not $Force) { + $StatusMessage = "Intune template '$DisplayName' from source '$Source' is already up to date. Skipping import." + Write-Information $StatusMessage + return $StatusMessage + } + + if ($Duplicate) { + $StatusMessage = "Updating Intune template '$DisplayName' from source '$Source' (SHA changed)." + Write-Information $StatusMessage + } else { + $StatusMessage = "Created Intune template '$DisplayName' from source '$Source'." + } + $entity = @{ JSON = "$RawJsonObj" PartitionKey = 'IntuneTemplate' SHA = $SHA - GUID = $id - RowKey = $id + GUID = if ($Duplicate) { $Duplicate.GUID } else { $id } + RowKey = if ($Duplicate) { $Duplicate.RowKey } else { $id } Source = $Source } @@ -174,13 +274,20 @@ function Import-CommunityTemplate { $entity.Package = $Existing.Package } + if ($Duplicate -and $Duplicate.Package) { + $entity.Package = $Duplicate.Package + } + Add-CIPPAzDataTableEntity @Table -Entity $entity -Force } } } } catch { - Write-Warning "Community template import failed. Error: $($_.Exception.Message)" + $StatusMessage = "Community template import failed. Error: $($_.Exception.Message)" + Write-Warning $StatusMessage Write-Information $_.InvocationInfo.PositionMessage } + + return $StatusMessage } diff --git a/Modules/CIPPCore/lib/data/SAMManifest.json b/Modules/CIPPCore/lib/data/SAMManifest.json index 534b0a29e5de..bbdeaa675acf 100644 --- a/Modules/CIPPCore/lib/data/SAMManifest.json +++ b/Modules/CIPPCore/lib/data/SAMManifest.json @@ -10,6 +10,10 @@ "http://localhost:8400" ] }, + "servicePrincipalLockConfiguration": { + "isEnabled": true, + "allProperties": true + }, "requiredResourceAccess": [ { "resourceAppId": "c5393580-f805-4401-95e8-94b7a6ef2fc2", diff --git a/Modules/CippEntrypoints/CippEntrypoints.psm1 b/Modules/CippEntrypoints/CippEntrypoints.psm1 index bc94fb638e28..08614116fba9 100644 --- a/Modules/CippEntrypoints/CippEntrypoints.psm1 +++ b/Modules/CippEntrypoints/CippEntrypoints.psm1 @@ -360,7 +360,7 @@ function Receive-CippActivityTrigger { try { Write-Verbose "Activity starting Function: $FunctionName." - Invoke-Command -ScriptBlock { & $FunctionName -Item $Item } + $Output = Invoke-Command -ScriptBlock { & $FunctionName -Item $Item } $Status = 'Completed' Write-Verbose "Activity completed Function: $FunctionName." diff --git a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 index 7d2b1a4b9aab..bca90aa87eba 100644 --- a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 +++ b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 @@ -461,15 +461,21 @@ function Invoke-NinjaOneTenantSync { [System.Collections.Generic.List[PSCustomObject]]$DeviceMap = @() } + $DevicesToProcess = $Devices | Where-Object { $_.id -notin $ParsedDevices.id } + # Parse Devices - foreach ($Device in $Devices | Where-Object { $_.id -notin $ParsedDevices.id }) { + foreach ($Device in $DevicesToProcess) { - # First lets match on serial - $MatchedNinjaDevice = $NinjaDevices | Where-Object { $_.system.biosSerialNumber -eq $Device.SerialNumber -or $_.system.serialNumber -eq $Device.SerialNumber } + # First lets match on serial (normalize by removing spaces for comparison) + $NormalizedDeviceSerial = $Device.SerialNumber -replace '\s', '' + $MatchedNinjaDevice = $NinjaDevices | Where-Object { + ($_.system.biosSerialNumber -replace '\s', '') -eq $NormalizedDeviceSerial -or + ($_.system.serialNumber -replace '\s', '') -eq $NormalizedDeviceSerial + } # See if we found just one device, if not match on name if (($MatchedNinjaDevice | Measure-Object).count -ne 1) { - $MatchedNinjaDevice = $NinjaDevices | Where-Object { $_.systemName -eq $Device.Name -or $_.dnsName -eq $Device.Name } + $MatchedNinjaDevice = $NinjaDevices | Where-Object { $_.systemName -eq $Device.deviceName -or $_.dnsName -eq $Device.deviceName } } # Check on a match again and set name @@ -710,7 +716,12 @@ function Invoke-NinjaOneTenantSync { # Update Device if ($MappedFields.DeviceSummary -or $MappedFields.DeviceLinks -or $MappedFields.DeviceCompliance) { - $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 ($NinjaDeviceUpdate | ConvertTo-Json -Depth 100) + 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 + } catch { + Write-Verbose "Error details: $($_ | ConvertTo-Json -Depth 5)" + } } } @@ -1543,8 +1554,6 @@ function Invoke-NinjaOneTenantSync { ### M365 Links Section if ($MappedFields.TenantLinks) { - Write-Information 'Tenant Links' - $ManagementLinksData = @( @{ Name = 'M365 Admin Portal' @@ -1650,8 +1659,6 @@ function Invoke-NinjaOneTenantSync { if ($MappedFields.TenantSummary) { - Write-Information 'Tenant Summary' - ### Tenant Overview Card $ParsedAdmins = [PSCustomObject]@{} @@ -1672,7 +1679,6 @@ function Invoke-NinjaOneTenantSync { $TenantSummaryCard = Get-NinjaOneInfoCard -Title 'Tenant Details' -Data $TenantDetailsItems -Icon 'fas fa-building' ### Users details card - Write-Information 'User Details' $TotalUsersCount = ($Users | Measure-Object).count $GuestUsersCount = ($Users | Where-Object { $_.UserType -eq 'Guest' } | Measure-Object).count $LicensedUsersCount = ($licensedUsers | Measure-Object).count @@ -1730,7 +1736,6 @@ function Invoke-NinjaOneTenantSync { ### Device Details Card - Write-Information 'Device Details' $TotalDeviceswCount = ($Devices | Measure-Object).count $ComplianceDevicesCount = ($Devices | Where-Object { $_.complianceState -eq 'compliant' } | Measure-Object).count $WindowsCount = ($Devices | Where-Object { $_.operatingSystem -eq 'Windows' } | Measure-Object).count @@ -1810,7 +1815,6 @@ function Invoke-NinjaOneTenantSync { $DeviceSummaryCardHTML = Get-NinjaOneCard -Title 'Device Details' -Body $DeviceCardBodyHTML -Icon 'fas fa-network-wired' -TitleLink $TitleLink #### Secure Score Card - Write-Information 'Secure Score Details' $Top5Actions = ($SecureScoreParsed | Where-Object { $_.scoreInPercentage -ne 100 } | Sort-Object 'Score Impact', adjustedRank -Descending) | Select-Object -First 5 # Score Chart @@ -1845,7 +1849,6 @@ function Invoke-NinjaOneTenantSync { ### CIPP Applied Standards Cards - Write-Information 'Applied Standards' $ModuleBase = Get-Module CIPPExtensions | Select-Object -ExpandProperty ModuleBase $CIPPRoot = (Get-Item $ModuleBase).Parent.Parent.FullName Set-Location $CIPPRoot @@ -2195,7 +2198,7 @@ function Invoke-NinjaOneTenantSync { $Token = Get-NinjaOneToken -configuration $Configuration - Write-Information "Ninja Body: $($NinjaOrgUpdate | ConvertTo-Json -Depth 100)" + #Write-Information "Ninja Body: $($NinjaOrgUpdate | ConvertTo-Json -Depth 100)" $Result = Invoke-WebRequest -Uri "https://$($Configuration.Instance)/api/v2/organization/$($MappedTenant.IntegrationId)/custom-fields" -Method PATCH -Headers @{Authorization = "Bearer $($token.access_token)" } -ContentType 'application/json; charset=utf-8' -Body ($NinjaOrgUpdate | ConvertTo-Json -Depth 100) diff --git a/Tests/Endpoint/Invoke-AddIntuneReusableSettingTemplate.Tests.ps1 b/Tests/Endpoint/Invoke-AddIntuneReusableSettingTemplate.Tests.ps1 index 14a79de3fca1..d29a4530611f 100644 --- a/Tests/Endpoint/Invoke-AddIntuneReusableSettingTemplate.Tests.ps1 +++ b/Tests/Endpoint/Invoke-AddIntuneReusableSettingTemplate.Tests.ps1 @@ -4,6 +4,7 @@ BeforeAll { $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntuneReusableSettingTemplate.ps1' + $MetadataPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Remove-CIPPReusableSettingMetadata.ps1' class HttpResponseContext { [int]$StatusCode @@ -19,9 +20,7 @@ BeforeAll { [pscustomobject]@{ NormalizedError = $Exception } } - # Pass-through for metadata cleanup used in the function - function Remove-CIPPReusableSettingMetadata { param($InputObject) $InputObject } - + . $MetadataPath . $FunctionPath } @@ -72,4 +71,33 @@ Describe 'Invoke-AddIntuneReusableSettingTemplate' { $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::InternalServerError) $response.Body.Results | Should -Match 'RawJSON is not valid JSON' } + + It 'normalizes children null values in reusable setting templates' { + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'AddIntuneReusableSettingTemplate' } + Headers = @{ Authorization = 'Bearer token' } + Body = [pscustomobject]@{ + displayName = 'Template With Children' + rawJSON = '{"displayName":"Template With Children","settingInstance":{"groupSettingCollectionValue":[{"children":[{"choiceSettingValue":{"children":null}}]}]}}' + GUID = 'template-children' + } + } + + $parsed = $request.Body.rawJSON | ConvertFrom-Json -Depth 100 + $clean = Remove-CIPPReusableSettingMetadata -InputObject $parsed + $clean.settingInstance.PSObject.Properties.Name | Should -Contain 'groupSettingCollectionValue' + $clean.settingInstance.groupSettingCollectionValue | Should -Not -BeNullOrEmpty + $clean.settingInstance.groupSettingCollectionValue.GetType().FullName | Should -Be 'System.Object[]' + ($clean.settingInstance.groupSettingCollectionValue -is [System.Collections.IEnumerable]) | Should -BeTrue + ($clean.settingInstance.groupSettingCollectionValue | Measure-Object).Count | Should -Be 1 + ($clean.settingInstance.groupSettingCollectionValue[0].children -is [System.Collections.IEnumerable]) | Should -BeTrue + ($clean.settingInstance.groupSettingCollectionValue[0].children | Measure-Object).Count | Should -Be 1 + ($clean.settingInstance.groupSettingCollectionValue[0].children[0].choiceSettingValue.children -is [System.Collections.IEnumerable]) | Should -BeTrue + + $response = Invoke-AddIntuneReusableSettingTemplate -Request $request -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $lastEntity.RawJSON | Should -Not -Match '"children":null' + $lastEntity.RawJSON | Should -Match '"children":\[\]' + } } diff --git a/cspell.json b/cspell.json index 28a883c89c5e..597a0ed1277a 100644 --- a/cspell.json +++ b/cspell.json @@ -43,6 +43,7 @@ "SharePoint", "Sherweb", "Signup", + "Skus", "SSPR", "Standardcal", "Terrl", diff --git a/openapi.json b/openapi.json index 013d331ba238..194c1d67d499 100644 --- a/openapi.json +++ b/openapi.json @@ -13,9 +13,7 @@ "post": { "description": "AddGroup", "summary": "AddGroup", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -53,9 +51,7 @@ "post": { "description": "AddChocoApp", "summary": "AddChocoApp", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -89,13 +85,65 @@ } } }, + "/AddWin32ScriptApp": { + "post": { + "description": "AddWin32ScriptApp", + "summary": "AddWin32ScriptApp", + "tags": ["POST"], + "parameters": [ + { + "required": true, + "schema": { + "type": "string" + }, + "name": "ApplicationName", + "in": "body" + }, + { + "required": true, + "schema": { + "type": "string" + }, + "name": "installScript", + "in": "body" + }, + { + "required": true, + "schema": { + "type": "string" + }, + "name": "AssignTo", + "in": "body" + }, + { + "required": false, + "schema": { + "type": "string" + }, + "name": "InstallationIntent", + "in": "body" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": {}, + "type": "object" + } + } + }, + "description": "Successful operation" + } + } + } + }, "/RemoveUser": { "get": { "description": "RemoveUser", "summary": "RemoveUser", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -133,9 +181,7 @@ "get": { "description": "ListTeams", "summary": "ListTeams", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -181,9 +227,7 @@ "get": { "description": "ExecGroupsDelete", "summary": "ExecGroupsDelete", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -237,9 +281,7 @@ "get": { "description": "ListRoles", "summary": "ListRoles", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -269,9 +311,7 @@ "get": { "description": "ListUserMailboxRules", "summary": "ListUserMailboxRules", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -317,9 +357,7 @@ "get": { "description": "ExecBECCheck", "summary": "ExecBECCheck", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -365,9 +403,7 @@ "get": { "description": "ListCalendarPermissions", "summary": "ListCalendarPermissions", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -405,9 +441,7 @@ "get": { "description": "ExecAddSPN", "summary": "ExecAddSPN", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -437,9 +471,7 @@ "get": { "description": "ListLicenses", "summary": "ListLicenses", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -469,9 +501,7 @@ "post": { "description": "AddCATemplate", "summary": "AddCATemplate", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -509,9 +539,7 @@ "get": { "description": "ExecIncidentsList", "summary": "ExecIncidentsList", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -541,9 +569,7 @@ "post": { "description": "AddSharedMailbox", "summary": "AddSharedMailbox", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -573,9 +599,7 @@ "get": { "description": "ListApps", "summary": "ListApps", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -605,9 +629,7 @@ "get": { "description": "ListSharepointSettings", "summary": "ListSharepointSettings", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -653,9 +675,7 @@ "get": { "description": "ExecSendOrgMessage", "summary": "ExecSendOrgMessage", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -717,9 +737,7 @@ "post": { "description": "AddSpamFilterTemplate", "summary": "AddSpamFilterTemplate", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -757,9 +775,7 @@ "get": { "description": "ListGroupTemplates", "summary": "ListGroupTemplates", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -789,9 +805,7 @@ "get": { "description": "ListAutopilotconfig", "summary": "ListAutopilotconfig", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -837,9 +851,7 @@ "get": { "description": "ExecSetSecurityIncident", "summary": "ExecSetSecurityIncident", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -917,9 +929,7 @@ "get": { "description": "EditExConnector", "summary": "EditExConnector", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -973,9 +983,7 @@ "post": { "description": "AddUser", "summary": "AddUser", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -1005,9 +1013,7 @@ "get": { "description": "ListUserPhoto", "summary": "ListUserPhoto", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -1045,9 +1051,7 @@ "get": { "description": "ListConditionalAccessPolicies", "summary": "ListConditionalAccessPolicies", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -1077,9 +1081,7 @@ "get": { "description": "ListBasicAuth", "summary": "ListBasicAuth", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -1109,9 +1111,7 @@ "get": { "description": "RemoveSpamfilterTemplate", "summary": "RemoveSpamfilterTemplate", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -1141,9 +1141,7 @@ "post": { "description": "ExecGDAPInvite", "summary": "ExecGDAPInvite", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -1173,9 +1171,7 @@ "get": { "description": "ListUserSigninLogs", "summary": "ListUserSigninLogs", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -1213,9 +1209,7 @@ "get": { "description": "EditCAPolicy", "summary": "EditCAPolicy", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -1261,9 +1255,7 @@ "get": { "description": "ListDomainHealth", "summary": "ListDomainHealth", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -1333,9 +1325,7 @@ "get": { "description": "ExecUniversalSearch", "summary": "ExecUniversalSearch", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -1365,9 +1355,7 @@ "get": { "description": "ListMailboxCAS", "summary": "ListMailboxCAS", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -1397,9 +1385,7 @@ "get": { "description": "RemoveExConnectorTemplate", "summary": "RemoveExConnectorTemplate", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -1429,9 +1415,7 @@ "get": { "description": "ListUserCounts", "summary": "ListUserCounts", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -1461,9 +1445,7 @@ "get": { "description": "ExecGetLocalAdminPassword", "summary": "ExecGetLocalAdminPassword", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -1501,9 +1483,7 @@ "get": { "description": "ListOrg", "summary": "ListOrg", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -1533,9 +1513,7 @@ "post": { "description": "ExecExcludeLicenses", "summary": "ExecExcludeLicenses", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -1603,9 +1581,7 @@ "get": { "description": "ExecExcludeLicenses", "summary": "ExecExcludeLicenses", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -1675,9 +1651,7 @@ "get": { "description": "ExecDeleteGDAPRelationship", "summary": "ExecDeleteGDAPRelationship", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -1707,9 +1681,7 @@ "post": { "description": "AddMSPApp", "summary": "AddMSPApp", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -1747,9 +1719,7 @@ "post": { "description": "EditPolicy", "summary": "EditPolicy", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -1811,9 +1781,7 @@ "post": { "description": "ExecDeviceAction", "summary": "ExecDeviceAction", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -1865,9 +1833,7 @@ "get": { "description": "ExecDeviceAction", "summary": "ExecDeviceAction", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -1921,9 +1887,7 @@ "post": { "description": "AddWinGetApp", "summary": "AddWinGetApp", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -1961,9 +1925,7 @@ "get": { "description": "RemovePolicy", "summary": "RemovePolicy", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -2009,9 +1971,7 @@ "post": { "description": "AddExConnector", "summary": "AddExConnector", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -2041,9 +2001,7 @@ "get": { "description": "ListTeamsVoice", "summary": "ListTeamsVoice", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -2073,9 +2031,7 @@ "get": { "description": "ListTeamsActivity", "summary": "ListTeamsActivity", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -2113,9 +2069,7 @@ "get": { "description": "ListSpamFilterTemplates", "summary": "ListSpamFilterTemplates", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -2145,9 +2099,7 @@ "get": { "description": "ExecCopyForSent", "summary": "ExecCopyForSent", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -2193,9 +2145,7 @@ "get": { "description": "ListAzureADConnectStatus", "summary": "ListAzureADConnectStatus", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -2233,9 +2183,7 @@ "get": { "description": "ExecEnableArchive", "summary": "ExecEnableArchive", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -2273,9 +2221,7 @@ "get": { "description": "ExecGetRecoveryKey", "summary": "ExecGetRecoveryKey", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -2313,9 +2259,7 @@ "get": { "description": "ListSharedMailboxStatistics", "summary": "ListSharedMailboxStatistics", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -2345,9 +2289,7 @@ "post": { "description": "ListPotentialApps", "summary": "ListPotentialApps", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -2385,9 +2327,7 @@ "get": { "description": "ExecCPVPermissions", "summary": "ExecCPVPermissions", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -2417,9 +2357,7 @@ "get": { "description": "ListSharepointQuota", "summary": "ListSharepointQuota", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -2449,9 +2387,7 @@ "get": { "description": "ListDefenderTVM", "summary": "ListDefenderTVM", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -2481,9 +2417,7 @@ "post": { "description": "ExecEditMailboxPermissions", "summary": "ExecEditMailboxPermissions", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -2569,9 +2503,7 @@ "post": { "description": "AddOfficeApp", "summary": "AddOfficeApp", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -2657,9 +2589,7 @@ "get": { "description": "EditSpamFilter", "summary": "EditSpamFilter", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -2705,9 +2635,7 @@ "get": { "description": "ListSignIns", "summary": "ListSignIns", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -2753,9 +2681,7 @@ "get": { "description": "ExecDnsConfig", "summary": "ExecDnsConfig", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -2801,9 +2727,7 @@ "post": { "description": "ExecEmailForward", "summary": "ExecEmailForward", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -2873,9 +2797,7 @@ "post": { "description": "ExecBECRemediate", "summary": "ExecBECRemediate", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -2913,9 +2835,7 @@ "get": { "description": "ListPartnerRelationships", "summary": "ListPartnerRelationships", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -2945,9 +2865,7 @@ "post": { "description": "ListAppsRepository", "summary": "ListAppsRepository", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -2985,9 +2903,7 @@ "get": { "description": "ExecClrImmId", "summary": "ExecClrImmId", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -3025,9 +2941,7 @@ "post": { "description": "AddGroupTemplate", "summary": "AddGroupTemplate", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -3097,9 +3011,7 @@ "post": { "description": "Standards_IntuneTemplate", "summary": "Standards_IntuneTemplate", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -3161,9 +3073,7 @@ "get": { "description": "ListIntuneTemplates", "summary": "ListIntuneTemplates", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -3193,9 +3103,7 @@ "get": { "description": "ExecResetPass", "summary": "ExecResetPass", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -3249,9 +3157,7 @@ "get": { "description": "ExecAlertsList", "summary": "ExecAlertsList", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -3281,9 +3187,7 @@ "get": { "description": "ExecQuarantineManagement", "summary": "ExecQuarantineManagement", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -3337,9 +3241,7 @@ "get": { "description": "ExecRestoreDeleted", "summary": "ExecRestoreDeleted", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -3377,9 +3279,7 @@ "get": { "description": "ListUserGroups", "summary": "ListUserGroups", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -3417,9 +3317,7 @@ "get": { "description": "RemoveStandard", "summary": "RemoveStandard", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -3449,9 +3347,7 @@ "get": { "description": "ListUserConditionalAccessPolicies", "summary": "ListUserConditionalAccessPolicies", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -3489,9 +3385,7 @@ "get": { "description": "ListCAtemplates", "summary": "ListCAtemplates", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -3521,9 +3415,7 @@ "get": { "description": "ListContacts", "summary": "ListContacts", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -3644,9 +3536,7 @@ "get": { "description": "ListContactPermissions - Retrieves contact folder permissions for a specified user", "summary": "ListContactPermissions", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -3729,20 +3619,14 @@ "post": { "description": "ExecModifyContactPerms - Modifies contact folder permissions for a specified user", "summary": "ExecModifyContactPerms", - "tags": [ - "POST" - ], + "tags": ["POST"], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": [ - "userID", - "tenantFilter", - "permissions" - ], + "required": ["userID", "tenantFilter", "permissions"], "properties": { "userID": { "type": "string", @@ -3801,11 +3685,7 @@ "default": false } }, - "required": [ - "PermissionLevel", - "Modification", - "UserID" - ] + "required": ["PermissionLevel", "Modification", "UserID"] } } } @@ -3897,9 +3777,7 @@ "get": { "description": "ListMailboxRules", "summary": "ListMailboxRules", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -3929,9 +3807,7 @@ "get": { "description": "ListSites", "summary": "ListSites", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -3977,9 +3853,7 @@ "post": { "description": "ExecSetOoO", "summary": "ExecSetOoO", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -4033,9 +3907,7 @@ "post": { "description": "AddExConnectorTemplate", "summary": "AddExConnectorTemplate", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -4073,9 +3945,7 @@ "post": { "description": "ExecOffboardUser", "summary": "ExecOffboardUser", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -4153,9 +4023,7 @@ "get": { "description": "ListTenantDetails", "summary": "ListTenantDetails", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -4185,9 +4053,7 @@ "get": { "description": "ExecConverttoSharedMailbox", "summary": "ExecConverttoSharedMailbox", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -4233,9 +4099,7 @@ "get": { "description": "ExecGraphRequest", "summary": "ExecGraphRequest", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -4281,9 +4145,7 @@ "get": { "description": "ListDefenderState", "summary": "ListDefenderState", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -4313,9 +4175,7 @@ "get": { "description": "ListMailQuarantine", "summary": "ListMailQuarantine", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -4345,9 +4205,7 @@ "get": { "description": "ListIntunePolicy", "summary": "ListIntunePolicy", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -4393,9 +4251,7 @@ "get": { "description": "ExecRevokeSessions", "summary": "ExecRevokeSessions", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -4433,9 +4289,7 @@ "get": { "description": "ListmailboxPermissions", "summary": "ListmailboxPermissions", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -4473,9 +4327,7 @@ "post": { "description": "ExecExtensionsConfig", "summary": "ExecExtensionsConfig", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -4505,9 +4357,7 @@ "get": { "description": "ListDevices", "summary": "ListDevices", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -4537,9 +4387,7 @@ "get": { "description": "RemoveCATemplate", "summary": "RemoveCATemplate", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -4569,9 +4417,7 @@ "get": { "description": "RemoveQueuedApp", "summary": "RemoveQueuedApp", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -4601,9 +4447,7 @@ "get": { "description": "DomainAnalyser_List", "summary": "DomainAnalyser_List", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -4633,9 +4477,7 @@ "post": { "description": "AddNamedLocation", "summary": "AddNamedLocation", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -4713,9 +4555,7 @@ "get": { "description": "ListTransportRulesTemplates", "summary": "ListTransportRulesTemplates", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -4745,9 +4585,7 @@ "get": { "description": "ExecEditCalendarPermissions", "summary": "ExecEditCalendarPermissions", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -4817,9 +4655,7 @@ "get": { "description": "ExecDisableUser", "summary": "ExecDisableUser", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -4865,9 +4701,7 @@ "post": { "description": "AddTransportRule", "summary": "AddTransportRule", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -4897,9 +4731,7 @@ "get": { "description": "ListTenants", "summary": "ListTenants", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -4953,9 +4785,7 @@ "get": { "description": "ExecResetMFA", "summary": "ExecResetMFA", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -4993,9 +4823,7 @@ "get": { "description": "ListIntuneIntents", "summary": "ListIntuneIntents", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5025,9 +4853,7 @@ "get": { "description": "ListStandards", "summary": "ListStandards", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5057,9 +4883,7 @@ "get": { "description": "ListDeletedItems", "summary": "ListDeletedItems", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5089,9 +4913,7 @@ "get": { "description": "ExecGroupsHideFromGAL", "summary": "ExecGroupsHideFromGAL", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5145,9 +4967,7 @@ "get": { "description": "ExecSendPush", "summary": "ExecSendPush", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5185,9 +5005,7 @@ "post": { "description": "ExecExtensionMapping", "summary": "ExecExtensionMapping", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -5231,9 +5049,7 @@ "get": { "description": "ExecExtensionMapping", "summary": "ExecExtensionMapping", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5279,9 +5095,7 @@ "get": { "description": "ListAppStatus", "summary": "ListAppStatus", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5319,9 +5133,7 @@ "get": { "description": "GetVersion", "summary": "GetVersion", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5351,9 +5163,7 @@ "get": { "description": "ExecMailboxMobileDevices", "summary": "ExecMailboxMobileDevices", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5415,9 +5225,7 @@ "get": { "description": "RemoveApp", "summary": "RemoveApp", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5455,9 +5263,7 @@ "post": { "description": "ExecPasswordConfig", "summary": "ExecPasswordConfig", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -5493,9 +5299,7 @@ "get": { "description": "ExecPasswordConfig", "summary": "ExecPasswordConfig", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5533,9 +5337,7 @@ "get": { "description": "ExecHideFromGAL", "summary": "ExecHideFromGAL", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5581,9 +5383,7 @@ "post": { "description": "EditUser", "summary": "EditUser", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -5613,9 +5413,7 @@ "get": { "description": "ListOAuthApps", "summary": "ListOAuthApps", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5645,9 +5443,7 @@ "get": { "description": "ListDeviceDetails", "summary": "ListDeviceDetails", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5701,9 +5497,7 @@ "get": { "description": "ListLogs", "summary": "ListLogs", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5765,9 +5559,7 @@ "get": { "description": "ListAllTenantDeviceCompliance", "summary": "ListAllTenantDeviceCompliance", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5797,9 +5589,7 @@ "get": { "description": "ListUsers", "summary": "ListUsers", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5853,9 +5643,7 @@ "get": { "description": "ListMessageTrace", "summary": "ListMessageTrace", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5925,9 +5713,7 @@ "get": { "description": "ListPhishPolicies", "summary": "ListPhishPolicies", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5957,9 +5743,7 @@ "get": { "description": "RemoveSpamfilter", "summary": "RemoveSpamfilter", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5997,9 +5781,7 @@ "post": { "description": "ExecNotificationConfig", "summary": "ExecNotificationConfig", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -6069,9 +5851,7 @@ "post": { "description": "ExecSAMSetup", "summary": "ExecSAMSetup", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -6195,9 +5975,7 @@ "get": { "description": "ExecSAMSetup", "summary": "ExecSAMSetup", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -6323,9 +6101,7 @@ "get": { "description": "ListUserMailboxDetails", "summary": "ListUserMailboxDetails", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -6363,9 +6139,7 @@ "get": { "description": "ListExConnectorTemplates", "summary": "ListExConnectorTemplates", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -6395,9 +6169,7 @@ "post": { "description": "AddDefenderDeployment", "summary": "AddDefenderDeployment", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -6459,9 +6231,7 @@ "get": { "description": "ListGDAPInvite", "summary": "ListGDAPInvite", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -6491,9 +6261,7 @@ "post": { "description": "AddIntuneTemplate", "summary": "AddIntuneTemplate", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -6569,9 +6337,7 @@ "get": { "description": "AddIntuneTemplate", "summary": "AddIntuneTemplate", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -6649,9 +6415,7 @@ "post": { "description": "AddAPDevice", "summary": "AddAPDevice", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -6697,9 +6461,7 @@ "get": { "description": "RemoveTransportRuleTemplate", "summary": "RemoveTransportRuleTemplate", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -6729,9 +6491,7 @@ "get": { "description": "ListMailboxMobileDevices", "summary": "ListMailboxMobileDevices", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -6769,9 +6529,7 @@ "post": { "description": "ExecExcludeTenant", "summary": "ExecExcludeTenant", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -6839,9 +6597,7 @@ "get": { "description": "ExecExcludeTenant", "summary": "ExecExcludeTenant", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -6911,9 +6667,7 @@ "get": { "description": "ExecSetSecurityAlert", "summary": "ExecSetSecurityAlert", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -6975,9 +6729,7 @@ "post": { "description": "ExecAddGDAPRole", "summary": "ExecAddGDAPRole", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -7015,9 +6767,7 @@ "get": { "description": "ListExchangeConnectors", "summary": "ListExchangeConnectors", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -7047,9 +6797,7 @@ "post": { "description": "AddTransportTemplate", "summary": "AddTransportTemplate", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -7087,9 +6835,7 @@ "get": { "description": "ExecCreateTAP", "summary": "ExecCreateTAP", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -7127,9 +6873,7 @@ "get": { "description": "RemoveCAPolicy", "summary": "RemoveCAPolicy", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -7167,9 +6911,7 @@ "get": { "description": "RemoveTransportRule", "summary": "RemoveTransportRule", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -7207,9 +6949,7 @@ "get": { "description": "RemoveAPDevice", "summary": "RemoveAPDevice", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -7247,9 +6987,7 @@ "post": { "description": "AddPolicy", "summary": "AddPolicy", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -7319,9 +7057,7 @@ "post": { "description": "AddContact", "summary": "AddContact", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -7351,9 +7087,7 @@ "get": { "description": "PublicScripts", "summary": "PublicScripts", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -7383,9 +7117,7 @@ "get": { "description": "RemoveContact", "summary": "RemoveContact", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -7423,9 +7155,7 @@ "get": { "description": "EditTransportRule", "summary": "EditTransportRule", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -7471,9 +7201,7 @@ "post": { "description": "AddSpamFilter", "summary": "AddSpamFilter", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -7503,9 +7231,7 @@ "get": { "description": "RemoveIntuneTemplate", "summary": "RemoveIntuneTemplate", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -7535,9 +7261,7 @@ "get": { "description": "ExecGroupsDeliveryManagement", "summary": "ExecGroupsDeliveryManagement", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -7591,9 +7315,7 @@ "get": { "description": "ListUserDevices", "summary": "ListUserDevices", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -7631,9 +7353,7 @@ "post": { "description": "EditTenant", "summary": "EditTenant", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -7687,9 +7407,7 @@ "get": { "description": "ExecAppApproval", "summary": "ExecAppApproval", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -7727,9 +7445,7 @@ "get": { "description": "ListInactiveAccounts", "summary": "ListInactiveAccounts", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -7759,9 +7475,7 @@ "post": { "description": "AddAlert", "summary": "AddAlert", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -7927,9 +7641,7 @@ "get": { "description": "ListMailboxes", "summary": "ListMailboxes", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -7959,9 +7671,7 @@ "get": { "description": "ListMFAUsers", "summary": "ListMFAUsers", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -7991,9 +7701,7 @@ "get": { "description": "RemoveGroupTemplate", "summary": "RemoveGroupTemplate", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8023,9 +7731,7 @@ "get": { "description": "ExecRunBackup", "summary": "ExecRunBackup", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8055,9 +7761,7 @@ "get": { "description": "ListTransportRules", "summary": "ListTransportRules", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8087,9 +7791,7 @@ "get": { "description": "ExecMaintenanceScripts", "summary": "ExecMaintenanceScripts", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8127,9 +7829,7 @@ "get": { "description": "GetCippAlerts", "summary": "GetCippAlerts", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8159,9 +7859,7 @@ "post": { "description": "ExecGDAPMigration", "summary": "ExecGDAPMigration", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -8199,9 +7897,7 @@ "get": { "description": "ListGroups", "summary": "ListGroups", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8255,9 +7951,7 @@ "get": { "description": "ListDomains", "summary": "ListDomains", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8287,9 +7981,7 @@ "get": { "description": "ListExternalTenantInfo", "summary": "ListExternalTenantInfo", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8319,9 +8011,7 @@ "get": { "description": "ListAPDevices", "summary": "ListAPDevices", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8359,9 +8049,7 @@ "post": { "description": "AddAutopilotConfig", "summary": "AddAutopilotConfig", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -8407,9 +8095,7 @@ "post": { "description": "AddCAPolicy", "summary": "AddCAPolicy", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -8439,9 +8125,7 @@ "get": { "description": "ListMailboxStatistics", "summary": "ListMailboxStatistics", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8471,9 +8155,7 @@ "get": { "description": "ExecAssignApp", "summary": "ExecAssignApp", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8519,9 +8201,7 @@ "get": { "description": "ExecExtensionTest", "summary": "ExecExtensionTest", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8551,9 +8231,7 @@ "post": { "description": "ExecSetMailboxQuota", "summary": "ExecSetMailboxQuota", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -8623,9 +8301,7 @@ "get": { "description": "ListNamedLocations", "summary": "ListNamedLocations", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8655,9 +8331,7 @@ "get": { "description": "ListMFAUsersAllTenants", "summary": "ListMFAUsersAllTenants", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8687,9 +8361,7 @@ "get": { "description": "RemoveQueuedAlert", "summary": "RemoveQueuedAlert", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8719,9 +8391,7 @@ "post": { "description": "ExecAccessChecks", "summary": "ExecAccessChecks", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -8765,9 +8435,7 @@ "get": { "description": "ExecAccessChecks", "summary": "ExecAccessChecks", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8813,9 +8481,7 @@ "get": { "description": "ListSharedMailboxAccountEnabled", "summary": "ListSharedMailboxAccountEnabled", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8845,9 +8511,7 @@ "get": { "description": "ListSpamfilter", "summary": "ListSpamfilter", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8877,9 +8541,7 @@ "get": { "description": "ListQuarantinePolicy", "summary": "ListQuarantinePolicy", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8917,9 +8579,7 @@ "post": { "description": "AddQuarantinePolicy", "summary": "AddQuarantinePolicy", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -9013,9 +8673,7 @@ "post": { "description": "EditQuarantinePolicy", "summary": "EditQuarantinePolicy", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -9141,9 +8799,7 @@ "get": { "description": "RemoveExConnector", "summary": "RemoveExConnector", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -9189,9 +8845,7 @@ "get": { "description": "RemoveQuarantinePolicy", "summary": "RemoveQuarantinePolicy", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -9238,9 +8892,7 @@ "get": { "description": "ExecDeleteSafeLinksPolicy", "summary": "ExecDeleteSafeLinksPolicy", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -9286,9 +8938,7 @@ "post": { "description": "EditSafeLinksPolicy", "summary": "EditSafeLinksPolicy", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -9434,9 +9084,7 @@ "get": { "description": "ListSafeLinksPolicyDetails", "summary": "ListSafeLinksPolicyDetails", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -9594,9 +9242,7 @@ "post": { "description": "Create a new SafeLinks policy and associated rule", "summary": "Create SafeLinks Policy and Rule Configuration", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -9791,9 +9437,7 @@ "get": { "description": "List SafeLinks Policy Templates", "summary": "List SafeLinks Policy Templates", - "tags": [ - "GET" - ], + "tags": ["GET"], "responses": { "200": { "content": { @@ -9816,9 +9460,7 @@ "get": { "description": "Remove SafeLinks Policy Template", "summary": "Remove SafeLinks Policy Template", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -9848,9 +9490,7 @@ "post": { "description": "Add SafeLinks Policy Template", "summary": "Add SafeLinks Policy Template", - "tags": [ - "POST" - ], + "tags": ["POST"], "requestBody": { "content": { "application/json": { @@ -9880,9 +9520,7 @@ "post": { "description": "Deploy SafeLinks Policy From Template", "summary": "Deploy SafeLinks Policy From Template", - "tags": [ - "POST" - ], + "tags": ["POST"], "requestBody": { "content": { "application/json": { @@ -9912,9 +9550,7 @@ "get": { "description": "List retention policies or get a specific retention policy by name", "summary": "Get Retention Policies", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -9996,9 +9632,7 @@ "post": { "description": "Create, modify, or delete retention policies", "summary": "Manage Retention Policies", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -10134,9 +9768,7 @@ "get": { "description": "List retention tags or get a specific retention tag by name", "summary": "Get Retention Tags", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -10224,9 +9856,7 @@ "post": { "description": "Create, modify, or delete retention tags", "summary": "Manage Retention Tags", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -10254,12 +9884,39 @@ }, "Type": { "type": "string", - "enum": ["All", "Inbox", "SentItems", "DeletedItems", "Drafts", "Outbox", "JunkEmail", "Journal", "SyncIssues", "ConversationHistory", "Personal", "RecoverableItems", "NonIpmRoot", "LegacyArchiveJournals", "Clutter", "Calendar", "Notes", "Tasks", "Contacts", "RssSubscriptions", "ManagedCustomFolder"], + "enum": [ + "All", + "Inbox", + "SentItems", + "DeletedItems", + "Drafts", + "Outbox", + "JunkEmail", + "Journal", + "SyncIssues", + "ConversationHistory", + "Personal", + "RecoverableItems", + "NonIpmRoot", + "LegacyArchiveJournals", + "Clutter", + "Calendar", + "Notes", + "Tasks", + "Contacts", + "RssSubscriptions", + "ManagedCustomFolder" + ], "description": "Type of the retention tag" }, "RetentionAction": { "type": "string", - "enum": ["DeleteAndAllowRecovery", "PermanentlyDelete", "MoveToArchive", "MarkAsPastRetentionLimit"], + "enum": [ + "DeleteAndAllowRecovery", + "PermanentlyDelete", + "MoveToArchive", + "MarkAsPastRetentionLimit" + ], "description": "Action to take when retention period expires" }, "AgeLimitForRetention": { @@ -10304,7 +9961,12 @@ }, "RetentionAction": { "type": "string", - "enum": ["DeleteAndAllowRecovery", "PermanentlyDelete", "MoveToArchive", "MarkAsPastRetentionLimit"], + "enum": [ + "DeleteAndAllowRecovery", + "PermanentlyDelete", + "MoveToArchive", + "MarkAsPastRetentionLimit" + ], "description": "Action to take when retention period expires" }, "AgeLimitForRetention": { @@ -10393,9 +10055,7 @@ "post": { "description": "Apply a retention policy to one or more mailboxes", "summary": "Set Mailbox Retention Policies", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, diff --git a/version_latest.txt b/version_latest.txt index 82f3d338cfb6..4149c39eec6f 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.0.9 +10.1.0