diff --git a/Config/openapi.json b/Config/openapi.json index 4bc57c7d41509..e019e964185ff 100644 --- a/Config/openapi.json +++ b/Config/openapi.json @@ -6280,7 +6280,7 @@ "Tenant > Standards" ], "requestBody": { - "required": true, + "required": false, "content": { "application/json": { "schema": { @@ -6299,9 +6299,6 @@ "type": "string" } }, - "required": [ - "tenantFilter" - ], "additionalProperties": true, "x-cipp-passthrough": true, "description": "This endpoint forwards the request body onward rather than reading a fixed set of fields. The properties listed here are the ones it is known to read; others may be accepted." @@ -6341,7 +6338,8 @@ "bearerAuth": [] } ], - "x-cipp-role": "Tenant.Standards.ReadWrite" + "x-cipp-role": "Tenant.Standards.ReadWrite", + "x-cipp-any-tenant": true } }, "/api/AddStoreApp": { @@ -26072,6 +26070,66 @@ "x-cipp-role": "Security.Alert.Read" } }, + "/api/ExecMigrateOneDriveShortCuts": { + "post": { + "summary": "ExecMigrateOneDriveShortCuts", + "operationId": "ExecMigrateOneDriveShortCuts", + "tags": [ + "Identity > Administration > Users" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "$ref": "#/components/schemas/LabelValue" + }, + "tenantFilter": { + "type": "string" + }, + "username": { + "$ref": "#/components/schemas/LabelValue" + } + }, + "required": [ + "tenantFilter" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardResults" + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Identity.User.ReadWrite" + } + }, "/api/ExecModifyCalPerms": { "post": { "summary": "ExecModifyCalPerms", @@ -28367,6 +28425,9 @@ "schema": { "type": "object", "properties": { + "destination": { + "$ref": "#/components/schemas/LabelValue" + }, "siteUrl": { "$ref": "#/components/schemas/LabelValue" }, @@ -29831,6 +29892,69 @@ "x-cipp-role": "Exchange.Mailbox.ReadWrite" } }, + "/api/ExecRemoveOneDriveShortCut": { + "post": { + "summary": "ExecRemoveOneDriveShortCut", + "operationId": "ExecRemoveOneDriveShortCut", + "tags": [ + "Identity > Administration > Users" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "$ref": "#/components/schemas/LabelValue" + }, + "name": { + "$ref": "#/components/schemas/LabelValue" + }, + "tenantFilter": { + "type": "string" + }, + "username": { + "$ref": "#/components/schemas/LabelValue" + } + }, + "required": [ + "tenantFilter" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardResults" + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Identity.User.ReadWrite" + } + }, "/api/ExecRemoveRestrictedUser": { "post": { "summary": "ExecRemoveRestrictedUser", @@ -61263,6 +61387,429 @@ "x-cipp-role": "Exchange.Mailbox.Read" } }, + "/api/ListUserOneDriveShortcuts": { + "get": { + "summary": "ListUserOneDriveShortcuts", + "operationId": "ListUserOneDriveShortcuts", + "tags": [ + "Identity > Administration > Users" + ], + "description": "Lists OneDrive remoteItem shortcuts for a user from the drive root and the Shortcuts folder.", + "parameters": [ + { + "$ref": "#/components/parameters/tenantFilter" + }, + { + "name": "userId", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "userPrincipalName", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Derived from the Microsoft Graph entity it queries. This endpoint returns the Graph response as-is without selecting fields, so these are the properties the entity CAN carry (x-cipp-field-source: graph-entity) rather than a proven projection - Graph returns a default subset unless asked otherwise.", + "properties": { + "aboutMe": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "accountEnabled": { + "type": "boolean", + "x-cipp-field-source": "graph-entity" + }, + "ageGroup": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "assignedLicenses": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "assignedPlans": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "authorizationInfo": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "birthday": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "businessPhones": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "city": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "cloudLicensing": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "cloudRealtimeCommunicationInfo": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "companyName": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "consentProvidedForMinor": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "country": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "createdDateTime": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "creationType": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "customSecurityAttributes": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "deletedDateTime": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "department": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "deviceEnrollmentLimit": { + "type": "integer", + "x-cipp-field-source": "graph-entity" + }, + "deviceKeys": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "displayName": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "employeeHireDate": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "employeeId": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "employeeLeaveDateTime": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "employeeOrgData": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "employeeType": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "externalUserState": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "externalUserStateChangeDateTime": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "faxNumber": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "givenName": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "hireDate": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "id": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "identities": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "identityGovernance": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "identityParentId": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "imAddresses": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "infoCatalogs": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "interests": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "isLicenseReconciliationNeeded": { + "type": "boolean", + "x-cipp-field-source": "graph-entity" + }, + "isManagementRestricted": { + "type": "boolean", + "x-cipp-field-source": "graph-entity" + }, + "isResourceAccount": { + "type": "boolean", + "x-cipp-field-source": "graph-entity" + }, + "jobTitle": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "lastPasswordChangeDateTime": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "legalAgeGroupClassification": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "licenseAssignmentStates": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "mail": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "mailboxSettings": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "mailNickname": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "mobilePhone": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "mySite": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "officeLocation": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "onPremisesDistinguishedName": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "onPremisesDomainName": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "onPremisesExtensionAttributes": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "onPremisesImmutableId": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "onPremisesLastSyncDateTime": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "onPremisesProvisioningErrors": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "onPremisesSamAccountName": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "onPremisesSecurityIdentifier": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "onPremisesSipInfo": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "onPremisesSyncEnabled": { + "type": "boolean", + "x-cipp-field-source": "graph-entity" + }, + "onPremisesUserPrincipalName": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "otherMails": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "passwordPolicies": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "passwordProfile": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "pastProjects": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "postalCode": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "preferredDataLocation": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "preferredLanguage": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "preferredName": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "print": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "provisionedPlans": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "proxyAddresses": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "refreshTokensValidFromDateTime": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "responsibilities": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "schools": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "securityIdentifier": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "serviceProvisioningErrors": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "showInAddressList": { + "type": "boolean", + "x-cipp-field-source": "graph-entity" + }, + "signInActivity": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "signInSessionsValidFromDateTime": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "skills": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "state": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "streetAddress": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "surname": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "usageLocation": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "userPrincipalName": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "userType": { + "type": "string", + "x-cipp-field-source": "graph-entity" + } + } + } + } + } + } + }, + "400": { + "description": "Bad request - missing required field or invalid input" + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Identity.User.Read" + } + }, "/api/ListUserPhoto": { "get": { "summary": "ListUserPhoto", diff --git a/Config/standards.json b/Config/standards.json index 5e79d23e0fcca..c645548e7d22a 100644 --- a/Config/standards.json +++ b/Config/standards.json @@ -5395,6 +5395,29 @@ "ONEDRIVEENTERPRISE" ] }, + { + "name": "standards.MigrateOneDriveShortcuts", + "cat": "SharePoint Standards", + "tag": [], + "helpText": "Finds SharePoint library shortcuts sitting in each user's OneDrive root and moves them into the Shortcuts folder (PATCH move into special/shortcuts), matching the optional Microsoft UI location.", + "docsDescription": "Over time Add shortcut to OneDrive can leave many remote library links in the OneDrive root. Microsoft also supports placing those links in an optional Shortcuts folder. This standard lists each enabled member user's OneDrive root with Prefer Include-Feature=AddToOneDrive, then for any remoteItem shortcuts still outside Shortcuts moves them into special/shortcuts. Users without a provisioned OneDrive are skipped. Failures name the user, shortcut, and site URL when available.", + "executiveText": "Keeps employee OneDrive roots tidy by moving SharePoint library shortcuts into the dedicated Shortcuts folder instead of leaving them scattered among personal files.", + "addedComponent": [], + "label": "Migrate OneDrive root shortcuts to the Shortcuts folder", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2026-09-08", + "powershellEquivalent": "PATCH drive/items/{id} parentReference → special/shortcuts", + "recommendedBy": [], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "SHAREPOINTENTERPRISE_GOV", + "ONEDRIVEENTERPRISE" + ] + }, { "name": "standards.SPFileRequests", "cat": "SharePoint Standards", diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandardsApplyBatch.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandardsApplyBatch.ps1 index e15912a163472..7000366e8e5d1 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandardsApplyBatch.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandardsApplyBatch.ps1 @@ -48,9 +48,23 @@ function Push-CIPPStandardsApplyBatch { Write-Information "Aggregated $($AllStandards.Count) standards from all tenants: $($AllStandards | ConvertTo-Json -Depth 5 -Compress)" + # Match the list phase's per-scope naming (see New-CIPPStandardsRun): once concurrent + # single-tenant list runs no longer collide, their apply phases must not collide either. The + # scope comes from the aggregated standards, which already carry Tenant and TemplateId: a single + # tenant and/or a single template contributes that part of the suffix, so two manual runs for the + # same tenant but different templates get distinct apply runs. The all-tenants sweep aggregates + # many tenants (and templates), so both parts drop and it keeps the bare name. + $ApplyTenants = @($AllStandards.Tenant | Where-Object { $_ } | Sort-Object -Unique) + $ApplyTemplates = @($AllStandards.TemplateId | Where-Object { $_ } | Sort-Object -Unique) + $ApplyScope = @( + if ($ApplyTenants.Count -eq 1) { $ApplyTenants[0] } + if ($ApplyTemplates.Count -eq 1) { $ApplyTemplates[0] } + ) -join '-' + $OrchestratorName = if ($ApplyScope) { "StandardsApply-$ApplyScope" } else { 'StandardsApply' } + # Start orchestrator to apply standards $InputObject = [PSCustomObject]@{ - OrchestratorName = 'StandardsApply' + OrchestratorName = $OrchestratorName Batch = @($AllStandards) SkipLog = $true } diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 index b03862179a2a7..29a90d26f7945 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 @@ -131,12 +131,33 @@ function Start-CIPPOrchestrator { # is exactly where this call runs. $ParentRunName = if ($null -ne $OpContext) { $OpContext.PSObject.Properties['RunName'].Value } - Write-Information "Craft: Queuing orchestrator '$OrchestratorName' ($TaskCount tasks, P$Priority$(if ($PostExecFunctionName) { ", PostExec: $PostExecFunctionName" })$(if ($ParentRunName) { ", Parent: $ParentRunName" }))" - # An older Craft runtime exposes the 6-parameter method only; probing the arity keeps this - # wrapper deployable against both. Passing 7 arguments to the old method would not degrade — - # it would throw a method-resolution error and fail the orchestration outright. + # Sequential mode: opt-in per run (e.g. offboarding, where a later step must not race the ones + # before it). Craft runs the batch one task at a time in payload order instead of fanning out. + # Absent/false marshals to $false, so existing callers are unaffected. + $Sequential = [bool]($InputObject.Sequential) + + Write-Information "Craft: Queuing orchestrator '$OrchestratorName' ($TaskCount tasks, P$Priority$(if ($Sequential) { ', Sequential' })$(if ($PostExecFunctionName) { ", PostExec: $PostExecFunctionName" })$(if ($ParentRunName) { ", Parent: $ParentRunName" }))" + # Probe the method arity so this wrapper stays deployable against older Craft runtimes: the + # 8-parameter form adds Sequential, the 7-parameter form adds ParentRunName, and the oldest + # exposes 6. Passing more arguments than the deployed method accepts would throw a + # method-resolution error and fail the orchestration outright, so match what is present. $QueueMethod = [Craft.Services.OrchestratorBridge].GetMethod('QueueOrchestrationFromFile') - if ($QueueMethod.GetParameters().Count -ge 7) { + $ParamCount = $QueueMethod.GetParameters().Count + if ($ParamCount -ge 8) { + [Craft.Services.OrchestratorBridge]::QueueOrchestrationFromFile( + $OrchestratorName, + $BatchPath, + $Priority, + $PostExecFunctionName, + $PostExecParametersJson, + $InputObject.Reference, + $ParentRunName, + $Sequential + ) + } elseif ($ParamCount -ge 7) { + if ($Sequential) { + Write-Warning "Craft: Sequential requested for '$OrchestratorName' but the deployed Craft runtime does not support it (running fan-out)" + } [Craft.Services.OrchestratorBridge]::QueueOrchestrationFromFile( $OrchestratorName, $BatchPath, @@ -147,6 +168,9 @@ function Start-CIPPOrchestrator { $ParentRunName ) } else { + if ($Sequential) { + Write-Warning "Craft: Sequential requested for '$OrchestratorName' but the deployed Craft runtime does not support it (running fan-out)" + } [Craft.Services.OrchestratorBridge]::QueueOrchestrationFromFile( $OrchestratorName, $BatchPath, diff --git a/Modules/CIPPCore/Public/Functions/Test-CIPPStandardLicense.ps1 b/Modules/CIPPCore/Public/Functions/Test-CIPPStandardLicense.ps1 index 4a998a6e6a9f7..2df16dfc7ba5a 100644 --- a/Modules/CIPPCore/Public/Functions/Test-CIPPStandardLicense.ps1 +++ b/Modules/CIPPCore/Public/Functions/Test-CIPPStandardLicense.ps1 @@ -45,8 +45,9 @@ function Test-CIPPStandardLicense { Exchange = @('EXCHANGE_S_STANDARD', 'EXCHANGE_S_ENTERPRISE', 'EXCHANGE_S_STANDARD_GOV', 'EXCHANGE_S_ENTERPRISE_GOV', 'EXCHANGE_LITE') - SharePoint = @('SHAREPOINTWAC', 'SHAREPOINTSTANDARD', 'SHAREPOINTENTERPRISE', - 'SHAREPOINTENTERPRISE_EDU', 'SHAREPOINTENTERPRISE_GOV', + SharePoint = @('SHAREPOINTWAC', 'SHAREPOINTWAC_EDU', + 'SHAREPOINTSTANDARD', 'SHAREPOINTSTANDARD_EDU', + 'SHAREPOINTENTERPRISE', 'SHAREPOINTENTERPRISE_EDU', 'SHAREPOINTENTERPRISE_GOV', 'ONEDRIVE_BASIC', 'ONEDRIVE_ENTERPRISE') Intune = @('INTUNE_A', 'MDM_Services', 'EMS', 'SCCM', 'MICROSOFTINTUNEPLAN1') Entra = @('AAD_PREMIUM', 'AAD_PREMIUM_P2') diff --git a/Modules/CIPPCore/Public/Get-CIPPSharePointSiteUsageReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPSharePointSiteUsageReport.ps1 index 6556c06095c27..5e46dd1df8111 100644 --- a/Modules/CIPPCore/Public/Get-CIPPSharePointSiteUsageReport.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPSharePointSiteUsageReport.ps1 @@ -1,94 +1,94 @@ -function Get-CIPPSharePointSiteUsageReport { - <# - .SYNOPSIS - Generates a SharePoint site usage report from the CIPP Reporting database - - .DESCRIPTION - Retrieves cached SharePoint site listing and usage data and combines them to match - the payload shape of Invoke-ListSites for Type=SharePointSiteUsage. - - .PARAMETER TenantFilter - The tenant to generate the report for - #> - [CmdletBinding()] - param( - [Parameter(Mandatory = $true)] - [string]$TenantFilter - ) - - try { - if ($TenantFilter -eq 'AllTenants') { - $AllSiteItems = @(Get-CIPPDbItem -TenantFilter 'allTenants' -Type 'SharePointSiteListing' | Where-Object { $_.RowKey -ne 'SharePointSiteListing-Count' }) - $AllUsageItems = @(Get-CIPPDbItem -TenantFilter 'allTenants' -Type 'SharePointSiteUsage' | Where-Object { $_.RowKey -ne 'SharePointSiteUsage-Count' }) - - $TenantList = Get-Tenants -IncludeErrors - $ValidTenants = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - foreach ($T in $TenantList) { [void]$ValidTenants.Add($T.defaultDomainName) } - - $UsageBySiteId = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) - foreach ($UsageItem in $AllUsageItems) { - $UsageRow = $UsageItem.Data | ConvertFrom-Json -Depth 10 - if (-not [string]::IsNullOrWhiteSpace($UsageRow.siteId)) { - $UsageBySiteId[[string]$UsageRow.siteId.Trim('{}')] = $UsageRow - } - } - - $AllResults = [System.Collections.Generic.List[PSCustomObject]]::new() - foreach ($SiteItem in $AllSiteItems) { - $Tenant = $SiteItem.PartitionKey - if (-not $ValidTenants.Contains($Tenant)) { continue } - - $Site = $SiteItem.Data | ConvertFrom-Json -Depth 10 - if ($Site.isPersonalSite -eq $true) { continue } - - $SiteUsage = $null - [void]$UsageBySiteId.TryGetValue([string]$Site.sharepointIds.siteId.Trim('{}'), [ref]$SiteUsage) - - $AllResults.Add((ConvertTo-CIPPSharePointSiteUsagePayload -Site $Site -SiteUsage $SiteUsage -Tenant $Tenant)) - } - return $AllResults - } - - $SiteItems = @(Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'SharePointSiteListing' | Where-Object { $_.RowKey -ne 'SharePointSiteListing-Count' }) - if (-not $SiteItems) { - throw 'No SharePoint site listing data found in reporting database. Sync SharePointSiteUsage cache first.' - } - - $UsageItems = @(Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'SharePointSiteUsage' | Where-Object { $_.RowKey -ne 'SharePointSiteUsage-Count' }) - - $LatestSiteTimestamp = ($SiteItems | Where-Object { $_.Timestamp } | Sort-Object Timestamp -Descending | Select-Object -First 1).Timestamp - $LatestUsageTimestamp = ($UsageItems | Where-Object { $_.Timestamp } | Sort-Object Timestamp -Descending | Select-Object -First 1).Timestamp - $CacheTimestamp = if ($LatestSiteTimestamp -and $LatestUsageTimestamp) { - if ($LatestSiteTimestamp -gt $LatestUsageTimestamp) { $LatestSiteTimestamp } else { $LatestUsageTimestamp } - } else { - $LatestSiteTimestamp ?? $LatestUsageTimestamp - } - - $UsageBySiteId = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) - foreach ($UsageItem in $UsageItems) { - $UsageRow = $UsageItem.Data | ConvertFrom-Json -Depth 10 - if (-not [string]::IsNullOrWhiteSpace($UsageRow.siteId)) { - $UsageBySiteId[[string]$UsageRow.siteId.Trim('{}')] = $UsageRow - } - } - - $Report = [System.Collections.Generic.List[PSCustomObject]]::new() - foreach ($SiteItem in $SiteItems) { - $Site = $SiteItem.Data | ConvertFrom-Json -Depth 10 - if ($Site.isPersonalSite -eq $true) { - continue - } - - $SiteUsage = $null - [void]$UsageBySiteId.TryGetValue([string]$Site.sharepointIds.siteId.Trim('{}'), [ref]$SiteUsage) - - $Report.Add((ConvertTo-CIPPSharePointSiteUsagePayload -Site $Site -SiteUsage $SiteUsage -CacheTimestamp $CacheTimestamp)) - } - - return $Report | Sort-Object -Property displayName - - } catch { - Write-LogMessage -API 'SharePointSiteUsageReport' -tenant $TenantFilter -message "Failed to generate SharePoint site usage report: $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) - throw - } -} +function Get-CIPPSharePointSiteUsageReport { + <# + .SYNOPSIS + Generates a SharePoint site usage report from the CIPP Reporting database + + .DESCRIPTION + Retrieves cached SharePoint site listing and usage data and combines them to match + the payload shape of Invoke-ListSites for Type=SharePointSiteUsage. + + .PARAMETER TenantFilter + The tenant to generate the report for + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter + ) + + try { + if ($TenantFilter -eq 'AllTenants') { + $AllSiteItems = @(Get-CIPPDbItem -TenantFilter 'allTenants' -Type 'SharePointSiteListing' | Where-Object { $_.RowKey -ne 'SharePointSiteListing-Count' }) + $AllUsageItems = @(Get-CIPPDbItem -TenantFilter 'allTenants' -Type 'SharePointSiteUsage' | Where-Object { $_.RowKey -ne 'SharePointSiteUsage-Count' }) + + $TenantList = Get-Tenants -IncludeErrors + $ValidTenants = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($T in $TenantList) { [void]$ValidTenants.Add($T.defaultDomainName) } + + $UsageBySiteId = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($UsageItem in $AllUsageItems) { + $UsageRow = $UsageItem.Data | ConvertFrom-Json -Depth 10 + if (-not [string]::IsNullOrWhiteSpace($UsageRow.siteId)) { + $UsageBySiteId[[string]$UsageRow.siteId.Trim('{}')] = $UsageRow + } + } + + $AllResults = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($SiteItem in $AllSiteItems) { + $Tenant = $SiteItem.PartitionKey + if (-not $ValidTenants.Contains($Tenant)) { continue } + + $Site = $SiteItem.Data | ConvertFrom-Json -Depth 10 + if ($Site.isPersonalSite -eq $true) { continue } + + $SiteUsage = $null + [void]$UsageBySiteId.TryGetValue([string]$Site.sharepointIds.siteId.Trim('{}'), [ref]$SiteUsage) + + $AllResults.Add((ConvertTo-CIPPSharePointSiteUsagePayload -Site $Site -SiteUsage $SiteUsage -Tenant $Tenant)) + } + return $AllResults + } + + $SiteItems = @(Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'SharePointSiteListing' | Where-Object { $_.RowKey -ne 'SharePointSiteListing-Count' }) + if (-not $SiteItems) { + throw 'No SharePoint site listing data found in reporting database. Sync SharePointSiteUsage cache first.' + } + + $UsageItems = @(Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'SharePointSiteUsage' | Where-Object { $_.RowKey -ne 'SharePointSiteUsage-Count' }) + + $LatestSiteTimestamp = ($SiteItems | Where-Object { $_.Timestamp } | Sort-Object Timestamp -Descending | Select-Object -First 1).Timestamp + $LatestUsageTimestamp = ($UsageItems | Where-Object { $_.Timestamp } | Sort-Object Timestamp -Descending | Select-Object -First 1).Timestamp + $CacheTimestamp = if ($LatestSiteTimestamp -and $LatestUsageTimestamp) { + if ($LatestSiteTimestamp -gt $LatestUsageTimestamp) { $LatestSiteTimestamp } else { $LatestUsageTimestamp } + } else { + $LatestSiteTimestamp ?? $LatestUsageTimestamp + } + + $UsageBySiteId = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($UsageItem in $UsageItems) { + $UsageRow = $UsageItem.Data | ConvertFrom-Json -Depth 10 + if (-not [string]::IsNullOrWhiteSpace($UsageRow.siteId)) { + $UsageBySiteId[[string]$UsageRow.siteId.Trim('{}')] = $UsageRow + } + } + + $Report = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($SiteItem in $SiteItems) { + $Site = $SiteItem.Data | ConvertFrom-Json -Depth 10 + if ($Site.isPersonalSite -eq $true) { + continue + } + + $SiteUsage = $null + [void]$UsageBySiteId.TryGetValue([string]$Site.sharepointIds.siteId.Trim('{}'), [ref]$SiteUsage) + + $Report.Add((ConvertTo-CIPPSharePointSiteUsagePayload -Site $Site -SiteUsage $SiteUsage -CacheTimestamp $CacheTimestamp)) + } + + return $Report | Sort-Object -Property displayName + + } catch { + Write-LogMessage -API 'SharePointSiteUsageReport' -tenant $TenantFilter -message "Failed to generate SharePoint site usage report: $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) + throw + } +} diff --git a/Modules/CIPPCore/Public/GraphHelper/New-CIPPMFAConnectorToken.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-CIPPMFAConnectorToken.ps1 index 994d7acb1e45b..8442e57f70d3c 100644 --- a/Modules/CIPPCore/Public/GraphHelper/New-CIPPMFAConnectorToken.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/New-CIPPMFAConnectorToken.ps1 @@ -41,7 +41,15 @@ function New-CIPPMFAConnectorToken { $Row = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'NPSSecret' and RowKey eq '$TenantId'" return $Row.SecretValue } - return Get-CippKeyVaultSecret -Name $SecretName -AsPlainText -ErrorAction SilentlyContinue + # A missing secret is the normal first-call state for a tenant. The Key Vault helper throws on a + # 404 rather than returning nothing, so treat not-found as "nothing cached yet" and let provisioning + # create the secret. Any other retrieval failure is a real problem and propagates. + try { + return Get-CippKeyVaultSecret -Name $SecretName -AsPlainText -ErrorAction Stop + } catch { + if ($_.Exception.Message -match '404') { return $null } + throw + } } function Set-StoredSecret { param($Value) diff --git a/Modules/CIPPCore/Public/Invoke-CIPPMigrateOneDriveShortCuts.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPMigrateOneDriveShortCuts.ps1 new file mode 100644 index 0000000000000..12bd32153aee4 --- /dev/null +++ b/Modules/CIPPCore/Public/Invoke-CIPPMigrateOneDriveShortCuts.ps1 @@ -0,0 +1,120 @@ + +function Invoke-CIPPMigrateOneDriveShortCuts { + <# + .SYNOPSIS + Migrates OneDrive root shortcuts into the Shortcuts folder. + .DESCRIPTION + Lists drive root children with Prefer: Include-Feature=AddToOneDrive, then PATCH-moves + each remoteItem shortcut that is not already under Shortcuts into special/shortcuts. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string]$Username, + + [Parameter(Mandatory = $true)] + $TenantFilter, + + $Headers, + + [string]$APIName = 'Migrate OneDrive shortcuts', + + [string]$ItemId, + + [switch]$ListOnly + ) + + $PreferHeaders = @{ Prefer = 'Include-Feature=AddToOneDrive' } + $EscapedUser = [System.Uri]::EscapeDataString($Username) + $ListUri = "https://graph.microsoft.com/beta/users/$EscapedUser/drive/root/children?`$select=id,name,remoteItem,parentReference" + + try { + $RootChildren = @(New-GraphGetRequest -uri $ListUri -tenantid $TenantFilter -asapp $true -extraHeaders $PreferHeaders) + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Normalized = $ErrorMessage.NormalizedError + if ($Normalized -match 'itemNotFound|ResourceNotFound|404|does not have a drive|no drive') { + $Result = "No OneDrive found for $Username" + if (-not $ListOnly) { + Write-LogMessage -API $APIName -headers $Headers -message $Result -Sev 'Info' + } + if ($ListOnly) { return @() } + throw $Result + } + $Result = "Could not list OneDrive shortcuts for $Username : $Normalized" + Write-LogMessage -API $APIName -headers $Headers -message $Result -Sev 'Error' -LogData $ErrorMessage + throw $Result + } + + $ToMigrate = @($RootChildren | Where-Object { + $_.remoteItem -and + ($_.parentReference.path -notmatch '/Shortcuts(/|$)') + }) + + if (-not [string]::IsNullOrWhiteSpace($ItemId)) { + $ToMigrate = @($ToMigrate | Where-Object { $_.id -eq $ItemId }) + if ($ToMigrate.Count -eq 0 -and -not $ListOnly) { + $Result = "No root OneDrive shortcut with id $ItemId found for $Username" + Write-LogMessage -API $APIName -headers $Headers -message $Result -Sev 'Info' + throw $Result + } + } + + if ($ListOnly) { + return $ToMigrate + } + + if ($ToMigrate.Count -eq 0) { + $Result = "No root OneDrive shortcuts to migrate for $Username" + Write-LogMessage -API $APIName -headers $Headers -message $Result -Sev 'Info' + return $Result + } + + # Resolve the Shortcuts destination folder once per run. + try { + $ShortcutsFolder = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$EscapedUser/drive/special/shortcuts?`$select=id,name" -tenantid $TenantFilter -asapp $true + $ShortcutsFolderId = $ShortcutsFolder.id + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Could not resolve the Shortcuts folder (special/shortcuts) for $Username : $($ErrorMessage.NormalizedError)" + Write-LogMessage -API $APIName -headers $Headers -message $Result -Sev 'Error' -LogData $ErrorMessage + throw $Result + } + + $Migrated = [System.Collections.Generic.List[string]]::new() + $Failures = [System.Collections.Generic.List[string]]::new() + + foreach ($Item in $ToMigrate) { + $ShortcutName = [string]$Item.name + $SiteUrl = $Item.remoteItem.sharepointIds.siteUrl + $SiteSuffix = if ($SiteUrl) { " (site $SiteUrl)" } else { '' } + + try { + $MoveBody = @{ + parentReference = @{ id = $ShortcutsFolderId } + } | ConvertTo-Json -Depth 5 + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/users/$EscapedUser/drive/items/$($Item.id)" -tenantid $TenantFilter -type 'PATCH' -body $MoveBody -asapp $true + $Migrated.Add($ShortcutName) + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $FailMsg = "Could not migrate OneDrive shortcut '$ShortcutName' for ${Username}${SiteSuffix}: $($ErrorMessage.NormalizedError)" + Write-LogMessage -API $APIName -headers $Headers -message $FailMsg -Sev 'Error' -LogData $ErrorMessage + $Failures.Add($FailMsg) + } + } + + $Summary = "Migrated $($Migrated.Count) OneDrive shortcut(s) to the Shortcuts folder for $Username" + if ($Migrated.Count -gt 0) { + $Summary += ": $($Migrated -join ', ')" + } + if ($Failures.Count -gt 0) { + $Summary += ". Failures ($($Failures.Count)): $($Failures -join ' | ')" + Write-LogMessage -API $APIName -headers $Headers -message $Summary -Sev 'Error' + throw $Summary + } + + Write-LogMessage -API $APIName -headers $Headers -message $Summary -Sev 'Info' + return $Summary +} diff --git a/Modules/CIPPCore/Public/Invoke-CIPPOffboardingJob.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPOffboardingJob.ps1 index 0c5025cd28673..f901d1e28f747 100644 --- a/Modules/CIPPCore/Public/Invoke-CIPPOffboardingJob.ps1 +++ b/Modules/CIPPCore/Public/Invoke-CIPPOffboardingJob.ps1 @@ -446,12 +446,21 @@ function Invoke-CIPPOffboardingJob { } } - # Start orchestration + # Start orchestration. + # + # Offboarding steps must run in payload order — a later step can undo an earlier one if they race + # (e.g. convert-to-shared reverting mailbox grants added a step earlier). DurableMode='Sequence' is + # the legacy Azure Functions durable flag and is kept for that host; Craft ignores it and instead + # honours Sequential, which pins the whole run to ONE worker and runs the steps one at a time in + # order. Start-CIPPOrchestrator probes the Craft bridge arity, so on a Craft too old to know + # Sequential it logs a warning and falls back to fan-out rather than failing — safe here because the + # grant steps are already idempotent (they read the ACE back), so the ordering is belt-and-suspenders. $InputObject = [PSCustomObject]@{ OrchestratorName = "OffboardingUser_$($Username)_$TenantFilter" Batch = @($Batch) SkipLog = $true DurableMode = 'Sequence' + Sequential = $true } # Add post-execution handler if TaskInfo is provided (from scheduled task) diff --git a/Modules/CIPPCore/Public/New-CIPPOneDriveShortCut.ps1 b/Modules/CIPPCore/Public/New-CIPPOneDriveShortCut.ps1 index 7121eab0005a1..8ae8023ddcc95 100644 --- a/Modules/CIPPCore/Public/New-CIPPOneDriveShortCut.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPOneDriveShortCut.ps1 @@ -7,10 +7,17 @@ function New-CIPPOneDriveShortCut { $URL, $TenantFilter, $APIName = 'Create OneDrive shortcut', - $Headers + $Headers, + [ValidateSet('root', 'shortcuts')] + [string]$Destination = 'root' ) - Write-Host "Received $Username and $UserId. We're using $URL and $TenantFilter" + Write-Host "Received $Username and $UserId. We're using $URL and $TenantFilter (destination=$Destination)" try { + $SPOTenant = Get-CIPPSPOTenant -TenantFilter $TenantFilter | Select-Object -First 1 + if ($SPOTenant.DisableAddToOneDrive -eq $true) { + throw "Add shortcut to OneDrive is disabled for this tenant (DisableAddToOneDrive). Enable it via the 'Set Add Shortcuts To OneDrive button state' standard, or Set-SPOTenant -DisableAddShortcutsToOneDrive `$false." + } + # Unwrap SharePoint browser URLs — e.g. AllItems.aspx?id=... or onedrive.aspx?id=... # The `id` query parameter holds the server-relative path to the folder, URL-encoded. if ($URL -match '[?&]id=([^&]+)') { @@ -20,21 +27,50 @@ function New-CIPPOneDriveShortCut { Write-Host "Resolved browser URL to: $URL" } - # Find site by prefix match (longest match wins — handles subsites correctly) - $SiteInfo = (New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/sites/' -tenantid $TenantFilter -asapp $true) | - Where-Object { $URL -like "$($_.weburl.TrimEnd('/'))/*" -or $URL -eq $_.weburl.TrimEnd('/') } | - Sort-Object { $_.weburl.Length } -Descending | - Select-Object -First 1 + # Strip list view paths so Shared Documents/Forms/AllItems.aspx resolves as the library + $URL = ($URL -replace '/Forms/AllItems\.aspx.*$', '' -replace '/Forms/.*$', '').TrimEnd('/') + + $ParsedUri = [System.Uri]$URL + $Hostname = $ParsedUri.Host + $AbsPath = [Uri]::UnescapeDataString($ParsedUri.AbsolutePath).TrimEnd('/') + + # Resolve site via hostname:path (avoids paging gaps on GET /sites). Try longest path first for subsites. + $SiteInfo = $null + $Candidates = [System.Collections.Generic.List[string]]::new() + if ($AbsPath -match '^/(sites|teams)/') { + $Parts = @($AbsPath.TrimStart('/') -split '/') + for ($i = $Parts.Length; $i -ge 2; $i--) { + $Candidates.Add('/' + ($Parts[0..($i - 1)] -join '/')) + } + } elseif ($AbsPath -match '^(?/personal/[^/]+)') { + $Candidates.Add($Matches['od']) + } else { + throw "Could not parse a SharePoint site path from URL: $URL" + } + + foreach ($Candidate in $Candidates) { + try { + $SiteInfo = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/sites/${Hostname}:${Candidate}?`$select=id,displayName,webUrl" -tenantid $TenantFilter -asapp $true + if ($SiteInfo.id) { break } + } catch { + $SiteInfo = $null + } + } if (-not $SiteInfo) { throw "Could not find a SharePoint site matching URL: $URL" } # Extract whatever comes after the site URL (library name + optional folder path) - $RelativePath = $URL.Substring($SiteInfo.weburl.TrimEnd('/').Length).TrimStart('/') + $SitePath = ([System.Uri]$SiteInfo.webUrl).AbsolutePath.TrimEnd('/') + $RelativePath = if ($AbsPath.Length -gt $SitePath.Length -and $AbsPath.StartsWith($SitePath, [System.StringComparison]::OrdinalIgnoreCase)) { + $AbsPath.Substring($SitePath.Length).TrimStart('/') + } else { + '' + } if ([string]::IsNullOrWhiteSpace($RelativePath)) { - # ── Root shortcut (original behaviour) ────────────────────────────── + # Same as the proven test script / HAR: default library via sites/{id}/drive sharePointIds $SPIds = (New-GraphGetRequest -uri "https://graph.microsoft.com/beta/sites/$($SiteInfo.id)/drive?`$select=SharepointIds" -tenantid $TenantFilter -asapp $true).SharePointIds $body = [PSCustomObject]@{ name = 'Documents' @@ -52,37 +88,31 @@ function New-CIPPOneDriveShortCut { $ShortcutDisplayName = $SiteInfo.displayName } else { # ── Subfolder shortcut ─────────────────────────────────────────────── - # Split "SharedDocuments/Folder123" into library name and optional subfolder $PathParts = $RelativePath -split '/' $LibraryName = [Uri]::UnescapeDataString($PathParts[0]) $FolderPath = if ($PathParts.Count -gt 1) { ($PathParts[1..($PathParts.Count - 1)] | ForEach-Object { [Uri]::UnescapeDataString($_) }) -join '/' } else { $null } - # Find the drive (document library) whose name matches the first path segment $Drives = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/sites/$($SiteInfo.id)/drives?`$select=id,name,webUrl" -tenantid $TenantFilter -asapp $true $Drive = $Drives | Where-Object { $_.name -eq $LibraryName -or [Uri]::UnescapeDataString($_.webUrl.TrimEnd('/').Split('/')[-1]) -eq $LibraryName } | Select-Object -First 1 - # Fall back to the default drive when no name match is found if (-not $Drive) { $Drive = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/sites/$($SiteInfo.id)/drive?`$select=id,name" -tenantid $TenantFilter -asapp $true } - # Resolve the target driveItem — subfolder or library root if ($FolderPath) { $EncodedFolderPath = ($FolderPath -split '/' | ForEach-Object { [Uri]::EscapeDataString($_) }) -join '/' $FolderItem = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/drives/$($Drive.id)/root:/$($EncodedFolderPath)?`$select=id,name,parentReference" -tenantid $TenantFilter -asapp $true $DisplayName = $FolderItem.name } else { $FolderItem = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/drives/$($Drive.id)/root?`$select=id,name" -tenantid $TenantFilter -asapp $true - # Graph returns name='root' for a drive's root item — use the drive (library) name instead $DisplayName = $Drive.name } - # POST body for subfolder uses driveItem.id + drive.id (not sharepointIds) $body = [PSCustomObject]@{ name = $DisplayName remoteItem = @{ @@ -94,9 +124,17 @@ function New-CIPPOneDriveShortCut { $ShortcutDisplayName = "$($SiteInfo.displayName) / $DisplayName" } - $null = New-GraphPOSTRequest -method POST "https://graph.microsoft.com/beta/users/$Username/drive/root/children" -body $Body -tenantid $TenantFilter -asapp $true - Write-LogMessage -API $APIName -headers $Headers -message "Created OneDrive shortcut called $ShortcutDisplayName for $Username" -Sev 'info' - return "Successfully created OneDrive Shortcut for $Username called $ShortcutDisplayName" + # Proven path is root/children. special/shortcuts create is optional/undocumented. + $PostUri = if ($Destination -eq 'shortcuts') { + "https://graph.microsoft.com/beta/users/$Username/drive/special/shortcuts/children" + } else { + "https://graph.microsoft.com/beta/users/$Username/drive/root/children" + } + $DestinationLabel = if ($Destination -eq 'shortcuts') { 'Shortcuts folder' } else { 'OneDrive root' } + + $null = New-GraphPOSTRequest -uri $PostUri -body $body -tenantid $TenantFilter -asapp $true + Write-LogMessage -API $APIName -headers $Headers -message "Created OneDrive shortcut called $ShortcutDisplayName for $Username in $DestinationLabel" -Sev 'info' + return "Successfully created OneDrive Shortcut for $Username called $ShortcutDisplayName in $DestinationLabel" } catch { $ErrorMessage = Get-CippException -Exception $_ $Result = "Could not add OneDrive shortcut to $Username : $($ErrorMessage.NormalizedError)" diff --git a/Modules/CIPPCore/Public/Standards/New-CIPPStandardsRun.ps1 b/Modules/CIPPCore/Public/Standards/New-CIPPStandardsRun.ps1 index f6183ddd08885..33c339232eb57 100644 --- a/Modules/CIPPCore/Public/Standards/New-CIPPStandardsRun.ps1 +++ b/Modules/CIPPCore/Public/Standards/New-CIPPStandardsRun.ps1 @@ -80,9 +80,20 @@ function New-CIPPStandardsRun { Write-Information "Built batch of $($Batch.Count) tenant standards list activities: $($Batch | ConvertTo-Json -Depth 5 -Compress)" + # The orchestrator name is the run identity, and a second run of the same name is skipped as + # "already active" while the caller is still told it started. A fixed 'StandardsList' therefore + # drops concurrent manual runs for different tenants/templates. Suffix the name with the run + # scope so each tenant/template gets its own run; the full scheduled sweep (allTenants + all + # templates) keeps the bare name, since it is a single run with nothing to collide with. + $RunScope = @( + if ($TenantFilter -and $TenantFilter -ne 'allTenants') { $TenantFilter } + if ($TemplateID -and $TemplateID -ne '*') { $TemplateID } + ) -join '-' + $OrchestratorName = if ($RunScope) { "StandardsList-$RunScope" } else { 'StandardsList' } + # Start orchestrator with distributed batch and post-exec aggregation $InputObject = [PSCustomObject]@{ - OrchestratorName = 'StandardsList' + OrchestratorName = $OrchestratorName Batch = @($Batch) PostExecution = @{ FunctionName = 'CIPPStandardsApplyBatch' diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddAssignmentFilterTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddAssignmentFilterTemplate.ps1 index d43f3555a4e28..3f8dbfa5a8bb7 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddAssignmentFilterTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddAssignmentFilterTemplate.ps1 @@ -56,7 +56,6 @@ function Invoke-AddAssignmentFilterTemplate { } - # Associate values to output bindings by calling 'Push-OutputBinding'. return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::OK Body = $body diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-EditAssignmentFilter.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-EditAssignmentFilter.ps1 index a3fda1715b35b..29a488aede822 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-EditAssignmentFilter.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-EditAssignmentFilter.ps1 @@ -53,7 +53,6 @@ function Invoke-EditAssignmentFilter { $StatusCode = [HttpStatusCode]::InternalServerError } - # Associate values to output bindings by calling 'Push-OutputBinding'. return ([HttpResponseContext]@{ StatusCode = $StatusCode Body = @{'Results' = $Result } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAssignmentFilter.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAssignmentFilter.ps1 index f08bc7bbbafb3..146159f80cc48 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAssignmentFilter.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAssignmentFilter.ps1 @@ -44,7 +44,6 @@ function Invoke-ExecAssignmentFilter { $StatusCode = [HttpStatusCode]::InternalServerError } - # Associate values to output bindings by calling 'Push-OutputBinding'. return ([HttpResponseContext]@{ StatusCode = $StatusCode Body = @{'Results' = $Result } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListAssignmentFilterTemplates.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListAssignmentFilterTemplates.ps1 index de465d96a1ab7..7d6a7cbf1bd62 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListAssignmentFilterTemplates.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListAssignmentFilterTemplates.ps1 @@ -31,7 +31,6 @@ function Invoke-ListAssignmentFilterTemplates { if ($ID) { $Templates = $Templates | Where-Object -Property GUID -EQ $ID } - # Associate values to output bindings by calling 'Push-OutputBinding'. return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::OK Body = @($Templates) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-RemoveAssignmentFilterTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-RemoveAssignmentFilterTemplate.ps1 index ccab5a1289605..e8973960f75c9 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-RemoveAssignmentFilterTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-RemoveAssignmentFilterTemplate.ps1 @@ -33,7 +33,6 @@ Function Invoke-RemoveAssignmentFilterTemplate { } - # Associate values to output bindings by calling 'Push-OutputBinding'. return ([HttpResponseContext]@{ StatusCode = $StatusCode Body = @{'Results' = $Result } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecMigrateOneDriveShortCuts.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecMigrateOneDriveShortCuts.ps1 new file mode 100644 index 0000000000000..2dc2e4d0d0bd1 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecMigrateOneDriveShortCuts.ps1 @@ -0,0 +1,38 @@ +Function Invoke-ExecMigrateOneDriveShortCuts { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Identity.User.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + $Headers = $Request.Headers + + $TenantFilter = $Request.Body.tenantFilter + $Username = $Request.Body.username + if ($Username -is [psobject] -and $Username.value) { $Username = $Username.value } + $ItemId = $Request.Body.id + if ($ItemId -is [psobject] -and $ItemId.value) { $ItemId = $ItemId.value } + + try { + $MigrateParams = @{ + Username = $Username + TenantFilter = $TenantFilter + Headers = $Headers + } + if (-not [string]::IsNullOrWhiteSpace([string]$ItemId)) { + $MigrateParams.ItemId = $ItemId + } + $Result = Invoke-CIPPMigrateOneDriveShortCuts @MigrateParams + $StatusCode = [HttpStatusCode]::OK + } catch { + $Result = $_.Exception.Message + $StatusCode = [HttpStatusCode]::InternalServerError + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{'Results' = $Result } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecOneDriveShortCut.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecOneDriveShortCut.ps1 index 822fd952191fd..86fe4df0c5064 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecOneDriveShortCut.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecOneDriveShortCut.ps1 @@ -15,9 +15,16 @@ Function Invoke-ExecOneDriveShortCut { $Username = $Request.Body.username $UserId = $Request.Body.userid $URL = $Request.Body.siteUrl.value + $Destination = $Request.Body.destination + if ($Destination -is [psobject] -and $Destination.value) { + $Destination = $Destination.value + } + if ([string]::IsNullOrWhiteSpace([string]$Destination)) { + $Destination = 'root' + } Try { - $Result = New-CIPPOneDriveShortCut -Username $Username -UserId $UserId -TenantFilter $TenantFilter -URL $URL -Headers $Headers + $Result = New-CIPPOneDriveShortCut -Username $Username -UserId $UserId -TenantFilter $TenantFilter -URL $URL -Destination $Destination -Headers $Headers $StatusCode = [HttpStatusCode]::OK } catch { $Result = $_.Exception.Message diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecRemoveOneDriveShortCut.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecRemoveOneDriveShortCut.ps1 new file mode 100644 index 0000000000000..015174bc2ccba --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecRemoveOneDriveShortCut.ps1 @@ -0,0 +1,41 @@ +Function Invoke-ExecRemoveOneDriveShortCut { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Identity.User.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + $Headers = $Request.Headers + + $TenantFilter = $Request.Body.tenantFilter + $Username = $Request.Body.username + if ($Username -is [psobject] -and $Username.value) { $Username = $Username.value } + $ItemId = $Request.Body.id + if ($ItemId -is [psobject] -and $ItemId.value) { $ItemId = $ItemId.value } + $Name = $Request.Body.name + if ($Name -is [psobject] -and $Name.value) { $Name = $Name.value } + + try { + if ([string]::IsNullOrWhiteSpace($Username) -or [string]::IsNullOrWhiteSpace($ItemId)) { + throw 'username and id are required to remove an OneDrive shortcut' + } + $EscapedUser = [System.Uri]::EscapeDataString($Username) + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/users/$EscapedUser/drive/items/$ItemId" -tenantid $TenantFilter -type 'DELETE' -asapp $true + $Label = if ($Name) { "'$Name'" } else { $ItemId } + $Result = "Removed OneDrive shortcut $Label for $Username" + Write-LogMessage -API 'Remove OneDrive shortcut' -headers $Headers -message $Result -Sev 'Info' + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Could not remove OneDrive shortcut for $Username : $($ErrorMessage.NormalizedError)" + Write-LogMessage -API 'Remove OneDrive shortcut' -headers $Headers -message $Result -Sev 'Error' -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::InternalServerError + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{'Results' = $Result } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserOneDriveShortcuts.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserOneDriveShortcuts.ps1 new file mode 100644 index 0000000000000..4dce26840f72f --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserOneDriveShortcuts.ps1 @@ -0,0 +1,96 @@ +Function Invoke-ListUserOneDriveShortcuts { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Identity.User.Read + .DESCRIPTION + Lists OneDrive remoteItem shortcuts for a user from the drive root and the Shortcuts folder. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter + $UserId = $Request.Query.userId + $Username = $Request.Query.userPrincipalName + + if ([string]::IsNullOrWhiteSpace($Username) -and -not [string]::IsNullOrWhiteSpace($UserId)) { + $User = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$UserId`?`$select=userPrincipalName" -tenantid $TenantFilter -asapp $true + $Username = $User.userPrincipalName + } + + if ([string]::IsNullOrWhiteSpace($Username)) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @(@{ Results = 'userPrincipalName or userId is required' }) + }) + } + + $PreferHeaders = @{ Prefer = 'Include-Feature=AddToOneDrive' } + $EscapedUser = [System.Uri]::EscapeDataString($Username) + $Select = 'id,name,remoteItem,parentReference,createdDateTime,lastModifiedDateTime' + + $Results = [System.Collections.Generic.List[object]]::new() + + try { + $RootChildren = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$EscapedUser/drive/root/children?`$select=$Select" -tenantid $TenantFilter -asapp $true -extraHeaders $PreferHeaders) + foreach ($Item in ($RootChildren | Where-Object { $_.remoteItem })) { + $Location = if ($Item.parentReference.path -match '/Shortcuts(/|$)') { 'Shortcuts folder' } else { 'OneDrive root' } + $Results.Add([PSCustomObject]@{ + id = $Item.id + name = $Item.name + location = $Location + siteUrl = $Item.remoteItem.sharepointIds.siteUrl + remoteItemId = $Item.remoteItem.id + remoteDriveId = $Item.remoteItem.parentReference.driveId + createdDateTime = $Item.createdDateTime + lastModifiedDateTime = $Item.lastModifiedDateTime + userPrincipalName = $Username + userId = $UserId + }) + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + if ($ErrorMessage.NormalizedError -match 'itemNotFound|ResourceNotFound|404|does not have a drive|no drive') { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @() + }) + } + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::InternalServerError + Body = @(@{ Results = "Could not list OneDrive root shortcuts for $Username : $($ErrorMessage.NormalizedError)" }) + }) + } + + try { + $ShortcutChildren = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$EscapedUser/drive/special/shortcuts/children?`$select=$Select" -tenantid $TenantFilter -asapp $true -extraHeaders $PreferHeaders) + foreach ($Item in $ShortcutChildren) { + # Avoid duplicates if root listing already returned Shortcuts children + if ($Results.id -contains $Item.id) { continue } + $Results.Add([PSCustomObject]@{ + id = $Item.id + name = $Item.name + location = 'Shortcuts folder' + siteUrl = $Item.remoteItem.sharepointIds.siteUrl + remoteItemId = $Item.remoteItem.id + remoteDriveId = $Item.remoteItem.parentReference.driveId + createdDateTime = $Item.createdDateTime + lastModifiedDateTime = $Item.lastModifiedDateTime + userPrincipalName = $Username + userId = $UserId + }) + } + } catch { + # special/shortcuts may 404 when the folder has never been created — treat as empty + $ErrorMessage = Get-CippException -Exception $_ + if ($ErrorMessage.NormalizedError -notmatch 'itemNotFound|ResourceNotFound|404|special') { + Write-LogMessage -API 'ListUserOneDriveShortcuts' -headers $Request.Headers -message "Could not list Shortcuts folder for $Username : $($ErrorMessage.NormalizedError)" -Sev 'Warning' -LogData $ErrorMessage + } + } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @($Results) + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-AddStandardsTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-AddStandardsTemplate.ps1 index 3f38f3f73e4c5..679aec62d08c7 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-AddStandardsTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-AddStandardsTemplate.ps1 @@ -1,7 +1,7 @@ function Invoke-AddStandardsTemplate { <# .FUNCTIONALITY - Entrypoint + Entrypoint,AnyTenant .ROLE Tenant.Standards.ReadWrite #> @@ -14,6 +14,41 @@ function Invoke-AddStandardsTemplate { throw 'Invalid Tenant Selection. A standard must be assigned to at least 1 tenant.' } + # tenantFilter is a *list* (and may include AllTenants or tenant groups), so this endpoint is + # AnyTenant and validates the whole list here: standards runs execute app-level without + # re-checking custom-role access, so this is the boundary that stops a scoped caller assigning + # standards to tenants outside their scope. + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + if ($AllowedTenants -notcontains 'AllTenants') { + $OutOfScope = foreach ($Item in @($Request.Body.tenantFilter)) { + if ($Item.value -eq 'AllTenants') { + # Assigning to every tenant requires an unrestricted (AllTenants) scope. + 'All Tenants' + continue + } + if ($Item.type -eq 'Group') { + foreach ($TargetId in @(Expand-CIPPTenantGroups -TenantFilter @($Item)).addedFields.customerId) { + if ($AllowedTenants -notcontains $TargetId) { $Item.label ?? $Item.value } + } + continue + } + # Single tenant: resolve to a customerId. An unresolved value is $null, which is never + # in the allowed list, so -notcontains fails closed on its own. + $TargetId = $Item.addedFields.customerId ?? (Get-Tenants -TenantFilter $Item.value).customerId + if ($AllowedTenants -notcontains $TargetId) { + $Item.label ?? $Item.value + } + } + if (($OutOfScope | Measure-Object).Count -gt 0) { + $Denied = $OutOfScope -join ', ' + Write-LogMessage -headers $Headers -API $APIName -message "Blocked standards template save; caller is not permitted for: $Denied" -Sev 'Warning' + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::Forbidden + Body = "Access to one or more of the selected tenants is not allowed: $Denied" + }) + } + } + $GUID = $Request.body.GUID ? $request.body.GUID : (New-Guid).GUID #updatedBy = $request.headers.'x-ms-client-principal' #updatedAt = (Get-Date).ToUniversalTime() diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/Report-Builder/Invoke-ExecGenerateReportBuilderReport.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/Report-Builder/Invoke-ExecGenerateReportBuilderReport.ps1 index 6e076663583e7..50a73242f55f5 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/Report-Builder/Invoke-ExecGenerateReportBuilderReport.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/Report-Builder/Invoke-ExecGenerateReportBuilderReport.ps1 @@ -66,7 +66,7 @@ function Invoke-ExecGenerateReportBuilderReport { $StatusCode = [HttpStatusCode]::BadRequest } - Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{ + return ([HttpResponseContext]@{ StatusCode = $StatusCode Body = ConvertTo-Json -InputObject $Result -Depth 20 }) diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMigrateOneDriveShortcuts.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMigrateOneDriveShortcuts.ps1 new file mode 100644 index 0000000000000..65c1c43719b8d --- /dev/null +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMigrateOneDriveShortcuts.ps1 @@ -0,0 +1,116 @@ +function Invoke-CIPPStandardMigrateOneDriveShortcuts { + <# + .FUNCTIONALITY + Internal + .COMPONENT + (APIName) MigrateOneDriveShortcuts + .SYNOPSIS + (Label) Migrate OneDrive root shortcuts to the Shortcuts folder + .DESCRIPTION + (Helptext) Finds SharePoint library shortcuts sitting in each user's OneDrive root and moves them into the Shortcuts folder (PATCH move into special/shortcuts), matching the optional Microsoft UI location. + (DocsDescription) Over time Add shortcut to OneDrive can leave many remote library links in the OneDrive root. Microsoft also supports placing those links in an optional Shortcuts folder. This standard lists each enabled member user's OneDrive root with Prefer Include-Feature=AddToOneDrive, then for any remoteItem shortcuts still outside Shortcuts moves them into special/shortcuts. Users without a provisioned OneDrive are skipped. Failures name the user, shortcut, and site URL when available. + .NOTES + CAT + SharePoint Standards + TAG + EXECUTIVETEXT + Keeps employee OneDrive roots tidy by moving SharePoint library shortcuts into the dedicated Shortcuts folder instead of leaving them scattered among personal files. + ADDEDCOMPONENT + IMPACT + Low Impact + ADDEDDATE + 2026-09-08 + POWERSHELLEQUIVALENT + PATCH drive/items/{id} parentReference → special/shortcuts + RECOMMENDEDBY + REQUIREDCAPABILITIES + "SHAREPOINTWAC" + "SHAREPOINTSTANDARD" + "SHAREPOINTENTERPRISE" + "SHAREPOINTENTERPRISE_EDU" + "SHAREPOINTENTERPRISE_GOV" + "ONEDRIVEENTERPRISE" + UPDATECOMMENTBLOCK + Run the Tools\Update-StandardsComments.ps1 script to update this comment block + .LINK + https://docs.cipp.app/user-documentation/tenant/standards/alignment/templates/available-standards + #> + + param($Tenant, $Settings) + + $TestResult = Test-CIPPStandardLicense -StandardName 'MigrateOneDriveShortcuts' -TenantFilter $Tenant -RequiredCapabilities @('SHAREPOINTWAC', 'SHAREPOINTSTANDARD', 'SHAREPOINTENTERPRISE', 'SHAREPOINTENTERPRISE_EDU', 'SHAREPOINTENTERPRISE_GOV', 'ONEDRIVEENTERPRISE') + if ($TestResult -eq $false) { + return $true + } + + try { + $AllUsers = New-CIPPDbRequest -TenantFilter $Tenant -Type 'Users' + $CandidateUsers = @($AllUsers | Where-Object { + $_.accountEnabled -eq $true -and + $_.userType -eq 'Member' -and + -not [string]::IsNullOrWhiteSpace($_.userPrincipalName) + }) + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the MigrateOneDriveShortcuts state for $Tenant. Error: $ErrorMessage" -Sev Error + return + } + + $UsersWithRootShortcuts = [System.Collections.Generic.List[object]]::new() + + foreach ($User in $CandidateUsers) { + try { + $RootShortcuts = @(Invoke-CIPPMigrateOneDriveShortCuts -Username $User.userPrincipalName -TenantFilter $Tenant -ListOnly) + if ($RootShortcuts.Count -gt 0) { + $UsersWithRootShortcuts.Add([PSCustomObject]@{ + userPrincipalName = $User.userPrincipalName + displayName = $User.displayName + shortcutCount = $RootShortcuts.Count + shortcuts = @($RootShortcuts | ForEach-Object { $_.name }) + }) + } + } catch { + # No OneDrive or list failure: skip quietly for scan (migrate helper already classifies no-drive). + $Msg = $_.Exception.Message + if ($Msg -notmatch 'No OneDrive found') { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "MigrateOneDriveShortcuts: could not scan $($User.userPrincipalName): $Msg" -sev Warning + } + } + } + + if ($Settings.remediate -eq $true) { + if ($UsersWithRootShortcuts.Count -eq 0) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'No root OneDrive shortcuts found to migrate.' -sev Info + } else { + foreach ($Row in @($UsersWithRootShortcuts)) { + try { + $Result = Invoke-CIPPMigrateOneDriveShortCuts -Username $Row.userPrincipalName -TenantFilter $Tenant + Write-LogMessage -API 'Standards' -tenant $Tenant -message $Result -sev Info + } catch { + Write-LogMessage -API 'Standards' -tenant $Tenant -message $_.Exception.Message -sev Error + } + } + } + } + + if ($Settings.alert -eq $true) { + if ($UsersWithRootShortcuts.Count -gt 0) { + Write-StandardsAlert -message "Users with OneDrive shortcuts still in the root: $($UsersWithRootShortcuts.Count)" -object $UsersWithRootShortcuts -tenant $Tenant -standardName 'MigrateOneDriveShortcuts' -standardId $Settings.standardId + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Users with OneDrive shortcuts still in the root: $($UsersWithRootShortcuts.Count)" -sev Info + } else { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'No root OneDrive shortcuts found.' -sev Info + } + } + + if ($Settings.report -eq $true) { + $FieldValue = @($UsersWithRootShortcuts | Select-Object userPrincipalName, displayName, shortcutCount, shortcuts) + $CurrentValue = [PSCustomObject]@{ + UsersWithRootShortcuts = $FieldValue + } + $ExpectedValue = [PSCustomObject]@{ + UsersWithRootShortcuts = @() + } + Set-CIPPStandardsCompareField -FieldName 'standards.MigrateOneDriveShortcuts' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant + Add-CIPPBPAField -FieldName 'MigrateOneDriveShortcuts' -FieldValue $FieldValue -StoreAs json -Tenant $Tenant + } +} diff --git a/Modules/CippExtensions/Public/Halo/Get-HaloMapping.ps1 b/Modules/CippExtensions/Public/Halo/Get-HaloMapping.ps1 index 2bd9fe338efe2..554fd8e35590c 100644 --- a/Modules/CippExtensions/Public/Halo/Get-HaloMapping.ps1 +++ b/Modules/CippExtensions/Public/Halo/Get-HaloMapping.ps1 @@ -42,9 +42,10 @@ function Get-HaloMapping { $Configuration = ((Get-CIPPAzDataTableEntity @Table).config | ConvertFrom-Json -ea stop).HaloPSA $Token = Get-HaloToken -configuration $Configuration + $UserAgent = Get-CippUserAgent $i = 1 $RawHaloClients = do { - $Result = Invoke-RestMethod -Uri "$($Configuration.ResourceURL)/Client?page_no=$i&page_size=999&pageinate=true" -ContentType 'application/json' -Method GET -Headers @{Authorization = "Bearer $($token.access_token)" } + $Result = Invoke-RestMethod -UserAgent $UserAgent -Uri "$($Configuration.ResourceURL)/Client?page_no=$i&page_size=999&pageinate=true" -ContentType 'application/json' -Method GET -Headers @{Authorization = "Bearer $($token.access_token)" } $Result.clients | Select-Object * -ExcludeProperty logo $i++ $pagecount = [Math]::Ceiling($Result.record_count / 999) diff --git a/Modules/CippExtensions/Public/Halo/Get-HaloPriority.ps1 b/Modules/CippExtensions/Public/Halo/Get-HaloPriority.ps1 index 629b97c3c2422..7e388a167de6e 100644 --- a/Modules/CippExtensions/Public/Halo/Get-HaloPriority.ps1 +++ b/Modules/CippExtensions/Public/Halo/Get-HaloPriority.ps1 @@ -36,6 +36,7 @@ function Get-HaloPriority { } $Headers = @{ Authorization = "Bearer $($Token.access_token)" } + $UserAgent = Get-CippUserAgent $SlaId = Get-HaloTicketTypeSlaId -TicketType $TicketType -Configuration $Configuration -Token $Token if (-not $SlaId) { @@ -51,7 +52,7 @@ function Get-HaloPriority { # The /SLA/{id} response shape varies between Halo versions: some return full priority # objects under .priorities, some only IDs. Resolve both by fetching the canonical # priority list and filtering by ID, which works regardless of the SLA payload shape. - $Sla = Invoke-RestMethod -Uri "$($Configuration.ResourceURL)/SLA/$SlaId" -ContentType 'application/json' -Method GET -Headers $Headers + $Sla = Invoke-RestMethod -UserAgent $UserAgent -Uri "$($Configuration.ResourceURL)/SLA/$SlaId" -ContentType 'application/json' -Method GET -Headers $Headers $SlaPriorityIds = @() if ($Sla.priorities) { @@ -60,7 +61,7 @@ function Get-HaloPriority { } } - $AllPriorities = Invoke-RestMethod -Uri "$($Configuration.ResourceURL)/Priority" -ContentType 'application/json' -Method GET -Headers $Headers + $AllPriorities = Invoke-RestMethod -UserAgent $UserAgent -Uri "$($Configuration.ResourceURL)/Priority" -ContentType 'application/json' -Method GET -Headers $Headers if ($SlaPriorityIds.Count -gt 0) { $AllPriorities | Where-Object { $_.id -in $SlaPriorityIds } | Sort-Object -Property priorityorder, name diff --git a/Modules/CippExtensions/Public/Halo/Get-HaloRequestSource.ps1 b/Modules/CippExtensions/Public/Halo/Get-HaloRequestSource.ps1 index 1aa8179773618..56cb989eafc74 100644 --- a/Modules/CippExtensions/Public/Halo/Get-HaloRequestSource.ps1 +++ b/Modules/CippExtensions/Public/Halo/Get-HaloRequestSource.ps1 @@ -21,8 +21,9 @@ function Get-HaloRequestSource { try { $Configuration = ((Get-CIPPAzDataTableEntity @Table).config | ConvertFrom-Json -ea stop).HaloPSA $Token = Get-HaloToken -configuration $Configuration + $UserAgent = Get-CippUserAgent - $Response = Invoke-RestMethod -Uri "$($Configuration.ResourceURL)/lookup?lookupid=22&showall=true" -ContentType 'application/json' -Method GET -Headers @{Authorization = "Bearer $($Token.access_token)" } + $Response = Invoke-RestMethod -UserAgent $UserAgent -Uri "$($Configuration.ResourceURL)/lookup?lookupid=22&showall=true" -ContentType 'application/json' -Method GET -Headers @{Authorization = "Bearer $($Token.access_token)" } # Halo returns a bare array here, but some of its lookup responses wrap the rows. Handle # both so a version difference reads as "no sources" rather than throwing. diff --git a/Modules/CippExtensions/Public/Halo/Get-HaloTicketOutcome.ps1 b/Modules/CippExtensions/Public/Halo/Get-HaloTicketOutcome.ps1 index c3e19b9b17076..2b1aa93c1b056 100644 --- a/Modules/CippExtensions/Public/Halo/Get-HaloTicketOutcome.ps1 +++ b/Modules/CippExtensions/Public/Halo/Get-HaloTicketOutcome.ps1 @@ -20,13 +20,14 @@ function Get-HaloTicketOutcome { try { $Configuration = ((Get-CIPPAzDataTableEntity @Table).config | ConvertFrom-Json -ea stop).HaloPSA $Token = Get-HaloToken -configuration $Configuration + $UserAgent = Get-CippUserAgent if (-not $TicketType) { $TicketType = $Configuration.TicketType.value ?? $Configuration.TicketType } if ($TicketType) { - $WorkflowId = (Invoke-RestMethod -Uri "$($Configuration.ResourceURL)/tickettype/$TicketType" -ContentType 'application/json' -Method GET -Headers @{Authorization = "Bearer $($Token.access_token)" }).workflow_id - $Workflow = Invoke-RestMethod -Uri "$($Configuration.ResourceURL)/workflow/$WorkflowId" -ContentType 'application/json' -Method GET -Headers @{Authorization = "Bearer $($Token.access_token)" } - $Outcomes = Invoke-RestMethod -Uri "$($Configuration.ResourceURL)/outcome" -ContentType 'application/json' -Method GET -Headers @{Authorization = "Bearer $($Token.access_token)" } + $WorkflowId = (Invoke-RestMethod -UserAgent $UserAgent -Uri "$($Configuration.ResourceURL)/tickettype/$TicketType" -ContentType 'application/json' -Method GET -Headers @{Authorization = "Bearer $($Token.access_token)" }).workflow_id + $Workflow = Invoke-RestMethod -UserAgent $UserAgent -Uri "$($Configuration.ResourceURL)/workflow/$WorkflowId" -ContentType 'application/json' -Method GET -Headers @{Authorization = "Bearer $($Token.access_token)" } + $Outcomes = Invoke-RestMethod -UserAgent $UserAgent -Uri "$($Configuration.ResourceURL)/outcome" -ContentType 'application/json' -Method GET -Headers @{Authorization = "Bearer $($Token.access_token)" } $Outcomes | Where-Object { $_.id -in $Workflow.steps.actions.action_id } | Sort-Object -Property buttonname } else { diff --git a/Modules/CippExtensions/Public/Halo/Get-HaloTicketType.ps1 b/Modules/CippExtensions/Public/Halo/Get-HaloTicketType.ps1 index 6ed6d21e40541..60b7bbd7a6d74 100644 --- a/Modules/CippExtensions/Public/Halo/Get-HaloTicketType.ps1 +++ b/Modules/CippExtensions/Public/Halo/Get-HaloTicketType.ps1 @@ -14,8 +14,9 @@ function Get-HaloTicketType { try { $Configuration = ((Get-CIPPAzDataTableEntity @Table).config | ConvertFrom-Json -ea stop).HaloPSA $Token = Get-HaloToken -configuration $Configuration + $UserAgent = Get-CippUserAgent - Invoke-RestMethod -Uri "$($Configuration.ResourceURL)/TicketType?showall=true" -ContentType 'application/json' -Method GET -Headers @{Authorization = "Bearer $($Token.access_token)" } + Invoke-RestMethod -UserAgent $UserAgent -Uri "$($Configuration.ResourceURL)/TicketType?showall=true" -ContentType 'application/json' -Method GET -Headers @{Authorization = "Bearer $($Token.access_token)" } } catch { $Message = if ($_.ErrorDetails.Message) { Get-NormalizedError -Message $_.ErrorDetails.Message diff --git a/Modules/CippExtensions/Public/Halo/Get-HaloTicketTypeSlaId.ps1 b/Modules/CippExtensions/Public/Halo/Get-HaloTicketTypeSlaId.ps1 index 1211f599654dc..1bc9fec3cb7c4 100644 --- a/Modules/CippExtensions/Public/Halo/Get-HaloTicketTypeSlaId.ps1 +++ b/Modules/CippExtensions/Public/Halo/Get-HaloTicketTypeSlaId.ps1 @@ -32,7 +32,8 @@ function Get-HaloTicketTypeSlaId { try { $Headers = @{ Authorization = "Bearer $($Token.access_token)" } - $TicketTypeRecord = Invoke-RestMethod -Uri "$($Configuration.ResourceURL)/tickettype/$TicketType" -ContentType 'application/json' -Method GET -Headers $Headers + $UserAgent = Get-CippUserAgent + $TicketTypeRecord = Invoke-RestMethod -UserAgent $UserAgent -Uri "$($Configuration.ResourceURL)/tickettype/$TicketType" -ContentType 'application/json' -Method GET -Headers $Headers # Halo's /tickettype/{id} response uses different field names for the linked SLA across # versions. Check the known variants in order and take the first usable match. Halo uses diff --git a/Modules/CippExtensions/Public/Halo/Get-HaloToken.ps1 b/Modules/CippExtensions/Public/Halo/Get-HaloToken.ps1 index 6ca2edaeefb70..311ada8742ba0 100644 --- a/Modules/CippExtensions/Public/Halo/Get-HaloToken.ps1 +++ b/Modules/CippExtensions/Public/Halo/Get-HaloToken.ps1 @@ -13,7 +13,8 @@ function Get-HaloToken { scope = 'all' } if ($Configuration.Tenant -ne 'None') { $Tenant = "?tenant=$($Configuration.Tenant)" } - $token = Invoke-RestMethod -Uri "$($Configuration.AuthURL)/token$Tenant" -Method Post -Body $body -ContentType 'application/x-www-form-urlencoded' + $UserAgent = Get-CippUserAgent + $token = Invoke-RestMethod -UserAgent $UserAgent -Uri "$($Configuration.AuthURL)/token$Tenant" -Method Post -Body $body -ContentType 'application/x-www-form-urlencoded' return $token } else { throw 'No Halo configuration' diff --git a/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 b/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 index cc2df9f62cb99..119dbe6d235a5 100644 --- a/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 +++ b/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 @@ -34,6 +34,7 @@ function Get-HaloUser { ) $Headers = @{ Authorization = "Bearer $($Token.access_token)" } + $UserAgent = Get-CippUserAgent $BaseUri = "$($Configuration.ResourceURL)/Users?client_id=$ClientId&includeinactive=false&pageinate=false" $BuildResult = { @@ -58,7 +59,7 @@ function Get-HaloUser { filter_value = $FilterValue }) $EncodedFilter = [System.Uri]::EscapeDataString($Filter) - $Response = Invoke-RestMethod -Uri "$BaseUri&advanced_search=$EncodedFilter" -ContentType 'application/json' -Method GET -Headers $Headers + $Response = Invoke-RestMethod -UserAgent $UserAgent -Uri "$BaseUri&advanced_search=$EncodedFilter" -ContentType 'application/json' -Method GET -Headers $Headers if ($Response.users) { return $Response.users } return $Response } catch { @@ -77,7 +78,7 @@ function Get-HaloUser { param($Term) try { $EncodedTerm = [System.Uri]::EscapeDataString($Term) - $Response = Invoke-RestMethod -Uri "$BaseUri&search=$EncodedTerm" -ContentType 'application/json' -Method GET -Headers $Headers + $Response = Invoke-RestMethod -UserAgent $UserAgent -Uri "$BaseUri&search=$EncodedTerm" -ContentType 'application/json' -Method GET -Headers $Headers if ($Response.users) { return $Response.users } return $Response } catch { diff --git a/Modules/CippExtensions/Public/Halo/Invoke-HaloAutoMap.ps1 b/Modules/CippExtensions/Public/Halo/Invoke-HaloAutoMap.ps1 index 96056480149ed..b4e7bb662968c 100644 --- a/Modules/CippExtensions/Public/Halo/Invoke-HaloAutoMap.ps1 +++ b/Modules/CippExtensions/Public/Halo/Invoke-HaloAutoMap.ps1 @@ -29,11 +29,12 @@ function Invoke-HaloAutoMap { $GuidRegex = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' $Headers = @{Authorization = "Bearer $($Token.access_token)" } + $UserAgent = Get-CippUserAgent # type=2 connections are Halo's customer-tenant (Microsoft 365) integrations; the # connection detail carries the client <-> Azure tenant ID mapping table. try { - $ConnectionsResponse = Invoke-RestMethod -Uri "$($Configuration.ResourceURL)/AzureADConnection?type=2" -Method GET -ContentType 'application/json' -Headers $Headers + $ConnectionsResponse = Invoke-RestMethod -UserAgent $UserAgent -Uri "$($Configuration.ResourceURL)/AzureADConnection?type=2" -Method GET -ContentType 'application/json' -Headers $Headers $Connections = if ($ConnectionsResponse -is [array]) { $ConnectionsResponse } elseif ($ConnectionsResponse.id) { @@ -43,7 +44,7 @@ function Invoke-HaloAutoMap { } $HaloTenantMappings = foreach ($Connection in $Connections) { - $Detail = Invoke-RestMethod -Uri "$($Configuration.ResourceURL)/AzureADConnection/$($Connection.id)?type=2&includedetails=true&includetenants=true" -Method GET -ContentType 'application/json' -Headers $Headers + $Detail = Invoke-RestMethod -UserAgent $UserAgent -Uri "$($Configuration.ResourceURL)/AzureADConnection/$($Connection.id)?type=2&includedetails=true&includetenants=true" -Method GET -ContentType 'application/json' -Headers $Headers $Detail.mappings_client | Where-Object { $_.azure_tenant_id -match $GuidRegex -and $_.client_id } } } catch { diff --git a/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 b/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 index 21b79d3fd87cf..f8bdc32b7a71e 100644 --- a/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 +++ b/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 @@ -18,6 +18,7 @@ function New-HaloPSATicket { $Configuration = ((Get-CIPPAzDataTableEntity @Table).config | ConvertFrom-Json).HaloPSA $TicketTable = Get-CIPPTable -TableName 'PSATickets' $token = Get-HaloToken -configuration $Configuration + $UserAgent = Get-CippUserAgent # Resolve affected user to a HaloPSA contact when the integration is configured for it. # Unmatched users fall through to userlookup.id = -1 (the client's General User contact). @@ -62,7 +63,7 @@ function New-HaloPSATicket { } if ($TargetTicketId) { - $Ticket = Invoke-RestMethod -Uri "$($Configuration.ResourceURL)/Tickets/$($TargetTicketId)?includedetails=true&includelastaction=false&nocache=undefined&includeusersassets=false&isdetailscreen=true" -ContentType 'application/json; charset=utf-8' -Method Get -Headers @{Authorization = "Bearer $($token.access_token)" } -SkipHttpErrorCheck + $Ticket = Invoke-RestMethod -UserAgent $UserAgent -Uri "$($Configuration.ResourceURL)/Tickets/$($TargetTicketId)?includedetails=true&includelastaction=false&nocache=undefined&includeusersassets=false&isdetailscreen=true" -ContentType 'application/json; charset=utf-8' -Method Get -Headers @{Authorization = "Bearer $($token.access_token)" } -SkipHttpErrorCheck if ($Ticket.id) { if (!$Ticket.hasbeenclosed) { Write-Information 'Ticket is still open, adding new note' @@ -87,7 +88,7 @@ function New-HaloPSATicket { $NoteAdded = $false try { if ($PSCmdlet.ShouldProcess('Add note to HaloPSA ticket', 'Add note')) { - $Action = Invoke-RestMethod -Uri "$($Configuration.ResourceURL)/actions" -ContentType 'application/json; charset=utf-8' -Method Post -Body $body -Headers @{Authorization = "Bearer $($token.access_token)" } + $Action = Invoke-RestMethod -UserAgent $UserAgent -Uri "$($Configuration.ResourceURL)/actions" -ContentType 'application/json; charset=utf-8' -Method Post -Body $body -Headers @{Authorization = "Bearer $($token.access_token)" } Write-Information "Note added to ticket in HaloPSA: $TargetTicketId" $NoteAdded = $true } @@ -228,7 +229,7 @@ function New-HaloPSATicket { Write-Information $body try { if ($PSCmdlet.ShouldProcess('Send ticket to HaloPSA', 'Create ticket')) { - $Ticket = Invoke-RestMethod -Uri "$($Configuration.ResourceURL)/Tickets" -ContentType 'application/json; charset=utf-8' -Method Post -Body $body -Headers @{Authorization = "Bearer $($token.access_token)" } + $Ticket = Invoke-RestMethod -UserAgent $UserAgent -Uri "$($Configuration.ResourceURL)/Tickets" -ContentType 'application/json; charset=utf-8' -Method Post -Body $body -Headers @{Authorization = "Bearer $($token.access_token)" } Write-Information "Ticket created in HaloPSA: $($Ticket.id)" if ($Configuration.ConsolidateTickets) { diff --git a/Tests/Extensions/New-HaloPSATicket.Priority.Tests.ps1 b/Tests/Extensions/New-HaloPSATicket.Priority.Tests.ps1 index 9e1b859ecb9c1..c3329dc8438be 100644 --- a/Tests/Extensions/New-HaloPSATicket.Priority.Tests.ps1 +++ b/Tests/Extensions/New-HaloPSATicket.Priority.Tests.ps1 @@ -5,6 +5,7 @@ BeforeAll { function Get-CIPPAzDataTableEntity { param($Filter, $Property) } function Add-CIPPAzDataTableEntity { param($Entity, [switch]$Force) } function Get-HaloToken { param($configuration) } + function Get-CippUserAgent { 'CIPP/test' } function Get-HaloTicketTypeSlaId { param($TicketType, $Configuration, $Token) } function Get-HaloUser { param($AzureOID, $Email, $ClientId, $Configuration, $Token) } function Get-StringHash { param($String) } diff --git a/Tests/Extensions/New-HaloPSATicket.Tests.ps1 b/Tests/Extensions/New-HaloPSATicket.Tests.ps1 index 089917cd505d9..8c412b4240a7a 100644 --- a/Tests/Extensions/New-HaloPSATicket.Tests.ps1 +++ b/Tests/Extensions/New-HaloPSATicket.Tests.ps1 @@ -14,6 +14,7 @@ BeforeAll { function Get-CIPPAzDataTableEntity { param($TableName, $Filter, $Property, $First) } function Add-CIPPAzDataTableEntity { param($TableName, $Entity, [switch]$Force) } function Get-HaloToken { param($configuration) } + function Get-CippUserAgent { 'CIPP/test' } function Get-HaloUser { param($AzureOID, $Email, $ClientId, $Configuration, $Token) } function Get-StringHash { param($String) } function Get-NormalizedError { param($Message) } diff --git a/Tests/Extensions/New-HaloPSATicket.TicketTarget.Tests.ps1 b/Tests/Extensions/New-HaloPSATicket.TicketTarget.Tests.ps1 index ccd844a9304be..7667dd3c9e495 100644 --- a/Tests/Extensions/New-HaloPSATicket.TicketTarget.Tests.ps1 +++ b/Tests/Extensions/New-HaloPSATicket.TicketTarget.Tests.ps1 @@ -13,6 +13,7 @@ BeforeAll { function Get-CIPPAzDataTableEntity { param($TableName, $Filter, $Property, $First) } function Add-CIPPAzDataTableEntity { param($TableName, $Entity, [switch]$Force) } function Get-HaloToken { param($configuration) } + function Get-CippUserAgent { 'CIPP/test' } function Get-HaloUser { param($AzureOID, $Email, $ClientId, $Configuration, $Token) } function Get-StringHash { param($String) } function Get-NormalizedError { param($Message) } diff --git a/Tests/GraphHelper/New-CIPPMFAConnectorToken.Tests.ps1 b/Tests/GraphHelper/New-CIPPMFAConnectorToken.Tests.ps1 index bcb4237ecb99d..e34ac330e0f7a 100644 --- a/Tests/GraphHelper/New-CIPPMFAConnectorToken.Tests.ps1 +++ b/Tests/GraphHelper/New-CIPPMFAConnectorToken.Tests.ps1 @@ -12,6 +12,8 @@ BeforeAll { function Get-CIPPAzDataTableEntity { param($Filter) } function Add-CIPPAzDataTableEntity { param($Entity, [switch]$Force) } function Get-Tenants { param($TenantFilter) } + function Get-CippKeyVaultSecret { param($Name, [switch]$AsPlainText) } + function Set-CippKeyVaultSecret { param($Name, $SecretValue) } . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/GraphHelper/New-CIPPMFAConnectorToken.ps1') @@ -61,3 +63,50 @@ Describe 'New-CIPPMFAConnectorToken secret caching' { Should -Invoke Add-CIPPAzDataTableEntity -Times 1 -Exactly } } + +Describe 'New-CIPPMFAConnectorToken Key Vault (production) storage path' { + BeforeEach { + # Production path: no dev-storage markers, so the secret is read from and written to Key Vault. + $script:SavedStorage = [Environment]::GetEnvironmentVariable('AzureWebJobsStorage') + Remove-Item env:AzureWebJobsStorage -ErrorAction SilentlyContinue + Remove-Item env:NonLocalHostAzurite -ErrorAction SilentlyContinue + Mock Set-CippKeyVaultSecret {} + Mock Update-AppManagementPolicy {} + Mock Invoke-RestMethod { [pscustomobject]@{ access_token = 'TOKEN123' } } + Mock New-GraphGetRequest { @([pscustomobject]@{ id = 'mfa-sp-id'; appId = $script:MFAAppID }) } + Mock New-GraphPostRequest { [pscustomobject]@{ secretText = 'NEWSECRET' } } + } + + AfterEach { + if ($null -ne $script:SavedStorage) { $env:AzureWebJobsStorage = $script:SavedStorage } + } + + It 'provisions when Key Vault has no cached secret yet (404) instead of failing' { + # The Key Vault helper throws on a missing secret rather than returning nothing; the first call for a + # tenant must treat that as a cache miss and provision, not surface the 404 to the user. + Mock Get-CippKeyVaultSecret { throw "Failed to retrieve secret 'NPS-x' from vault 'cippx': Response status code does not indicate success: 404" } + + $result = New-CIPPMFAConnectorToken -TenantFilter $script:TenantGuid + + $result.AccessToken | Should -Be 'TOKEN123' + Should -Invoke New-GraphPostRequest -Times 1 -Exactly + Should -Invoke Set-CippKeyVaultSecret -Times 1 -Exactly + } + + It 'reuses a cached Key Vault secret without provisioning' { + Mock Get-CippKeyVaultSecret { 'CACHEDSECRET' } + + $result = New-CIPPMFAConnectorToken -TenantFilter $script:TenantGuid + + $result.AccessToken | Should -Be 'TOKEN123' + Should -Not -Invoke New-GraphPostRequest + Should -Not -Invoke Set-CippKeyVaultSecret + } + + It 'surfaces a non-404 Key Vault failure rather than silently reprovisioning' { + Mock Get-CippKeyVaultSecret { throw "Failed to retrieve secret 'NPS-x' from vault 'cippx': Response status code does not indicate success: 403" } + + { New-CIPPMFAConnectorToken -TenantFilter $script:TenantGuid } | Should -Throw -ExpectedMessage '*403*' + Should -Not -Invoke New-GraphPostRequest + } +} diff --git a/version_latest.txt b/version_latest.txt index 4843a6d662fa9..b4ebbcb7157d2 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.10.0 \ No newline at end of file +10.10.1 \ No newline at end of file