Skip to content

Commit e5ef3bd

Browse files
authored
Merge pull request #1086 from KelvinTegelaar/dev
[pull] dev from KelvinTegelaar:dev
2 parents b6be9de + 5ae4171 commit e5ef3bd

11 files changed

Lines changed: 515 additions & 32 deletions

.github/copilot-instructions.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,44 @@ Detailed PowerShell coding conventions are in `.github/instructions/powershell-c
162162
- Do not create new Azure Function trigger folders — use the existing five triggers
163163
- Do not call `Write-Output` in HTTP functions — return an `[HttpResponseContext]` (the outer trigger handles `Push-OutputBinding`)
164164
- Do not hardcode tenant IDs or secrets — use environment variables and `Get-GraphToken`
165+
166+
## Commit messages
167+
168+
Generate all commit messages in **Conventional Commits** format:
169+
170+
```
171+
<type>(<optional scope>): <description>
172+
173+
[optional body]
174+
175+
[optional footer(s)]
176+
```
177+
178+
**Rules:**
179+
180+
- **Type** is required and must be one of:
181+
- `feat` — a new feature
182+
- `fix` — a bug fix
183+
- `docs` — documentation only
184+
- `style` — formatting, whitespace, no code-behavior change
185+
- `refactor` — code change that neither fixes a bug nor adds a feature
186+
- `perf` — a performance improvement
187+
- `test` — adding or correcting tests
188+
- `build` — build system or dependency changes
189+
- `ci` — CI/CD configuration changes
190+
- `chore` — routine maintenance, no production code change
191+
- `revert` — reverts a previous commit
192+
- **Scope** is optional and given in parentheses after the type (e.g. `feat(identity):`). Use a short, lowercase area name when it adds clarity.
193+
- **Description** is a short, imperative-mood summary ("add", not "added"/"adds"), lowercase, no trailing period, ideally ≤ 72 characters.
194+
- **Body** (optional) explains the *what* and *why*, not the *how*. Separate it from the description with one blank line.
195+
- **Breaking changes** are indicated with a `!` before the colon (e.g. `feat!:`) and/or a `BREAKING CHANGE:` footer describing the change.
196+
- Reference issues/PRs in the footer where relevant (e.g. `Closes #123`).
197+
198+
**Examples:**
199+
200+
```
201+
feat(identity): add bulk user offboarding endpoint
202+
fix(graph): handle expired token on retry
203+
docs: update authentication model overview
204+
refactor(standards)!: rename remediation parameter
205+
```
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
name: Conventional Commits Check
2+
3+
on:
4+
# Using pull_request_target instead of pull_request for secure handling of fork PRs
5+
pull_request_target:
6+
# Re-run on title edits so a corrected title clears the check
7+
types: [opened, synchronize, reopened, edited]
8+
# Only check PRs targeting the dev branch
9+
branches:
10+
- dev
11+
12+
permissions:
13+
pull-requests: write
14+
issues: write
15+
16+
jobs:
17+
conventional-commits:
18+
name: Validate Conventional Commits
19+
runs-on: ubuntu-slim
20+
steps:
21+
- name: Validate PR title
22+
uses: actions/github-script@v9
23+
with:
24+
github-token: ${{ secrets.GITHUB_TOKEN }}
25+
script: |
26+
const title = context.payload.pull_request.title || '';
27+
const pattern = /^(feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert)(\(.+\))?!?: .+/;
28+
29+
if (pattern.test(title)) {
30+
console.log(`✓ PR title follows Conventional Commits format: "${title}"`);
31+
return;
32+
}
33+
34+
console.log(`❌ PR title does not follow Conventional Commits format: "${title}"`);
35+
36+
const message = [
37+
'❌ **This PR title does not follow the [Conventional Commits](https://www.conventionalcommits.org/) format.**',
38+
'',
39+
'Expected format: `type(scope)?: description`',
40+
'',
41+
'Allowed types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`, `ci`, `build`, `revert`',
42+
'',
43+
'Examples:',
44+
'- `feat: add SharePoint external user management`',
45+
'- `fix(auth): handle expired refresh tokens`',
46+
'- `docs: update self-hosting guide`',
47+
'',
48+
`Received: \`${title}\``,
49+
'',
50+
'🔒 This PR has been automatically closed. Please update the title to match the format above and reopen the PR.'
51+
].join('\n');
52+
53+
await github.rest.issues.createComment({
54+
...context.repo,
55+
issue_number: context.issue.number,
56+
body: message
57+
});
58+
59+
await github.rest.pulls.update({
60+
...context.repo,
61+
pull_number: context.issue.number,
62+
state: 'closed'
63+
});
64+
65+
core.setFailed(`PR title does not follow Conventional Commits format: "${title}"`);

Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,13 @@ function Get-CIPPAuthentication {
4949
}
5050
}
5151

52-
if (-not $env:SAMCertificate) {
53-
# First run on this instance: provision the certificate now.
52+
if (-not $env:SAMCertificate -and $env:SAMCertProvisionAttempted -ne 'true') {
53+
# First run on this instance: provision the certificate now, at most once per
54+
# process. The guard also breaks a recursion loop: Update-CIPPSAMCertificate
55+
# calls Get-GraphToken, which re-enters this function when the AppCache
56+
# ApplicationId does not match the environment.
5457
# Set-CIPPSAMCertificate refreshes $env:SAMCertificate on success.
58+
$env:SAMCertProvisionAttempted = 'true'
5559
Write-Information 'No SAM certificate found, provisioning one now'
5660
$CertResult = Update-CIPPSAMCertificate -ErrorAction Stop
5761
Write-LogMessage -message "Provisioned SAM certificate during authentication load. Thumbprint: $($CertResult.Thumbprint), storage mode: $($CertResult.StorageMode)" -Sev 'Info' -API 'CIPP Authentication'
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
function Get-CIPPSPOExternalUsers {
2+
<#
3+
.SYNOPSIS
4+
List the SharePoint tenant external users store via CSOM
5+
6+
.DESCRIPTION
7+
Enumerates every external user SharePoint Online knows about (the store behind
8+
Get-SPOExternalUser), using the CSOM Office365Tenant.GetExternalUsers method against the
9+
admin endpoint. This includes legacy email-authenticated guests (urn:spo:guest) that have
10+
no backing Entra object, and B2B guests whose Entra user may since have been deleted.
11+
12+
.PARAMETER TenantFilter
13+
Tenant to query
14+
15+
.EXAMPLE
16+
Get-CIPPSPOExternalUsers -TenantFilter 'contoso.onmicrosoft.com'
17+
18+
.FUNCTIONALITY
19+
Internal
20+
#>
21+
[CmdletBinding()]
22+
param(
23+
[Parameter(Mandatory = $true)]
24+
[string]$TenantFilter
25+
)
26+
27+
$SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter
28+
$AdminUrl = $SharePointInfo.AdminUrl
29+
$AdditionalHeaders = @{ 'Accept' = 'application/json;odata=verbose' }
30+
31+
$AllUsers = [System.Collections.Generic.List[object]]::new()
32+
$Position = 0
33+
$PageSize = 50
34+
35+
do {
36+
# Office365Tenant (TenantManagement) constructor -> GetExternalUsers(position, pageSize, filter, sortOrder)
37+
$XML = @"
38+
<Request AddExpandoFieldTypeSuffix="true" SchemaVersion="15.0.0.0" LibraryVersion="16.0.0.0" ApplicationName="SharePoint Online PowerShell (16.0.24908.0)" xmlns="http://schemas.microsoft.com/sharepoint/clientquery/2009"><Actions><ObjectPath Id="1" ObjectPathId="0" /><ObjectPath Id="3" ObjectPathId="2" /><Query Id="4" ObjectPathId="2"><Query SelectAllProperties="true"><Properties><Property Name="ExternalUserCollection"><Query SelectAllProperties="true"><Properties /></Query><ChildItemQuery SelectAllProperties="true"><Properties /></ChildItemQuery></Property></Properties></Query></Query></Actions><ObjectPaths><Constructor Id="0" TypeId="{e45fd516-a408-4ca4-b6dc-268e2f1f0f83}" /><Method Id="2" ParentId="0" Name="GetExternalUsers"><Parameters><Parameter Type="Int32">$Position</Parameter><Parameter Type="Int32">$PageSize</Parameter><Parameter Type="String"></Parameter><Parameter Type="Enum">0</Parameter></Parameters></Method></ObjectPaths></Request>
39+
"@
40+
41+
$Results = New-GraphPostRequest -scope "$AdminUrl/.default" -tenantid $TenantFilter -Uri "$AdminUrl/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AddedHeaders $AdditionalHeaders
42+
43+
$CsomError = ($Results | Where-Object { $_.ErrorInfo } | Select-Object -First 1).ErrorInfo.ErrorMessage
44+
if ($CsomError) { throw $CsomError }
45+
46+
$ResultObject = $Results | Where-Object { $null -ne $_.TotalUserCount } | Select-Object -First 1
47+
$Batch = @($ResultObject.ExternalUserCollection._Child_Items_)
48+
foreach ($User in $Batch) {
49+
[void]$AllUsers.Add($User)
50+
}
51+
$Position = $ResultObject.UserCollectionPosition
52+
# Continue while SPO reports more users beyond the current position.
53+
} while ($Batch.Count -eq $PageSize -and $Position -ge 0 -and $AllUsers.Count -lt [int]$ResultObject.TotalUserCount)
54+
55+
return $AllUsers
56+
}

Modules/CIPPCore/Public/Get-CippKeyVaultSecret.ps1

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ function Get-CippKeyVaultSecret {
6060
break
6161
} catch {
6262
$lastError = $_
63+
# 404 is definitive - the secret does not exist and retrying cannot change that
64+
if ($_.Exception.Message -match '404|SecretNotFound') {
65+
throw "Failed to retrieve secret '$Name' from vault '$VaultName': $($_.Exception.Message)"
66+
}
6367
if ($i -lt ($maxRetries - 1)) {
6468
Start-Sleep -Seconds $retryDelay
6569
$retryDelay *= 2 # Exponential backoff
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
function Remove-CIPPSPOSiteUser {
2+
<#
3+
.SYNOPSIS
4+
Remove a user from one or more SharePoint sites entirely
5+
6+
.DESCRIPTION
7+
Deletes the user from each site's user list via the SharePoint REST API with certificate
8+
authentication, which removes them from every site group and direct permission grant on
9+
that site at once. Site collection admins are refused (remove their admin flag first).
10+
11+
.PARAMETER TenantFilter
12+
Tenant the sites belong to
13+
14+
.PARAMETER SiteUrls
15+
One or more site URLs to remove the user from
16+
17+
.PARAMETER LoginName
18+
SharePoint claims login (i:0#.f|membership|upn); a bare UPN is converted automatically
19+
20+
.EXAMPLE
21+
Remove-CIPPSPOSiteUser -TenantFilter 'contoso.onmicrosoft.com' -SiteUrls @('https://contoso.sharepoint.com/sites/HR') -LoginName 'guest_example.com#ext#@contoso.onmicrosoft.com'
22+
23+
.FUNCTIONALITY
24+
Internal
25+
#>
26+
[CmdletBinding(SupportsShouldProcess = $true)]
27+
param(
28+
[Parameter(Mandatory = $true)]
29+
[string]$TenantFilter,
30+
[Parameter(Mandatory = $true)]
31+
[string[]]$SiteUrls,
32+
[Parameter(Mandatory = $true)]
33+
[string]$LoginName
34+
)
35+
36+
if ($LoginName -notmatch '\|') { $LoginName = "i:0#.f|membership|$LoginName" }
37+
38+
$SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter
39+
$Scope = "$($SharePointInfo.SharePointUrl)/.default"
40+
$JsonAccept = @{ Accept = 'application/json;odata=nometadata' }
41+
42+
$Succeeded = [System.Collections.Generic.List[string]]::new()
43+
$Failed = [System.Collections.Generic.List[string]]::new()
44+
45+
foreach ($SiteUrl in $SiteUrls) {
46+
if (-not $PSCmdlet.ShouldProcess($SiteUrl, "Remove $LoginName")) { continue }
47+
$BaseUri = "$($SiteUrl.TrimEnd('/'))/_api"
48+
try {
49+
try {
50+
$EnsureBody = ConvertTo-Json -Compress -InputObject @{ logonName = $LoginName }
51+
$EnsuredUser = New-GraphPostRequest -uri "$BaseUri/web/ensureuser" -tenantid $TenantFilter -scope $Scope -type POST -body $EnsureBody -contentType 'application/json;odata=nometadata' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true
52+
} catch {
53+
throw "could not resolve the user (ensureuser): $($_.Exception.Message)"
54+
}
55+
if (-not $EnsuredUser.Id) { throw 'could not resolve the user on the site.' }
56+
if ($EnsuredUser.IsSiteAdmin) {
57+
throw 'user is a site collection admin; remove their admin permission first (Remove Site Admin action).'
58+
}
59+
try {
60+
$null = New-GraphPostRequest -uri "$BaseUri/web/siteusers/removebyid($($EnsuredUser.Id))" -tenantid $TenantFilter -scope $Scope -type POST -body '{}' -contentType 'application/json;odata=nometadata' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true
61+
} catch {
62+
throw "removal failed: $($_.Exception.Message)"
63+
}
64+
$Succeeded.Add($SiteUrl)
65+
} catch {
66+
$Failed.Add("$($SiteUrl): $($_.Exception.Message)")
67+
}
68+
}
69+
70+
return [PSCustomObject]@{
71+
Succeeded = @($Succeeded)
72+
Failed = @($Failed)
73+
}
74+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
function Invoke-ExecRemoveSPOExternalUser {
2+
<#
3+
.FUNCTIONALITY
4+
Entrypoint
5+
.ROLE
6+
Sharepoint.Site.ReadWrite
7+
.DESCRIPTION
8+
Fully removes an external user's guest access: deletes their Entra guest account (when
9+
one exists) AND removes them from every SharePoint site they hold membership on, in one
10+
pass - so no orphaned accounts or lingering site access are left behind. The inert
11+
SharePoint external-store entry cannot be deleted (Microsoft deprecated
12+
RemoveExternalUsers) and ages out on its own; sharing links the user received are
13+
revoked separately via the Sharing Report.
14+
#>
15+
[CmdletBinding()]
16+
param($Request, $TriggerMetadata)
17+
18+
$APIName = $Request.Params.CIPPEndpoint
19+
$Headers = $Request.Headers
20+
$TenantFilter = $Request.Body.tenantFilter
21+
$EntraUserId = $Request.Body.EntraUserId
22+
$LoginName = $Request.Body.LoginName
23+
$SiteUrls = @($Request.Body.SiteUrls) | Where-Object { $_ }
24+
$DisplayName = $Request.Body.DisplayName ?? $EntraUserId ?? $LoginName
25+
26+
try {
27+
if (-not $EntraUserId -and $SiteUrls.Count -eq 0) {
28+
throw 'This entry has no Entra guest account and no known site memberships. The remaining SharePoint store entry cannot be removed (Microsoft deprecated the API) and ages out on its own; revoke any sharing links they hold via the Sharing Report.'
29+
}
30+
31+
$Messages = [System.Collections.Generic.List[string]]::new()
32+
$Errors = [System.Collections.Generic.List[string]]::new()
33+
34+
# 1. Strip the SharePoint footprint first (needs the login; falls back to the Entra UPN).
35+
if ($SiteUrls.Count -gt 0) {
36+
$RemovalLogin = $LoginName
37+
if (-not $RemovalLogin -and $EntraUserId) {
38+
try {
39+
$RemovalLogin = (New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/users/$($EntraUserId)?`$select=userPrincipalName" -tenantid $TenantFilter -AsApp $true).userPrincipalName
40+
} catch {
41+
$Errors.Add("Could not resolve the user's login for site removal: $($_.Exception.Message)")
42+
}
43+
}
44+
if ($RemovalLogin) {
45+
$Removal = Remove-CIPPSPOSiteUser -TenantFilter $TenantFilter -SiteUrls $SiteUrls -LoginName $RemovalLogin
46+
if ($Removal.Succeeded.Count -gt 0) {
47+
$Messages.Add("Removed from $($Removal.Succeeded.Count) site(s): $($Removal.Succeeded -join ', ').")
48+
}
49+
if ($Removal.Failed.Count -gt 0) {
50+
$Errors.Add("Site removal failed on: $($Removal.Failed -join '; ')")
51+
}
52+
}
53+
}
54+
55+
# 2. Delete the Entra guest account so the user cannot sign in anywhere.
56+
if ($EntraUserId) {
57+
try {
58+
$null = New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/users/$EntraUserId" -tenantid $TenantFilter -type DELETE -body '' -asapp $true
59+
$Messages.Add('Deleted the Entra guest account, blocking their sign-in.')
60+
} catch {
61+
$Errors.Add("Deleting the Entra guest account failed: $($_.Exception.Message)")
62+
}
63+
}
64+
65+
if ($Messages.Count -eq 0) {
66+
throw ($Errors -join ' ')
67+
}
68+
$Results = "Removed guest access for $($DisplayName): $($Messages -join ' ')"
69+
if ($Errors.Count -gt 0) {
70+
$Results += " Issues: $($Errors -join '; ')"
71+
}
72+
Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info
73+
$StatusCode = [HttpStatusCode]::OK
74+
} catch {
75+
$ErrorMessage = Get-CippException -Exception $_
76+
$Results = "Failed to remove guest access for $($DisplayName): $($ErrorMessage.NormalizedError)"
77+
Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Error -LogData $ErrorMessage
78+
$StatusCode = [HttpStatusCode]::BadRequest
79+
}
80+
81+
return ([HttpResponseContext]@{
82+
StatusCode = $StatusCode
83+
Body = @{ 'Results' = $Results }
84+
})
85+
}

0 commit comments

Comments
 (0)