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
41 changes: 41 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,44 @@ Detailed PowerShell coding conventions are in `.github/instructions/powershell-c
- Do not create new Azure Function trigger folders — use the existing five triggers
- Do not call `Write-Output` in HTTP functions — return an `[HttpResponseContext]` (the outer trigger handles `Push-OutputBinding`)
- Do not hardcode tenant IDs or secrets — use environment variables and `Get-GraphToken`

## Commit messages

Generate all commit messages in **Conventional Commits** format:

```
<type>(<optional scope>): <description>

[optional body]

[optional footer(s)]
```

**Rules:**

- **Type** is required and must be one of:
- `feat` — a new feature
- `fix` — a bug fix
- `docs` — documentation only
- `style` — formatting, whitespace, no code-behavior change
- `refactor` — code change that neither fixes a bug nor adds a feature
- `perf` — a performance improvement
- `test` — adding or correcting tests
- `build` — build system or dependency changes
- `ci` — CI/CD configuration changes
- `chore` — routine maintenance, no production code change
- `revert` — reverts a previous commit
- **Scope** is optional and given in parentheses after the type (e.g. `feat(identity):`). Use a short, lowercase area name when it adds clarity.
- **Description** is a short, imperative-mood summary ("add", not "added"/"adds"), lowercase, no trailing period, ideally ≤ 72 characters.
- **Body** (optional) explains the *what* and *why*, not the *how*. Separate it from the description with one blank line.
- **Breaking changes** are indicated with a `!` before the colon (e.g. `feat!:`) and/or a `BREAKING CHANGE:` footer describing the change.
- Reference issues/PRs in the footer where relevant (e.g. `Closes #123`).

**Examples:**

```
feat(identity): add bulk user offboarding endpoint
fix(graph): handle expired token on retry
docs: update authentication model overview
refactor(standards)!: rename remediation parameter
```
65 changes: 65 additions & 0 deletions .github/workflows/Conventional_Commits.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
name: Conventional Commits Check

on:
# Using pull_request_target instead of pull_request for secure handling of fork PRs
pull_request_target:
# Re-run on title edits so a corrected title clears the check
types: [opened, synchronize, reopened, edited]
# Only check PRs targeting the dev branch
branches:
- dev

permissions:
pull-requests: write
issues: write

jobs:
conventional-commits:
name: Validate Conventional Commits
runs-on: ubuntu-slim
steps:
- name: Validate PR title
uses: actions/github-script@v9
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const title = context.payload.pull_request.title || '';
const pattern = /^(feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert)(\(.+\))?!?: .+/;

if (pattern.test(title)) {
console.log(`✓ PR title follows Conventional Commits format: "${title}"`);
return;
}

console.log(`❌ PR title does not follow Conventional Commits format: "${title}"`);

const message = [
'❌ **This PR title does not follow the [Conventional Commits](https://www.conventionalcommits.org/) format.**',
'',
'Expected format: `type(scope)?: description`',
'',
'Allowed types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`, `ci`, `build`, `revert`',
'',
'Examples:',
'- `feat: add SharePoint external user management`',
'- `fix(auth): handle expired refresh tokens`',
'- `docs: update self-hosting guide`',
'',
`Received: \`${title}\``,
'',
'🔒 This PR has been automatically closed. Please update the title to match the format above and reopen the PR.'
].join('\n');

await github.rest.issues.createComment({
...context.repo,
issue_number: context.issue.number,
body: message
});

await github.rest.pulls.update({
...context.repo,
pull_number: context.issue.number,
state: 'closed'
});

core.setFailed(`PR title does not follow Conventional Commits format: "${title}"`);
8 changes: 6 additions & 2 deletions Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,13 @@ function Get-CIPPAuthentication {
}
}

