Skip to content

Commit a337d11

Browse files
authored
Merge pull request #1100 from KelvinTegelaar/dev
[pull] dev from KelvinTegelaar:dev
2 parents c20dbe1 + 9368bca commit a337d11

16 files changed

Lines changed: 2036 additions & 26 deletions

Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ function Push-CIPPDBCacheData {
198198
} else {
199199
Write-Host "Skipping Compliance data collection for $TenantFilter - no required license"
200200
}
201-
201+
202202
if ($DefenderCapable) {
203203
$Tasks.Add(@{
204204
FunctionName = 'ExecCIPPDBCache'
@@ -219,15 +219,7 @@ function Push-CIPPDBCacheData {
219219
QueueId = $QueueId
220220
QueueName = "DB Cache SharePoint - $TenantFilter"
221221
})
222-
# SharePointSharingLinks runs as its own activity — it's heavy (scans every drive) and
223-
# spawns a child orchestrator (one activity per site) that needs its own time budget.
224-
$Tasks.Add(@{
225-
FunctionName = 'ExecCIPPDBCache'
226-
Name = 'SharePointSharingLinks'
227-
TenantFilter = $TenantFilter
228-
QueueId = $QueueId
229-
QueueName = "DB Cache SharePointSharingLinks - $TenantFilter"
230-
})
222+
# SharePointSharingLinks runs adhoc since it can take a long time to enumerate all sharing links for large tenants
231223
} else {
232224
Write-Host "Skipping SharePoint data collection for $TenantFilter - no required license"
233225
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
function Push-ExecSharePointTemplateDeploy {
2+
<#
3+
.FUNCTIONALITY
4+
Entrypoint
5+
.DESCRIPTION
6+
Queue worker that deploys a SharePoint provisioning template to a single tenant.
7+
Queued per tenant by Invoke-ExecSharePointTemplate (Action=Deploy).
8+
#>
9+
param($Item)
10+
11+
try {
12+
$Item = $Item | ConvertTo-Json -Depth 10 | ConvertFrom-Json
13+
$TemplateId = $Item.TemplateId
14+
if (-not $TemplateId) {
15+
Write-LogMessage -message 'No SharePoint template specified' -tenant $Item.Tenant -API 'Deploy SharePoint Template' -sev Error
16+
return $false
17+
}
18+
19+
$Table = Get-CIPPTable -TableName 'templates'
20+
$Template = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'SharePointTemplate' and RowKey eq '$TemplateId'"
21+
if (-not $Template) {
22+
Write-LogMessage -message "SharePoint template $TemplateId not found" -tenant $Item.Tenant -API 'Deploy SharePoint Template' -sev Error
23+
return $false
24+
}
25+
26+
$TemplateData = $Template.JSON | ConvertFrom-Json
27+
$Results = Invoke-CIPPSharePointTemplateDeploy -TemplateData $TemplateData -SiteOwner $Item.SiteOwner -TenantFilter $Item.Tenant
28+
foreach ($Result in $Results) {
29+
Write-Information $Result
30+
}
31+
return $true
32+
} catch {
33+
Write-LogMessage -message "Error deploying SharePoint template to tenant $($Item.Tenant) - $($_.Exception.Message)" -tenant $Item.Tenant -API 'Deploy SharePoint Template' -sev Error
34+
Write-Error $_.Exception.Message
35+
}
36+
}

Modules/CIPPCore/Public/Get-CIPPDrift.ps1

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -377,12 +377,23 @@ function Get-CIPPDrift {
377377
$tenantPolicy.policy | Add-Member -MemberType NoteProperty -Name 'URLName' -Value $TenantPolicy.Type -Force
378378
$TenantPolicyName = if ($TenantPolicy.Policy.displayName) { $TenantPolicy.Policy.displayName } else { $TenantPolicy.Policy.name }
379379
foreach ($TemplatePolicy in $TemplateIntuneTemplates) {
380-
$TemplatePolicyName = if ($TemplatePolicy.displayName) { $TemplatePolicy.displayName } else { $TemplatePolicy.name }
381-
382-
if ($TemplatePolicy.displayName -eq $TenantPolicy.Policy.displayName -or
383-
$TemplatePolicy.name -eq $TenantPolicy.Policy.name -or
384-
$TemplatePolicy.displayName -eq $TenantPolicy.Policy.name -or
385-
$TemplatePolicy.name -eq $TenantPolicy.Policy.displayName) {
380+
# Compare displayName-to-displayName and name-to-name (plus the cross pairings,
381+
# since some policy types are captured under one property but deployed under the
382+
# other) but require BOTH sides of each pairing to be non-empty before treating it
383+
# as a match. Most Intune policy types (compliance policies, device configurations,
384+
# group policy configs, etc.) only expose displayName and have no .name property at
385+
# all, so comparing raw .name values directly (as before) compared $null -eq $null,
386+
# which is $true in PowerShell - causing every tenant policy to falsely "match" the
387+
# first template as soon as any template lacked a .name property, suppressing all
388+
# tenant-only deviations. Note: templates always get a .displayName forced onto them
389+
# (see Add-Member above) even for name-only policy types like Settings Catalog
390+
# (deviceManagement/configurationPolicies), so the name-to-name pairing must still be
391+
# compared directly from the raw properties - collapsing to a single "effective name
392+
# preferring displayName" per side would silently break matching for those policies.
393+
if (($TemplatePolicy.displayName -and $TenantPolicy.Policy.displayName -and $TemplatePolicy.displayName -eq $TenantPolicy.Policy.displayName) -or
394+
($TemplatePolicy.name -and $TenantPolicy.Policy.name -and $TemplatePolicy.name -eq $TenantPolicy.Policy.name) -or
395+
($TemplatePolicy.displayName -and $TenantPolicy.Policy.name -and $TemplatePolicy.displayName -eq $TenantPolicy.Policy.name) -or
396+
($TemplatePolicy.name -and $TenantPolicy.Policy.displayName -and $TemplatePolicy.name -eq $TenantPolicy.Policy.displayName)) {
386397
$PolicyFound = $true
387398
break
388399
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
function Invoke-CIPPSharePointTemplateDeploy {
2+
<#
3+
.SYNOPSIS
4+
Deploy a SharePoint provisioning template to a single tenant
5+
6+
.DESCRIPTION
7+
Provisions every site template in a SharePoint provisioning template against one tenant.
8+
When the template is marked createAsTeams the container is created as a full Microsoft
9+
Team first (via the Teams API, so channels and Teams functionality stay intact) and the
10+
document libraries are added to the backing SharePoint site afterwards. Otherwise a plain
11+
SharePoint site is created. Root-level and per-library permissions are applied by group
12+
display name, optionally creating missing groups as security groups.
13+
14+
.PARAMETER TemplateData
15+
The deserialized template object (templateName, createAsTeams, createMissingGroups, siteTemplates)
16+
17+
.PARAMETER SiteOwner
18+
UPN set as the owner of every site or Team the template creates
19+
20+
.PARAMETER TenantFilter
21+
The tenant to deploy to
22+
#>
23+
[CmdletBinding()]
24+
param(
25+
[Parameter(Mandatory = $true)]
26+
$TemplateData,
27+
28+
[Parameter(Mandatory = $true)]
29+
[string]$SiteOwner,
30+
31+
[Parameter(Mandatory = $true)]
32+
[string]$TenantFilter,
33+
34+
$APIName = 'Deploy SharePoint Template',
35+
$Headers
36+
)
37+
38+
# Extracts the group display name from a stored permission entry: the frontend saves plain
39+
# strings, but older entries may be autocomplete objects ({label,value}).
40+
$GetPrincipalName = { param($Principal) $Principal.value ?? $Principal }
41+
$CreateMissingGroups = $TemplateData.createMissingGroups -eq $true
42+
43+
$Results = [System.Collections.Generic.List[string]]::new()
44+
foreach ($SiteTemplate in $TemplateData.siteTemplates) {
45+
try {
46+
# Create the container first: a full Team (Teams API) so all Teams functionality
47+
# stays intact, or a plain SharePoint site otherwise.
48+
if ($TemplateData.createAsTeams -eq $true) {
49+
$Team = New-CIPPTeam -DisplayName $SiteTemplate.displayName -Description ($SiteTemplate.description ?? '') -Owner $SiteOwner -TenantFilter $TenantFilter -Headers $Headers -APIName $APIName
50+
$SiteUrl = $Team.SiteUrl
51+
$Results.Add("[$TenantFilter] Created Team '$($SiteTemplate.displayName)' with site $SiteUrl")
52+
} else {
53+
$null = New-CIPPSharepointSite -SiteName $SiteTemplate.displayName -SiteDescription ($SiteTemplate.description ?? $SiteTemplate.displayName) -SiteOwner $SiteOwner -TemplateName 'Team' -TenantFilter $TenantFilter -Headers $Headers -APIName $APIName
54+
$SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter
55+
$SitePath = $SiteTemplate.displayName -replace ' ' -replace '[^A-Za-z0-9-]'
56+
$SiteUrl = "https://$($SharePointInfo.TenantName).sharepoint.com/sites/$SitePath"
57+
$Results.Add("[$TenantFilter] Created site '$($SiteTemplate.displayName)' at $SiteUrl")
58+
}
59+
60+
# Root-level permissions, grouped per permission level.
61+
$RootPermGroups = @($SiteTemplate.permissions) | Group-Object -Property permissionLevel
62+
foreach ($PermGroup in $RootPermGroups) {
63+
$GroupNames = @($PermGroup.Group | ForEach-Object { & $GetPrincipalName $_.principal }) | Where-Object { $_ }
64+
try {
65+
$PermResult = Set-CIPPSharePointObjectPermission -SiteUrl $SiteUrl -PermissionLevel $PermGroup.Name -GroupNames $GroupNames -CreateMissingGroups:$CreateMissingGroups -TenantFilter $TenantFilter -Headers $Headers -APIName $APIName
66+
$Results.Add("[$TenantFilter] $($SiteTemplate.displayName): $PermResult")
67+
} catch {
68+
$Results.Add("[$TenantFilter] $($SiteTemplate.displayName): root permissions failed - $($_.Exception.Message)")
69+
}
70+
}
71+
72+
# Then the document libraries via the SharePoint module.
73+
foreach ($Library in $SiteTemplate.libraries) {
74+
try {
75+
$NewLibrary = New-CIPPSharePointLibrary -SiteUrl $SiteUrl -LibraryName $Library.name -Description ($Library.description ?? '') -TenantFilter $TenantFilter -Headers $Headers -APIName $APIName
76+
$Results.Add("[$TenantFilter] $($SiteTemplate.displayName): library '$($Library.name)' $($NewLibrary.Created ? 'created' : 'already existed')")
77+
78+
$LibPermGroups = @($Library.permissions) | Group-Object -Property permissionLevel
79+
foreach ($PermGroup in $LibPermGroups) {
80+
$GroupNames = @($PermGroup.Group | ForEach-Object { & $GetPrincipalName $_.principal }) | Where-Object { $_ }
81+
try {
82+
$PermResult = Set-CIPPSharePointObjectPermission -SiteUrl $SiteUrl -ListId $NewLibrary.ListId -PermissionLevel $PermGroup.Name -GroupNames $GroupNames -CreateMissingGroups:$CreateMissingGroups -TenantFilter $TenantFilter -Headers $Headers -APIName $APIName
83+
$Results.Add("[$TenantFilter] $($SiteTemplate.displayName)/$($Library.name): $PermResult")
84+
} catch {
85+
$Results.Add("[$TenantFilter] $($SiteTemplate.displayName)/$($Library.name): permissions failed - $($_.Exception.Message)")
86+
}
87+
}
88+
} catch {
89+
$Results.Add("[$TenantFilter] $($SiteTemplate.displayName): library '$($Library.name)' failed - $($_.Exception.Message)")
90+
}
91+
}
92+
} catch {
93+
$Results.Add("[$TenantFilter] Failed to deploy '$($SiteTemplate.displayName)': $($_.Exception.Message)")
94+
}
95+
}
96+
return $Results
97+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
function New-CIPPSharePointLibrary {
2+
<#
3+
.SYNOPSIS
4+
Create a document library on a SharePoint site
5+
6+
.DESCRIPTION
7+
Creates a document library (BaseTemplate 101) on the given site via the SharePoint REST
8+
API. If a list with the same title already exists it is returned instead, so deploys are
9+
idempotent.
10+
11+
.PARAMETER SiteUrl
12+
The full URL of the site to create the library on
13+
14+
.PARAMETER LibraryName
15+
The title of the document library
16+
17+
.PARAMETER Description
18+
The description of the document library
19+
20+
.PARAMETER TenantFilter
21+
The tenant the site belongs to
22+
#>
23+
[CmdletBinding(SupportsShouldProcess = $true)]
24+
param(
25+
[Parameter(Mandatory = $true)]
26+
[string]$SiteUrl,
27+
28+
[Parameter(Mandatory = $true)]
29+
[string]$LibraryName,
30+
31+
[string]$Description = '',
32+
33+
[Parameter(Mandatory = $true)]
34+
[string]$TenantFilter,
35+
36+
$APIName = 'Create SharePoint Library',
37+
$Headers
38+
)
39+
40+
$SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter
41+
$Scope = "$($SharePointInfo.SharePointUrl)/.default"
42+
$JsonAccept = @{ Accept = 'application/json;odata=nometadata' }
43+
$BaseUri = "$($SiteUrl.TrimEnd('/'))/_api"
44+
45+
# Idempotency: return the existing library when one with this title is already present.
46+
$EscapedTitle = $LibraryName -replace "'", "''"
47+
try {
48+
$Existing = New-GraphGetRequest -uri "$BaseUri/web/lists/GetByTitle('$EscapedTitle')?`$select=Id,Title" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true
49+
if ($Existing.Id) {
50+
Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Library $LibraryName already exists on $SiteUrl, reusing it." -sev Info
51+
return [PSCustomObject]@{
52+
ListId = $Existing.Id
53+
Title = $Existing.Title
54+
Created = $false
55+
}
56+
}
57+
} catch {
58+
# 404 means the library does not exist yet, which is the normal path.
59+
}
60+
61+
if (-not $PSCmdlet.ShouldProcess($LibraryName, "Create document library on $SiteUrl")) { return }
62+
63+
try {
64+
$Body = ConvertTo-Json -Compress -InputObject @{
65+
BaseTemplate = 101
66+
Title = $LibraryName
67+
Description = $Description
68+
}
69+
$NewList = New-GraphPostRequest -uri "$BaseUri/web/lists" -tenantid $TenantFilter -scope $Scope -type POST -body $Body -AddedHeaders $JsonAccept -UseCertificate -AsApp $true
70+
Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Successfully created document library $LibraryName on $SiteUrl" -sev Info
71+
return [PSCustomObject]@{
72+
ListId = $NewList.Id
73+
Title = $NewList.Title
74+
Created = $true
75+
}
76+
} catch {
77+
$ErrorMessage = Get-CippException -Exception $_
78+
$Result = "Failed to create document library $LibraryName on $SiteUrl. Error: $($ErrorMessage.NormalizedError)"
79+
Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -sev Error -LogData $ErrorMessage
80+
throw $Result
81+
}
82+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
function New-CIPPTeam {
2+
<#
3+
.SYNOPSIS
4+
Create a new Microsoft Team and return its group id and SharePoint site URL
5+
6+
.DESCRIPTION
7+
Creates a Team via the Graph Teams API (standard template) so the full Teams stack
8+
(group, channels, Teams-enabled SharePoint site) is provisioned, then waits for the
9+
backing SharePoint site to become available and returns both identifiers.
10+
11+
.PARAMETER DisplayName
12+
The display name of the team
13+
14+
.PARAMETER Description
15+
The description of the team
16+
17+
.PARAMETER Owner
18+
UPN of the team owner. Required by Graph when creating a team with application permissions.
19+
20+
.PARAMETER Visibility
21+
Public or Private. Defaults to Private.
22+
23+
.PARAMETER TenantFilter
24+
The tenant to create the team in
25+
#>
26+
[CmdletBinding(SupportsShouldProcess = $true)]
27+
param(
28+
[Parameter(Mandatory = $true)]
29+
[string]$DisplayName,
30+
31+
[string]$Description = '',
32+
33+
[Parameter(Mandatory = $true)]
34+
[string]$Owner,
35+
36+
[ValidateSet('Public', 'Private')]
37+
[string]$Visibility = 'Private',
38+
39+
[Parameter(Mandatory = $true)]
40+
[string]$TenantFilter,
41+
42+
$APIName = 'Create Team',
43+
$Headers
44+
)
45+
46+
$TeamsSettings = [PSCustomObject]@{
47+
'template@odata.bind' = "https://graph.microsoft.com/v1.0/teamsTemplates('standard')"
48+
'visibility' = $Visibility.ToLower()
49+
'displayName' = $DisplayName
50+
'description' = $Description
51+
'members' = @(
52+
@{
53+
'@odata.type' = '#microsoft.graph.aadUserConversationMember'
54+
'roles' = @('owner')
55+
'user@odata.bind' = "https://graph.microsoft.com/beta/users('$Owner')"
56+
}
57+
)
58+
} | ConvertTo-Json -Depth 10
59+
60+
if (-not $PSCmdlet.ShouldProcess($DisplayName, 'Create new Team')) { return }
61+
62+
try {
63+
# Team creation is async: Graph returns 202 with a Content-Location of /teams('{id}').
64+
$ResponseHeaders = New-GraphPostRequest -AsApp $true -uri 'https://graph.microsoft.com/beta/teams' -tenantid $TenantFilter -type POST -body $TeamsSettings -returnHeaders $true
65+
$ContentLocation = [string]($ResponseHeaders.'Content-Location' | Select-Object -First 1)
66+
$GroupId = [regex]::Match($ContentLocation, "teams\('([^']+)'\)").Groups[1].Value
67+
if (-not $GroupId) {
68+
throw "Team creation was accepted but no team id was returned (Content-Location: '$ContentLocation')."
69+
}
70+
} catch {
71+
$ErrorMessage = Get-CippException -Exception $_
72+
$Result = "Failed to create Team $DisplayName. Error: $($ErrorMessage.NormalizedError)"
73+
Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -sev Error -LogData $ErrorMessage
74+
throw $Result
75+
}
76+
77+
# Wait for the backing SharePoint site to be provisioned so the caller can add libraries.
78+
$SiteUrl = $null
79+
$Attempts = 0
80+
do {
81+
$Attempts++
82+
try {
83+
$Site = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/groups/$GroupId/sites/root?`$select=id,webUrl" -tenantid $TenantFilter -AsApp $true
84+
$SiteUrl = $Site.webUrl
85+
} catch {
86+
if ($Attempts -lt 10) { Start-Sleep -Seconds 6 }
87+
}
88+
} while (-not $SiteUrl -and $Attempts -lt 10)
89+
90+
if (-not $SiteUrl) {
91+
$Result = "Created Team $DisplayName ($GroupId) but the SharePoint site was not available yet. Libraries and permissions were not applied."
92+
Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -sev Warning
93+
throw $Result
94+
}
95+
96+
Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Successfully created Team $DisplayName with site $SiteUrl" -sev Info
97+
return [PSCustomObject]@{
98+
GroupId = $GroupId
99+
SiteUrl = $SiteUrl
100+
}
101+
}

0 commit comments

Comments
 (0)