diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b4fad38b5fd26..467dab7a3d371 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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: + +``` +(): + +[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 +``` diff --git a/.github/workflows/Conventional_Commits.yml b/.github/workflows/Conventional_Commits.yml new file mode 100644 index 0000000000000..537e86d6823f9 --- /dev/null +++ b/.github/workflows/Conventional_Commits.yml @@ -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}"`); diff --git a/Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1 b/Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1 index 348d103e911a1..3f244f5e8418e 100644 --- a/Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1 @@ -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' diff --git a/Modules/CIPPCore/Public/Get-CIPPSPOExternalUsers.ps1 b/Modules/CIPPCore/Public/Get-CIPPSPOExternalUsers.ps1 new file mode 100644 index 0000000000000..522f788fed179 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPSPOExternalUsers.ps1 @@ -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 = @" +$Position$PageSize0 +"@ + + $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 +} diff --git a/Modules/CIPPCore/Public/Get-CippKeyVaultSecret.ps1 b/Modules/CIPPCore/Public/Get-CippKeyVaultSecret.ps1 index 30f0ac4b08f05..c88ba805076ef 100644 --- a/Modules/CIPPCore/Public/Get-CippKeyVaultSecret.ps1 +++ b/Modules/CIPPCore/Public/Get-CippKeyVaultSecret.ps1 @@ -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 diff --git a/Modules/CIPPCore/Public/Remove-CIPPSPOSiteUser.ps1 b/Modules/CIPPCore/Public/Remove-CIPPSPOSiteUser.ps1 new file mode 100644 index 0000000000000..f62defdce7a55 --- /dev/null +++ b/Modules/CIPPCore/Public/Remove-CIPPSPOSiteUser.ps1 @@ -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) + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSPOExternalUser.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSPOExternalUser.ps1 new file mode 100644 index 0000000000000..945cd60f15cdf --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSPOExternalUser.ps1 @@ -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 } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSiteUser.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSiteUser.ps1 index 693e8e993b7ab..a2315847ea6ae 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSiteUser.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSiteUser.ps1 @@ -5,10 +5,10 @@ function Invoke-ExecRemoveSiteUser { .ROLE Sharepoint.Site.ReadWrite .DESCRIPTION - Removes a user from an entire SharePoint site: deleting the site user removes them - from every site group and direct permission grant at once. Uses the SharePoint REST - API with certificate authentication. Note this does not revoke sharing links the user - received by mail; use the sharing link actions for those. + Removes a user from one or more SharePoint sites entirely: deleting the site user + removes them from every site group and direct permission grant at once. Uses the + SharePoint REST API with certificate authentication. Note this does not revoke + sharing links the user received by mail; use the sharing link actions for those. #> [CmdletBinding()] param($Request, $TriggerMetadata) @@ -16,44 +16,34 @@ function Invoke-ExecRemoveSiteUser { $APIName = $Request.Params.CIPPEndpoint $Headers = $Request.Headers $TenantFilter = $Request.Body.tenantFilter - $SiteUrl = $Request.Body.SiteUrl + # Single site (SiteUrl) or several at once (SiteUrls, e.g. from the External Users report). + $SiteUrls = @($Request.Body.SiteUrls ?? $Request.Body.SiteUrl) | Where-Object { $_ } # The picker supplies the SP claims login via addedFields; fall back to building it from the UPN. $LoginName = $Request.Body.user.addedFields.LoginName ?? $Request.Body.user.value - $Label = $Request.Body.user.value ?? $LoginName + $Label = $Request.Body.DisplayName ?? $Request.Body.user.value ?? $LoginName try { - if (-not $SiteUrl) { throw 'SiteUrl is required.' } + if ($SiteUrls.Count -eq 0) { throw 'SiteUrl is required.' } if (-not $LoginName) { throw 'No user was selected.' } - 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' } - $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + $Removal = Remove-CIPPSPOSiteUser -TenantFilter $TenantFilter -SiteUrls $SiteUrls -LoginName $LoginName - 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 $Label on the site (ensureuser): $($_.Exception.Message)" + $Messages = [System.Collections.Generic.List[string]]::new() + if ($Removal.Succeeded.Count -gt 0) { + $Messages.Add("Successfully removed $Label (all site groups and direct permissions) from: $($Removal.Succeeded -join ', ').") } - if (-not $EnsuredUser.Id) { throw "Could not resolve $Label on the site." } - if ($EnsuredUser.IsSiteAdmin) { - throw "$Label is a site collection admin. Remove their admin permission first (Remove Site Admin action)." + if ($Removal.Failed.Count -gt 0) { + $Messages.Add("Failed on: $($Removal.Failed -join '; ')") } - - 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 "Could not remove $Label from the site: $($_.Exception.Message)" + $Results = $Messages -join ' ' + if ($Removal.Succeeded.Count -eq 0) { + throw $Results } - - $Results = "Successfully removed $Label from $SiteUrl (all site groups and direct permissions)." Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info $StatusCode = [HttpStatusCode]::OK } catch { $ErrorMessage = Get-CippException -Exception $_ - $Results = "Failed to remove $Label from $($SiteUrl): $($ErrorMessage.NormalizedError)" + $Results = "Failed to remove $Label from the selected site(s): $($ErrorMessage.NormalizedError)" Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Error -LogData $ErrorMessage $StatusCode = [HttpStatusCode]::BadRequest } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharePointExternalUsers.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharePointExternalUsers.ps1 new file mode 100644 index 0000000000000..803dfa847989b --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharePointExternalUsers.ps1 @@ -0,0 +1,164 @@ +function Invoke-ListSharePointExternalUsers { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.Read + .DESCRIPTION + Lists external/guest users known to SharePoint from two sources: the tenant external + users store (populated when a guest first redeems a share) and a sweep of every site's + user list (which also catches guests who were granted membership but never signed in). + Every entry is classified against Entra: 'Entra B2B' (live Entra guest), 'Orphaned B2B' + (the Entra guest was deleted but SharePoint still references them) or 'SharePoint-only' + (legacy email-authenticated guest that never had an Entra object). + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter + + # CSOM verbose JSON dates arrive as '/Date(year,month,day,h,m,s,ms)/' with a 0-based month. + function ConvertFrom-CsomDate($Value) { + if ("$Value" -match '/Date\((\d+),(\d+),(\d+),(\d+),(\d+),(\d+),(\d+)\)/') { + return ([datetime]::new([int]$Matches[1], ([int]$Matches[2] + 1), [int]$Matches[3], [int]$Matches[4], [int]$Matches[5], [int]$Matches[6], [System.DateTimeKind]::Utc)).ToString('yyyy-MM-ddTHH:mm:ssZ') + } + return $Value + } + + try { + # --- Source 1: tenant external users store --- + try { + $StoreUsers = @(Get-CIPPSPOExternalUsers -TenantFilter $TenantFilter) + } catch { + throw "Could not enumerate the SharePoint external users store: $($_.Exception.Message)" + } + + # --- Source 2: guest entries in every site's user list --- + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $Scope = "$($SharePointInfo.SharePointUrl)/.default" + $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } + $SiteGuests = @{} + try { + # NB: getAllSites returns an empty set when $select is combined with this $filter. + $Sites = @((New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/sites/getAllSites?`$filter=isPersonalSite eq false&`$top=999" -tenantid $TenantFilter -AsApp $true).webUrl) | Where-Object { $_ } + } catch { + $Sites = @() + Write-Information "Site enumeration failed; external users report is store-only: $($_.Exception.Message)" + } + foreach ($SiteUrl in $Sites) { + try { + $Users = New-GraphGetRequest -uri "$($SiteUrl.TrimEnd('/'))/_api/web/siteusers?`$select=Title,Email,LoginName,IsShareByEmailGuestUser,IsEmailAuthenticationGuestUser" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true + } catch { + continue # sites the app cannot reach (locked etc.) are skipped + } + foreach ($User in $Users) { + $IsGuest = [bool]$User.IsShareByEmailGuestUser -or [bool]$User.IsEmailAuthenticationGuestUser -or $User.LoginName -match '(?i)#ext#|urn%3aspo%3aguest' + if (-not $IsGuest) { continue } + $Key = ($User.LoginName -split '\|')[-1].ToLower() + if (-not $SiteGuests.ContainsKey($Key)) { + $SiteGuests[$Key] = [PSCustomObject]@{ + Title = $User.Title + Email = $User.Email + LoginName = $User.LoginName + Sites = [System.Collections.Generic.List[string]]::new() + } + } + [void]$SiteGuests[$Key].Sites.Add($SiteUrl) + } + } + + # --- Entra guest sweep for the join --- + $EntraGuests = @() + if ($StoreUsers.Count -gt 0 -or $SiteGuests.Count -gt 0) { + try { + $EntraGuests = @(New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/users?`$filter=userType eq 'Guest'&`$select=id,userPrincipalName,mail&`$top=999" -tenantid $TenantFilter -AsApp $true) + } catch { + throw "Could not list Entra guest users for cross-referencing: $($_.Exception.Message)" + } + } + $GuestsByUpn = @{} + $GuestsByMail = @{} + foreach ($Guest in $EntraGuests) { + if ($Guest.userPrincipalName) { $GuestsByUpn[$Guest.userPrincipalName.ToLower()] = $Guest } + if ($Guest.mail) { $GuestsByMail[$Guest.mail.ToLower()] = $Guest } + } + + function Resolve-GuestClassification($LoginName, $AcceptedAs, $InvitedAs) { + if ("$LoginName" -match '(?i)urn(%3a|:)spo(%3a|:)guest') { + return @('SharePoint-only (email authenticated)', $null) + } + $ClaimsUpn = ("$LoginName" -split '\|')[-1].ToLower() + $EntraUser = $GuestsByUpn[$ClaimsUpn] + if (-not $EntraUser -and $AcceptedAs) { $EntraUser = $GuestsByMail[([string]$AcceptedAs).ToLower()] } + if (-not $EntraUser -and $InvitedAs) { $EntraUser = $GuestsByMail[([string]$InvitedAs).ToLower()] } + if ($EntraUser) { return @('Entra B2B', $EntraUser) } + return @('Orphaned B2B (not in Entra)', $null) + } + + $Rows = [System.Collections.Generic.List[object]]::new() + $SeenKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + + foreach ($User in $StoreUsers) { + $GuestType, $EntraUser = Resolve-GuestClassification $User.LoginName $User.AcceptedAs $User.InvitedAs + # Match this store entry to any site membership sweep hit for a Sites column. + $MatchKey = $null + foreach ($Key in $SiteGuests.Keys) { + $SG = $SiteGuests[$Key] + if (($User.AcceptedAs -and $SG.Email -eq $User.AcceptedAs) -or ($EntraUser -and $Key -eq $EntraUser.userPrincipalName.ToLower())) { $MatchKey = $Key; break } + } + if ($MatchKey) { [void]$SeenKeys.Add($MatchKey) } + # Direct assignment keeps the List intact; an if-expression would pipeline-unwrap + # a single-element list to a scalar and the API would emit a string, not an array. + $RowSites = [System.Collections.Generic.List[string]]::new() + if ($MatchKey) { $RowSites = $SiteGuests[$MatchKey].Sites } + $Rows.Add([PSCustomObject]@{ + DisplayName = $User.DisplayName + InvitedAs = $User.InvitedAs + AcceptedAs = $User.AcceptedAs + # Store entries often carry no login; the site sweep's claims login fills the gap. + LoginName = if ($User.LoginName) { $User.LoginName } elseif ($MatchKey) { $SiteGuests[$MatchKey].LoginName } else { $null } + UniqueId = $User.UniqueId + WhenCreated = ConvertFrom-CsomDate $User.WhenCreated + InvitedBy = $User.InvitedBy + GuestType = $GuestType + InEntra = [bool]$EntraUser + EntraUserId = $EntraUser.id + Source = 'External users store' + Sites = $RowSites + }) + } + + # Guests that only exist as site members (never redeemed / store aged out) + foreach ($Key in $SiteGuests.Keys) { + if ($SeenKeys.Contains($Key)) { continue } + $SG = $SiteGuests[$Key] + $GuestType, $EntraUser = Resolve-GuestClassification $SG.LoginName $SG.Email $null + $Rows.Add([PSCustomObject]@{ + DisplayName = $SG.Title + InvitedAs = $null + AcceptedAs = $SG.Email + LoginName = $SG.LoginName + UniqueId = $null + WhenCreated = $null + InvitedBy = $null + GuestType = $GuestType + InEntra = [bool]$EntraUser + EntraUserId = $EntraUser.id + Source = 'Site membership' + Sites = $SG.Sites + }) + } + + $Body = @($Rows) + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Body = "Failed to list SharePoint external users: $($ErrorMessage.NormalizedError)" + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ 'Results' = $Body } + }) +} diff --git a/host.json b/host.json index 9de15356dcb97..a88d17f4bdc9d 100644 --- a/host.json +++ b/host.json @@ -16,7 +16,7 @@ "distributedTracingEnabled": false, "version": "None" }, - "defaultVersion": "10.5.8", + "defaultVersion": "10.6.0", "versionMatchStrategy": "Strict", "versionFailureStrategy": "Fail" } diff --git a/version_latest.txt b/version_latest.txt index 0b09579a68d16..d1dd3f904c728 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.5.8 +10.6.0