Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
557 changes: 552 additions & 5 deletions Config/openapi.json

Large diffs are not rendered by default.

23 changes: 23 additions & 0 deletions Config/standards.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
188 changes: 94 additions & 94 deletions Modules/CIPPCore/Public/Get-CIPPSharePointSiteUsageReport.ps1
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading