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
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ function Push-CIPPDBCacheData {
} else {
Write-Host "Skipping Compliance data collection for $TenantFilter - no required license"
}

if ($DefenderCapable) {
$Tasks.Add(@{
FunctionName = 'ExecCIPPDBCache'
Expand All @@ -219,15 +219,7 @@ function Push-CIPPDBCacheData {
QueueId = $QueueId
QueueName = "DB Cache SharePoint - $TenantFilter"
})
# SharePointSharingLinks runs as its own activity — it's heavy (scans every drive) and
# spawns a child orchestrator (one activity per site) that needs its own time budget.
$Tasks.Add(@{
FunctionName = 'ExecCIPPDBCache'
Name = 'SharePointSharingLinks'
TenantFilter = $TenantFilter
QueueId = $QueueId
QueueName = "DB Cache SharePointSharingLinks - $TenantFilter"
})
# SharePointSharingLinks runs adhoc since it can take a long time to enumerate all sharing links for large tenants
} else {
Write-Host "Skipping SharePoint data collection for $TenantFilter - no required license"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
function Push-ExecSharePointTemplateDeploy {
<#
.FUNCTIONALITY
Entrypoint
.DESCRIPTION
Queue worker that deploys a SharePoint provisioning template to a single tenant.
Queued per tenant by Invoke-ExecSharePointTemplate (Action=Deploy).
#>
param($Item)

try {
$Item = $Item | ConvertTo-Json -Depth 10 | ConvertFrom-Json
$TemplateId = $Item.TemplateId
if (-not $TemplateId) {
Write-LogMessage -message 'No SharePoint template specified' -tenant $Item.Tenant -API 'Deploy SharePoint Template' -sev Error
return $false
}

$Table = Get-CIPPTable -TableName 'templates'
$Template = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'SharePointTemplate' and RowKey eq '$TemplateId'"
if (-not $Template) {
Write-LogMessage -message "SharePoint template $TemplateId not found" -tenant $Item.Tenant -API 'Deploy SharePoint Template' -sev Error
return $false
}

$TemplateData = $Template.JSON | ConvertFrom-Json
$Results = Invoke-CIPPSharePointTemplateDeploy -TemplateData $TemplateData -SiteOwner $Item.SiteOwner -TenantFilter $Item.Tenant
foreach ($Result in $Results) {
Write-Information $Result
}
return $true
} catch {
Write-LogMessage -message "Error deploying SharePoint template to tenant $($Item.Tenant) - $($_.Exception.Message)" -tenant $Item.Tenant -API 'Deploy SharePoint Template' -sev Error
Write-Error $_.Exception.Message
}
}
23 changes: 17 additions & 6 deletions Modules/CIPPCore/Public/Get-CIPPDrift.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -377,12 +377,23 @@ function Get-CIPPDrift {
$tenantPolicy.policy | Add-Member -MemberType NoteProperty -Name 'URLName' -Value $TenantPolicy.Type -Force
$TenantPolicyName = if ($TenantPolicy.Policy.displayName) { $TenantPolicy.Policy.displayName } else { $TenantPolicy.Policy.name }
foreach ($TemplatePolicy in $TemplateIntuneTemplates) {
$TemplatePolicyName = if ($TemplatePolicy.displayName) { $TemplatePolicy.displayName } else { $TemplatePolicy.name }

if ($TemplatePolicy.displayName -eq $TenantPolicy.Policy.displayName -or
$TemplatePolicy.name -eq $TenantPolicy.Policy.name -or
$TemplatePolicy.displayName -eq $TenantPolicy.Policy.name -or
$TemplatePolicy.name -eq $TenantPolicy.Policy.displayName) {
# Compare displayName-to-displayName and name-to-name (plus the cross pairings,
# since some policy types are captured under one property but deployed under the
# other) but require BOTH sides of each pairing to be non-empty before treating it
# as a match. Most Intune policy types (compliance policies, device configurations,
# group policy configs, etc.) only expose displayName and have no .name property at
# all, so comparing raw .name values directly (as before) compared $null -eq $null,
# which is $true in PowerShell - causing every tenant policy to falsely "match" the
# first template as soon as any template lacked a .name property, suppressing all
# tenant-only deviations. Note: templates always get a .displayName forced onto them
# (see Add-Member above) even for name-only policy types like Settings Catalog
# (deviceManagement/configurationPolicies), so the name-to-name pairing must still be
# compared directly from the raw properties - collapsing to a single "effective name
# preferring displayName" per side would silently break matching for those policies.
if (($TemplatePolicy.displayName -and $TenantPolicy.Policy.displayName -and $TemplatePolicy.displayName -eq $TenantPolicy.Policy.displayName) -or
($TemplatePolicy.name -and $TenantPolicy.Policy.name -and $TemplatePolicy.name -eq $TenantPolicy.Policy.name) -or
($TemplatePolicy.displayName -and $TenantPolicy.Policy.name -and $TemplatePolicy.displayName -eq $TenantPolicy.Policy.name) -or
($TemplatePolicy.name -and $TenantPolicy.Policy.displayName -and $TemplatePolicy.name -eq $TenantPolicy.Policy.displayName)) {
$PolicyFound = $true
break
}
Expand Down
97 changes: 97 additions & 0 deletions Modules/CIPPCore/Public/Invoke-CIPPSharePointTemplateDeploy.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
function Invoke-CIPPSharePointTemplateDeploy {
<#
.SYNOPSIS
Deploy a SharePoint provisioning template to a single tenant

.DESCRIPTION
Provisions every site template in a SharePoint provisioning template against one tenant.
When the template is marked createAsTeams the container is created as a full Microsoft
Team first (via the Teams API, so channels and Teams functionality stay intact) and the
document libraries are added to the backing SharePoint site afterwards. Otherwise a plain
SharePoint site is created. Root-level and per-library permissions are applied by group
display name, optionally creating missing groups as security groups.

.PARAMETER TemplateData
The deserialized template object (templateName, createAsTeams, createMissingGroups, siteTemplates)

.PARAMETER SiteOwner
UPN set as the owner of every site or Team the template creates

.PARAMETER TenantFilter
The tenant to deploy to
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
$TemplateData,

[Parameter(Mandatory = $true)]
[string]$SiteOwner,

[Parameter(Mandatory = $true)]
[string]$TenantFilter,

$APIName = 'Deploy SharePoint Template',
$Headers
)

# Extracts the group display name from a stored permission entry: the frontend saves plain
# strings, but older entries may be autocomplete objects ({label,value}).
$GetPrincipalName = { param($Principal) $Principal.value ?? $Principal }
$CreateMissingGroups = $TemplateData.createMissingGroups -eq $true

$Results = [System.Collections.Generic.List[string]]::new()
foreach ($SiteTemplate in $TemplateData.siteTemplates) {
try {
# Create the container first: a full Team (Teams API) so all Teams functionality
# stays intact, or a plain SharePoint site otherwise.
if ($TemplateData.createAsTeams -eq $true) {
$Team = New-CIPPTeam -DisplayName $SiteTemplate.displayName -Description ($SiteTemplate.description ?? '') -Owner $SiteOwner -TenantFilter $TenantFilter -Headers $Headers -APIName $APIName
$SiteUrl = $Team.SiteUrl
$Results.Add("[$TenantFilter] Created Team '$($SiteTemplate.displayName)' with site $SiteUrl")
} else {
$null = New-CIPPSharepointSite -SiteName $SiteTemplate.displayName -SiteDescription ($SiteTemplate.description ?? $SiteTemplate.displayName) -SiteOwner $SiteOwner -TemplateName 'Team' -TenantFilter $TenantFilter -Headers $Headers -APIName $APIName
$SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter
$SitePath = $SiteTemplate.displayName -replace ' ' -replace '[^A-Za-z0-9-]'
$SiteUrl = "https://$($SharePointInfo.TenantName).sharepoint.com/sites/$SitePath"
$Results.Add("[$TenantFilter] Created site '$($SiteTemplate.displayName)' at $SiteUrl")
}

# Root-level permissions, grouped per permission level.
$RootPermGroups = @($SiteTemplate.permissions) | Group-Object -Property permissionLevel
foreach ($PermGroup in $RootPermGroups) {
$GroupNames = @($PermGroup.Group | ForEach-Object { & $GetPrincipalName $_.principal }) | Where-Object { $_ }
try {
$PermResult = Set-CIPPSharePointObjectPermission -SiteUrl $SiteUrl -PermissionLevel $PermGroup.Name -GroupNames $GroupNames -CreateMissingGroups:$CreateMissingGroups -TenantFilter $TenantFilter -Headers $Headers -APIName $APIName
$Results.Add("[$TenantFilter] $($SiteTemplate.displayName): $PermResult")
} catch {
$Results.Add("[$TenantFilter] $($SiteTemplate.displayName): root permissions failed - $($_.Exception.Message)")
}
}

# Then the document libraries via the SharePoint module.
foreach ($Library in $SiteTemplate.libraries) {
try {
$NewLibrary = New-CIPPSharePointLibrary -SiteUrl $SiteUrl -LibraryName $Library.name -Description ($Library.description ?? '') -TenantFilter $TenantFilter -Headers $Headers -APIName $APIName
$Results.Add("[$TenantFilter] $($SiteTemplate.displayName): library '$($Library.name)' $($NewLibrary.Created ? 'created' : 'already existed')")

$LibPermGroups = @($Library.permissions) | Group-Object -Property permissionLevel
foreach ($PermGroup in $LibPermGroups) {
$GroupNames = @($PermGroup.Group | ForEach-Object { & $GetPrincipalName $_.principal }) | Where-Object { $_ }
try {
$PermResult = Set-CIPPSharePointObjectPermission -SiteUrl $SiteUrl -ListId $NewLibrary.ListId -PermissionLevel $PermGroup.Name -GroupNames $GroupNames -CreateMissingGroups:$CreateMissingGroups -TenantFilter $TenantFilter -Headers $Headers -APIName $APIName
$Results.Add("[$TenantFilter] $($SiteTemplate.displayName)/$($Library.name): $PermResult")
} catch {
$Results.Add("[$TenantFilter] $($SiteTemplate.displayName)/$($Library.name): permissions failed - $($_.Exception.Message)")
}
}
} catch {
$Results.Add("[$TenantFilter] $($SiteTemplate.displayName): library '$($Library.name)' failed - $($_.Exception.Message)")
}
}
} catch {
$Results.Add("[$TenantFilter] Failed to deploy '$($SiteTemplate.displayName)': $($_.Exception.Message)")
}
}
return $Results
}
82 changes: 82 additions & 0 deletions Modules/CIPPCore/Public/New-CIPPSharePointLibrary.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
function New-CIPPSharePointLibrary {
<#
.SYNOPSIS
Create a document library on a SharePoint site

.DESCRIPTION
Creates a document library (BaseTemplate 101) on the given site via the SharePoint REST
API. If a list with the same title already exists it is returned instead, so deploys are
idempotent.

.PARAMETER SiteUrl
The full URL of the site to create the library on

.PARAMETER LibraryName
The title of the document library

.PARAMETER Description
The description of the document library

.PARAMETER TenantFilter
The tenant the site belongs to
#>
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[Parameter(Mandatory = $true)]
[string]$SiteUrl,

[Parameter(Mandatory = $true)]
[string]$LibraryName,

[string]$Description = '',

[Parameter(Mandatory = $true)]
[string]$TenantFilter,

$APIName = 'Create SharePoint Library',
$Headers
)

$SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter
$Scope = "$($SharePointInfo.SharePointUrl)/.default"
$JsonAccept = @{ Accept = 'application/json;odata=nometadata' }
$BaseUri = "$($SiteUrl.TrimEnd('/'))/_api"

# Idempotency: return the existing library when one with this title is already present.
$EscapedTitle = $LibraryName -replace "'", "''"
try {
$Existing = New-GraphGetRequest -uri "$BaseUri/web/lists/GetByTitle('$EscapedTitle')?`$select=Id,Title" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true
if ($Existing.Id) {
Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Library $LibraryName already exists on $SiteUrl, reusing it." -sev Info
return [PSCustomObject]@{
ListId = $Existing.Id
Title = $Existing.Title
Created = $false
}
}
} catch {
# 404 means the library does not exist yet, which is the normal path.
}

if (-not $PSCmdlet.ShouldProcess($LibraryName, "Create document library on $SiteUrl")) { return }

try {
$Body = ConvertTo-Json -Compress -InputObject @{
BaseTemplate = 101
Title = $LibraryName
Description = $Description
}
$NewList = New-GraphPostRequest -uri "$BaseUri/web/lists" -tenantid $TenantFilter -scope $Scope -type POST -body $Body -AddedHeaders $JsonAccept -UseCertificate -AsApp $true
Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Successfully created document library $LibraryName on $SiteUrl" -sev Info
return [PSCustomObject]@{
ListId = $NewList.Id
Title = $NewList.Title
Created = $true
}
} catch {
$ErrorMessage = Get-CippException -Exception $_
$Result = "Failed to create document library $LibraryName on $SiteUrl. Error: $($ErrorMessage.NormalizedError)"
Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -sev Error -LogData $ErrorMessage
throw $Result
}
}
101 changes: 101 additions & 0 deletions Modules/CIPPCore/Public/New-CIPPTeam.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
function New-CIPPTeam {
<#
.SYNOPSIS
Create a new Microsoft Team and return its group id and SharePoint site URL

.DESCRIPTION
Creates a Team via the Graph Teams API (standard template) so the full Teams stack
(group, channels, Teams-enabled SharePoint site) is provisioned, then waits for the
backing SharePoint site to become available and returns both identifiers.

.PARAMETER DisplayName
The display name of the team

.PARAMETER Description
The description of the team

.PARAMETER Owner
UPN of the team owner. Required by Graph when creating a team with application permissions.

.PARAMETER Visibility
Public or Private. Defaults to Private.

.PARAMETER TenantFilter
The tenant to create the team in
#>
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[Parameter(Mandatory = $true)]
[string]$DisplayName,

[string]$Description = '',

[Parameter(Mandatory = $true)]
[string]$Owner,

[ValidateSet('Public', 'Private')]
[string]$Visibility = 'Private',

[Parameter(Mandatory = $true)]
[string]$TenantFilter,

$APIName = 'Create Team',
$Headers
)

$TeamsSettings = [PSCustomObject]@{
'template@odata.bind' = "https://graph.microsoft.com/v1.0/teamsTemplates('standard')"
'visibility' = $Visibility.ToLower()
'displayName' = $DisplayName
'description' = $Description
'members' = @(
@{
'@odata.type' = '#microsoft.graph.aadUserConversationMember'
'roles' = @('owner')
'user@odata.bind' = "https://graph.microsoft.com/beta/users('$Owner')"
}
)
} | ConvertTo-Json -Depth 10

if (-not $PSCmdlet.ShouldProcess($DisplayName, 'Create new Team')) { return }

try {
# Team creation is async: Graph returns 202 with a Content-Location of /teams('{id}').
$ResponseHeaders = New-GraphPostRequest -AsApp $true -uri 'https://graph.microsoft.com/beta/teams' -tenantid $TenantFilter -type POST -body $TeamsSettings -returnHeaders $true
$ContentLocation = [string]($ResponseHeaders.'Content-Location' | Select-Object -First 1)
$GroupId = [regex]::Match($ContentLocation, "teams\('([^']+)'\)").Groups[1].Value
if (-not $GroupId) {
throw "Team creation was accepted but no team id was returned (Content-Location: '$ContentLocation')."
}
} catch {
$ErrorMessage = Get-CippException -Exception $_
$Result = "Failed to create Team $DisplayName. Error: $($ErrorMessage.NormalizedError)"
Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -sev Error -LogData $ErrorMessage
throw $Result
}

# Wait for the backing SharePoint site to be provisioned so the caller can add libraries.
$SiteUrl = $null
$Attempts = 0
do {
$Attempts++
try {
$Site = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/groups/$GroupId/sites/root?`$select=id,webUrl" -tenantid $TenantFilter -AsApp $true
$SiteUrl = $Site.webUrl
} catch {
if ($Attempts -lt 10) { Start-Sleep -Seconds 6 }
}
} while (-not $SiteUrl -and $Attempts -lt 10)

if (-not $SiteUrl) {
$Result = "Created Team $DisplayName ($GroupId) but the SharePoint site was not available yet. Libraries and permissions were not applied."
Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -sev Warning
throw $Result
}

Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Successfully created Team $DisplayName with site $SiteUrl" -sev Info
return [PSCustomObject]@{
GroupId = $GroupId
SiteUrl = $SiteUrl
}
}
Loading
Loading