if (-not $env:SAMCertificate) {
# First run on this instance: provision the certificate now.
if (-not $env:SAMCertificate -and $env:SAMCertProvisionAttempted -ne 'true') {
# First run on this instance: provision the certificate now, at most once per
# process. The guard also breaks a recursion loop: Update-CIPPSAMCertificate
# calls Get-GraphToken, which re-enters this function when the AppCache
# ApplicationId does not match the environment.
# Set-CIPPSAMCertificate refreshes $env:SAMCertificate on success.
$env:SAMCertProvisionAttempted = 'true'
Write-Information 'No SAM certificate found, provisioning one now'
$CertResult = Update-CIPPSAMCertificate -ErrorAction Stop
Write-LogMessage -message "Provisioned SAM certificate during authentication load. Thumbprint: $($CertResult.Thumbprint), storage mode: $($CertResult.StorageMode)" -Sev 'Info' -API 'CIPP Authentication'
Expand Down
56 changes: 56 additions & 0 deletions Modules/CIPPCore/Public/Get-CIPPSPOExternalUsers.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
function Get-CIPPSPOExternalUsers {
<#
.SYNOPSIS
List the SharePoint tenant external users store via CSOM

.DESCRIPTION
Enumerates every external user SharePoint Online knows about (the store behind
Get-SPOExternalUser), using the CSOM Office365Tenant.GetExternalUsers method against the
admin endpoint. This includes legacy email-authenticated guests (urn:spo:guest) that have
no backing Entra object, and B2B guests whose Entra user may since have been deleted.

.PARAMETER TenantFilter
Tenant to query

.EXAMPLE
Get-CIPPSPOExternalUsers -TenantFilter 'contoso.onmicrosoft.com'

.FUNCTIONALITY
Internal
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$TenantFilter
)

$SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter
$AdminUrl = $SharePointInfo.AdminUrl
$AdditionalHeaders = @{ 'Accept' = 'application/json;odata=verbose' }

$AllUsers = [System.Collections.Generic.List[object]]::new()
$Position = 0
$PageSize = 50

do {
# Office365Tenant (TenantManagement) constructor -> GetExternalUsers(position, pageSize, filter, sortOrder)
$XML = @"
<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>
"@

$Results = New-GraphPostRequest -scope "$AdminUrl/.default" -tenantid $TenantFilter -Uri "$AdminUrl/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AddedHeaders $AdditionalHeaders

$CsomError = ($Results | Where-Object { $_.ErrorInfo } | Select-Object -First 1).ErrorInfo.ErrorMessage
if ($CsomError) { throw $CsomError }

$ResultObject = $Results | Where-Object { $null -ne $_.TotalUserCount } | Select-Object -First 1
$Batch = @($ResultObject.ExternalUserCollection._Child_Items_)
foreach ($User in $Batch) {
[void]$AllUsers.Add($User)
}
$Position = $ResultObject.UserCollectionPosition
# Continue while SPO reports more users beyond the current position.
} while ($Batch.Count -eq $PageSize -and $Position -ge 0 -and $AllUsers.Count -lt [int]$ResultObject.TotalUserCount)

return $AllUsers
}
4 changes: 4 additions & 0 deletions Modules/CIPPCore/Public/Get-CippKeyVaultSecret.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ function Get-CippKeyVaultSecret {
break
} catch {
$lastError = $_
# 404 is definitive - the secret does not exist and retrying cannot change that
if ($_.Exception.Message -match '404|SecretNotFound') {
throw "Failed to retrieve secret '$Name' from vault '$VaultName': $($_.Exception.Message)"
}
if ($i -lt ($maxRetries - 1)) {
Start-Sleep -Seconds $retryDelay
$retryDelay *= 2 # Exponential backoff
Expand Down
74 changes: 74 additions & 0 deletions Modules/CIPPCore/Public/Remove-CIPPSPOSiteUser.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
function Remove-CIPPSPOSiteUser {
<#
.SYNOPSIS
Remove a user from one or more SharePoint sites entirely

.DESCRIPTION
Deletes the user from each site's user list via the SharePoint REST API with certificate
authentication, which removes them from every site group and direct permission grant on
that site at once. Site collection admins are refused (remove their admin flag first).

.PARAMETER TenantFilter
Tenant the sites belong to

.PARAMETER SiteUrls
One or more site URLs to remove the user from

.PARAMETER LoginName
SharePoint claims login (i:0#.f|membership|upn); a bare UPN is converted automatically

.EXAMPLE
Remove-CIPPSPOSiteUser -TenantFilter 'contoso.onmicrosoft.com' -SiteUrls @('https://contoso.sharepoint.com/sites/HR') -LoginName 'guest_example.com#ext#@contoso.onmicrosoft.com'

.FUNCTIONALITY
Internal
#>
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[Parameter(Mandatory = $true)]
[string]$TenantFilter,
[Parameter(Mandatory = $true)]
[string[]]$SiteUrls,
[Parameter(Mandatory = $true)]
[string]$LoginName
)

if ($LoginName -notmatch '\|') { $LoginName = "i:0#.f|membership|$LoginName" }

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

$Succeeded = [System.Collections.Generic.List[string]]::new()
$Failed = [System.Collections.Generic.List[string]]::new()

foreach ($SiteUrl in $SiteUrls) {
if (-not $PSCmdlet.ShouldProcess($SiteUrl, "Remove $LoginName")) { continue }
$BaseUri = "$($SiteUrl.TrimEnd('/'))/_api"
try {
try {
$EnsureBody = ConvertTo-Json -Compress -InputObject @{ logonName = $LoginName }
$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
} catch {
throw "could not resolve the user (ensureuser): $($_.Exception.Message)"
}
if (-not $EnsuredUser.Id) { throw 'could not resolve the user on the site.' }
if ($EnsuredUser.IsSiteAdmin) {
throw 'user is a site collection admin; remove their admin permission first (Remove Site Admin action).'
}
try {
$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
} catch {
throw "removal failed: $($_.Exception.Message)"
}
$Succeeded.Add($SiteUrl)
} catch {
$Failed.Add("$($SiteUrl): $($_.Exception.Message)")
}
}

return [PSCustomObject]@{
Succeeded = @($Succeeded)
Failed = @($Failed)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
function Invoke-ExecRemoveSPOExternalUser {
<#
.FUNCTIONALITY
Entrypoint
.ROLE
Sharepoint.Site.ReadWrite
.DESCRIPTION
Fully removes an external user's guest access: deletes their Entra guest account (when
one exists) AND removes them from every SharePoint site they hold membership on, in one
pass - so no orphaned accounts or lingering site access are left behind. The inert
SharePoint external-store entry cannot be deleted (Microsoft deprecated
RemoveExternalUsers) and ages out on its own; sharing links the user received are
revoked separately via the Sharing Report.
#>
[CmdletBinding()]
param($Request, $TriggerMetadata)

$APIName = $Request.Params.CIPPEndpoint
$Headers = $Request.Headers
$TenantFilter = $Request.Body.tenantFilter
$EntraUserId = $Request.Body.EntraUserId
$LoginName = $Request.Body.LoginName
$SiteUrls = @($Request.Body.SiteUrls) | Where-Object { $_ }
$DisplayName = $Request.Body.DisplayName ?? $EntraUserId ?? $LoginName

try {
if (-not $EntraUserId -and $SiteUrls.Count -eq 0) {
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.'
}

$Messages = [System.Collections.Generic.List[string]]::new()
$Errors = [System.Collections.Generic.List[string]]::new()

# 1. Strip the SharePoint footprint first (needs the login; falls back to the Entra UPN).
if ($SiteUrls.Count -gt 0) {
$RemovalLogin = $LoginName
if (-not $RemovalLogin -and $EntraUserId) {
try {
$RemovalLogin = (New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/users/$($EntraUserId)?`$select=userPrincipalName" -tenantid $TenantFilter -AsApp $true).userPrincipalName
} catch {
$Errors.Add("Could not resolve the user's login for site removal: $($_.Exception.Message)")
}
}
if ($RemovalLogin) {
$Removal = Remove-CIPPSPOSiteUser -TenantFilter $TenantFilter -SiteUrls $SiteUrls -LoginName $RemovalLogin
if ($Removal.Succeeded.Count -gt 0) {
$Messages.Add("Removed from $($Removal.Succeeded.Count) site(s): $($Removal.Succeeded -join ', ').")
}
if ($Removal.Failed.Count -gt 0) {
$Errors.Add("Site removal failed on: $($Removal.Failed -join '; ')")
}
}
}

# 2. Delete the Entra guest account so the user cannot sign in anywhere.
if ($EntraUserId) {
try {
$null = New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/users/$EntraUserId" -tenantid $TenantFilter -type DELETE -body '' -asapp $true
$Messages.Add('Deleted the Entra guest account, blocking their sign-in.')
} catch {
$Errors.Add("Deleting the Entra guest account failed: $($_.Exception.Message)")
}
}

if ($Messages.Count -eq 0) {
throw ($Errors -join ' ')
}
$Results = "Removed guest access for $($DisplayName): $($Messages -join ' ')"
if ($Errors.Count -gt 0) {
$Results += " Issues: $($Errors -join '; ')"
}
Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info
$StatusCode = [HttpStatusCode]::OK
} catch {
$ErrorMessage = Get-CippException -Exception $_
$Results = "Failed to remove guest access for $($DisplayName): $($ErrorMessage.NormalizedError)"
Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Error -LogData $ErrorMessage
$StatusCode = [HttpStatusCode]::BadRequest
}

return ([HttpResponseContext]@{
StatusCode = $StatusCode
Body = @{ 'Results' = $Results }
})
}
Loading
Loading