diff --git a/.build/Build.ps1 b/.build/Build.ps1 index 22ddb3c0ad..dd96e78a89 100644 --- a/.build/Build.ps1 +++ b/.build/Build.ps1 @@ -16,6 +16,7 @@ Set-StrictMode -Version Latest . $PSScriptRoot\BuildFunctions\Get-FileTimestampHashtable.ps1 . $PSScriptRoot\BuildFunctions\Get-ScriptDependencyTree.ps1 . $PSScriptRoot\BuildFunctions\Show-ScriptDependencyTree.ps1 +. $PSScriptRoot\BuildFunctions\New-PrototypeSkillFiles.ps1 Write-Host "Build process is running on: Windows? $IsWindows - MacOS? $IsMacOS - Linux? $IsLinux" @@ -215,6 +216,8 @@ $otherFiles | ForEach-Object { Copy-Item $_ $distFolder } +New-PrototypeSkillFiles -RepoRoot $repoRoot -DestinationFolder $distFolder + <# Warn about unreferenced Shared scripts, just so we don't leave dead code lying around unnoticed. diff --git a/.build/BuildFunctions/New-PrototypeSkillFiles.ps1 b/.build/BuildFunctions/New-PrototypeSkillFiles.ps1 new file mode 100644 index 0000000000..54663538c3 --- /dev/null +++ b/.build/BuildFunctions/New-PrototypeSkillFiles.ps1 @@ -0,0 +1,94 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# TODO: Split the reusable skill-package composition and validation into a common build function +# when a second troubleshooting skill adopts this workflow. Keep RBA-specific link and heading +# transformations in an RBA adapter instead of adding product-specific branches to the common function. +function New-RbaTroubleshootingSkillFile { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory)] + [string]$SkillPath, + + [Parameter(Mandatory)] + [string]$TsgRulesPath, + + [Parameter(Mandatory)] + [string]$RulesPath, + + [Parameter(Mandatory)] + [string]$ManifestPath, + + [Parameter(Mandatory)] + [string]$DestinationPath + ) + + foreach ($requiredPath in @($SkillPath, $TsgRulesPath, $RulesPath, $ManifestPath)) { + if (-not (Test-Path -Path $requiredPath -PathType Leaf)) { + throw "Required RBA troubleshooting skill source is missing: $requiredPath" + } + } + + $manifest = Get-Content -Path $ManifestPath -Raw | ConvertFrom-Json + $skillContent = Get-Content -Path $SkillPath -Raw + $tsgRulesContent = Get-Content -Path $TsgRulesPath -Raw + $rulesContent = Get-Content -Path $RulesPath -Raw + $skillContent = $skillContent.Replace( + "[TSG-Rules.md](TSG-Rules.md)", + "the TSG core rules included in this file") + $skillContent = $skillContent.Replace( + "Validate each finding against [RBA-Rules.md](RBA-Rules.md)", + "Validate each finding against the RBA finding rules included in this file") + $skillContent = $skillContent.Replace( + "[RBA-Rules.md](RBA-Rules.md)", + "the RBA finding rules included in this file") + $tsgRulesContent = $tsgRulesContent.Replace("# EXO TSG core rules", "## TSG core rules") + $rulesContent = $rulesContent.Replace("# EXO RBA finding rules", "## RBA finding rules") + + $packageMetadata = @" + +## Package metadata + +- Collection: $($manifest.displayCollection) +- Skill ID: ``$($manifest.id)`` +- Skill version: ``$($manifest.version)`` +- TSG rules version: ``$($manifest.tsgRulesVersion)`` +- Supported report schemas: ``$($manifest.reportSchemaVersions -join "``, ``")`` +- Canonical download: $($manifest.downloadUrl) + +"@ + + $sectionSeparator = [Environment]::NewLine + [Environment]::NewLine + $combinedContent = @( + $skillContent.Trim() + $packageMetadata.Trim() + $tsgRulesContent.Trim() + $rulesContent.Trim() + ) -join $sectionSeparator + + if ($PSCmdlet.ShouldProcess($DestinationPath, "Create RBA troubleshooting skill file")) { + Set-Content -Path $DestinationPath -Value ($combinedContent + [Environment]::NewLine) -Encoding utf8 + } +} + +function New-PrototypeSkillFiles { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory)] + [System.IO.DirectoryInfo]$RepoRoot, + + [Parameter(Mandatory)] + [string]$DestinationFolder + ) + + $destinationPath = Join-Path -Path $DestinationFolder -ChildPath "EXO-RBA-Troubleshooting-SKILL.md" + if ($PSCmdlet.ShouldProcess($destinationPath, "Create prototype troubleshooting skill files")) { + $rbaSkillSource = Join-Path -Path $RepoRoot -ChildPath "Calendar\RBA\exo-rba-troubleshooting" + New-RbaTroubleshootingSkillFile ` + -SkillPath (Join-Path -Path $rbaSkillSource -ChildPath "SKILL.md") ` + -TsgRulesPath (Join-Path -Path $rbaSkillSource -ChildPath "TSG-Rules.md") ` + -RulesPath (Join-Path -Path $rbaSkillSource -ChildPath "RBA-Rules.md") ` + -ManifestPath (Join-Path -Path $rbaSkillSource -ChildPath "manifest.json") ` + -DestinationPath $destinationPath -Confirm:$false + } +} diff --git a/.build/cspell-words.txt b/.build/cspell-words.txt index 1efe5e150e..8e3bfbb538 100644 --- a/.build/cspell-words.txt +++ b/.build/cspell-words.txt @@ -53,6 +53,7 @@ FYDIBOHF GCDO github globalsequence +Goid Hashtable HHMM HKCR diff --git a/Calendar/Get-RBASummary.ps1 b/Calendar/Get-RBASummary.ps1 index dd1d470064..ed031dfd24 100644 --- a/Calendar/Get-RBASummary.ps1 +++ b/Calendar/Get-RBASummary.ps1 @@ -1,30 +1,136 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +# .SYNOPSIS +# Collects and summarizes Resource Booking Assistant configuration, permissions, and diagnostic log evidence. # # .DESCRIPTION -# This script runs the Get-CalendarProcessing cmdlet and returns the output with more details in clear english, -# highlighting the key settings that affect RBA and some of the common errors in configuration. +# Collects Exchange resource-mailbox, CalendarProcessing, permission, inbox-rule, Place, and RBA diagnostic-log +# evidence. It produces a human-readable summary, a structured JSON report, and a readable RBA log file when +# diagnostic log evidence is available. Mailbox existence and resource type are validated before the remaining +# collectors run; after that validation, independent collectors continue after non-fatal failures. +# +# Use Subject to locate recent retained RBA processing by a case-insensitive subject substring. The script extracts +# meeting IDs from matching blocks and then correlates processing by meeting ID. If the subject resolves to multiple +# IDs, each meeting is reported separately. Use MeetingId to target one clean global object ID directly. # # .PARAMETER Identity -# Address of Resource Mailbox to query +# Identity of the room or equipment mailbox to query. An SMTP address is recommended. +# +# .PARAMETER Subject +# Case-insensitive literal subject substring used to discover retained meeting processing. After meeting IDs are +# extracted, correlation uses those IDs. Subject cannot be combined with MeetingId. MeetingSubject remains an alias. +# +# .PARAMETER MeetingId +# Clean global object ID used to select retained RBA processing directly. A comma after the documented 040000008 +# prefix is normalized for correlation. MeetingId cannot be combined with Subject. +# +# .PARAMETER IncludeSensitiveData +# Includes full-fidelity identities, complete RBA log content, and transcript content in the JSON report. Without +# this switch, identities are sanitized; Subject and MeetingId searches still include targeted sensitive evidence. +# +# .PARAMETER SkipVersionCheck +# Skips the automatic script update check. Intended primarily for controlled testing. # # .EXAMPLE # .\Get-RBASummary.ps1 -Identity Room1@Contoso.com -# or +# +# Collects a standard sanitized report for the resource mailbox. +# +# .EXAMPLE # .\Get-RBASummary.ps1 -Identity Room1@Contoso.com -Verbose +# +# Collects a standard report and displays additional configuration explanations. +# +# .EXAMPLE +# .\Get-RBASummary.ps1 -Identity Room1@Contoso.com -Subject "Quarterly planning" +# +# Searches retained RBA logs for the subject, extracts meeting IDs, and reports each resolved meeting separately. +# +# .EXAMPLE +# .\Get-RBASummary.ps1 -Identity Room1@Contoso.com -MeetingId "04000000800E00074C5A7101A82E00700000000..." +# +# Searches retained RBA logs directly for one meeting ID. +# +# .EXAMPLE +# .\Get-RBASummary.ps1 -Identity Room1@Contoso.com -IncludeSensitiveData +# +# Includes complete identities, RBA log evidence, and transcript content in the JSON report. Handle the generated +# files as sensitive customer data. +# +# .OUTPUTS +# Creates timestamp-correlated text summary and JSON report files in the current directory. When RBA diagnostic log +# evidence is available, also creates a readable RBA log text file. The script writes progress to the host. +# +# .NOTES +# The targeted meeting and full reports can contain meeting subjects, identities, timestamps, and processing details. +# Review collectionErrors, evaluationErrors, and NotEvaluated findings before relying on a partial report. [CmdletBinding()] param ( [Parameter(Mandatory = $true, Position = 0)] - [string]$Identity + [string]$Identity, + + [Alias("MeetingSubject")] + [ValidateNotNullOrEmpty()] + [string]$Subject, + + [ValidateNotNullOrEmpty()] + [string]$MeetingId, + + [switch]$IncludeSensitiveData, + + [switch]$SkipVersionCheck ) +if (-not [string]::IsNullOrWhiteSpace($Subject) -and -not [string]::IsNullOrWhiteSpace($MeetingId)) { + throw "Specify either Subject or MeetingId, not both." +} + +function ConvertTo-RbaCommandLineValue { + param( + [AllowNull()] + [object]$Value + ) + + if ($null -eq $Value) { + return '$null' + } + + if ($Value -is [array]) { + $values = @($Value | ForEach-Object { ConvertTo-RbaCommandLineValue -Value $_ }) + return "@($($values -join ', '))" + } + + return "'$(([string]$Value).Replace("'", "''"))'" +} + +$invocationParts = [System.Collections.Generic.List[string]]::new() +$invocationParts.Add(".\Get-RBASummary.ps1") +$parameterOrder = @( + "Identity", "Subject", "MeetingId", "IncludeSensitiveData", "SkipVersionCheck", + "Verbose", "Debug", "ErrorAction", "WarningAction", "InformationAction", + "ErrorVariable", "WarningVariable", "InformationVariable", "OutVariable", "OutBuffer", "PipelineVariable" +) +foreach ($parameterName in $parameterOrder) { + if (-not $PSBoundParameters.ContainsKey($parameterName)) { + continue + } + + $parameterValue = $PSBoundParameters[$parameterName] + if ($parameterValue -is [System.Management.Automation.SwitchParameter] -or $parameterValue -is [bool]) { + $invocationParts.Add("-$parameterName`:$($parameterValue.ToString().ToLowerInvariant())") + } else { + $invocationParts.Add("-$parameterName $(ConvertTo-RbaCommandLineValue -Value $parameterValue)") + } +} +$script:InvocationCommandLine = $invocationParts -join ' ' + $BuildVersion = "" . $PSScriptRoot\..\Shared\ScriptUpdateFunctions\Test-ScriptVersion.ps1 -if (Test-ScriptVersion -AutoUpdate) { +if (-not $SkipVersionCheck -and (Test-ScriptVersion -AutoUpdate)) { # Update was downloaded, so stop here. Write-Host "Script was updated. Please rerun the command." -ForegroundColor Yellow return @@ -32,64 +138,261 @@ if (Test-ScriptVersion -AutoUpdate) { Write-Verbose "Script Versions: $BuildVersion" -$SummaryFilename = "RBA-Summary-For_$($Identity.Split('@')[0])_$((Get-Date).ToString('yyyy-MM-dd_HH-mm-ss')).txt" -Write-Host "`r`nRBA Summary Output saved as [" -NoNewline -Write-Host -ForegroundColor Cyan $SummaryFilename -NoNewline -Write-Host "] in the current directory." -Start-Transcript -Path $SummaryFilename +$runTimestamp = (Get-Date).ToString('yyyy-MM-dd_HH-mm-ss') +$SummaryFilename = "RBA-Summary-For_$($Identity.Split('@')[0])_$runTimestamp.txt" +$JsonFilename = "RBA-Summary-For_$($Identity.Split('@')[0])_$runTimestamp.json" +$script:RbaLogFilename = $null +$script:collectorStatuses = [ordered]@{} +$script:collectionErrors = [System.Collections.Generic.List[object]]::new() +$script:evaluationErrors = [System.Collections.Generic.List[object]]::new() +$script:TranscriptStarted = $false +$script:ResourceDelegateIdentitySets = @() +$script:ResourceDelegateIdentitySetsAvailable = $false +$script:SanitizedIdentityMap = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase) +$script:SanitizedIdentitySequence = 0 +Write-Host -ForegroundColor Cyan "`r`nRBA Summary Output saved as [$SummaryFilename] in the current directory." +try { + Start-Transcript -Path $SummaryFilename -ErrorAction Stop | Out-Null + $script:TranscriptStarted = $true +} catch { + Write-Warning "Unable to start transcript '$SummaryFilename': $($_.Exception.Message)" +} +Write-Host "Command line: $script:InvocationCommandLine" Write-Host "`r`n" -function ValidateMailbox { - Write-Host -NoNewline "Running : "; Write-Host -ForegroundColor Cyan "Get-Mailbox -Identity $Identity" - $script:Mailbox = Get-Mailbox -Identity $Identity +function ConvertTo-RbaSafeErrorText { + param( + [AllowNull()] + [object]$Value, + + [int]$MaximumLength = 2048 + ) + + if ($null -eq $Value) { + return $null + } + + try { + $text = [string]$Value + } catch { + return $null + } + + $text = ($text -replace '[\r\n\t]+', ' ').Trim() + if ($text.Length -gt $MaximumLength) { + return $text.Substring(0, $MaximumLength) + } + return $text +} + +function ConvertTo-RbaErrorInfo { + param( + [AllowNull()] + [object]$ErrorRecord + ) + + $exception = $null + if ($ErrorRecord -is [System.Exception]) { + $exception = $ErrorRecord + } elseif ($null -ne $ErrorRecord) { + try { + $exception = $ErrorRecord.PSObject.Properties['Exception'].Value + } catch { + $exception = $null + } + } + + $message = $null + $exceptionType = $null + $innerExceptionMessage = $null + if ($null -ne $exception) { + try { + $message = ConvertTo-RbaSafeErrorText -Value $exception.Message + } catch { + $message = $null + } + try { + $exceptionType = ConvertTo-RbaSafeErrorText -Value $exception.GetType().FullName -MaximumLength 256 + } catch { + $exceptionType = $null + } + try { + $innerExceptionMessage = ConvertTo-RbaSafeErrorText -Value $exception.InnerException.Message -MaximumLength 1024 + } catch { + $innerExceptionMessage = $null + } + } + if ([string]::IsNullOrEmpty($message) -and $ErrorRecord -is [string]) { + $message = ConvertTo-RbaSafeErrorText -Value $ErrorRecord + } + if ([string]::IsNullOrEmpty($message)) { + $message = "Unknown error." + } + + $category = $null + $fullyQualifiedErrorId = $null + if ($null -ne $ErrorRecord) { + try { + $category = ConvertTo-RbaSafeErrorText -Value $ErrorRecord.PSObject.Properties['CategoryInfo'].Value.Category -MaximumLength 256 + } catch { + $category = $null + } + try { + $fullyQualifiedErrorId = ConvertTo-RbaSafeErrorText -Value $ErrorRecord.PSObject.Properties['FullyQualifiedErrorId'].Value -MaximumLength 256 + } catch { + $fullyQualifiedErrorId = $null + } + } + + return [PSCustomObject]@{ + message = $message + exceptionType = $exceptionType + category = $category + fullyQualifiedErrorId = $fullyQualifiedErrorId + innerExceptionMessage = $innerExceptionMessage + } +} + +function Invoke-RbaCollector { + param( + [Parameter(Mandatory)] + [string]$Name, + + [Parameter(Mandatory)] + [ScriptBlock]$Action, + + [switch]$AllowEmptyCollection, + + [string]$FailureMessage + ) + + try { + $result = & $Action + if ($null -eq $result -and -not $AllowEmptyCollection) { + throw "$Name returned null." + } + $script:collectorStatuses[$Name] = [PSCustomObject]@{ + status = "Success" + error = $null + exceptionType = $null + category = $null + fullyQualifiedErrorId = $null + innerExceptionMessage = $null + } + return $result + } catch { + $errorInfo = ConvertTo-RbaErrorInfo -ErrorRecord $_ + $script:collectorStatuses[$Name] = [PSCustomObject]@{ + status = "Failed" + error = $errorInfo.message + exceptionType = $errorInfo.exceptionType + category = $errorInfo.category + fullyQualifiedErrorId = $errorInfo.fullyQualifiedErrorId + innerExceptionMessage = $errorInfo.innerExceptionMessage + } + $script:collectionErrors.Add([PSCustomObject]@{ + collector = $Name + message = $errorInfo.message + exceptionType = $errorInfo.exceptionType + category = $errorInfo.category + fullyQualifiedErrorId = $errorInfo.fullyQualifiedErrorId + innerExceptionMessage = $errorInfo.innerExceptionMessage + }) + if ([string]::IsNullOrWhiteSpace($FailureMessage)) { + Write-Warning "$Name collection failed: $($errorInfo.message)" + } else { + Write-Warning $FailureMessage + } + return $null + } +} + +function CollectMailbox { + Write-Host -ForegroundColor Cyan "Running: Get-Mailbox -Identity $Identity" + $script:Mailbox = Invoke-RbaCollector -Name "Mailbox" -Action { + try { + $mailbox = Get-Mailbox -Identity $Identity -ErrorAction Stop + if ($null -eq $mailbox) { + throw "Active mailbox lookup returned null." + } + $script:MailboxObjectState = "Active" + return $mailbox + } catch { + Write-Verbose "Active mailbox lookup failed. Checking for a recoverable soft-deleted mailbox." + $mailbox = Get-Mailbox -Identity $Identity -SoftDeletedMailbox -ErrorAction Stop + if ($null -eq $mailbox) { + throw "Soft-deleted mailbox lookup returned null." + } + $script:MailboxObjectState = "SoftDeleted" + return $mailbox + } + } # check we get a response if ($null -eq $script:Mailbox) { - Write-Host -ForegroundColor Red "Get-Mailbox returned null. Make sure you Import-Module ExchangeOnlineManagement and Connect-ExchangeOnline. Exiting script." - Stop-Transcript - exit + Write-Host -ForegroundColor Red "Get-Mailbox was unavailable. Make sure you Import-Module ExchangeOnlineManagement and Connect-ExchangeOnline." } else { - if ($script:Mailbox.RecipientTypeDetails -ne "RoomMailbox" -and $script:Mailbox.RecipientTypeDetails -ne "EquipmentMailbox") { - Write-Host -ForegroundColor Red "The mailbox is not a Room Mailbox / Equipment Mailbox. RBA will only work with these. Exiting script." - Stop-Transcript - exit + if ($script:MailboxObjectState -eq "SoftDeleted") { + Write-Host -ForegroundColor Red "The resource mailbox is soft-deleted and cannot perform active RBA processing." + } elseif ($script:Mailbox.RecipientTypeDetails -ne "RoomMailbox" -and $script:Mailbox.RecipientTypeDetails -ne "EquipmentMailbox") { + Write-Host -ForegroundColor Red "The mailbox is not a Room Mailbox / Equipment Mailbox. RBA will only work with these. Stopping." } if ($script:Mailbox.ResourceType -eq "Workspace") { $script:Workspace = $true } - Write-Host -ForegroundColor Green "The mailbox is valid for RBA will work with." + if ($script:Mailbox.RecipientTypeDetails -eq "RoomMailbox" -or $script:Mailbox.RecipientTypeDetails -eq "EquipmentMailbox") { + Write-Host -ForegroundColor Green "The mailbox is valid for RBA to work with." + } } +} +function CollectPlace { # Get-Place does not cross forest boundaries so we will get an error here if we are not in the right forest. - Write-Host -NoNewline "Running : "; Write-Host -ForegroundColor Cyan "Get-Place -Identity $Identity" - $script:Place = Get-Place $Identity + Write-Host -ForegroundColor Cyan "Running: Get-Place -Identity $Identity" + $placeFailureMessage = "Get-Place failed to get information from $Identity. Double-check the setup of the room." + $script:Place = Invoke-RbaCollector -Name "Place" -FailureMessage $placeFailureMessage -Action { + $placeOutput = @(Get-Place -Identity $Identity -ErrorAction Stop *>&1) + $placeError = @($placeOutput | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] } | Select-Object -First 1) + if ($placeError.Count -gt 0) { + throw $placeError[0] + } + @($placeOutput | Where-Object { + $_ -isnot [System.Management.Automation.InformationRecord] -and + $_ -isnot [System.Management.Automation.WarningRecord] -and + $_ -isnot [System.Management.Automation.VerboseRecord] -and + $_ -isnot [System.Management.Automation.DebugRecord] + }) + } if ($null -eq $script:Place) { - Write-Error "Error: Get-Place returned Null for $Identity." Write-Host -ForegroundColor Red "Make sure you are running from the correct forest. Get-Place does not cross forest boundaries." - Write-Host "Hint Forest is likely something like: [$($script:Mailbox.Database.split("DG")[0])]." - Write-Error "Exiting Script." - Stop-Transcript - exit + if ($null -ne $script:Mailbox -and $null -ne $script:Mailbox.Database) { + Write-Host "Hint Forest is likely something like: [$($script:Mailbox.Database.split("DG")[0])]." + } } - Write-Host -ForegroundColor Yellow "For more information see https://learn.microsoft.com/en-us/powershell/module/exchange/get-mailbox?view=exchange-ps" + Write-Host -ForegroundColor Yellow "For more information, see https://learn.microsoft.com/powershell/module/exchange/get-place" Write-Host } # Validate that there are not delegate rules that will block RBA functionality function ValidateInboxRules { Write-Host "Checking for Delegate Rules that will block RBA functionality..." - Write-Host -NoNewline "Running : "; Write-Host -ForegroundColor Cyan "Get-InboxRule -mailbox $Identity -IncludeHidden" - [array]$rules = Get-InboxRule -mailbox $Identity -IncludeHidden + Write-Host -ForegroundColor Cyan "Running: Get-InboxRule -Mailbox $Identity -IncludeHidden" + [array]$script:InboxRules = Invoke-RbaCollector -Name "InboxRules" -AllowEmptyCollection -Action { + @(Get-InboxRule -Mailbox $Identity -IncludeHidden -ErrorAction Stop) + } + if ($script:collectorStatuses["InboxRules"].status -ne "Success") { + Write-Host -ForegroundColor Yellow "Delegate Rules could not be evaluated because inbox rules are unavailable." + return + } + [array]$rules = $script:InboxRules # Note as far as I can tell "Delegate Rule " is not localized. if ($rules.Name -like "Delegate Rule*") { Write-Host -ForegroundColor Red "Error: There is a user style Delegate Rule setup on this resource mailbox. This will block RBA functionality. Please remove the rule via Remove-InboxRule cmdlet and re-run this script." Write-Host -NoNewline "Rule to look into: " Write-Host -ForegroundColor Red "$($rules.Name -like "Delegate Rule*")" - Write-Host -ForegroundColor Red "Exiting script." - Stop-Transcript - exit + Write-Host -ForegroundColor Red "Continuing collection so all available evidence is captured." } elseif ($rules.Name -like "REDACTED-*") { Write-Host -ForegroundColor Yellow "Warning: No PII Access to MB so cannot check for Delegate Rules." Write-Host -ForegroundColor Yellow "To gain PII access, Mailbox is located on $($mailbox.Database) on server $($mailbox.ServerName)" @@ -108,26 +411,102 @@ function ValidateInboxRules { # Retrieve the CalendarProcessing information function GetCalendarProcessing { - Write-Host -NoNewline "Running : "; Write-Host -ForegroundColor Cyan "Get-CalendarProcessing -Identity $Identity" - $script:RbaSettings = Get-CalendarProcessing -Identity $Identity + Write-Host -ForegroundColor Cyan "Running: Get-CalendarProcessing -Identity $Identity" + $script:RbaSettings = Invoke-RbaCollector -Name "CalendarProcessing" -Action { + Get-CalendarProcessing -Identity $Identity -ErrorAction Stop + } # check we get a response if ($null -eq $RbaSettings) { Write-Host -ForegroundColor Red "Get-CalendarProcessing returned null. Make sure you Import-Module ExchangeOnlineManagement and Connect-ExchangeOnline - Exiting script." - Stop-Transcript - exit + Continuing with other available evidence." + return } - $RbaSettings | Format-List - - Write-Host -ForegroundColor Yellow "For more information on Set-CalendarProcessing see - https://learn.microsoft.com/en-us/powershell/module/exchange/set-calendarprocessing?view=exchange-ps" + Write-Host -ForegroundColor Green "Calendar processing settings collected successfully." + Write-Host -ForegroundColor Yellow "For more information, see https://learn.microsoft.com/powershell/module/exchange/set-calendarprocessing" Write-Host } +function Get-RbaPermissionIdentity { + param( + [AllowNull()] + [object]$PermissionUser + ) + + foreach ($propertyPath in @( + @("ADRecipient", "PrimarySmtpAddress"), + @("RecipientPrincipal", "PrimarySmtpAddress"), + @("PrimarySmtpAddress") + )) { + try { + $value = $PermissionUser + foreach ($propertyName in $propertyPath) { + $value = $value.PSObject.Properties[$propertyName].Value + } + if (-not [string]::IsNullOrWhiteSpace([string]$value)) { + return ([string]$value).ToLowerInvariant() + } + } catch { + continue + } + } + + return ([string]$PermissionUser).ToLowerInvariant() +} + +function CollectCalendarFolderPermissions { + Write-Host -ForegroundColor Cyan "Running: Get-MailboxFolderPermission for the Calendar folder of $Identity" + $failureMessage = "Unable to collect Calendar folder permissions for $Identity. Continuing with other available evidence." + [array]$script:CalendarFolderPermissions = Invoke-RbaCollector -Name "CalendarFolderPermissions" -AllowEmptyCollection -FailureMessage $failureMessage -Action { + Write-Verbose "Locating the Calendar folder for $Identity." + # Materialize the remote result before selecting a folder. Select-Object -First can stop the + # remote pipeline early and add a misleading "The pipeline has been stopped" transcript entry. + $calendarFolders = @(Get-MailboxFolderStatistics -Identity $Identity -FolderScope Calendar -ErrorAction Stop) + $calendarFolder = @($calendarFolders | Where-Object { $_.FolderType -eq "Calendar" })[0] + if ($null -eq $calendarFolder) { + throw "The Calendar folder could not be located." + } + + $calendarFolderIdentity = "$Identity`:\$($calendarFolder.Name)" + Write-Verbose "Collecting permissions from $calendarFolderIdentity." + @(Get-MailboxFolderPermission -Identity $calendarFolderIdentity -ErrorAction Stop) + } + if ($script:collectorStatuses["CalendarFolderPermissions"].status -eq "Success") { + Write-Host -ForegroundColor Green "Calendar folder permissions collected successfully." + } +} + +function Initialize-RbaResourceDelegateIdentitySets { + $resourceDelegateIdentitySets = @($script:RbaSettings.ResourceDelegates | ForEach-Object { + $delegateIdentity = ([string]$_).ToLowerInvariant() + $identityAliases = [System.Collections.Generic.List[string]]::new() + $identityAliases.Add($delegateIdentity) + try { + $recipient = Get-Recipient -Identity $_ -ErrorAction Stop + if ($null -ne $recipient.PrimarySmtpAddress) { + $identityAliases.Add(([string]$recipient.PrimarySmtpAddress).ToLowerInvariant()) + } + } catch { + Write-Verbose "Unable to resolve resource delegate '$delegateIdentity' for direct Calendar permission comparison." + } + [PSCustomObject]@{ + aliases = @($identityAliases | Sort-Object -Unique) + } + }) + $script:ResourceDelegateIdentitySets = $resourceDelegateIdentitySets + $script:ResourceDelegateIdentitySetsAvailable = $true +} + +function CollectMailboxPermissions { + Write-Host -ForegroundColor Cyan "Running: Get-MailboxPermission -Identity $Identity" + [array]$script:MailboxPermissions = Invoke-RbaCollector -Name "MailboxPermissions" -AllowEmptyCollection -Action { + @(Get-MailboxPermission -Identity $Identity -ErrorAction Stop) + } +} + function EvaluateCalProcessing { if ($RbaSettings.AutomateProcessing -ne "AutoAccept") { @@ -135,9 +514,7 @@ function EvaluateCalProcessing { Write-Host -ForegroundColor Red "Error: For RBA to do anything AutomateProcessing must be set to AutoAccept." Write-Host -ForegroundColor Red "Error: AutomateProcessing is set to $($RbaSettings.AutomateProcessing)." Write-Host -ForegroundColor Yellow "Use 'Set-CalendarProcessing -Identity $Identity -AutomateProcessing AutoAccept' to set AutomateProcessing to AutoAccept." - Write-Host -ForegroundColor Red "Exiting script." - Stop-Transcript - exit + Write-Host -ForegroundColor Red "Continuing collection and reporting." } else { Write-Host -ForegroundColor Green "AutomateProcessing is set to AutoAccept. RBA will analyze the meeting request." } @@ -188,12 +565,12 @@ function RBACriteria { $RBACriteriaExtra = "" if ($RbaSettings.AllowConflicts -eq $true) { - $RBACriteriaExtra += "Unlimited conflicts are allowed. This is Required for Workspaces.`r`n" + $RBACriteriaExtra += "Conflicts are accepted without percentage or count limits. This is required for Workspaces.`r`n" } elseif ($RbaSettings.ConflictPercentageAllowed -eq 0 ` -and $RbaSettings.MaximumConflictInstances -eq 0) { $RBACriteriaExtra += "No conflicts are allowed.`r`n" } else { - $RBACriteriaExtra += "For Recurring meetings, conflicts are allowed as long as they are less than $($RbaSettings.ConflictPercentageAllowed)% or less than $($RbaSettings.MaximumConflictInstances) instances.`r`n" + $RBACriteriaExtra += "For recurring meetings, the series is declined when conflicts exceed either $($RbaSettings.ConflictPercentageAllowed)% of instances or $($RbaSettings.MaximumConflictInstances) instances; otherwise, the conflicting instances are declined.`r`n" } if ($RbaSettings.AllowDistributionGroup -eq $true) { @@ -228,10 +605,10 @@ function RBACriteria { $RBACriteriaExtra += "Meetings are allowed at any time.`r`n" } - if ($RbaSettings.EnforceSchedulingHorizon -eq $true -and $RbaSettings.BookingWindowInDays -gt 0) { - $RBACriteriaExtra += "Meetings are only allowed if it starts within $($RbaSettings.BookingWindowInDays) days.`r`n" + if ($RbaSettings.EnforceSchedulingHorizon -eq $true) { + $RBACriteriaExtra += "Recurring series that extend beyond the $($RbaSettings.BookingWindowInDays)-day booking window are declined.`r`n" } else { - $RBACriteriaExtra += "SchedulingHorizon is not enforced.`r`n" + $RBACriteriaExtra += "Recurring series that start within the $($RbaSettings.BookingWindowInDays)-day booking window can be accepted, but occurrences beyond the window are removed.`r`n" } if ($RbaSettings.ProcessExternalMeetingMessages -eq $true) { @@ -240,7 +617,7 @@ function RBACriteria { $RBACriteriaExtra += "RBA will reject all External meeting requests.`r`n" } - $RBACriteriaExtra += "Meetings will only be accepted if within $($RbaSettings.BookingWindowInDays) days.`r`n" + $RBACriteriaExtra += "The resource booking window is $($RbaSettings.BookingWindowInDays) days; 0 means today.`r`n" Write-Verbose $RBACriteriaExtra } @@ -265,9 +642,7 @@ function RBAProcessingValidation { Write-Host "`t AllBookInPolicy: "$RbaSettings.AllBookInPolicy Write-Host "`t RequestInPolicy: {$($RbaSettings.RequestInPolicy)}" Write-Host "`t AllRequestInPolicy: "$RbaSettings.AllRequestInPolicy - Write-Host -ForegroundColor Red "Exiting script." - Stop-Transcript - exit + Write-Host -ForegroundColor Red "Continuing collection and reporting." } } @@ -279,15 +654,19 @@ function OutputMBList { [string[]]$MBList ) foreach ($User in $MBList) { - # MS Support will error as we need the Organization to process from CN - $Org = $Identity.Split('@')[1] + try { + # MS Support will error as we need the Organization to process from CN + $Org = $Identity.Split('@')[1] - if ($null -ne $Org) { - $User = Get-Recipient -Identity $User -organization $Org - Write-Host " `t `t [$($User.DisplayName)] -- $($User.PrimarySmtpAddress)" - } else { - $User = Get-Recipient -Identity $User - Write-Host " `t `t [$($User.DisplayName)] -- $($User.PrimarySmtpAddress)" + if ($null -ne $Org) { + $recipient = Get-Recipient -Identity $User -Organization $Org -ErrorAction Stop + } else { + $recipient = Get-Recipient -Identity $User -ErrorAction Stop + } + Write-Host " `t `t [$($recipient.DisplayName)] -- $($recipient.PrimarySmtpAddress)" + } catch { + Write-Warning "Unable to resolve recipient '$User': $($_.Exception.Message)" + Write-Host " `t `t [$User]" } } } @@ -513,118 +892,685 @@ function VerbosePostProcessing { #Add information about RBA logs. function RBAPostScript { - Write-Host - Write-Host "If more information is needed about this resource mailbox, please look at the RBA logs saved in this directory to - see how the system proceed the meeting request." - Write-Host "To get new RBA Logs, run the following command:" + Write-DashLineBoxColor @("Next Steps") -Color Cyan + Write-Host "Review the saved RBA log to see how meeting requests were processed." + Write-Host "To collect a new RBA log:" Write-Host -ForegroundColor Yellow "`tExport-MailboxDiagnosticLogs $Identity -ComponentName RBA" Write-Host - Write-Host "To continue troubleshooting further, suggestion is to create a Test Meeting and send it to this room, making sure that the meeting is in the future, as the RBA does not process meeting in the past)." - Write-Host "Then pull the RBA Logs as well as the Calendar Diagnostic Objects for the Meeting Organizer and the Room to see how the system processed the meeting request." - Write-Host "For Calendar Diagnostic Objects, try [CalLogSummaryScript](https://github.com/microsoft/CSS-Exchange/releases/latest/download/Get-CalendarDiagnosticObjectsSummary.ps1)" + Write-Host "For additional troubleshooting, send a future test meeting to the room, then collect RBA logs and Calendar Diagnostic Objects for the organizer and room." + Write-Host "Calendar Diagnostic Objects tool:" + Write-Host -ForegroundColor Cyan "`thttps://github.com/microsoft/CSS-Exchange/releases/latest/download/Get-CalendarDiagnosticObjectsSummary.ps1" + Write-Host "`r`nFeedback: CalLogFormatterDevs@microsoft.com" +} + +function CollectRBALog { + Write-Host -ForegroundColor Cyan "Running: Export-MailboxDiagnosticLogs -Identity $Identity -ComponentName RBA" + $diagnosticLog = Invoke-RbaCollector -Name "RbaLog" -Action { + Export-MailboxDiagnosticLogs -Identity $Identity -ComponentName RBA -ErrorAction Stop + } + + if ($null -ne $diagnosticLog) { + [array]$script:RBALog = @($diagnosticLog.MailboxLog -split "`r?`n" | Where-Object { + -not [string]::IsNullOrWhiteSpace($_) + }) + } +} + +function Get-RbaMeetingIdsFromLogLines { + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [AllowEmptyString()] + [string[]]$Lines + ) + + $meetingIds = [System.Collections.Generic.List[string]]::new() + $content = $Lines -join [Environment]::NewLine + $labelPattern = '(?i)(?:CleanGlobalObjectId|GlobalObjectId|Global Object Id|MeetingId|Meeting ID|UID)\s*[:=]\s*[\[\{]?(?[A-Za-z0-9+/=_-]{16,})' + foreach ($match in [regex]::Matches($content, $labelPattern)) { + $meetingIds.Add(($match.Groups['MeetingId'].Value -replace ',', '')) + } + + $processRequestPattern = '(?i)\bBegin Process(?:Update)?Request\s+Goid:\s*[\[\{]?(?[A-Za-z0-9+/=_,-]{16,})' + foreach ($match in [regex]::Matches($content, $processRequestPattern)) { + $meetingIds.Add(($match.Groups['MeetingId'].Value -replace ',', '')) + } + + foreach ($match in [regex]::Matches($content, '(?i)\b040000008,?[A-F0-9]{23,}\b')) { + $meetingIds.Add(($match.Value -replace ',', '')) + } - Write-Host "`n`rIf you found an error with this script or a misconfigured RBA case that this should cover, - send mail to Shanefe@microsoft.com" + return @($meetingIds | Sort-Object -Unique) +} + +function ConvertTo-RbaNormalizedMeetingId { + param( + [Parameter(Mandatory)] + [string]$Value + ) + + return (($Value.Trim() -replace '^[\[\{]', '') -replace '[\]\}]$', '') -replace ',', '' +} + +function Split-RbaLogProcessingBlocks { + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [AllowEmptyString()] + [string[]]$Lines + ) + + if ($Lines.Count -eq 0) { + return @() + } + + $exactStartPattern = 'START - HandleEventInternal Automatic Booking is enabled for resource\.\s*$' + $startIndexes = @(0..($Lines.Count - 1) | Where-Object { $Lines[$_] -match $exactStartPattern }) + $blocks = [System.Collections.Generic.List[object]]::new() + + if ($startIndexes.Count -eq 0) { + $blocks.Add([PSCustomObject]@{ + sequence = 1 + startLine = 1 + endLine = $Lines.Count + startBoundaryFound = $false + boundaryStatus = "MissingStartBoundary" + startMarker = $null + startTimeText = $null + meetingIds = @(Get-RbaMeetingIdsFromLogLines -Lines $Lines) + lines = @($Lines) + }) + return $blocks.ToArray() + } + + for ($blockIndex = 0; $blockIndex -lt $startIndexes.Count; $blockIndex++) { + $endIndex = $startIndexes[$blockIndex] + $startIndex = if ($blockIndex -eq 0) { + 0 + } else { + $startIndexes[$blockIndex - 1] + 1 + } + $blockLines = @($Lines[$startIndex..$endIndex]) + $startMarker = [string]$Lines[$endIndex] + $startTimeText = if ($startMarker.Contains(',')) { + ($startMarker -split ',', 2)[0].Trim() + } else { + $null + } + $blocks.Add([PSCustomObject]@{ + sequence = $blockIndex + 1 + startLine = $startIndex + 1 + endLine = $endIndex + 1 + startBoundaryFound = $true + boundaryStatus = $(if ($blockIndex -eq 0) { "SourceStartToExactStart" } else { "BetweenExactStartBoundaries" }) + startMarker = $startMarker + startTimeText = $startTimeText + meetingIds = @(Get-RbaMeetingIdsFromLogLines -Lines $blockLines) + lines = $blockLines + }) + } + + $lastStartIndex = $startIndexes[-1] + if ($lastStartIndex -lt ($Lines.Count - 1)) { + $partialLines = @($Lines[($lastStartIndex + 1)..($Lines.Count - 1)]) + if (@($partialLines | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }).Count -gt 0) { + $blocks.Add([PSCustomObject]@{ + sequence = $blocks.Count + 1 + startLine = $lastStartIndex + 2 + endLine = $Lines.Count + startBoundaryFound = $false + boundaryStatus = "MissingStartBoundary" + startMarker = $null + startTimeText = $null + meetingIds = @(Get-RbaMeetingIdsFromLogLines -Lines $partialLines) + lines = $partialLines + }) + } + } + + return $blocks.ToArray() +} + +function Test-RbaLogLinesContainText { + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [AllowEmptyString()] + [string[]]$Lines, + + [Parameter(Mandatory)] + [string]$Text + ) + + foreach ($line in $Lines) { + if ($line.IndexOf($Text, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) { + return $true + } + } + return $false +} + +function Get-RbaLogLineTimeText { + param( + [AllowNull()] + [string]$Line + ) + + if (-not [string]::IsNullOrWhiteSpace($Line) -and $Line -match '^(?[^,]+),') { + return $Matches['TimeText'].Trim() + } + return $null +} + +function Get-RbaTargetedMeetingDetails { + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]]$Events + ) + + $lines = @($Events | ForEach-Object { @($_.rawLog) }) + if ($lines.Count -eq 0) { + return [PSCustomObject]@{ + firstLogTimeText = $null + lastLogTimeText = $null + lastUpdateTimeText = $null + recurrenceStatus = "Unknown" + policyResult = "Unknown" + disposition = "Unknown" + forwardedToDelegates = $false + delegateMessageCount = $null + tentativeResponseSent = $false + } + } + + $timestampedLines = @($lines | Where-Object { $_ -match '^[^,]+,' }) + $initialRequestLines = @($lines | Where-Object { $_ -match '(?i)\bBegin ProcessRequest\s+Goid:' }) + $updateLines = @($lines | Where-Object { $_ -match '(?i)\b(?:Begin|End) ProcessUpdateRequest\s+Goid:' }) + $meetingActivityLines = @($timestampedLines | Where-Object { + $_ -match '(?i)\b(?:Begin|End) Process(?:Update)?Request\s+Goid:' -or + $_ -match '(?i)Action:(?:Accept|Decline|Tentative)' -or + $_ -match '(?i)meeting cancellation|Cancellation processing completed' -or + $_ -match '(?i)\bEND - Sending the .*response to organizer\.' -or + $_ -match '(?i)\bPostProcessing completed on ' + }) + $recurringDetected = @($lines | Where-Object { + $_ -match '(?i)\bIsRecurring\s*[:=]\s*True\b' -or + $_ -match '(?i)\bRecurring meeting request\b' -or + $_ -match '(?i)Recurrence ends is past the booking window\. Meeting will be declined\.' -or + $_ -match '(?i)Truncating meeting recurrence end window' + }).Count -gt 0 + $notRecurringDetected = @($lines | Where-Object { + $_ -match '(?i)\bIsRecurring\s*[:=]\s*False\b' -or + $_ -match '(?i)\bNon-recurring meeting request\b' + }).Count -gt 0 + $policyResults = @($Events | ForEach-Object { $_.policyResult } | + Where-Object { $_ -ne "Unknown" } | Sort-Object -Unique) + $dispositions = @($Events | ForEach-Object { $_.disposition } | + Where-Object { $_ -ne "Unknown" } | Sort-Object -Unique) + $delegateMessageCounts = @($Events | ForEach-Object { $_.delegateMessageCount } | + Where-Object { $null -ne $_ }) + + return [PSCustomObject]@{ + firstLogTimeText = Get-RbaLogLineTimeText -Line $(if ($initialRequestLines.Count -gt 0) { $initialRequestLines[-1] } elseif ($timestampedLines.Count -gt 0) { $timestampedLines[-1] } else { $null }) + lastLogTimeText = Get-RbaLogLineTimeText -Line $(if ($meetingActivityLines.Count -gt 0) { $meetingActivityLines[0] } elseif ($timestampedLines.Count -gt 0) { $timestampedLines[0] } else { $null }) + lastUpdateTimeText = Get-RbaLogLineTimeText -Line $(if ($updateLines.Count -gt 0) { $updateLines[0] } else { $null }) + recurrenceStatus = $(if ($recurringDetected) { "Recurring" } elseif ($notRecurringDetected) { "NotRecurring" } else { "Unknown" }) + policyResult = $(if ($policyResults.Count -eq 1) { $policyResults[0] } elseif ($policyResults.Count -gt 1) { "Mixed" } else { "Unknown" }) + disposition = $(if ($dispositions.Count -eq 1) { $dispositions[0] } elseif ($dispositions.Count -gt 1) { "Multiple" } else { "Unknown" }) + forwardedToDelegates = @($Events | Where-Object { $_.delegateReferralDetected }).Count -gt 0 + delegateMessageCount = $(if ($delegateMessageCounts.Count -gt 0) { ($delegateMessageCounts | Measure-Object -Sum).Sum } else { $null }) + tentativeResponseSent = @($Events | Where-Object { $_.tentativeResponseSent }).Count -gt 0 + } +} + +function Get-RbaTargetedMeetingSummaries { + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [string[]]$MeetingIds, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]]$Events + ) + + return @($MeetingIds | ForEach-Object { + $currentMeetingId = $_ + $meetingEvents = @($Events | Where-Object { @($_.meetingIds) -contains $currentMeetingId }) + $details = Get-RbaTargetedMeetingDetails -Events $meetingEvents + [PSCustomObject]@{ + meetingId = $currentMeetingId + eventCount = $meetingEvents.Count + firstLogTimeText = $details.firstLogTimeText + lastLogTimeText = $details.lastLogTimeText + lastUpdateTimeText = $details.lastUpdateTimeText + recurrenceStatus = $details.recurrenceStatus + policyResult = $details.policyResult + disposition = $details.disposition + tentativeResponseSent = $details.tentativeResponseSent + forwardedToDelegates = $details.forwardedToDelegates + delegateMessageCount = $details.delegateMessageCount + acceptCount = @($meetingEvents | Where-Object { $_.actions -contains "Accept" }).Count + tentativeCount = @($meetingEvents | Where-Object { $_.actions -contains "Tentative" }).Count + declineCount = @($meetingEvents | Where-Object { $_.actions -contains "Decline" }).Count + updateCount = @($meetingEvents | Where-Object { $_.updateDetected }).Count + cancellationCount = @($meetingEvents | Where-Object { $_.cancellationDetected }).Count + delegateReferralCount = @($meetingEvents | Where-Object { $_.delegateReferralDetected }).Count + eventSequences = @($meetingEvents.sequence) + } + }) +} + +function Get-RbaTargetedLogBlockObject { + param( + [Parameter(Mandatory)] + [object]$Block, + + [Parameter(Mandatory)] + [bool]$SubjectMatched + ) + + $actions = @($Block.lines | ForEach-Object { + foreach ($match in [regex]::Matches($_, '(?i)Action:(?Accept|Decline|Tentative)')) { + $match.Groups['Action'].Value + } + } | Sort-Object -Unique) + $evaluationResults = @($Block.lines | ForEach-Object { + foreach ($match in [regex]::Matches($_, '(?i)Meeting request evaluate returns result\s+(?Accept|Decline|Tentative)')) { + $match.Groups['Action'].Value + } + } | Sort-Object -Unique) + $dispositions = @($actions + $evaluationResults | Sort-Object -Unique) + $delegateMessageCounts = @($Block.lines | ForEach-Object { + foreach ($match in [regex]::Matches($_, '(?i)Sending approval messages to\s+(?\d+)\s+delegates\.')) { + [int]$match.Groups['Count'].Value + } + }) + $inPolicyDetected = Test-RbaLogLinesContainText -Lines $Block.lines -Text 'Defaulting to in policy.' + $outOfPolicyDetected = Test-RbaLogLinesContainText -Lines $Block.lines -Text 'Not in policy.' + + return [PSCustomObject]@{ + sequence = $Block.sequence + startLine = $Block.startLine + endLine = $Block.endLine + startBoundaryFound = $Block.startBoundaryFound + boundaryStatus = $Block.boundaryStatus + startMarker = $Block.startMarker + startTimeText = $Block.startTimeText + eventTimeText = $Block.startTimeText + rawLogOrder = "NewestFirst" + chronologicalReadDirection = "BottomToTop" + subjectMatched = $SubjectMatched + meetingIds = @($Block.meetingIds) + actions = $actions + policyResult = $(if ($inPolicyDetected -and $outOfPolicyDetected) { "Mixed" } elseif ($inPolicyDetected) { "InPolicy" } elseif ($outOfPolicyDetected) { "OutOfPolicy" } else { "Unknown" }) + disposition = $(if ($dispositions.Count -eq 1) { $dispositions[0] } elseif ($dispositions.Count -gt 1) { "Multiple" } else { "Unknown" }) + updateDetected = Test-RbaLogLinesContainText -Lines $Block.lines -Text 'Begin ProcessUpdateRequest' + cancellationDetected = Test-RbaLogLinesContainText -Lines $Block.lines -Text "It's a meeting cancellation." + delegateReferralDetected = Test-RbaLogLinesContainText -Lines $Block.lines -Text 'Forwarding Request To Delegates' + delegateMessageCount = $(if ($delegateMessageCounts.Count -gt 0) { ($delegateMessageCounts | Measure-Object -Sum).Sum } else { $null }) + tentativeResponseSent = Test-RbaLogLinesContainText -Lines $Block.lines -Text 'END - Sending the tentatively acceptance response to organizer.' + externalProcessingSkipped = Test-RbaLogLinesContainText -Lines $Block.lines -Text 'Skipping processing because user settings for processing external items is false.' + horizonDeclineDetected = Test-RbaLogLinesContainText -Lines $Block.lines -Text 'Recurrence ends is past the booking window. Meeting will be declined.' + recurrenceTruncateDetected = Test-RbaLogLinesContainText -Lines $Block.lines -Text 'Truncating meeting recurrence end window' + rawLog = @($Block.lines) + } +} + +function Get-RbaMeetingLogSearchObject { + $normalizedRequestedMeetingId = if (-not [string]::IsNullOrWhiteSpace($MeetingId)) { + ConvertTo-RbaNormalizedMeetingId -Value $MeetingId + } else { $null } + $searchType = if (-not [string]::IsNullOrWhiteSpace($Subject)) { + "Subject" + } elseif (-not [string]::IsNullOrWhiteSpace($normalizedRequestedMeetingId)) { + "MeetingId" + } else { "None" } + + if ($searchType -eq "None") { + return [PSCustomObject]@{ + searchType = $searchType + searchSubject = $null + searchMeetingId = $null + status = "NotRequested" + sourceOrder = "NewestFirst" + eventOrder = "NewestFirst" + rawLogChronologicalReadDirection = "BottomToTop" + subjectMatchCount = 0 + meetingIds = @() + eventCount = 0 + acceptCount = 0 + tentativeCount = 0 + declineCount = 0 + updateCount = 0 + cancellationCount = 0 + delegateReferralCount = 0 + externalSkippedCount = 0 + horizonDeclineCount = 0 + recurrenceTruncateCount = 0 + firstLogTimeText = $null + lastLogTimeText = $null + lastUpdateTimeText = $null + recurrenceStatus = "Unknown" + policyResult = "Unknown" + disposition = "Unknown" + forwardedToDelegates = $false + delegateMessageCount = $null + tentativeResponseSent = $false + meetings = @() + events = @() + } + } + + if ($script:collectorStatuses["RbaLog"].status -ne "Success") { + return [PSCustomObject]@{ + searchType = $searchType + searchSubject = $Subject + searchMeetingId = $normalizedRequestedMeetingId + status = "LogUnavailable" + sourceOrder = "NewestFirst" + eventOrder = "NewestFirst" + rawLogChronologicalReadDirection = "BottomToTop" + subjectMatchCount = 0 + meetingIds = @() + eventCount = 0 + updateCount = 0 + cancellationCount = 0 + declineCount = 0 + firstLogTimeText = $null + lastLogTimeText = $null + lastUpdateTimeText = $null + recurrenceStatus = "Unknown" + policyResult = "Unknown" + disposition = "Unknown" + forwardedToDelegates = $false + delegateMessageCount = $null + tentativeResponseSent = $false + meetings = @() + events = @() + } + } + + $blocks = @(Split-RbaLogProcessingBlocks -Lines @($script:RBALog)) + if ($searchType -eq "MeetingId") { + $selectedBlocks = @($blocks | Where-Object { + @($_.meetingIds) -contains $normalizedRequestedMeetingId + }) + $events = @($selectedBlocks | ForEach-Object { + Get-RbaTargetedLogBlockObject -Block $_ -SubjectMatched $false + }) + $meetingDetails = Get-RbaTargetedMeetingDetails -Events $events + $meetingSummaries = @(Get-RbaTargetedMeetingSummaries -MeetingIds @($normalizedRequestedMeetingId) -Events $events) + + return [PSCustomObject]@{ + searchType = $searchType + searchSubject = $null + searchMeetingId = $normalizedRequestedMeetingId + status = $(if ($events.Count -gt 0) { "Found" } else { "NotFound" }) + sourceOrder = "NewestFirst" + eventOrder = "NewestFirst" + rawLogChronologicalReadDirection = "BottomToTop" + subjectMatchCount = 0 + meetingIds = $(if ($events.Count -gt 0) { @($normalizedRequestedMeetingId) } else { @() }) + eventCount = $events.Count + acceptCount = @($events | Where-Object { $_.actions -contains "Accept" }).Count + tentativeCount = @($events | Where-Object { $_.actions -contains "Tentative" }).Count + declineCount = @($events | Where-Object { $_.actions -contains "Decline" }).Count + updateCount = @($events | Where-Object { $_.updateDetected }).Count + cancellationCount = @($events | Where-Object { $_.cancellationDetected }).Count + delegateReferralCount = @($events | Where-Object { $_.delegateReferralDetected }).Count + externalSkippedCount = @($events | Where-Object { $_.externalProcessingSkipped }).Count + horizonDeclineCount = @($events | Where-Object { $_.horizonDeclineDetected }).Count + recurrenceTruncateCount = @($events | Where-Object { $_.recurrenceTruncateDetected }).Count + firstLogTimeText = $meetingDetails.firstLogTimeText + lastLogTimeText = $meetingDetails.lastLogTimeText + lastUpdateTimeText = $meetingDetails.lastUpdateTimeText + recurrenceStatus = $meetingDetails.recurrenceStatus + policyResult = $meetingDetails.policyResult + disposition = $meetingDetails.disposition + forwardedToDelegates = $meetingDetails.forwardedToDelegates + delegateMessageCount = $meetingDetails.delegateMessageCount + tentativeResponseSent = $meetingDetails.tentativeResponseSent + meetings = $meetingSummaries + events = $events + } + } + + $subjectBlocks = @($blocks | Where-Object { + Test-RbaLogLinesContainText -Lines $_.lines -Text $Subject + }) + if ($subjectBlocks.Count -eq 0) { + return [PSCustomObject]@{ + searchType = $searchType + searchSubject = $Subject + searchMeetingId = $null + status = "NotFound" + sourceOrder = "NewestFirst" + eventOrder = "NewestFirst" + rawLogChronologicalReadDirection = "BottomToTop" + subjectMatchCount = 0 + meetingIds = @() + eventCount = 0 + updateCount = 0 + cancellationCount = 0 + declineCount = 0 + firstLogTimeText = $null + lastLogTimeText = $null + lastUpdateTimeText = $null + recurrenceStatus = "Unknown" + policyResult = "Unknown" + disposition = "Unknown" + forwardedToDelegates = $false + delegateMessageCount = $null + tentativeResponseSent = $false + meetings = @() + events = @() + } + } + + $meetingIds = @($subjectBlocks | ForEach-Object { + @($_.meetingIds) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + } | Sort-Object -Unique) + $selectedBlocks = if ($meetingIds.Count -gt 0) { + @($blocks | Where-Object { + $blockMeetingIds = @($_.meetingIds) + $blockMatches = $false + foreach ($resolvedMeetingId in $meetingIds) { + if ($blockMeetingIds -contains $resolvedMeetingId) { + $blockMatches = $true + break + } + } + $blockMatches + }) + } else { + $subjectBlocks + } + + $events = @($selectedBlocks | ForEach-Object { + Get-RbaTargetedLogBlockObject -Block $_ ` + -SubjectMatched (Test-RbaLogLinesContainText -Lines $_.lines -Text $Subject) + }) + $status = if ($meetingIds.Count -gt 0) { "Found" } else { "FoundWithoutMeetingId" } + $meetingDetails = Get-RbaTargetedMeetingDetails -Events $events + $meetingSummaries = @(Get-RbaTargetedMeetingSummaries -MeetingIds $meetingIds -Events $events) + + return [PSCustomObject]@{ + searchType = $searchType + searchSubject = $Subject + searchMeetingId = $null + status = $status + sourceOrder = "NewestFirst" + eventOrder = "NewestFirst" + rawLogChronologicalReadDirection = "BottomToTop" + subjectMatchCount = $subjectBlocks.Count + meetingIds = $meetingIds + eventCount = $events.Count + acceptCount = @($events | Where-Object { $_.actions -contains "Accept" }).Count + tentativeCount = @($events | Where-Object { $_.actions -contains "Tentative" }).Count + declineCount = @($events | Where-Object { $_.actions -contains "Decline" }).Count + updateCount = @($events | Where-Object { $_.updateDetected }).Count + cancellationCount = @($events | Where-Object { $_.cancellationDetected }).Count + delegateReferralCount = @($events | Where-Object { $_.delegateReferralDetected }).Count + externalSkippedCount = @($events | Where-Object { $_.externalProcessingSkipped }).Count + horizonDeclineCount = @($events | Where-Object { $_.horizonDeclineDetected }).Count + recurrenceTruncateCount = @($events | Where-Object { $_.recurrenceTruncateDetected }).Count + firstLogTimeText = $meetingDetails.firstLogTimeText + lastLogTimeText = $meetingDetails.lastLogTimeText + lastUpdateTimeText = $meetingDetails.lastUpdateTimeText + recurrenceStatus = $meetingDetails.recurrenceStatus + policyResult = $meetingDetails.policyResult + disposition = $meetingDetails.disposition + forwardedToDelegates = $meetingDetails.forwardedToDelegates + delegateMessageCount = $meetingDetails.delegateMessageCount + tentativeResponseSent = $meetingDetails.tentativeResponseSent + meetings = $meetingSummaries + events = $events + } +} + +function Write-RbaTargetedMeetingSummary { + param( + [Parameter(Mandatory)] + [object]$MeetingSummary, + + [string]$Indent = " " + ) + + Write-Host "$($Indent)Meeting ID $($MeetingSummary.meetingId)" + Write-Host "$($Indent)Correlated events $($MeetingSummary.eventCount)" + Write-Host "$($Indent)First meeting log $($MeetingSummary.firstLogTimeText)" + Write-Host "$($Indent)Latest meeting log $($MeetingSummary.lastLogTimeText)" + Write-Host "$($Indent)Last meeting update $(if ($null -ne $MeetingSummary.lastUpdateTimeText) { $MeetingSummary.lastUpdateTimeText } else { '[None found]' })" + Write-Host "$($Indent)Recurrence $($MeetingSummary.recurrenceStatus)" + Write-Host "$($Indent)Policy result $(switch ($MeetingSummary.policyResult) { 'InPolicy' { 'In policy' } 'OutOfPolicy' { 'Out of policy' } default { $MeetingSummary.policyResult } })" + Write-Host "$($Indent)Disposition $(switch ($MeetingSummary.disposition) { 'Accept' { 'Accepted' } 'Tentative' { 'Tentatively accepted' } 'Decline' { 'Declined' } default { $MeetingSummary.disposition } })" + Write-Host "$($Indent)Tentative response sent $(if ($MeetingSummary.tentativeResponseSent) { 'Yes' } else { 'No' })" + Write-Host "$($Indent)Forwarded to delegates $(if ($MeetingSummary.forwardedToDelegates) { 'Yes' } else { 'No' })" + if ($null -ne $MeetingSummary.delegateMessageCount) { + Write-Host "$($Indent)Delegate approval messages $($MeetingSummary.delegateMessageCount)" + } + Write-Host "$($Indent)Actions Accept=$($MeetingSummary.acceptCount), Tentative=$($MeetingSummary.tentativeCount), Decline=$($MeetingSummary.declineCount)" + Write-Host "$($Indent)Updates / cancellations $($MeetingSummary.updateCount) / $($MeetingSummary.cancellationCount)" } function RBALogSummary { Write-DashLineBoxColor @("RBA Log Summary") -Color Blue -DashChar = - $RBALog = ((Export-MailboxDiagnosticLogs $Identity -ComponentName RBA).MailboxLog -split "`\n`\r").Trim() + if ($script:collectorStatuses["RbaLog"].status -ne "Success") { + Write-Warning "RBA Log summary could not be evaluated because the log is unavailable." + return + } - if ($RBALog.count -gt 1) { - Write-Host "`tFound $($RBALog.count) RBA Log entries in RBALog. Summarizing Accepts, Declines, and Tentative meetings." - $Starts = $RBALog | Select-String -Pattern "START -" + if ($script:RBALog.count -gt 1) { + $Starts = $script:RBALog | Select-String -Pattern "START -" $FirstDate = "[Unknown]" $LastDate = "[Unknown]" if ($starts.count -gt 1) { $LastDate = ($Starts[0] -split ",")[0].Trim() $FirstDate = ($starts[$($Starts.count) -1 ] -split ",")[0].Trim() - Write-Host "`tThe RBA Log for [$Identity] shows the following:" - Write-Host "`t $($starts.count) Processed events times between $FirstDate and $LastDate" } - $AcceptLogs = $RBALog | Select-String -Pattern "Action:Accept" - $DeclineLogs = $RBALog | Select-String -Pattern "Action:Decline" - $TentativeLogs = $RBALog | Select-String -Pattern "Action:Tentative" - $UpdatedLogs = $RBALog | Select-String -Pattern "Begin ProcessUpdateRequest" - $SkippedExternal = $RBALog | Select-String -Pattern "Skipping processing because user settings for processing external items is false." - $DelegateReferrals = $RBALog | Select-String -Pattern "Forwarding Request To Delegates" - $NonMeetingRequests = $RBALog | Select-String -Pattern "Item is not a meeting request" - $Cancellations = $RBALog | Select-String -Pattern "It's a meeting cancellation." + $AcceptLogs = $script:RBALog | Select-String -Pattern "Action:Accept" + $DeclineLogs = $script:RBALog | Select-String -Pattern "Action:Decline" + $TentativeLogs = $script:RBALog | Select-String -Pattern "Action:Tentative" + $UpdatedLogs = $script:RBALog | Select-String -Pattern "Begin ProcessUpdateRequest" + $SkippedExternal = $script:RBALog | Select-String -Pattern "Skipping processing because user settings for processing external items is false." + $DelegateReferrals = $script:RBALog | Select-String -Pattern "Forwarding Request To Delegates" + $NonMeetingRequests = $script:RBALog | Select-String -Pattern "Item is not a meeting request" + $Cancellations = $script:RBALog | Select-String -Pattern "It's a meeting cancellation." + + Write-Host "RBA log activity for [$Identity]:" + Write-Host (" {0,-26} {1,6}" -f "Log entries", $script:RBALog.count) + Write-Host (" {0,-26} {1,6}" -f "Processed events", $Starts.count) + Write-Host (" {0,-26} {1,6}" -f "Accepted", $AcceptLogs.count) + Write-Host (" {0,-26} {1,6}" -f "Tentatively accepted", $TentativeLogs.count) + Write-Host (" {0,-26} {1,6}" -f "Declined", $DeclineLogs.count) + Write-Host (" {0,-26} {1,6}" -f "Updates", $UpdatedLogs.count) + Write-Host (" {0,-26} {1,6}" -f "Cancellations", $Cancellations.count) + Write-Host (" {0,-26} {1,6}" -f "Delegate referrals", $DelegateReferrals.count) + Write-Host (" {0,-26} {1,6}" -f "Non-meeting requests", $NonMeetingRequests.count) + Write-Host (" {0,-26} {1,6}" -f "Skipped external meetings", $SkippedExternal.count) + Write-Host " Date range $FirstDate to $LastDate" if ($AcceptLogs.count -ne 0) { $LastAccept = ($AcceptLogs[0] -split ",")[0].Trim() - Write-Host "`t $($AcceptLogs.count) were Accepted between $FirstDate and $LastDate" - Write-Host "`t`t with the last meeting Accepted on $LastAccept" + Write-Host " Last accepted $LastAccept" } if ($TentativeLogs.count -ne 0) { $LastTentative = ($TentativeLogs[0] -split ",")[0].Trim() - Write-Host "`t $($TentativeLogs.count) Tentatively Accepted meetings between $FirstDate and $LastDate" - Write-Host "`t`t with the last meeting Tentatively Accepted on $LastTentative" + Write-Host " Last tentatively accepted $LastTentative" } if ($DeclineLogs.count -ne 0) { $LastDecline = ($DeclineLogs[0] -split ",")[0].Trim() - Write-Host "`t $($DeclineLogs.count) Declined meetings between $FirstDate and $LastDate" - Write-Host "`t`t with the last meeting Declined on $LastDecline" - } - - if ($AcceptLogs.count -eq 0 -and $TentativeLogs.count -eq 0 -and $DeclineLogs.count -eq 0) { - Write-Host -ForegroundColor Red "`t No meetings were processed in the RBA Log." + Write-Host " Last declined $LastDecline" } if ($UpdatedLogs.count -ne 0) { $LastUpdated = ($UpdatedLogs[0] -split ",")[0].Trim() - Write-Host "`t $($UpdatedLogs.count) Updates to meetings between $FirstDate and $LastDate" - Write-Host "`t`t with the last meeting updated on $LastUpdated" - } else { - Write-Host -ForegroundColor Red "`t No meetings were updated in the RBA Log." - } - - if ($Cancellations.count -ne 0) { - Write-Host "`t $($Cancellations.count) Cancellations were processed." - } else { - Write-Host "`t No meetings were canceled in the RBA Log." + Write-Host " Last updated $LastUpdated" } if ($DelegateReferrals.count -ne 0) { $LastDelegateReferral = ($DelegateReferrals[0] -split ",")[0].Trim() - Write-Host "`t $($DelegateReferrals.count) Delegate Referrals were sent between $FirstDate and $LastDate" - Write-Host "`t`t with the last Delegate Referral sent on $LastDelegateReferral" - } else { - Write-Host "`t No Delegate Referrals were sent in the RBA Log." + Write-Host " Last delegate referral $LastDelegateReferral" } if ($NonMeetingRequests.count -ne 0) { $LastNonMeetingRequest = ($NonMeetingRequests[0] -split ",")[0].Trim() - Write-Host "`t $($NonMeetingRequests.count) Non Meeting Requests were skipped between $FirstDate and $LastDate" - Write-Host "`t`t with the last Non Meeting Request skipped on $LastNonMeetingRequest" - } else { - Write-Host "`t No Non Meeting Requests were skipped in the RBA Log." + Write-Host " Last non-meeting request $LastNonMeetingRequest" + } + + if ($script:MeetingLogSearch.status -ne "NotRequested") { + Write-Host + Write-Host -ForegroundColor DarkBlue "Targeted meeting search:" + if ($script:MeetingLogSearch.searchType -eq "Subject") { + Write-Host " Subject [$($script:MeetingLogSearch.searchSubject)]" + Write-Host " Subject matches $($script:MeetingLogSearch.subjectMatchCount)" + } else { + Write-Host " Requested meeting ID $($script:MeetingLogSearch.searchMeetingId)" + } + Write-Host " Search result $($script:MeetingLogSearch.status)" + Write-Host " Correlated events (total) $($script:MeetingLogSearch.eventCount)" + if (@($script:MeetingLogSearch.meetingIds).Count -gt 0) { + $meetingSummaries = @($script:MeetingLogSearch.meetings) + if ($meetingSummaries.Count -gt 1) { + Write-Warning "The subject matched $($meetingSummaries.Count) meeting IDs. Results are separated below; rerun with -MeetingId to investigate one meeting." + for ($meetingIndex = 0; $meetingIndex -lt $meetingSummaries.Count; $meetingIndex++) { + Write-Host + Write-Host -ForegroundColor DarkBlue " Meeting $($meetingIndex + 1) of $($meetingSummaries.Count):" + Write-RbaTargetedMeetingSummary -MeetingSummary $meetingSummaries[$meetingIndex] -Indent " " + } + } else { + Write-RbaTargetedMeetingSummary -MeetingSummary $meetingSummaries[0] + } + if ($script:MeetingLogSearch.searchType -eq "Subject") { + Write-Host " Subject discovery completed; subsequent correlation uses the meeting ID(s)." + } + } elseif ($script:MeetingLogSearch.status -eq "NotFound") { + Write-Warning "The requested meeting was not found in the retained RBA log. Older events may have rolled off." + } elseif ($script:MeetingLogSearch.status -eq "FoundWithoutMeetingId") { + Write-Warning "The subject was found, but no meeting ID could be extracted for correlation." + } } if ($SkippedExternal.count -ne 0) { if ($SkippedExternal.Count -lt 3) { - Write-Host "`t Warning: $($SkippedExternal.count) External meetings were skipped as processing external items is false." + Write-Host -ForegroundColor Yellow "Warning: $($SkippedExternal.count) external meetings were skipped because external-item processing is disabled." } else { - Write-Host -ForegroundColor Red "`t Warning: $($SkippedExternal.count) External meetings were skipped as processing external items is false." - Write-Host -ForegroundColor Red "`t`t Many skipped external meetings may indicate a configuration issue in Transport." - Write-Host -ForegroundColor Red "`t`t Validate that Internal Meetings are not getting marked as External." + Write-Host -ForegroundColor Red "Warning: $($SkippedExternal.count) external meetings were skipped because external-item processing is disabled." + Write-Host -ForegroundColor Red "Many skipped external meetings may indicate a Transport configuration issue. Validate that internal meetings are not marked as external." } } - # Making RBA Log more readable. - $RBALog = $RBALog.replace(", Entry Action: Message, LogComment", "") - $RBALog = $RBALog.replace("Mailbox: ", "") - - $Filename = "RBA-Logs_$($Identity.Split('@')[0])_$((Get-Date).ToString('yyyy-MM-dd_HH-mm-ss')).txt" - Write-Host "`r`n`t RBA Logs saved as [" -NoNewline - Write-Host -ForegroundColor Cyan $Filename -NoNewline - Write-Host "] in the current directory." - $RBALog | Out-File $Filename + $script:RbaLogFilename = "RBA-Logs_$($Identity.Split('@')[0])_$runTimestamp.txt" + $script:RBALog.replace(", Entry Action: Message, LogComment", "").replace("Mailbox: ", "") | + Out-File -FilePath $script:RbaLogFilename -Encoding utf8 + Write-Host -ForegroundColor Cyan "`r`nRBA logs saved as [$script:RbaLogFilename] in the current directory." RBAPostScript } else { @@ -753,20 +1699,850 @@ function Write-DashLineBoxColor { Write-Host } -# Call the Functions in this order: -ValidateMailbox -ValidateInboxRules -GetCalendarProcessing -EvaluateCalProcessing -ValidateWorkspace -ValidateRoomListSettings -ProcessingLogic -RBACriteria -RBAProcessingValidation -InPolicyProcessing -OutOfPolicyProcessing -RBADelegateSettings -RBAPostProcessing -VerbosePostProcessing -RBALogSummary -Stop-Transcript +function Invoke-RbaCollectorOperation { + param( + [Parameter(Mandatory)] + [string]$Name, + + [Parameter(Mandatory)] + [ScriptBlock]$Action + ) + + $ErrorActionPreference = "Stop" + try { + & $Action + } catch { + # Invoke-RbaCollector owns failures raised during collection. The operation wrapper owns + # failures before collection or after a successful collection, such as evidence processing. + if ($script:collectorStatuses.Contains($Name) -and + $script:collectorStatuses[$Name].status -eq "Failed") { + return + } + + $errorInfo = ConvertTo-RbaErrorInfo -ErrorRecord $_ + $script:collectorStatuses[$Name] = [PSCustomObject]@{ + status = "Failed" + error = $errorInfo.message + exceptionType = $errorInfo.exceptionType + category = $errorInfo.category + fullyQualifiedErrorId = $errorInfo.fullyQualifiedErrorId + innerExceptionMessage = $errorInfo.innerExceptionMessage + } + $script:collectionErrors.Add([PSCustomObject]@{ + collector = $Name + message = $errorInfo.message + exceptionType = $errorInfo.exceptionType + category = $errorInfo.category + fullyQualifiedErrorId = $errorInfo.fullyQualifiedErrorId + innerExceptionMessage = $errorInfo.innerExceptionMessage + }) + Write-Warning "$Name collection failed: $($errorInfo.message)" + } +} + +function Invoke-RbaEvaluation { + param( + [Parameter(Mandatory)] + [string]$Name, + + [Parameter(Mandatory)] + [ScriptBlock]$Action + ) + + try { + & $Action + } catch { + $errorInfo = ConvertTo-RbaErrorInfo -ErrorRecord $_ + $script:evaluationErrors.Add([PSCustomObject]@{ + evaluation = $Name + message = $errorInfo.message + exceptionType = $errorInfo.exceptionType + category = $errorInfo.category + fullyQualifiedErrorId = $errorInfo.fullyQualifiedErrorId + innerExceptionMessage = $errorInfo.innerExceptionMessage + }) + Write-Warning "$Name evaluation was skipped after an error: $($errorInfo.message)" + } +} + +function Add-RbaFinding { + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.List[object]]$Findings, + + [Parameter(Mandatory)] + [string]$RuleId, + + [Parameter(Mandatory)] + [ValidateSet("Critical", "Error", "Warning", "Information")] + [string]$Severity, + + [Parameter(Mandatory)] + [ValidateSet("Detected", "NotDetected", "NotEvaluated", "NotApplicable")] + [string]$Status, + + [Parameter(Mandatory)] + [string]$Title, + + [AllowNull()] + [object]$Evidence + ) + + $Findings.Add([PSCustomObject]@{ + ruleId = $RuleId + severity = $Severity + status = $Status + title = $Title + evidence = $Evidence + }) +} + +function Get-RbaFindings { + $findings = [System.Collections.Generic.List[object]]::new() + $mailboxAvailable = $script:collectorStatuses["Mailbox"].status -eq "Success" + $placeAvailable = $script:collectorStatuses["Place"].status -eq "Success" + $rulesAvailable = $script:collectorStatuses["InboxRules"].status -eq "Success" + $settingsAvailable = $script:collectorStatuses["CalendarProcessing"].status -eq "Success" + $logAvailable = $script:collectorStatuses["RbaLog"].status -eq "Success" + $calendarPermissionsAvailable = $script:collectorStatuses["CalendarFolderPermissions"].status -eq "Success" + $mailboxPermissionsAvailable = $script:collectorStatuses["MailboxPermissions"].status -eq "Success" + + Add-RbaFinding -Findings $findings -RuleId "RBA001" -Severity Error ` + -Status $(if ($mailboxAvailable) { "NotDetected" } else { "Detected" }) ` + -Title "Mailbox evidence unavailable" -Evidence $script:collectorStatuses["Mailbox"].error + + Add-RbaFinding -Findings $findings -RuleId "RBA002" -Severity Error ` + -Status $(if ($placeAvailable) { "NotDetected" } else { "Detected" }) ` + -Title "Place evidence unavailable" -Evidence $script:collectorStatuses["Place"].error + + Add-RbaFinding -Findings $findings -RuleId "RBA003" -Severity Error ` + -Status $(if ($rulesAvailable) { "NotDetected" } else { "Detected" }) ` + -Title "Inbox rule evidence unavailable" -Evidence $script:collectorStatuses["InboxRules"].error + + Add-RbaFinding -Findings $findings -RuleId "RBA004" -Severity Error ` + -Status $(if ($settingsAvailable) { "NotDetected" } else { "Detected" }) ` + -Title "Calendar processing evidence unavailable" -Evidence $script:collectorStatuses["CalendarProcessing"].error + + Add-RbaFinding -Findings $findings -RuleId "RBA005" -Severity Warning ` + -Status $(if ($logAvailable) { "NotDetected" } else { "Detected" }) ` + -Title "RBA log evidence unavailable" -Evidence $script:collectorStatuses["RbaLog"].error + + Add-RbaFinding -Findings $findings -RuleId "RBA006" -Severity Warning ` + -Status $(if ($calendarPermissionsAvailable) { "NotDetected" } else { "Detected" }) ` + -Title "Calendar folder permission evidence unavailable" -Evidence $script:collectorStatuses["CalendarFolderPermissions"].error + + Add-RbaFinding -Findings $findings -RuleId "RBA007" -Severity Warning ` + -Status $(if ($mailboxPermissionsAvailable) { "NotDetected" } else { "Detected" }) ` + -Title "Mailbox permission evidence unavailable" -Evidence $script:collectorStatuses["MailboxPermissions"].error + + $mailboxIsSoftDeleted = $mailboxAvailable -and $script:MailboxObjectState -eq "SoftDeleted" + $invalidMailboxType = $mailboxAvailable -and -not $mailboxIsSoftDeleted -and + $script:Mailbox.RecipientTypeDetails -notin @("RoomMailbox", "EquipmentMailbox") + Add-RbaFinding -Findings $findings -RuleId "RBA100" -Severity Critical ` + -Status $(if (-not $mailboxAvailable) { "NotEvaluated" } elseif ($mailboxIsSoftDeleted) { "NotApplicable" } elseif ($invalidMailboxType) { "Detected" } else { "NotDetected" }) ` + -Title "Mailbox type is not supported by RBA" -Evidence $script:Mailbox.RecipientTypeDetails + + Add-RbaFinding -Findings $findings -RuleId "RBA101" -Severity Critical ` + -Status $(if (-not $mailboxAvailable) { "NotEvaluated" } elseif ($mailboxIsSoftDeleted) { "Detected" } else { "NotDetected" }) ` + -Title "Resource mailbox is soft-deleted" ` + -Evidence @{ objectState = $script:MailboxObjectState; recipientTypeDetails = $script:Mailbox.RecipientTypeDetails } + + $mailboxIdentitySummary = Get-RbaMailboxIdentitySummaryObject + Add-RbaFinding -Findings $findings -RuleId "RBA102" -Severity Information ` + -Status $(if (-not $mailboxAvailable) { "NotEvaluated" } elseif ($mailboxIdentitySummary.inputIdentityMatch -eq "ProxyAddress") { "Detected" } else { "NotDetected" }) ` + -Title "Input identity resolved through a proxy address" ` + -Evidence @{ inputIdentityMatch = $mailboxIdentitySummary.inputIdentityMatch; primarySmtpAddress = $mailboxIdentitySummary.primarySmtpAddress } + + $delegateRules = @($script:InboxRules | Where-Object { $_.Name -like "Delegate Rule*" }) + Add-RbaFinding -Findings $findings -RuleId "RBA200" -Severity Critical ` + -Status $(if (-not $rulesAvailable) { "NotEvaluated" } elseif ($delegateRules.Count -gt 0) { "Detected" } else { "NotDetected" }) ` + -Title "Delegate inbox rule can block RBA" -Evidence @{ count = $delegateRules.Count } + + $redactedRules = @($script:InboxRules | Where-Object { $_.Name -like "REDACTED-*" }) + Add-RbaFinding -Findings $findings -RuleId "RBA201" -Severity Warning ` + -Status $(if (-not $rulesAvailable) { "NotEvaluated" } elseif ($redactedRules.Count -gt 0) { "Detected" } else { "NotDetected" }) ` + -Title "Inbox rule visibility is redacted" -Evidence @{ count = $redactedRules.Count } + + Add-RbaFinding -Findings $findings -RuleId "RBA300" -Severity Critical ` + -Status $(if (-not $settingsAvailable) { "NotEvaluated" } elseif ($RbaSettings.AutomateProcessing -ne "AutoAccept") { "Detected" } else { "NotDetected" }) ` + -Title "AutomateProcessing is not AutoAccept" -Evidence $RbaSettings.AutomateProcessing + + $noProcessingRoutes = $settingsAvailable -and $RbaSettings.RequestOutOfPolicy.Count -eq 0 -and + $RbaSettings.AllRequestOutOfPolicy -eq $false -and $RbaSettings.BookInPolicy.Count -eq 0 -and + $RbaSettings.AllBookInPolicy -eq $false -and $RbaSettings.RequestInPolicy.Count -eq 0 -and + $RbaSettings.AllRequestInPolicy -eq $false + Add-RbaFinding -Findings $findings -RuleId "RBA301" -Severity Critical ` + -Status $(if (-not $settingsAvailable) { "NotEvaluated" } elseif ($noProcessingRoutes) { "Detected" } else { "NotDetected" }) ` + -Title "RBA has no configured processing route" -Evidence $noProcessingRoutes + + Add-RbaFinding -Findings $findings -RuleId "RBA302" -Severity Information ` + -Status $(if (-not $settingsAvailable) { "NotEvaluated" } else { "Detected" }) ` + -Title "A resource booking window is configured" ` + -Evidence @{ bookingWindowInDays = $RbaSettings.BookingWindowInDays; allowRecurringMeetings = $RbaSettings.AllowRecurringMeetings; enforceSchedulingHorizon = $RbaSettings.EnforceSchedulingHorizon } + + Add-RbaFinding -Findings $findings -RuleId "RBA303" -Severity Information ` + -Status $(if (-not $settingsAvailable) { "NotEvaluated" } elseif ($RbaSettings.MaximumDurationInMinutes -gt 0) { "Detected" } else { "NotDetected" }) ` + -Title "Meeting duration is limited" ` + -Evidence @{ maximumDurationInMinutes = $RbaSettings.MaximumDurationInMinutes } + + Add-RbaFinding -Findings $findings -RuleId "RBA304" -Severity Warning ` + -Status $(if (-not $settingsAvailable) { "NotEvaluated" } elseif (-not $RbaSettings.AllowRecurringMeetings) { "Detected" } else { "NotDetected" }) ` + -Title "Recurring meetings are disabled" ` + -Evidence @{ allowRecurringMeetings = $RbaSettings.AllowRecurringMeetings } + + Add-RbaFinding -Findings $findings -RuleId "RBA305" -Severity Information ` + -Status $(if (-not $settingsAvailable) { "NotEvaluated" } elseif (-not $RbaSettings.AllowRecurringMeetings) { "NotApplicable" } elseif ($RbaSettings.EnforceSchedulingHorizon) { "Detected" } else { "NotDetected" }) ` + -Title "Recurring series beyond the booking window are declined" ` + -Evidence @{ enforceSchedulingHorizon = $RbaSettings.EnforceSchedulingHorizon; bookingWindowInDays = $RbaSettings.BookingWindowInDays } + + Add-RbaFinding -Findings $findings -RuleId "RBA306" -Severity Information ` + -Status $(if (-not $settingsAvailable) { "NotEvaluated" } elseif (-not $RbaSettings.AllowRecurringMeetings) { "NotApplicable" } elseif (-not $RbaSettings.EnforceSchedulingHorizon) { "Detected" } else { "NotDetected" }) ` + -Title "Recurring series are truncated at the booking window" ` + -Evidence @{ enforceSchedulingHorizon = $RbaSettings.EnforceSchedulingHorizon; bookingWindowInDays = $RbaSettings.BookingWindowInDays } + + Add-RbaFinding -Findings $findings -RuleId "RBA307" -Severity Warning ` + -Status $(if (-not $settingsAvailable) { "NotEvaluated" } elseif ($RbaSettings.ScheduleOnlyDuringWorkHours) { "Detected" } else { "NotDetected" }) ` + -Title "Bookings are restricted to resource work hours" ` + -Evidence @{ scheduleOnlyDuringWorkHours = $RbaSettings.ScheduleOnlyDuringWorkHours } + + Add-RbaFinding -Findings $findings -RuleId "RBA308" -Severity Information ` + -Status $(if (-not $settingsAvailable) { "NotEvaluated" } elseif ($RbaSettings.AllowConflicts) { "Detected" } else { "NotDetected" }) ` + -Title "Conflicting requests are allowed" ` + -Evidence @{ allowConflicts = $RbaSettings.AllowConflicts; conflictPercentageAllowed = $RbaSettings.ConflictPercentageAllowed; maximumConflictInstances = $RbaSettings.MaximumConflictInstances } + + $recurringConflictThresholdsApply = $settingsAvailable -and $RbaSettings.AllowRecurringMeetings -and + -not $RbaSettings.AllowConflicts + Add-RbaFinding -Findings $findings -RuleId "RBA309" -Severity Information ` + -Status $(if (-not $settingsAvailable) { "NotEvaluated" } elseif (-not $recurringConflictThresholdsApply) { "NotApplicable" } elseif ($RbaSettings.ConflictPercentageAllowed -gt 0) { "Detected" } else { "NotDetected" }) ` + -Title "A recurring-series conflict percentage is allowed" ` + -Evidence @{ allowConflicts = $RbaSettings.AllowConflicts; allowRecurringMeetings = $RbaSettings.AllowRecurringMeetings; conflictPercentageAllowed = $RbaSettings.ConflictPercentageAllowed } + + Add-RbaFinding -Findings $findings -RuleId "RBA310" -Severity Information ` + -Status $(if (-not $settingsAvailable) { "NotEvaluated" } elseif (-not $recurringConflictThresholdsApply) { "NotApplicable" } elseif ($RbaSettings.MaximumConflictInstances -gt 0) { "Detected" } else { "NotDetected" }) ` + -Title "A recurring-series conflict count is allowed" ` + -Evidence @{ allowConflicts = $RbaSettings.AllowConflicts; allowRecurringMeetings = $RbaSettings.AllowRecurringMeetings; maximumConflictInstances = $RbaSettings.MaximumConflictInstances } + + Add-RbaFinding -Findings $findings -RuleId "RBA311" -Severity Warning ` + -Status $(if (-not $settingsAvailable) { "NotEvaluated" } elseif (-not $RbaSettings.ProcessExternalMeetingMessages) { "Detected" } else { "NotDetected" }) ` + -Title "External meeting messages are not processed" ` + -Evidence @{ processExternalMeetingMessages = $RbaSettings.ProcessExternalMeetingMessages } + + $isWorkspace = $mailboxAvailable -and $script:Mailbox.ResourceType -eq "Workspace" + Add-RbaFinding -Findings $findings -RuleId "RBA500" -Severity Error ` + -Status $(if (-not $mailboxAvailable) { "NotEvaluated" } elseif (-not $isWorkspace) { "NotApplicable" } elseif (-not $placeAvailable) { "NotEvaluated" } elseif ([string]::IsNullOrEmpty($script:Place.Capacity)) { "Detected" } else { "NotDetected" }) ` + -Title "Workspace capacity is missing" -Evidence $script:Place.Capacity + + $workspaceSettingsInvalid = $isWorkspace -and $settingsAvailable -and + ($RbaSettings.EnforceCapacity -ne $true -or $RbaSettings.AllowConflicts -ne $true) + Add-RbaFinding -Findings $findings -RuleId "RBA501" -Severity Error ` + -Status $(if (-not $mailboxAvailable) { "NotEvaluated" } elseif (-not $isWorkspace) { "NotApplicable" } elseif (-not $settingsAvailable) { "NotEvaluated" } elseif ($workspaceSettingsInvalid) { "Detected" } else { "NotDetected" }) ` + -Title "Workspace calendar settings are incomplete" ` + -Evidence @{ enforceCapacity = $RbaSettings.EnforceCapacity; allowConflicts = $RbaSettings.AllowConflicts } + + Add-RbaFinding -Findings $findings -RuleId "RBA510" -Severity Warning ` + -Status $(if (-not $placeAvailable) { "NotEvaluated" } elseif ([string]::IsNullOrEmpty($script:Place.Localities)) { "Detected" } else { "NotDetected" }) ` + -Title "Resource is not in a room list" -Evidence @{ roomListCount = @($script:Place.Localities).Count } + + $missingPlaceProperties = if ($placeAvailable) { + @(@("City", "Floor", "Capacity") | Where-Object { [string]::IsNullOrEmpty($script:Place.$_) }) + } else { @() } + Add-RbaFinding -Findings $findings -RuleId "RBA511" -Severity Warning ` + -Status $(if (-not $placeAvailable) { "NotEvaluated" } elseif ($missingPlaceProperties.Count -gt 0) { "Detected" } else { "NotDetected" }) ` + -Title "Room finder properties are missing" -Evidence @{ properties = $missingPlaceProperties } + + $delegateCount = @($RbaSettings.ResourceDelegates).Count + $requestOutOfPolicyCount = @($RbaSettings.RequestOutOfPolicy).Count + $bookInPolicyCount = @($RbaSettings.BookInPolicy).Count + $noDelegates = $settingsAvailable -and $delegateCount -eq 0 + $noDelegateRouteRequired = $noDelegates -and $RbaSettings.AllBookInPolicy -eq $true -and + $RbaSettings.AllRequestOutOfPolicy -eq $false -and $requestOutOfPolicyCount -eq 0 + Add-RbaFinding -Findings $findings -RuleId "RBA400" -Severity Information ` + -Status $(if (-not $settingsAvailable) { "NotEvaluated" } elseif ($noDelegateRouteRequired) { "Detected" } else { "NotApplicable" }) ` + -Title "No delegates are required by the configured request routes" ` + -Evidence @{ delegateCount = $delegateCount; allBookInPolicy = $RbaSettings.AllBookInPolicy; allRequestOutOfPolicy = $RbaSettings.AllRequestOutOfPolicy; requestOutOfPolicyCount = $requestOutOfPolicyCount } + + Add-RbaFinding -Findings $findings -RuleId "RBA401" -Severity Warning ` + -Status $(if (-not $settingsAvailable) { "NotEvaluated" } elseif (-not $noDelegates) { "NotApplicable" } elseif ($RbaSettings.ForwardRequestsToDelegates -and -not $RbaSettings.AllBookInPolicy) { "Detected" } else { "NotDetected" }) ` + -Title "Forwarding is enabled without delegates for in-policy requests" ` + -Evidence @{ delegateCount = $delegateCount; forwardRequestsToDelegates = $RbaSettings.ForwardRequestsToDelegates; allBookInPolicy = $RbaSettings.AllBookInPolicy } + + Add-RbaFinding -Findings $findings -RuleId "RBA402" -Severity Error ` + -Status $(if (-not $settingsAvailable) { "NotEvaluated" } elseif (-not $noDelegates) { "NotApplicable" } elseif ($requestOutOfPolicyCount -gt 0) { "Detected" } else { "NotDetected" }) ` + -Title "Out-of-policy requesters are configured without delegates" -Evidence @{ requesterCount = $requestOutOfPolicyCount; delegateCount = $delegateCount } + + Add-RbaFinding -Findings $findings -RuleId "RBA403" -Severity Error ` + -Status $(if (-not $settingsAvailable) { "NotEvaluated" } elseif (-not $noDelegates) { "NotApplicable" } elseif ($RbaSettings.AllRequestOutOfPolicy) { "Detected" } else { "NotDetected" }) ` + -Title "All out-of-policy requests are enabled without delegates" ` + -Evidence @{ allRequestOutOfPolicy = $RbaSettings.AllRequestOutOfPolicy; delegateCount = $delegateCount } + + Add-RbaFinding -Findings $findings -RuleId "RBA600" -Severity Warning ` + -Status $(if (-not $settingsAvailable) { "NotEvaluated" } elseif ($RbaSettings.DeleteComments) { "Detected" } else { "NotDetected" }) ` + -Title "Meeting body deletion can remove Teams information" -Evidence $RbaSettings.DeleteComments + + Add-RbaFinding -Findings $findings -RuleId "RBA601" -Severity Warning ` + -Status $(if (-not $settingsAvailable) { "NotEvaluated" } elseif ($RbaSettings.RemovePrivateProperty) { "Detected" } else { "NotDetected" }) ` + -Title "The private flag is cleared from incoming meetings" ` + -Evidence @{ removePrivateProperty = $RbaSettings.RemovePrivateProperty } + + Add-RbaFinding -Findings $findings -RuleId "RBA602" -Severity Information ` + -Status $(if (-not $settingsAvailable) { "NotEvaluated" } elseif ($RbaSettings.DeleteSubject) { "Detected" } else { "NotDetected" }) ` + -Title "The original meeting subject is removed" ` + -Evidence @{ deleteSubject = $RbaSettings.DeleteSubject; addOrganizerToSubject = $RbaSettings.AddOrganizerToSubject } + + Add-RbaFinding -Findings $findings -RuleId "RBA603" -Severity Information ` + -Status $(if (-not $settingsAvailable) { "NotEvaluated" } elseif ($RbaSettings.AddOrganizerToSubject) { "Detected" } else { "NotDetected" }) ` + -Title "The organizer name replaces the meeting subject" ` + -Evidence @{ addOrganizerToSubject = $RbaSettings.AddOrganizerToSubject; deleteSubject = $RbaSettings.DeleteSubject } + + Add-RbaFinding -Findings $findings -RuleId "RBA604" -Severity Information ` + -Status $(if (-not $settingsAvailable) { "NotEvaluated" } elseif (-not $RbaSettings.RemoveCanceledMeetings) { "Detected" } else { "NotDetected" }) ` + -Title "Canceled meetings are retained on the resource calendar" ` + -Evidence @{ removeCanceledMeetings = $RbaSettings.RemoveCanceledMeetings } + + $skippedExternalCount = if ($logAvailable) { + @($script:RBALog | Select-String -Pattern "Skipping processing because user settings for processing external items is false.").Count + } else { 0 } + Add-RbaFinding -Findings $findings -RuleId "RBA700" -Severity Warning ` + -Status $(if (-not $logAvailable) { "NotEvaluated" } elseif ($skippedExternalCount -gt 0) { "Detected" } else { "NotDetected" }) ` + -Title "External meeting requests were skipped" -Evidence @{ count = $skippedExternalCount } + + Add-RbaFinding -Findings $findings -RuleId "RBA410" -Severity Warning ` + -Status $(if (-not $settingsAvailable) { "NotEvaluated" } elseif ($delegateCount -eq 0) { "NotApplicable" } elseif (-not $RbaSettings.AddNewRequestsTentatively) { "Detected" } else { "NotDetected" }) ` + -Title "New requests are not added tentatively for delegate review" ` + -Evidence @{ addNewRequestsTentatively = $RbaSettings.AddNewRequestsTentatively; delegateCount = $delegateCount } + + Add-RbaFinding -Findings $findings -RuleId "RBA411" -Severity Information ` + -Status $(if (-not $settingsAvailable) { "NotEvaluated" } elseif ($delegateCount -eq 0 -or -not $RbaSettings.ForwardRequestsToDelegates) { "NotApplicable" } elseif ($RbaSettings.AllBookInPolicy) { "Detected" } else { "NotDetected" }) ` + -Title "All in-policy requests auto-book without delegate review" ` + -Evidence @{ allBookInPolicy = $RbaSettings.AllBookInPolicy; forwardRequestsToDelegates = $RbaSettings.ForwardRequestsToDelegates; delegateCount = $delegateCount } + + Add-RbaFinding -Findings $findings -RuleId "RBA412" -Severity Information ` + -Status $(if (-not $settingsAvailable) { "NotEvaluated" } elseif ($delegateCount -eq 0 -or -not $RbaSettings.ForwardRequestsToDelegates -or $RbaSettings.AllBookInPolicy) { "NotApplicable" } elseif ($bookInPolicyCount -gt 0) { "Detected" } else { "NotDetected" }) ` + -Title "BookInPolicy users auto-book without delegate review" ` + -Evidence @{ bookInPolicyCount = $bookInPolicyCount; allBookInPolicy = $RbaSettings.AllBookInPolicy; forwardRequestsToDelegates = $RbaSettings.ForwardRequestsToDelegates; delegateCount = $delegateCount } + + $delegateRoutingApplies = $settingsAvailable -and $delegateCount -gt 0 -and $RbaSettings.ForwardRequestsToDelegates + Add-RbaFinding -Findings $findings -RuleId "RBA420" -Severity Warning ` + -Status $(if (-not $settingsAvailable) { "NotEvaluated" } elseif (-not $delegateRoutingApplies) { "NotApplicable" } elseif (-not $RbaSettings.AllRequestOutOfPolicy -and $requestOutOfPolicyCount -eq 0) { "Detected" } else { "NotDetected" }) ` + -Title "No out-of-policy requests can be routed to delegates" ` + -Evidence @{ allRequestOutOfPolicy = $RbaSettings.AllRequestOutOfPolicy; requestOutOfPolicyCount = $requestOutOfPolicyCount; forwardRequestsToDelegates = $RbaSettings.ForwardRequestsToDelegates; delegateCount = $delegateCount } + + Add-RbaFinding -Findings $findings -RuleId "RBA421" -Severity Information ` + -Status $(if (-not $settingsAvailable) { "NotEvaluated" } elseif (-not $delegateRoutingApplies -or $RbaSettings.AllRequestOutOfPolicy) { "NotApplicable" } elseif ($requestOutOfPolicyCount -gt 0) { "Detected" } else { "NotDetected" }) ` + -Title "Out-of-policy delegate referrals are limited to listed requesters" ` + -Evidence @{ allRequestOutOfPolicy = $RbaSettings.AllRequestOutOfPolicy; requestOutOfPolicyCount = $requestOutOfPolicyCount; forwardRequestsToDelegates = $RbaSettings.ForwardRequestsToDelegates; delegateCount = $delegateCount } + + Add-RbaFinding -Findings $findings -RuleId "RBA422" -Severity Information ` + -Status $(if (-not $settingsAvailable) { "NotEvaluated" } elseif (-not $delegateRoutingApplies) { "NotApplicable" } elseif ($RbaSettings.AllRequestOutOfPolicy) { "Detected" } else { "NotDetected" }) ` + -Title "All users can submit out-of-policy requests for delegate review" ` + -Evidence @{ allRequestOutOfPolicy = $RbaSettings.AllRequestOutOfPolicy; forwardRequestsToDelegates = $RbaSettings.ForwardRequestsToDelegates; delegateCount = $delegateCount } + + Add-RbaFinding -Findings $findings -RuleId "RBA423" -Severity Warning ` + -Status $(if (-not $settingsAvailable) { "NotEvaluated" } elseif (-not $RbaSettings.AllRequestOutOfPolicy) { "NotApplicable" } elseif ($requestOutOfPolicyCount -gt 0) { "Detected" } else { "NotDetected" }) ` + -Title "AllRequestOutOfPolicy overrides the requester list" ` + -Evidence @{ allRequestOutOfPolicy = $RbaSettings.AllRequestOutOfPolicy; requestOutOfPolicyCount = $requestOutOfPolicyCount } + + $logEntryCount = @($script:RBALog).Count + $processedActionCount = if ($logAvailable) { + @($script:RBALog | Select-String -Pattern "Action:Accept|Action:Decline|Action:Tentative").Count + } else { 0 } + $updatedCount = if ($logAvailable) { + @($script:RBALog | Select-String -Pattern "Begin ProcessUpdateRequest").Count + } else { 0 } + Add-RbaFinding -Findings $findings -RuleId "RBA701" -Severity Warning ` + -Status $(if (-not $logAvailable) { "NotEvaluated" } elseif ($logEntryCount -le 1) { "Detected" } else { "NotDetected" }) ` + -Title "No usable RBA log history was found" -Evidence @{ entryCount = $logEntryCount } + + Add-RbaFinding -Findings $findings -RuleId "RBA702" -Severity Warning ` + -Status $(if (-not $logAvailable) { "NotEvaluated" } elseif ($logEntryCount -le 1) { "NotApplicable" } elseif ($processedActionCount -eq 0) { "Detected" } else { "NotDetected" }) ` + -Title "No meeting actions were found in the RBA log" ` + -Evidence @{ entryCount = $logEntryCount; processedActionCount = $processedActionCount } + + Add-RbaFinding -Findings $findings -RuleId "RBA703" -Severity Warning ` + -Status $(if (-not $logAvailable) { "NotEvaluated" } elseif ($logEntryCount -le 1) { "NotApplicable" } elseif ($updatedCount -eq 0) { "Detected" } else { "NotDetected" }) ` + -Title "No meeting updates were found in the RBA log" ` + -Evidence @{ entryCount = $logEntryCount; updatedCount = $updatedCount } + + $recurrenceHorizonDeclineCount = if ($logAvailable) { + @($script:RBALog | Select-String -Pattern "Recurrence ends is past the booking window. Meeting will be declined.").Count + } else { 0 } + Add-RbaFinding -Findings $findings -RuleId "RBA704" -Severity Warning ` + -Status $(if (-not $logAvailable) { "NotEvaluated" } elseif ($recurrenceHorizonDeclineCount -gt 0) { "Detected" } else { "NotDetected" }) ` + -Title "Recurring requests exceeded the booking window and were declined" ` + -Evidence @{ count = $recurrenceHorizonDeclineCount } + + $recurrenceTruncationCount = if ($logAvailable) { + @($script:RBALog | Select-String -Pattern "Truncating meeting recurrence end window").Count + } else { 0 } + Add-RbaFinding -Findings $findings -RuleId "RBA705" -Severity Warning ` + -Status $(if (-not $logAvailable) { "NotEvaluated" } elseif ($recurrenceTruncationCount -gt 0) { "Detected" } else { "NotDetected" }) ` + -Title "Recurring requests were truncated at the booking window" ` + -Evidence @{ count = $recurrenceTruncationCount } + + $meetingSearchRequested = -not [string]::IsNullOrWhiteSpace($Subject) -or -not [string]::IsNullOrWhiteSpace($MeetingId) + $meetingSearchStatus = $script:MeetingLogSearch.status + Add-RbaFinding -Findings $findings -RuleId "RBA710" -Severity Warning ` + -Status $(if (-not $meetingSearchRequested) { "NotApplicable" } elseif (-not $logAvailable) { "NotEvaluated" } elseif ($meetingSearchStatus -eq "NotFound") { "Detected" } else { "NotDetected" }) ` + -Title "Requested meeting was not found in the retained RBA log" ` + -Evidence @{ searchStatus = $meetingSearchStatus; subjectMatchCount = $script:MeetingLogSearch.subjectMatchCount } + + Add-RbaFinding -Findings $findings -RuleId "RBA711" -Severity Information ` + -Status $(if (-not $meetingSearchRequested) { "NotApplicable" } elseif (-not $logAvailable) { "NotEvaluated" } elseif ($meetingSearchStatus -in @("Found", "FoundWithoutMeetingId")) { "Detected" } else { "NotDetected" }) ` + -Title "Requested meeting was found in the retained RBA log" ` + -Evidence @{ searchStatus = $meetingSearchStatus; subjectMatchCount = $script:MeetingLogSearch.subjectMatchCount; meetingIdCount = @($script:MeetingLogSearch.meetingIds).Count; eventCount = $script:MeetingLogSearch.eventCount } + + Add-RbaFinding -Findings $findings -RuleId "RBA712" -Severity Information ` + -Status $(if (-not $meetingSearchRequested) { "NotApplicable" } elseif (-not $logAvailable) { "NotEvaluated" } elseif ($script:MeetingLogSearch.updateCount -gt 0) { "Detected" } else { "NotDetected" }) ` + -Title "Meeting updates were found in targeted RBA log events" ` + -Evidence @{ updateCount = $script:MeetingLogSearch.updateCount } + + Add-RbaFinding -Findings $findings -RuleId "RBA713" -Severity Information ` + -Status $(if (-not $meetingSearchRequested) { "NotApplicable" } elseif (-not $logAvailable) { "NotEvaluated" } elseif ($script:MeetingLogSearch.cancellationCount -gt 0) { "Detected" } else { "NotDetected" }) ` + -Title "Meeting cancellations were found in targeted RBA log events" ` + -Evidence @{ cancellationCount = $script:MeetingLogSearch.cancellationCount } + + Add-RbaFinding -Findings $findings -RuleId "RBA714" -Severity Warning ` + -Status $(if (-not $meetingSearchRequested) { "NotApplicable" } elseif (-not $logAvailable) { "NotEvaluated" } elseif ($meetingSearchStatus -eq "FoundWithoutMeetingId") { "Detected" } else { "NotDetected" }) ` + -Title "Meeting subject matched but no meeting ID was extracted" ` + -Evidence @{ searchStatus = $meetingSearchStatus; subjectMatchCount = $script:MeetingLogSearch.subjectMatchCount } + + Add-RbaFinding -Findings $findings -RuleId "RBA715" -Severity Information ` + -Status $(if (-not $meetingSearchRequested) { "NotApplicable" } elseif (-not $logAvailable) { "NotEvaluated" } elseif ($script:MeetingLogSearch.declineCount -gt 0) { "Detected" } else { "NotDetected" }) ` + -Title "Decline actions were found in targeted RBA log events" ` + -Evidence @{ declineCount = $script:MeetingLogSearch.declineCount; horizonDeclineCount = $script:MeetingLogSearch.horizonDeclineCount } + + $defaultCalendarPermission = @($script:CalendarFolderPermissions | Where-Object { + (Get-RbaPermissionIdentity -PermissionUser $_.User) -eq "default" + } | Select-Object -First 1) + $defaultAccessRights = if ($defaultCalendarPermission.Count -gt 0) { + @($defaultCalendarPermission[0].AccessRights | ForEach-Object { [string]$_ }) + } else { @() } + Add-RbaFinding -Findings $findings -RuleId "RBA801" -Severity Information ` + -Status $(if (-not $calendarPermissionsAvailable) { "NotEvaluated" } else { "Detected" }) ` + -Title "Default Calendar folder visibility" ` + -Evidence @{ present = $defaultCalendarPermission.Count -gt 0; accessRights = $defaultAccessRights } + + $ownerPermissions = @($script:CalendarFolderPermissions | Where-Object { + @($_.AccessRights | ForEach-Object { [string]$_ }) -contains "Owner" + }) + Add-RbaFinding -Findings $findings -RuleId "RBA802" -Severity Warning ` + -Status $(if (-not $calendarPermissionsAvailable) { "NotEvaluated" } elseif ($ownerPermissions.Count -gt 0) { "Detected" } else { "NotDetected" }) ` + -Title "Owner access is assigned on the resource Calendar folder" ` + -Evidence @{ ownerPermissionCount = $ownerPermissions.Count } + + $directCalendarEditorIdentities = @($script:CalendarFolderPermissions | Where-Object { + $rights = @($_.AccessRights | ForEach-Object { [string]$_ }) + $rights -contains "Editor" -or $rights -contains "Owner" + } | ForEach-Object { Get-RbaPermissionIdentity -PermissionUser $_.User }) + $configuredDelegateCount = if ($settingsAvailable) { + @($script:RbaSettings.ResourceDelegates).Count + } else { 0 } + $delegatesWithoutDirectCalendarAccess = if ($settingsAvailable -and $calendarPermissionsAvailable -and + $script:ResourceDelegateIdentitySetsAvailable) { + @($script:ResourceDelegateIdentitySets | Where-Object { + @($_.aliases | Where-Object { $_ -in $directCalendarEditorIdentities }).Count -eq 0 + }) + } else { @() } + Add-RbaFinding -Findings $findings -RuleId "RBA803" -Severity Warning ` + -Status $(if (-not $settingsAvailable -or -not $calendarPermissionsAvailable -or -not $script:ResourceDelegateIdentitySetsAvailable) { "NotEvaluated" } elseif ($configuredDelegateCount -eq 0) { "NotApplicable" } elseif ($delegatesWithoutDirectCalendarAccess.Count -gt 0) { "Detected" } else { "NotDetected" }) ` + -Title "A resource delegate has no matching direct Calendar Editor permission" ` + -Evidence @{ configuredDelegateCount = $configuredDelegateCount; unmatchedIdentityCount = $delegatesWithoutDirectCalendarAccess.Count } + + Add-RbaFinding -Findings $findings -RuleId "RBA804" -Severity Information ` + -Status $(if (-not $settingsAvailable -or -not $calendarPermissionsAvailable) { "NotEvaluated" } else { "Detected" }) ` + -Title "Calendar visibility and subject post-processing are separate controls" ` + -Evidence @{ defaultAccessRights = $defaultAccessRights; deleteSubject = $RbaSettings.DeleteSubject; addOrganizerToSubject = $RbaSettings.AddOrganizerToSubject; relatedRuleIds = @("RBA602", "RBA603") } + + Add-RbaFinding -Findings $findings -RuleId "RBA805" -Severity Information ` + -Status $(if (-not $settingsAvailable -or -not $calendarPermissionsAvailable) { "NotEvaluated" } else { "Detected" }) ` + -Title "Calendar visibility and private-property removal are separate controls" ` + -Evidence @{ defaultAccessRights = $defaultAccessRights; removePrivateProperty = $RbaSettings.RemovePrivateProperty; relatedRuleIds = @("RBA601") } + + $explicitFullAccessPermissions = @($script:MailboxPermissions | Where-Object { + -not $_.IsInherited -and -not $_.Deny -and + @($_.AccessRights | ForEach-Object { [string]$_ }) -contains "FullAccess" -and + (Get-RbaPermissionIdentity -PermissionUser $_.User) -notin @("nt authority\self", "self") + }) + Add-RbaFinding -Findings $findings -RuleId "RBA820" -Severity Warning ` + -Status $(if (-not $mailboxPermissionsAvailable) { "NotEvaluated" } elseif ($explicitFullAccessPermissions.Count -gt 0) { "Detected" } else { "NotDetected" }) ` + -Title "Explicit Full Access is assigned on the resource mailbox" ` + -Evidence @{ explicitFullAccessCount = $explicitFullAccessPermissions.Count } + + return $findings +} + +function ConvertTo-RbaIdentityList { + param( + [AllowNull()] + [object[]]$Value + ) + + $result = [System.Collections.Generic.List[string]]::new() + foreach ($item in @($Value)) { + $result.Add((Get-RbaSanitizedIdentity -Value $item -PreserveTargetIdentity)) + } + return $result.ToArray() +} + +function Get-RbaSanitizedIdentity { + param( + [AllowNull()] + [object]$Value, + + [switch]$PreserveTargetIdentity + ) + + $identityText = [string]$Value + if ($IncludeSensitiveData) { + return $identityText + } + + $normalizedIdentity = $identityText.Trim().ToLowerInvariant() + if ($PreserveTargetIdentity -and $normalizedIdentity -eq $Identity.Trim().ToLowerInvariant()) { + return $identityText + } + + if (-not [string]::IsNullOrEmpty($normalizedIdentity) -and + $script:SanitizedIdentityMap.ContainsKey($normalizedIdentity)) { + return $script:SanitizedIdentityMap[$normalizedIdentity] + } + + $script:SanitizedIdentitySequence++ + $sanitizedIdentity = "SanitizedIdentity-$($script:SanitizedIdentitySequence)" + if (-not [string]::IsNullOrEmpty($normalizedIdentity)) { + $script:SanitizedIdentityMap.Add($normalizedIdentity, $sanitizedIdentity) + } + # An identity without a stable key receives a unique placeholder for each occurrence. + return $sanitizedIdentity +} + +function Get-RbaMailboxIdentitySummaryObject { + if ($script:collectorStatuses["Mailbox"].status -ne "Success") { + return $null + } + + $primarySmtpAddress = [string]$script:Mailbox.PrimarySmtpAddress + $emailAddresses = @($script:Mailbox.EmailAddresses | ForEach-Object { [string]$_ }) + $normalizedInput = $Identity.Trim() + $proxyAddressMatch = @($emailAddresses | Where-Object { + ($_ -replace '^(?i)smtp:', '') -ieq $normalizedInput + }).Count -gt 0 + $inputIdentityMatch = if (-not [string]::IsNullOrWhiteSpace($primarySmtpAddress) -and + $primarySmtpAddress -ieq $normalizedInput) { + "PrimarySmtpAddress" + } elseif ($proxyAddressMatch) { + "ProxyAddress" + } else { + "OtherResolvedIdentity" + } + + return [PSCustomObject]@{ + objectState = $script:MailboxObjectState + displayName = [string]$script:Mailbox.DisplayName + alias = [string]$script:Mailbox.Alias + primarySmtpAddress = $primarySmtpAddress + inputIdentityMatch = $inputIdentityMatch + emailAddressCount = $emailAddresses.Count + whenCreatedUtc = $(if ($null -ne $script:Mailbox.WhenCreatedUTC) { ([DateTime]$script:Mailbox.WhenCreatedUTC).ToUniversalTime().ToString("o") } else { $null }) + whenChangedUtc = $(if ($null -ne $script:Mailbox.WhenChangedUTC) { ([DateTime]$script:Mailbox.WhenChangedUTC).ToUniversalTime().ToString("o") } else { $null }) + emailAddresses = $emailAddresses + exchangeGuid = [string]$script:Mailbox.ExchangeGuid + externalDirectoryId = [string]$script:Mailbox.ExternalDirectoryObjectId + } +} + +function Get-RbaLogSummaryObject { + if ($script:collectorStatuses["RbaLog"].status -ne "Success") { + return $null + } + + $starts = @($script:RBALog | Select-String -Pattern "START -") + return [PSCustomObject]@{ + entryCount = @($script:RBALog).Count + processedEventCount = $starts.Count + acceptedCount = @($script:RBALog | Select-String -Pattern "Action:Accept").Count + declinedCount = @($script:RBALog | Select-String -Pattern "Action:Decline").Count + tentativeCount = @($script:RBALog | Select-String -Pattern "Action:Tentative").Count + updatedCount = @($script:RBALog | Select-String -Pattern "Begin ProcessUpdateRequest").Count + cancellationCount = @($script:RBALog | Select-String -Pattern "It's a meeting cancellation.").Count + delegateReferralCount = @($script:RBALog | Select-String -Pattern "Forwarding Request To Delegates").Count + skippedExternalCount = @($script:RBALog | Select-String -Pattern "Skipping processing because user settings for processing external items is false.").Count + horizonDeclineCount = @($script:RBALog | Select-String -Pattern "Recurrence ends is past the booking window. Meeting will be declined.").Count + recurrenceTruncateCount = @($script:RBALog | Select-String -Pattern "Truncating meeting recurrence end window").Count + } +} + +function Get-RbaCalendarPermissionSummaryObject { + if ($script:collectorStatuses["CalendarFolderPermissions"].status -ne "Success") { + return $null + } + + $entries = [System.Collections.Generic.List[object]]::new() + foreach ($permission in @($script:CalendarFolderPermissions)) { + $permissionIdentity = Get-RbaPermissionIdentity -PermissionUser $permission.User + $principal = if ($permissionIdentity.Trim() -in @("default", "anonymous") -or $IncludeSensitiveData) { + [string]$permission.User + } else { + Get-RbaSanitizedIdentity -Value $permissionIdentity + } + $entries.Add([PSCustomObject]@{ + principal = $principal + accessRights = @($permission.AccessRights | ForEach-Object { [string]$_ }) + sharingPermissionFlags = @($permission.SharingPermissionFlags | ForEach-Object { [string]$_ }) + }) + } + + return [PSCustomObject]@{ + entryCount = $entries.Count + entries = $entries.ToArray() + } +} + +function Get-RbaMailboxPermissionSummaryObject { + if ($script:collectorStatuses["MailboxPermissions"].status -ne "Success") { + return $null + } + + $fullAccessPermissions = @($script:MailboxPermissions | Where-Object { + -not $_.IsInherited -and -not $_.Deny -and + @($_.AccessRights | ForEach-Object { [string]$_ }) -contains "FullAccess" -and + (Get-RbaPermissionIdentity -PermissionUser $_.User) -notin @("nt authority\self", "self") + }) + $grantees = @($fullAccessPermissions | ForEach-Object { + if ($IncludeSensitiveData) { + [string]$_.User + } else { + Get-RbaSanitizedIdentity -Value (Get-RbaPermissionIdentity -PermissionUser $_.User) + } + }) + + return [PSCustomObject]@{ + explicitFullAccessCount = $fullAccessPermissions.Count + grantees = $grantees + } +} + +function Write-RbaJson { + $successfulCollectors = @($script:collectorStatuses.Values | Where-Object { $_.status -eq "Success" }).Count + $collectionStatus = if ($successfulCollectors -eq $script:collectorStatuses.Count) { + "Complete" + } elseif ($successfulCollectors -eq 0) { + "Failed" + } else { + "Partial" + } + + $inboxRules = if ($script:collectorStatuses["InboxRules"].status -eq "Success") { + [PSCustomObject]@{ + totalCount = @($script:InboxRules).Count + delegateRuleCount = @($script:InboxRules | Where-Object { $_.Name -like "Delegate Rule*" }).Count + redactedCount = @($script:InboxRules | Where-Object { $_.Name -like "REDACTED-*" }).Count + } + } else { $null } + if ($IncludeSensitiveData -and $null -ne $inboxRules) { + $inboxRules | Add-Member -MemberType NoteProperty -Name ruleNames -Value @($script:InboxRules.Name) + } + + $calendarProcessing = if ($script:collectorStatuses["CalendarProcessing"].status -eq "Success") { + [PSCustomObject]@{ + automateProcessing = $RbaSettings.AutomateProcessing + allowConflicts = $RbaSettings.AllowConflicts + allowDistributionGroup = $RbaSettings.AllowDistributionGroup + allowMultipleResources = $RbaSettings.AllowMultipleResources + maximumDurationInMinutes = $RbaSettings.MaximumDurationInMinutes + minimumDurationInMinutes = $RbaSettings.MinimumDurationInMinutes + allowRecurringMeetings = $RbaSettings.AllowRecurringMeetings + scheduleOnlyDuringWorkHours = $RbaSettings.ScheduleOnlyDuringWorkHours + processExternalMeetingMessages = $RbaSettings.ProcessExternalMeetingMessages + bookingWindowInDays = $RbaSettings.BookingWindowInDays + conflictPercentageAllowed = $RbaSettings.ConflictPercentageAllowed + maximumConflictInstances = $RbaSettings.MaximumConflictInstances + maximumConflictPercentage = $RbaSettings.MaximumConflictPercentage + enforceSchedulingHorizon = $RbaSettings.EnforceSchedulingHorizon + enforceCapacity = $RbaSettings.EnforceCapacity + requestOutOfPolicy = ConvertTo-RbaIdentityList -Value $RbaSettings.RequestOutOfPolicy + allRequestOutOfPolicy = $RbaSettings.AllRequestOutOfPolicy + bookInPolicy = ConvertTo-RbaIdentityList -Value $RbaSettings.BookInPolicy + allBookInPolicy = $RbaSettings.AllBookInPolicy + requestInPolicy = ConvertTo-RbaIdentityList -Value $RbaSettings.RequestInPolicy + allRequestInPolicy = $RbaSettings.AllRequestInPolicy + resourceDelegates = ConvertTo-RbaIdentityList -Value $RbaSettings.ResourceDelegates + addNewRequestsTentatively = $RbaSettings.AddNewRequestsTentatively + forwardRequestsToDelegates = $RbaSettings.ForwardRequestsToDelegates + addOrganizerToSubject = $RbaSettings.AddOrganizerToSubject + deleteSubject = $RbaSettings.DeleteSubject + deleteComments = $RbaSettings.DeleteComments + deleteAttachments = $RbaSettings.DeleteAttachments + removePrivateProperty = $RbaSettings.RemovePrivateProperty + deleteNonCalendarItems = $RbaSettings.DeleteNonCalendarItems + removeForwardedMeetingNotifications = $RbaSettings.RemoveForwardedMeetingNotifications + removeCanceledMeetings = $RbaSettings.RemoveCanceledMeetings + enableAutoRelease = $RbaSettings.EnableAutoRelease + addAdditionalResponse = $RbaSettings.AddAdditionalResponse + } + } else { $null } + if ($IncludeSensitiveData -and $null -ne $calendarProcessing) { + $calendarProcessing | Add-Member -MemberType NoteProperty -Name additionalResponse -Value $RbaSettings.AdditionalResponse + } + + $mailboxIdentitySummary = Get-RbaMailboxIdentitySummaryObject + $mailboxSummary = if ($null -ne $mailboxIdentitySummary) { + $summary = [PSCustomObject]@{ + identity = $Identity + recipientTypeDetails = $script:Mailbox.RecipientTypeDetails + resourceType = $script:Mailbox.ResourceType + objectState = $mailboxIdentitySummary.objectState + displayName = $mailboxIdentitySummary.displayName + alias = $mailboxIdentitySummary.alias + primarySmtpAddress = $mailboxIdentitySummary.primarySmtpAddress + inputIdentityMatch = $mailboxIdentitySummary.inputIdentityMatch + emailAddressCount = $mailboxIdentitySummary.emailAddressCount + whenCreatedUtc = $mailboxIdentitySummary.whenCreatedUtc + whenChangedUtc = $mailboxIdentitySummary.whenChangedUtc + } + if ($IncludeSensitiveData) { + $summary | Add-Member -MemberType NoteProperty -Name emailAddresses -Value $mailboxIdentitySummary.emailAddresses + $summary | Add-Member -MemberType NoteProperty -Name exchangeGuid -Value $mailboxIdentitySummary.exchangeGuid + $summary | Add-Member -MemberType NoteProperty -Name externalDirectoryId -Value $mailboxIdentitySummary.externalDirectoryId + } + $summary + } else { $null } + + $data = [ordered]@{ + metadata = [ordered]@{ + schemaVersion = "1.0-preview" + scriptVersion = $BuildVersion + collectedAtUtc = (Get-Date).ToUniversalTime().ToString("o") + identity = $Identity + commandLine = $script:InvocationCommandLine + collectionStatus = $collectionStatus + privacyMode = $(if ($IncludeSensitiveData) { "Full" } elseif (-not [string]::IsNullOrWhiteSpace($Subject) -or -not [string]::IsNullOrWhiteSpace($MeetingId)) { "TargetedMeeting" } else { "Sanitized" }) + } + collectors = $script:collectorStatuses + mailbox = $mailboxSummary + place = $(if ($script:collectorStatuses["Place"].status -eq "Success") { + [PSCustomObject]@{ + city = $script:Place.City + floor = $script:Place.Floor + capacity = $script:Place.Capacity + roomListCount = @($script:Place.Localities).Count + } + } else { $null }) + calendarProcessing = $calendarProcessing + calendarPermissions = Get-RbaCalendarPermissionSummaryObject + mailboxPermissions = Get-RbaMailboxPermissionSummaryObject + inboxRules = $inboxRules + rbaLogSummary = Get-RbaLogSummaryObject + meetingLogSearch = $script:MeetingLogSearch + findings = @(Get-RbaFindings) + collectionErrors = @($script:collectionErrors) + evaluationErrors = @($script:evaluationErrors) + } + + if ($IncludeSensitiveData) { + if ($null -ne $data.place) { + $data.place | Add-Member -MemberType NoteProperty -Name roomLists -Value @($script:Place.Localities) + } + $data.fullRbaLog = @($script:RBALog) + if (Test-Path -Path $SummaryFilename) { + $data.transcript = Get-Content -Path $SummaryFilename -Raw + } + } + + $json = $data | ConvertTo-Json -Depth 8 -ErrorAction Stop + Set-Content -Path $JsonFilename -Value $json -Encoding utf8 -ErrorAction Stop +} + +try { + # Mailbox existence and type are prerequisites for all RBA collection. + Invoke-RbaCollectorOperation -Name "Mailbox" -Action { CollectMailbox } + if ($script:collectorStatuses["Mailbox"].status -ne "Success") { + Write-Host -ForegroundColor Red "Unable to resolve '$Identity' to a mailbox. Stopping." + return + } + if ($script:Mailbox.RecipientTypeDetails -notin @("RoomMailbox", "EquipmentMailbox")) { + return + } + + # Attempt every remaining independent collector before running dependent evaluations. + Invoke-RbaCollectorOperation -Name "Place" -Action { CollectPlace } + Invoke-RbaCollectorOperation -Name "InboxRules" -Action { ValidateInboxRules } + Invoke-RbaCollectorOperation -Name "CalendarProcessing" -Action { GetCalendarProcessing } + Invoke-RbaCollectorOperation -Name "CalendarFolderPermissions" -Action { CollectCalendarFolderPermissions } + Invoke-RbaCollectorOperation -Name "MailboxPermissions" -Action { CollectMailboxPermissions } + Invoke-RbaCollectorOperation -Name "RbaLog" -Action { CollectRBALog } + $script:MeetingLogSearch = Get-RbaMeetingLogSearchObject + + if ($script:collectorStatuses["CalendarProcessing"].status -eq "Success") { + Invoke-RbaEvaluation -Name "Resource delegate identity enrichment" -Action { Initialize-RbaResourceDelegateIdentitySets } + Invoke-RbaEvaluation -Name "Calendar processing" -Action { EvaluateCalProcessing } + if ($script:collectorStatuses["Mailbox"].status -eq "Success" -and + (-not $script:Workspace -or $script:collectorStatuses["Place"].status -eq "Success")) { + Invoke-RbaEvaluation -Name "Workspace" -Action { ValidateWorkspace } + } + ProcessingLogic + Invoke-RbaEvaluation -Name "Policy criteria" -Action { RBACriteria } + Invoke-RbaEvaluation -Name "Processing routes" -Action { RBAProcessingValidation } + Invoke-RbaEvaluation -Name "In-policy processing" -Action { InPolicyProcessing } + Invoke-RbaEvaluation -Name "Out-of-policy processing" -Action { OutOfPolicyProcessing } + Invoke-RbaEvaluation -Name "Delegate settings" -Action { RBADelegateSettings } + Invoke-RbaEvaluation -Name "Post-processing" -Action { RBAPostProcessing; VerbosePostProcessing } + } else { + Write-Warning "Calendar processing evaluations were skipped because required evidence is unavailable." + } + + if ($script:collectorStatuses["Place"].status -eq "Success") { + Invoke-RbaEvaluation -Name "Room list settings" -Action { ValidateRoomListSettings } + } else { + Write-Warning "Place evaluations were skipped because required evidence is unavailable." + } + + Invoke-RbaEvaluation -Name "RBA log summary" -Action { RBALogSummary } +} catch { + $errorInfo = ConvertTo-RbaErrorInfo -ErrorRecord $_ + $script:evaluationErrors.Add([PSCustomObject]@{ + evaluation = "Unhandled script operation" + message = $errorInfo.message + exceptionType = $errorInfo.exceptionType + category = $errorInfo.category + fullyQualifiedErrorId = $errorInfo.fullyQualifiedErrorId + innerExceptionMessage = $errorInfo.innerExceptionMessage + }) + Write-Warning "An unexpected reporting error occurred: $($errorInfo.message)" +} finally { + if ($script:TranscriptStarted) { + Stop-Transcript | Out-Null + $script:TranscriptStarted = $false + } +} + +try { + Write-RbaJson + Write-Host -ForegroundColor Cyan "`r`nRBA JSON Output saved as [$JsonFilename] in the current directory." +} catch { + $errorInfo = ConvertTo-RbaErrorInfo -ErrorRecord $_ + Write-Warning "Unable to write RBA JSON output '$JsonFilename': $($errorInfo.message)" +} + +$outputFileLines = [System.Collections.Generic.List[string]]::new() +$outputFileLines.Add("RBA output files:") +$outputFileLines.Add(" Text summary: [$SummaryFilename]") +if (Test-Path -Path $JsonFilename) { + $outputFileLines.Add(" JSON report: [$JsonFilename]") +} +if (-not [string]::IsNullOrWhiteSpace($script:RbaLogFilename) -and (Test-Path -Path $script:RbaLogFilename)) { + $outputFileLines.Add(" RBA logs: [$script:RbaLogFilename]") +} +Write-Host +$outputFileLines | ForEach-Object { Write-Host -ForegroundColor Cyan $_ } +if (Test-Path -Path $SummaryFilename) { + Add-Content -Path $SummaryFilename -Value ([Environment]::NewLine + ($outputFileLines -join [Environment]::NewLine)) -Encoding utf8 +} + +$skillUrl = "https://github.com/microsoft/CSS-Exchange/releases/latest/download/EXO-RBA-Troubleshooting-SKILL.md" +Write-Host "`r`nBeta Feature: The EXO RBA troubleshooting skill is currently in beta." -ForegroundColor Yellow +Write-Host "Review its analysis and recommendations before making configuration changes." +Write-Host "Tenant admins can install and use the EXO RBA troubleshooting skill for deeper analysis:" +Write-Host -ForegroundColor Cyan $skillUrl +Write-Host "Send feedback to CalLogFormatterDevs@microsoft.com" diff --git a/Calendar/RBA/exo-rba-troubleshooting/RBA-Rules.md b/Calendar/RBA/exo-rba-troubleshooting/RBA-Rules.md new file mode 100644 index 0000000000..748e5e96e1 --- /dev/null +++ b/Calendar/RBA/exo-rba-troubleshooting/RBA-Rules.md @@ -0,0 +1,251 @@ +# EXO RBA finding rules + +These IDs are stable within the `1.0-preview` report schema. Number ranges identify the source or logic area and intentionally leave room for new rules. + +| Range | Rule group | +|---|---| +| RBA000–RBA099 | Script execution and evidence collection | +| RBA100–RBA199 | Mailbox validation | +| RBA200–RBA299 | Inbox rules | +| RBA300–RBA399 | Calendar processing and policy configuration | +| RBA400–RBA499 | Delegate and request routing | +| RBA500–RBA599 | Place, Workspace, and Room Finder | +| RBA600–RBA699 | Meeting post-processing | +| RBA700–RBA799 | RBA diagnostic logs | +| RBA800–RBA829 | Permissions and visibility | +| RBA830–RBA899 | Reserved for Calendar Diagnostic Log observations | +| RBA900–RBA999 | Reserved for additional RBA logic groups | + +- `Detected` means the documented condition was observed. +- `NotDetected` means the rule was applicable and its condition was not observed. +- `NotEvaluated` means required collector evidence was unavailable. It must not be treated as healthy or unhealthy. +- `NotApplicable` means the evidence was available, but the rule does not apply to this resource or routing configuration. + +## Script execution and evidence collection (RBA000–RBA099) + +Collector failures are evidence gaps, not configuration root causes. Fix collection before drawing conclusions that depend on the missing data. + +| Rule ID | Severity | Applicability and supported interpretation | Required evidence | +|---|---|---|---| +| RBA001 | Error | Mailbox collection failed; mailbox-dependent configuration cannot be assessed. | `collectors.Mailbox.error` | +| RBA002 | Error | Place collection failed; Place-dependent checks cannot be completed. | `collectors.Place.error` | +| RBA003 | Error | Inbox-rule collection failed; blocking delegate rules cannot be ruled out. | `collectors.InboxRules.error` | +| RBA004 | Error | Calendar-processing collection failed; core RBA configuration cannot be assessed. | `collectors.CalendarProcessing.error` | +| RBA005 | Warning | RBA log collection failed; recent processing behavior cannot be assessed. | `collectors.RbaLog.error` | +| RBA006 | Warning | Calendar folder permission collection failed; Calendar visibility, Owner access, and direct delegate access cannot be assessed. | `collectors.CalendarFolderPermissions.error` | +| RBA007 | Warning | Mailbox permission collection failed; explicit Full Access grants cannot be assessed. | `collectors.MailboxPermissions.error` | + +## Mailbox validation (RBA100–RBA199) + +| Rule ID | Severity | Applicability and supported interpretation | Required evidence | +|---|---|---|---| +| RBA100 | Critical | Applicable when mailbox evidence is available. The target is not a room or equipment mailbox, so RBA is unsupported. | `mailbox.recipientTypeDetails` | +| RBA101 | Critical | Applicable when mailbox evidence is available. The active lookup failed and the same identity resolved only with Exchange Online's soft-deleted-mailbox lookup. A soft-deleted resource is recoverable but cannot perform active RBA processing. This does not choose or perform a recovery action. | `mailbox.objectState` | +| RBA102 | Information | Detected when the supplied identity resolved to the active or soft-deleted mailbox through one of its proxy addresses instead of its current primary SMTP address. This can confirm that an old address still resolves to the current object, but it does not prove that a rename occurred, when an address changed, or which address an existing meeting used. | `mailbox.inputIdentityMatch`, `mailbox.primarySmtpAddress` | + +The mailbox summary also reports the current display name, alias, primary SMTP address, proxy-address count, and available creation/change timestamps. Full privacy mode adds the target mailbox's proxy addresses and stable Exchange identifiers for protected comparison. These are current-state observations: `WhenChangedUTC` does not identify which property changed, and one resolved mailbox does not rule out duplicate or stale objects elsewhere in the tenant. + +## Inbox rules (RBA200–RBA299) + +| Rule ID | Severity | Applicability and supported interpretation | Required evidence | +|---|---|---|---| +| RBA200 | Critical | Applicable when inbox-rule evidence is available. A user-style delegate inbox rule can block RBA processing. | `inboxRules.delegateRuleCount` | +| RBA201 | Warning | Applicable when inbox-rule evidence is available. Redacted names make delegate-rule validation incomplete; this does not prove that a blocking rule exists. | `inboxRules.redactedCount` | + +## Calendar processing and policy configuration (RBA300–RBA399) + +| Rule ID | Severity | Applicability and supported interpretation | Required evidence | +|---|---|---|---| +| RBA300 | Critical | Applicable when calendar-processing evidence is available. `AutomateProcessing` is not `AutoAccept`, so RBA will not process requests as expected. | `calendarProcessing.automateProcessing` | +| RBA301 | Critical | Applicable when calendar-processing evidence is available. No booking or referral route is configured for either policy class. | The six `BookInPolicy`, `RequestInPolicy`, and `RequestOutOfPolicy` list/all-user settings | +| RBA302 | Information | Always detected when calendar-processing evidence is available. `BookingWindowInDays` is the maximum advance-booking window; `0` means today and the supported maximum is 1,080 days. A request's dates are needed to determine whether this policy affected it. | `calendarProcessing.bookingWindowInDays`, `calendarProcessing.allowRecurringMeetings`, `calendarProcessing.enforceSchedulingHorizon` | +| RBA303 | Information | Detected when `MaximumDurationInMinutes` is greater than `0`, which limits each meeting or recurring instance to that duration. `0` means unlimited and is `NotDetected`. A request's duration is needed to determine whether this policy affected it. | `calendarProcessing.maximumDurationInMinutes` | +| RBA304 | Warning | Detected when `AllowRecurringMeetings` is false. Recurring requests aren't allowed; this setting says nothing about one specific request unless recurrence evidence is available. | `calendarProcessing.allowRecurringMeetings` | +| RBA305 | Information | Applicable to resources that allow recurring meetings. Detected when `EnforceSchedulingHorizon` is true: a series that starts within the booking window but extends beyond it is declined. | `calendarProcessing.enforceSchedulingHorizon`, `calendarProcessing.bookingWindowInDays`, `calendarProcessing.allowRecurringMeetings` | +| RBA306 | Information | Applicable to resources that allow recurring meetings. Detected when `EnforceSchedulingHorizon` is false: a series that starts within the booking window can be accepted, but its recurrence is truncated at the window and nothing beyond that boundary exists on the resource calendar. RBA705 provides log evidence when this actually occurred. | `calendarProcessing.enforceSchedulingHorizon`, `calendarProcessing.bookingWindowInDays`, `calendarProcessing.allowRecurringMeetings` | +| RBA307 | Warning | Detected when `ScheduleOnlyDuringWorkHours` is true. Requests outside the resource mailbox's configured work days, hours, or time zone are rejected. This report doesn't collect those work-hour values or prove that a request was outside them. | `calendarProcessing.scheduleOnlyDuringWorkHours` | +| RBA308 | Information | Detected when `AllowConflicts` is true. All conflicts are accepted regardless of the percentage/count settings, which are not evaluated. This is required behavior for Workspaces when combined with capacity enforcement, but it can permit overlapping reservations on other resources. This configuration does not prove a particular double booking. | `calendarProcessing.allowConflicts`, both recurring-conflict thresholds | +| RBA309 | Information | Applicable when recurring meetings are enabled and `AllowConflicts` is false. Detected when `ConflictPercentageAllowed` is greater than `0`; a new recurring series is declined if its conflicting-occurrence percentage is higher than this value. `0` permits no recurring conflicts. | `calendarProcessing.allowRecurringMeetings`, `calendarProcessing.allowConflicts`, `calendarProcessing.conflictPercentageAllowed` | +| RBA310 | Information | Applicable when recurring meetings are enabled and `AllowConflicts` is false. Detected when `MaximumConflictInstances` is greater than `0`; a new recurring series is declined if its conflicting-occurrence count is higher than this value. `0` permits no recurring conflicts. | `calendarProcessing.allowRecurringMeetings`, `calendarProcessing.allowConflicts`, `calendarProcessing.maximumConflictInstances` | +| RBA311 | Warning | Detected when `ProcessExternalMeetingMessages` is false. RBA doesn't process items that Transport classified as external. Classification is based on a property stamped during routing and delivery, not merely a comparison of sender and accepted domains. Unlike RBA700, this configuration finding does not prove that an external request was received or skipped. | `calendarProcessing.processExternalMeetingMessages` | + +For `RBA309` and `RBA310`, the series is declined when either configured limit is exceeded. If neither limit is exceeded, the series can be accepted while conflicting occurrences are declined. Use meeting-level evidence and the RBA response/log before attributing a particular outcome to these limits. + +Read-only verification for this section: + +```powershell +Get-CalendarProcessing -Identity | Format-List AutomateProcessing,BookingWindowInDays,MaximumDurationInMinutes,AllowRecurringMeetings,EnforceSchedulingHorizon,ScheduleOnlyDuringWorkHours,AllowConflicts,ConflictPercentageAllowed,MaximumConflictInstances,ProcessExternalMeetingMessages +``` + +When `RBA307` is detected, also verify the resource's work-hour inputs: + +```powershell +Get-MailboxCalendarConfiguration -Identity | Format-List WorkDays,WorkingHoursStartTime,WorkingHoursEndTime,WorkingHoursTimeZone +``` + +## Delegate and request routing (RBA400–RBA499) + +| Rule ID | Severity | Applicability and supported interpretation | Required evidence | +|---|---|---|---| +| RBA400 | Information | Applicable only when no delegate route is needed: no delegates, all in-policy requests auto-book, and no out-of-policy requests can be referred. This is expected behavior, not a fault. | Delegate count, `allBookInPolicy`, `allRequestOutOfPolicy`, out-of-policy requester count | +| RBA401 | Warning | Applicable when no delegates exist. In-policy forwarding is enabled for requests that do not auto-book, so those requests have no delegate recipient. | Delegate count, `forwardRequestsToDelegates`, `allBookInPolicy` | +| RBA402 | Error | Applicable when no delegates exist. Listed users can submit out-of-policy requests, but no delegate can decide them; the script warns they may remain tentatively accepted. | Delegate and out-of-policy requester counts | +| RBA403 | Error | Applicable when no delegates exist. All users can submit out-of-policy requests, but no delegate can decide them; the script warns they may remain tentatively accepted. | Delegate count, `allRequestOutOfPolicy` | +| RBA410 | Warning | Applicable when delegates exist. `AddNewRequestsTentatively` is false, so the Calendar Attendant only updates existing calendar items instead of adding new requests tentatively for review. | Delegate count, `calendarProcessing.addNewRequestsTentatively` | +| RBA411 | Information | Applicable when delegates exist and forwarding is enabled. `AllBookInPolicy` auto-books every in-policy request, so delegates do not receive in-policy requests. This is expected routing behavior. | Delegate count, `forwardRequestsToDelegates`, `allBookInPolicy` | +| RBA412 | Information | Applicable when delegates exist, forwarding is enabled, and `AllBookInPolicy` is false. Listed `BookInPolicy` users bypass delegate review because their in-policy requests auto-book. | Delegate count, forwarding setting, all-book setting, `bookInPolicy` count | +| RBA420 | Warning | Applicable when delegates exist and forwarding is enabled. Neither all users nor listed users may submit out-of-policy requests, so delegates receive none; such requests are denied. | Delegate count, forwarding setting, `allRequestOutOfPolicy`, requester count | +| RBA421 | Information | Applicable when delegates exist, forwarding is enabled, and all-user out-of-policy referral is disabled. Only listed users can send out-of-policy requests to delegates. | Delegate count, forwarding setting, `allRequestOutOfPolicy`, requester count | +| RBA422 | Information | Applicable when delegates exist and forwarding is enabled. All users can submit out-of-policy requests for delegate review. | Delegate count, forwarding setting, `allRequestOutOfPolicy` | +| RBA423 | Warning | Applicable when `AllRequestOutOfPolicy` is true. A non-empty `RequestOutOfPolicy` list is overridden because all users are allowed to submit. | `allRequestOutOfPolicy`, requester count | + +## Place, Workspace, and Room Finder (RBA500–RBA599) + +| Rule ID | Severity | Applicability and supported interpretation | Required evidence | +|---|---|---|---| +| RBA500 | Error | Workspace only. Capacity is required but missing. Non-workspaces are `NotApplicable`. | `mailbox.resourceType`, `place.capacity` | +| RBA501 | Error | Workspace only. `EnforceCapacity` and `AllowConflicts` are not both enabled. Non-workspaces are `NotApplicable`. | `mailbox.resourceType`, `calendarProcessing.enforceCapacity`, `calendarProcessing.allowConflicts` | +| RBA510 | Warning | Adjacent Room Finder discovery check, not a core RBA blocker. The resource is not associated with a room list. | `place.roomListCount` | +| RBA511 | Warning | Adjacent Room Finder discovery check, not a core RBA blocker. City, Floor, or Capacity is missing. | Missing names derived from `place.city`, `place.floor`, and `place.capacity` | + +## Meeting post-processing (RBA600–RBA699) + +| Rule ID | Severity | Applicability and supported interpretation | Required evidence | +|---|---|---|---| +| RBA600 | Warning | Applicable when calendar-processing evidence is available. Deleting meeting comments can remove Teams join information from the body. | `calendarProcessing.deleteComments` | +| RBA601 | Warning | Detected when `RemovePrivateProperty` is true. RBA clears the private flag from incoming meetings; false preserves it. Calendar permissions still determine what viewers can see. | `calendarProcessing.removePrivateProperty` | +| RBA602 | Information | Detected when `DeleteSubject` is true. RBA removes the original subject during `AutoAccept` processing. Evaluate it with `AddOrganizerToSubject`; this does not explain subject visibility caused by Calendar folder permissions. | `calendarProcessing.deleteSubject`, `calendarProcessing.addOrganizerToSubject` | +| RBA603 | Information | Detected when `AddOrganizerToSubject` is true. RBA replaces the existing subject with the organizer's name during `AutoAccept` processing; false preserves the original subject unless another setting removes it. | `calendarProcessing.addOrganizerToSubject`, `calendarProcessing.deleteSubject` | +| RBA604 | Information | Detected when `RemoveCanceledMeetings` is false. Organizer-canceled meetings are retained on the resource calendar; true automatically deletes them. The setting is Exchange Online only. | `calendarProcessing.removeCanceledMeetings` | + +The post-processing settings in this section describe configured behavior, not proof that RBA changed a particular calendar item. `RBA604` can explain why an organizer-canceled item is retained, but it cannot identify former organizers, establish that an item is stale, or prove that a cancellation reached the resource. Confirm `AutomateProcessing` is `AutoAccept`, then correlate item-level evidence before assigning causality. Default Calendar folder permissions commonly expose only availability; subject visibility requires at least `LimitedDetails` and must not be inferred from `DeleteSubject` alone. + +Read-only verification for this section: + +```powershell +Get-CalendarProcessing -Identity | Format-List AutomateProcessing,RemovePrivateProperty,DeleteSubject,AddOrganizerToSubject,RemoveCanceledMeetings +``` + +## RBA diagnostic logs (RBA700–RBA799) + +| Rule ID | Severity | Applicability and supported interpretation | Required evidence | +|---|---|---|---| +| RBA700 | Warning | Applicable when RBA log evidence is available. One or more external requests were skipped because external processing was disabled. This is an observation, not proof of a transport problem. | `rbaLogSummary.skippedExternalCount` | +| RBA701 | Warning | Applicable when RBA log collection succeeds. No usable log history was returned; send a future test meeting before relying on log analysis. | `rbaLogSummary.entryCount` | +| RBA702 | Warning | Applicable only when usable RBA log history exists. No accept, decline, or tentative action was observed in that history. | RBA log entry and processed-action counts | +| RBA703 | Warning | Applicable only when usable RBA log history exists. No meeting update operation was observed in that history. | RBA log entry and update counts | +| RBA704 | Warning | Applicable when RBA log evidence is available. The log explicitly records one or more recurring requests whose recurrence end exceeded `BookingWindowInDays` and whose entire request was declined. Correlate by global object identifier, subject, and timestamp before applying this observation to a reported meeting. | `rbaLogSummary.horizonDeclineCount` | +| RBA705 | Warning | Applicable when RBA log evidence is available. The log explicitly records one or more recurring requests whose recurrence was truncated at the booking-window boundary. Correlate by global object identifier, subject, and timestamp before applying this observation to a reported meeting. | `rbaLogSummary.recurrenceTruncateCount` | +| RBA710 | Warning | Applicable when `-Subject` or `-MeetingId` was supplied and RBA log collection succeeded. The requested value was not found in the retained log. This does not prove that the meeting was never processed because bounded history can roll off. | `meetingLogSearch.searchType`, `meetingLogSearch.status`, `meetingLogSearch.subjectMatchCount` | +| RBA711 | Information | Applicable when a targeted search succeeds. A subject search discovers meeting IDs and then switches to ID-only correlation; a direct meeting-ID search skips subject discovery. All retained blocks carrying a resolved ID are included even when update or cancellation blocks omit the subject. | `meetingLogSearch.searchType`, subject, requested meeting ID, resolved meeting IDs, and event counts | +| RBA712 | Information | Applicable only to a successful targeted search. One or more correlated blocks contain the exact `Begin ProcessUpdateRequest` marker. This establishes that RBA logged update processing for the correlated meeting ID; it does not prove the final resource-calendar state. | `meetingLogSearch.updateCount`, targeted event markers and raw log | +| RBA713 | Information | Applicable only to a successful targeted search. One or more correlated blocks contain the exact `It's a meeting cancellation.` marker. This establishes that RBA recognized cancellation processing for the correlated meeting ID; interpret the resulting item state with `RemoveCanceledMeetings` and Calendar Diagnostic Logs. | `meetingLogSearch.cancellationCount`, targeted event markers and raw log | +| RBA714 | Warning | Applicable when the subject matched retained log text but no meeting ID could be extracted from the matching block. Only subject-matching blocks are included, and later blocks cannot be safely correlated. | `meetingLogSearch.status`, `meetingLogSearch.subjectMatchCount` | +| RBA715 | Information | Applicable only to a successful targeted search. One or more correlated blocks contain an explicit `Action:Decline` marker. This proves an RBA decline action was logged for a correlated block, but the reason remains unknown unless an approved reason marker occurs in that same block. | `meetingLogSearch.declineCount`, targeted event actions and raw log | + +`RBA703` reports only whether the available RBA log contains any update operations. The report's aggregate `updatedCount` and `cancellationCount` cannot establish whether one reported update or cancellation reached or changed the resource item. Likewise, `RBA704` and `RBA705` explain recorded horizon outcomes but do not establish that a current recurring series is orphaned. Specific-item propagation, organizer state, calendar state, and recurrence exceptions require Calendar Diagnostic Log evidence correlated by meeting ID, subject, and timestamp. + +## Targeted meeting log analysis + +`meetingLogSearch` has status `NotRequested`, zero counts, and empty meeting ID and event collections when the administrator supplies neither `-Subject` nor `-MeetingId`. Findings `RBA710`–`RBA715` are then `NotApplicable`. A subject search is a case-insensitive literal substring search used to discover meeting IDs, after which correlation uses only those IDs. A direct meeting-ID search skips discovery. The exported source is newest-first, with the newest line at the top. RBA is single-threaded, so contiguous extraction is intentional: a processing block contains every line after the preceding newer exact boundary through and including its own row ending `START - HandleEventInternal Automatic Booking is enabled for resource.` at the bottom. Generic `START -` rows are not boundaries. + +The `events` array preserves top-down source order, so sequence 1 is the newest retained processing unit. Each event's `rawLog` also preserves newest-first source text and must be read bottom-up for chronology. Across events, traverse highest sequence to lowest for chronological history. Printed timestamps corroborate the rows on which they occur but must not be used to reorder equal or ambiguous rows. `startMarker` is the complete exact START row, while `startTimeText` and compatibility field `eventTimeText` contain that row's timestamp—not an END or result timestamp. `startBoundaryFound` and `boundaryStatus` expose whether an exact lower boundary was retained; evidence with `MissingStartBoundary` is partial and must not be attached to a neighboring event. + +Meeting IDs are extracted from recognized `CleanGlobalObjectId`, `GlobalObjectId`, `Global Object Id`, `MeetingId`, `Meeting ID`, and `UID` labels; the exact `Begin ProcessRequest Goid:` and `Begin ProcessUpdateRequest Goid:` formats are also recognized. A comma after the documented `040000008` prefix is removed for correlation, while the unchanged source remains in `rawLog`. The report includes every retained block containing an extracted normalized ID. A subject can resolve to multiple meeting IDs; never merge their timelines or assume that identical subjects identify one meeting. + +Use the manual log-reference phases narrowly: + +- **Entry and classification:** exact START, process-request or process-update GOID, received subject, and exact request/cancellation/non-meeting classification markers. +- **Policy evaluation:** evaluation start, conflict count, completed policy check, and explicit in-policy or not-in-policy text. These identify only the stated evaluation evidence. +- **Decision:** explicit evaluation result, `Action:*`, delegate-forwarding, and delegate-message-count markers. +- **Post-processing and end:** explicit begin/completed post-processing and documented acceptance or tentative END markers. No universal completion marker is assumed. +- **Recurrence, Workspace, and external handling:** only the exact horizon-decline, truncation, capacity, and external-skip markers support those observations. + +Read one raw block chronologically as START at the bottom, then entry/classification, policy evaluation, decision, optional post-processing, and finally the newest result or documented END line toward the top. Preserve the raw text and cite it in exported order rather than rewriting it. + +The targeted event fields are deterministic observations of exact markers. Current `1.0-preview` reports emit every field in this table on each targeted event. Before citing a field, verify that the property exists in the supplied event object. If an older or malformed report omits it, do not invent or cite the missing property. An approved marker in that event's `rawLog` may be quoted only as an explicitly labeled raw-only observation; otherwise the value is unavailable. + +| Emitted event field | Required marker | +|---|---| +| `actions` | `Action:Accept`, `Action:Decline`, or `Action:Tentative` | +| `policyResult` | `Defaulting to in policy.` or `Not in policy.` | +| `disposition` | `Meeting request evaluate returns result ` or an `Action:` marker | +| `updateDetected` | `Begin ProcessUpdateRequest` | +| `cancellationDetected` | `It's a meeting cancellation.` | +| `delegateReferralDetected` | `Forwarding Request To Delegates` | +| `delegateMessageCount` | `Sending approval messages to delegates.` | +| `tentativeResponseSent` | `END - Sending the tentatively acceptance response to organizer.` | +| `externalProcessingSkipped` | `Skipping processing because user settings for processing external items is false.` | +| `horizonDeclineDetected` | `Recurrence ends is past the booking window. Meeting will be declined.` | +| `recurrenceTruncateDetected` | `Truncating meeting recurrence end window` | + +Treat the targeted raw blocks as sensitive meeting evidence even when the rest of the report is sanitized. `TargetedMeeting` privacy mode records this explicit scope. Cite the event sequence and exact raw marker when describing an observation. Do not infer delivery, final calendar state, rescheduling, conflict, policy route, or decline reason from the absence of a marker. + +## Future decline evidence contract + +A deterministic answer to "why did this meeting decline?" requires a per-processing-block decoder, not a comparison between a historical action and current configuration. Future schema revisions should expose the following bounded fields for each meeting ID and event. Each condition must use `Observed`, `NotObserved`, or `Unknown`, include the exact source marker and event sequence, and remain `Unknown` when no approved marker exists. + +| Structured evidence | Required meaning | +|---|---| +| `policyRoute` | The route RBA explicitly selected, such as in-policy, out-of-policy, or delegate decision. | +| `conflict` | Whether RBA explicitly detected a conflict and, for recurrence, the observed instance or percentage result. | +| `bookingHorizon` | Whether meeting or recurrence dates exceeded the booking window and whether RBA declined or truncated the request. | +| `duration` | Whether RBA explicitly compared the meeting duration with the configured limit and found it exceeded. | +| `workingHours` | Whether RBA explicitly found the request outside the resource's work-hour policy. | +| `recurrence` | Whether recurrence was allowed and any explicit recurrence restriction, conflict, truncation, or rejection. | +| `externalMessage` | Whether RBA treated the message as external and processed or skipped it. | +| `delegateReferral` | Whether the request was explicitly referred to delegates. | +| `action` | The actual `Accept`, `Decline`, or `Tentative` action in that processing block. | +| `reason` | An approved reason classification backed by an exact marker in the same block as the action. | + +Until that decoder exists, the skill may explain only approved exact markers already documented above. Current `CalendarProcessing` values can corroborate how a rule normally behaves, but cannot supply the historical reason for a targeted decline. If a block contains `Action:Decline` without an approved reason marker, report "decline observed; reason not established by the current decoder." + +## Permissions and visibility (RBA800–RBA829) + +This family intentionally does not collapse all access-related behavior into "room permissions." Calendar folder visibility, mailbox access, booking eligibility, booking-delegate routing, and RBA post-processing are separate control planes. + +| Rule ID | Severity | Applicability and supported interpretation | Required evidence | +|---|---|---|---| +| RBA801 | Information | Always detected when Calendar folder permission evidence is available. Reports whether the `Default` principal is present and its access rights. This is a visibility observation, not a booking-eligibility decision. | `calendarPermissions.entries` filtered to `Default` | +| RBA802 | Warning | Detected when any Calendar folder entry has `Owner` access. Owner permits direct calendar management outside normal RBA ownership. It does not prove that the principal edited an item. | Count of Calendar folder entries with `Owner` | +| RBA803 | Warning | Applicable when `ResourceDelegates` are configured. A configured delegate has no matching **direct** Calendar `Editor` or `Owner` entry. Validate effective access separately before remediation because the user might receive rights through a group or another assignment path. This finding does not change booking eligibility or prove that delegate routing failed. | Configured delegate identities and direct Calendar `Editor`/`Owner` entries | +| RBA804 | Information | Always detected when Calendar permission and CalendarProcessing evidence are available. Calendar folder access controls who can view item details; `DeleteSubject` and `AddOrganizerToSubject` control what RBA stores as the subject during post-processing. Interpret with RBA602 and RBA603 rather than treating these as one permission. | `Default` access rights, `deleteSubject`, `addOrganizerToSubject` | +| RBA805 | Information | Always detected when Calendar permission and CalendarProcessing evidence are available. Calendar folder access and delegate flags control who can view item details; `RemovePrivateProperty` controls whether RBA clears the item's private flag. Interpret with RBA601 rather than treating these as one permission. | `Default` access rights, `removePrivateProperty` | +| RBA820 | Warning | Detected for explicit, non-inherited Full Access grants other than the mailbox's self entry. Full Access permits mailbox access that can bypass normal RBA ownership, but it does not prove that the grantee directly edited a calendar item. | `mailboxPermissions.explicitFullAccessCount` | + +Booking eligibility remains in RBA300–RBA311 and delegate/request routing remains in RBA400–RBA423. `ResourceDelegates` identifies who receives requests for approval; it is not equivalent to Calendar folder visibility or Full Access. Subject and privacy transformations remain in RBA601–RBA603. + +Read-only verification for this section: + +```powershell +$calendarFolder = Get-MailboxFolderStatistics -Identity -FolderScope Calendar | Where-Object { $_.FolderType -eq "Calendar" } | Select-Object -First 1 +Get-MailboxFolderPermission -Identity ":\$($calendarFolder.Name)" +Get-MailboxPermission -Identity | Where-Object { -not $_.IsInherited -and -not $_.Deny -and $_.AccessRights -contains "FullAccess" } +Get-CalendarProcessing -Identity | Format-List ResourceDelegates,BookInPolicy,AllBookInPolicy,RequestInPolicy,AllRequestInPolicy,RequestOutOfPolicy,AllRequestOutOfPolicy,DeleteSubject,AddOrganizerToSubject,RemovePrivateProperty +``` + +### Reserved direct-edit observation (RBA830) + +`RBA830` is reserved and is not emitted by the current report. A direct-edit finding requires Calendar Diagnostic Log evidence showing a non-RBA client modifying the resource calendar. Calendar `Owner`, `Editor`, or mailbox Full Access grants only establish capability and must never be presented as proof that an edit occurred. + +## Evidence rules + +- Use collector errors only to describe missing evidence; they do not prove a configuration cause. +- A collector marked `Failed` may have retrieved evidence before immediate processing failed. Treat its collector-dependent evidence as unavailable; use the bounded structured error metadata for failure classification, not as configuration evidence. +- Use RBA log counts as observations, not proof of why a particular meeting succeeded or failed. +- `RBA704` and `RBA705` are stronger behavioral evidence than the corresponding configuration findings, but aggregated counts still don't identify the affected meeting. Use the full sensitive log only in an appropriately protected workflow, and correlate the request boundary by global object identifier, subject, and timestamp. +- Targeted event blocks correlated by an extracted meeting ID are stronger than aggregate log counts. Correlation does not make every free-form line authoritative: use only documented exact markers for deterministic classifications and preserve unknown states for future decoder fields. +- A targeted event's exact START boundary is at the bottom of its newest-first `rawLog`. Read the block bottom-up and event sequences from highest to lowest for chronology; do not reinterpret top-down source order as chronological execution. +- `RBA710` is bounded by retained log history. "Not found" must never be restated as "not processed," especially while the service retains only a limited number of recent processing events. +- If many requests believed to be internal appear under `RBA700`, investigate Transport classification and routing evidence before recommending `ProcessExternalMeetingMessages = $true`; changing the setting can also permit genuinely external requests. +- Information findings describe expected routing consequences and should not be remediated unless they conflict with the administrator's intent. +- `RBA400` explicitly describes a healthy no-delegate combination. Use `RBA401` through `RBA403` for faulty no-delegate routes. +- `RBA510` and `RBA511` affect discovery and workspace usability; do not claim they directly stop ordinary RBA processing. +- Calendar-processing configuration findings describe possible policy consequences. Do not claim that a setting caused a historical accept, decline, subject change, privacy change, or cancellation state without event-level evidence. +- Keep permission boundaries explicit: Calendar ACLs control folder access; `ResourceDelegates` controls approval routing; policy wells control booking eligibility; post-processing controls stored subject/private state; Full Access enables mailbox access; Calendar Diagnostic Logs are needed to establish direct editing. +- A missing direct permission match in `RBA803` does not prove missing effective access. Validate whether the delegate receives rights through a group or another supported assignment path before recommending a change. +- `RBA802` and `RBA820` establish the ability to edit or access the resource mailbox, not evidence that the permission was exercised. Do not claim direct editing unless future `RBA830` Calendar Diagnostic Log evidence is available. +- RBA reads the current `CalendarProcessing` configuration for each item it processes. The report is a snapshot at `metadata.collectedAtUtc` and does not prove which values were present when an older item was processed. +- Mailbox identity fields describe the one object resolved at collection time. A proxy-address match does not prove a rename, creation/change timestamps do not prove staleness, and the report does not inventory the tenant for duplicate objects. +- Validate coupled settings together: booking window with scheduling horizon; both conflict thresholds with `AllowConflicts` and recurring-meeting support; subject deletion with organizer substitution; work-hour restriction with mailbox calendar configuration. +- A `NotDetected` result applies only to the evidence captured at `metadata.collectedAtUtc`. diff --git a/Calendar/RBA/exo-rba-troubleshooting/SKILL.md b/Calendar/RBA/exo-rba-troubleshooting/SKILL.md new file mode 100644 index 0000000000..6f39964946 --- /dev/null +++ b/Calendar/RBA/exo-rba-troubleshooting/SKILL.md @@ -0,0 +1,161 @@ +--- +name: exo-rba-troubleshooting +description: Diagnose Exchange Online Resource Booking Assistant policy, routing, post-processing, and targeted meeting RBA log issues for resource mailboxes using Get-RBASummary.ps1 JSON. Do not use for Teams Rooms devices, Microsoft Places, Graph or Power Automate, non-resource sharing, generic event repair, Copilot Rebook, service delays, reporting, or audits. +--- + +# EXO RBA Troubleshooting + +Use this skill only to analyze a `Get-RBASummary.ps1` JSON report for an Exchange Online tenant administrator. Do not request or rely on the text transcript when sanitized JSON is sufficient. + +## Beta Feature + +This skill is currently in beta. Its analysis and recommendations may change as the feature evolves. Review all evidence, commands, expected impacts, and rollback guidance before making configuration changes. + +Send feedback, issues, and suggestions to `CalLogFormatterDevs@microsoft.com`. + +The generic guardrails in [TSG-Rules.md](TSG-Rules.md) and the RBA-specific findings in [RBA-Rules.md](RBA-Rules.md) are mandatory. Apply the generic rules throughout the interaction and use the RBA rules to interpret individual findings. + +## Safety boundary + +- Analyze evidence and recommend actions; never execute a configuration change. +- Never claim a cause that is not supported by report evidence. +- Treat `NotEvaluated` as an evidence gap, not healthy or unhealthy. Treat `NotApplicable` as intentionally outside the rule's scope, not an evidence gap. +- Preserve sanitized identities. Do not try to identify `SanitizedIdentity-*` values. +- Before suggesting a modifying command, explain its expected impact and provide rollback guidance that restores the observed value. +- Ask the tenant administrator to review and run any modifying command themselves. + +## Explicit out-of-scope routing + +Classify the reported symptom before interpreting findings. This skill answers only RBA questions about a resource mailbox's booking-policy evaluation, request routing, delegate approval routing, RBA post-processing, and behavior explicitly observed in RBA logs. + +If the request matches an out-of-scope classification below, state that the symptom requires a different troubleshooting skill or workflow, name the classification, and stop analysis for that symptom branch. Do not stretch a nearby RBA finding into an answer. Do not invent the name of another skill when no installed skill is known; describe the required specialist workflow instead. + +| Routing classification | Out-of-scope examples | Route to | +|---|---|---| +| Teams Rooms device health | Device sign-in, application health, peripherals, firmware, console, display, camera, microphone, or Teams Admin Center device alerts | Teams Rooms device-health troubleshooting | +| Microsoft Places and PlaceV3 | Buildings, floors, sections, desks, Places hierarchy, PlaceV3 synchronization, workplace presence, or broad room-discovery behavior | Microsoft Places or PlaceV3 troubleshooting | +| Graph and Power Automate | Microsoft Graph requests, application permissions, API payloads, subscriptions, connectors, flows, or automation failures | Graph API or Power Automate troubleshooting | +| Non-resource calendar sharing and delegation | User or shared-mailbox calendar sharing, Outlook delegates, sharing invitations, cross-tenant sharing, or non-room folder permissions | Calendar sharing and delegation troubleshooting | +| Generic calendar-event repair | Corrupt, missing, duplicated, repaired, or unexpectedly modified events where RBA evidence does not establish resource booking behavior | Calendar event and Calendar Diagnostic Log troubleshooting | +| Copilot Rebook or automated room booking | Alternative-room suggestions, rebook attempts, licensing, proximity selection, or automated room replacement after a decline | Copilot Rebook or automated room-booking troubleshooting | +| Service availability or processing delay | Service incidents, assistant backlog, delayed processing, broad tenant impact, or availability questions | Service health and processing-delay investigation | +| Reporting and audit | Utilization reports, booking history exports, compliance searches, audit attribution, trend analysis, or inventory requests | Reporting, compliance, or audit workflow | + +Boundary notes: + +- `Get-Place` findings in the report cover only the narrow resource prerequisites documented by their RBA rules. They do not authorize diagnosis of Microsoft Places, PlaceV3, or general room-discovery architecture. +- Calendar permission findings describe the target resource mailbox only. They do not authorize diagnosis of user calendars, shared-mailbox calendars, or general sharing/delegation. +- RBA log observations can establish what RBA recorded. They do not establish device health, service availability, event corruption, audit attribution, or Copilot Rebook health. +- For a mixed request, separate the symptom branches. Analyze only the RBA branch and explicitly route each non-RBA branch. +- A supplied RBA JSON report does not make an otherwise out-of-scope question an RBA question. + +## Lifecycle and item propagation triage + +Use the existing report evidence where it can answer a bounded part of the question, then route only the unresolved branch. Do not turn current configuration or aggregate RBA log counts into item-level history. + +| Customer question | What this report can answer | Required disposition | +|---|---|---| +| Soft-deleted room mailbox | `RBA101` identifies when the supplied identity resolves only as a recoverable soft-deleted mailbox. | State that active RBA processing is unavailable and route recovery decisions to a mailbox-recovery workflow. Do not recommend restore or purge without recovery requirements and hold state. | +| Duplicate or stale room object | The report identifies the single mailbox resolved at collection time and provides current object timestamps and, in full privacy mode, stable identifiers. | Do not claim uniqueness or staleness. Route tenant-wide duplicate, obsolete-object, and synchronization analysis to recipient inventory and object-lifecycle troubleshooting. | +| Room rename or SMTP change | `RBA102` and `mailbox.inputIdentityMatch` distinguish the current primary SMTP address from a resolving proxy address. | State only the current resolution. Route change timing, previous values, directory synchronization, and meetings addressed to an old object to identity-history or calendar-item analysis. | +| Former organizer reservations | `RBA604` explains whether organizer-canceled meetings are retained by configuration. Aggregate RBA logs can show that some cancellations were processed. | Do not identify a former organizer or classify an item as abandoned. Route the specific reservation to Calendar Diagnostic Log and organizer-object analysis. | +| Stale room calendar items | Current settings can explain retention behavior, but the report does not enumerate calendar items or determine age, validity, or ownership. | Route item discovery, age criteria, validation, and cleanup to a calendar-item lifecycle workflow. | +| Cancellation or update not reflected | `rbaLogSummary.cancellationCount`, `rbaLogSummary.updatedCount`, `RBA604`, and `RBA703` describe aggregate processing and configured retention. | Do not claim that a specific message reached or changed the room item. Route the meeting to Calendar Diagnostic Log correlation by meeting ID, subject, and timestamp. | +| Orphaned recurring meetings | `RBA304`–`RBA306` explain recurrence policy; `RBA704` and `RBA705` identify aggregate horizon declines or truncations. | Do not label a series orphaned. Route current series state, organizer validity, missing instances, and recurrence exceptions to Calendar Diagnostic Log analysis. | + +The minimal downstream evidence for a specific meeting is Calendar Diagnostic Log output from the resource and, when available, the organizer, correlated by the same meeting ID. Use subject and timestamp only as secondary correlation values. Calendar Diagnostic Logs—not this summary—provide item create/update actions, cancellation state, responsible actor, meeting-request type, and recurrence exceptions. + +## Targeted meeting log workflow + +When `meetingLogSearch.status` is not `NotRequested`, treat it as explicitly requested sensitive meeting evidence. Analyze it before aggregate `rbaLogSummary` counts. A `NotRequested` object contains only ordering metadata, zero counts, and empty meeting ID and event collections and remains sanitized. + +1. State the requested search status exactly: `LogUnavailable`, `NotFound`, `FoundWithoutMeetingId`, or `Found`. +2. For `NotFound`, use `searchType` to say either "the subject was not found in the retained RBA log" or "the meeting ID was not found in the retained RBA log." Do not say the meeting was never processed. Explain that RBA log history is bounded and older events might have rolled off. +3. For `FoundWithoutMeetingId`, analyze only the subject-matching blocks. State that updates or cancellations in other blocks cannot be correlated safely. +4. For `Found`, analyze each object in `meetings` separately. A subject search switches to ID-only correlation after discovery. If multiple IDs are present, do not describe top-level targeted counts or outcomes as one meeting; recommend rerunning with `-MeetingId`. Never merge IDs merely because their subjects match. +5. Report `firstLogTimeText`, `lastLogTimeText`, and `lastUpdateTimeText` as retained RBA-log timestamps, not proof of final calendar state. Report `recurrenceStatus` exactly; `Unknown` means no supported explicit recurrence marker was retained and must not be converted to `NotRecurring`. +6. Treat `events` and `sequence` as newest-first source order: sequence 1 is nearest the top of the exported log and is the newest retained processing unit. To present event history chronologically, traverse the event array from highest sequence to lowest. Use `startTimeText` only as the timestamp printed on the exact START row; do not reorder equal or ambiguous timestamps or infer elapsed time from them. Do not call an update a reschedule unless an approved marker explicitly establishes rescheduling. +7. Cite the event sequence and exact raw marker supporting every action, update, cancellation, delegate referral, external skip, horizon decline, or recurrence truncation statement. +8. Apply `RBA710`–`RBA715` and the targeted marker table in the RBA rules. Free-form raw text can be quoted as an observation but must not be promoted to a deterministic classification unless the rules approve that marker. +9. If `Action:Decline` is present, look for an approved reason marker in the same processing block. Current configuration can corroborate documented behavior but cannot retroactively establish the decline reason. If no approved marker is present, state "decline observed; reason not established by the current decoder." +10. Distinguish RBA processing from final calendar state. Route final item state, participant divergence, recurrence exceptions, or responsible-actor questions to Calendar Diagnostic Log analysis using the extracted meeting ID. + +Before citing any decoded event field, verify that the property exists on that event object. Current reports emit `policyResult`, `disposition`, `delegateMessageCount`, and `tentativeResponseSent` on each targeted event. If an older or malformed report omits one of these properties, do not cite the nonexistent field or synthesize a value for it. An approved exact marker in that event's `rawLog` may still be quoted as a raw observation, explicitly labeled as raw-only; otherwise report the value as unavailable. + +The report deliberately exports only blocks correlated by an extracted or supplied meeting ID unless `IncludeSensitiveData` was also used. If a subject matches without an extractable ID, it exports only those subject-matching blocks. Do not request the full RBA log when the targeted evidence is sufficient. + +### RBA source ordering and event boundaries + +The RBA log examples establish these structural rules: + +- Exported RBA log lines are newest-first: the top is newest and the bottom is oldest. +- RBA processing is single-threaded, so each processing unit is a contiguous range. The only boundary recognized by this report is the exact row ending `START - HandleEventInternal Automatic Booking is enabled for resource.` Generic `START -` text is not a boundary. +- In source order, a complete unit begins immediately after the preceding newer exact START boundary (or at the top of the export for the newest retained unit) and includes its own exact START row at the bottom. +- Each event's `rawLog` remains newest-first. Read it **bottom-up** for chronological processing: exact START, entry/classification, policy evaluation, decision, optional post-processing, then the newest result or END row at the top. +- `startMarker` is the complete exact START row and `startTimeText` is that row's timestamp text. `eventTimeText` is a compatibility alias for the same start timestamp, not a completion time. +- `startBoundaryFound = false` and `boundaryStatus = MissingStartBoundary` identify retained lines that cannot be closed by an exact START boundary. Report the evidence as partial; do not attach it to a neighboring event or infer its start time. `SourceStartToExactStart` means the newest unit starts at the top of the export, not that a completion marker was observed. + +Do not reverse or rewrite `rawLog` in citations. Preserve the exported text and state that chronological reading is bottom-up. An END marker can support only the exact completion path it names; the manual does not document a universal END marker for every outcome. + +### Deterministic phase and marker interpretation + +- **Entry/classification:** the exact START row opens chronological processing; `Begin ProcessRequest Goid:` identifies new-request processing, `Begin ProcessUpdateRequest Goid:` identifies update processing, and the exact request, cancellation, or non-meeting classification row states the observed item class or skip. +- **Policy evaluation:** `Begin meeting evaluation.`, `CheckConflict found N busy conflicts`, `Evaluate: Completed IsRequestInPolicy.`, `Defaulting to in policy.`, and `Not in policy.` support only the evaluation step or result they state. `Not in policy.` alone does not decode a decline reason. +- **Decision:** `Meeting request evaluate returns result ` and `Action:Accept`, `Action:Tentative`, or `Action:Decline` state the recorded disposition. Delegate forwarding requires `Forwarding Request To Delegates` or the explicit delegate-message count marker. +- **Post-processing/end:** `Begin meeting post-processing.`, `PostProcessing completed on .`, and the documented acceptance or tentative END marker establish only those named stages. Do not infer an undocumented completion path from marker absence. +- **Recurrence, Workspace, and external handling:** use only the exact booking-window decline, recurrence-truncation, capacity-check, or external-processing-skip text for the corresponding observation. These markers do not establish unrelated final calendar state. + +## Workflow + +1. Classify the symptom using the explicit out-of-scope routing table. Stop and route any non-RBA symptom branch before interpreting RBA findings. +2. Attempt the non-blocking version check below once. Record one of `Current`, `UpdateAvailable`, or `Unavailable`, then continue immediately. `Unavailable` is operational metadata, not an RBA finding or evidence gap. +3. Parse the supplied JSON. If parsing fails, stop diagnosis and explain that a valid JSON report is required. +4. Validate that `metadata.schemaVersion` is `1.0-preview`, `metadata.identity` is present, `metadata.collectionStatus` is `Complete`, `Partial`, or `Failed`, `metadata.privacyMode` is `Sanitized`, `TargetedMeeting`, or `Full`, and `collectors`, `findings`, and `collectionErrors` are present. +5. Apply [TSG-Rules.md](TSG-Rules.md), including its evidence-validation, truthfulness, output, remediation, and data-handling requirements. +6. Validate each finding against [RBA-Rules.md](RBA-Rules.md): the `ruleId` must be known and each finding must have `severity`, `status`, and `evidence`. Flag unknown IDs or malformed evidence; do not reinterpret them as known rules. +7. State the collection status and list failed collectors first. Explain which conclusions cannot be made from unavailable evidence. +8. Rank `Detected` findings by severity in this order: `Critical`, `Error`, `Warning`, `Information`. Use rule ID as the stable tie-breaker. Keep `NotEvaluated` findings in a separate evidence-gap section. Omit `NotApplicable` unless applicability is relevant to the explanation. Do not present `NotDetected`, `NotApplicable`, or informational expected-routing findings as problems. +9. For each ranked finding, report the rule ID, observed evidence, supported interpretation, verification command, and—only when justified—a remediation command with impact and rollback guidance. +10. End with the smallest safe next-step plan. Prefer collecting missing evidence before configuration changes. + +## Command guidance + +Commands are recommendations for tenant administrators, not actions for the skill to run. Use explicit named parameters and the target from `metadata.identity`. Start with read-only verification commands such as: + +- `Get-Mailbox -Identity ` +- `Get-Mailbox -Identity -SoftDeletedMailbox` +- `Get-Recipient -Identity -IncludeSoftDeletedRecipients` +- `Get-Place -Identity ` +- `Get-InboxRule -Mailbox -IncludeHidden` +- `Get-CalendarProcessing -Identity ` +- `Get-MailboxFolderStatistics -Identity -FolderScope Calendar` +- `Get-MailboxFolderPermission -Identity ` +- `Get-MailboxPermission -Identity ` +- `Export-MailboxDiagnosticLogs -Identity -ComponentName RBA` +- `Get-CalendarDiagnosticObjects -Identity -MeetingId ` + +For any suggested `Set-CalendarProcessing`, `Set-Place`, or `Remove-InboxRule` command: + +- show the current value from evidence; +- describe user-visible and booking impact; +- recommend exporting or recording the current configuration first; +- provide a rollback command using the observed prior value; and +- never recommend broad changes when a narrower change addresses the detected rule. + +If an inbox rule name or identity was sanitized, recommend a read-only command to resolve it locally rather than guessing the value. + +Keep permission conclusions separate. Calendar folder permissions describe visibility and direct folder capabilities; `ResourceDelegates` describes approval routing; booking-policy lists describe eligibility; CalendarProcessing post-processing describes stored subject and private state; Full Access describes mailbox access. Do not claim that Owner, Editor, or Full Access was exercised. Direct editing requires Calendar Diagnostic Log evidence, and reserved rule `RBA830` is not available in the current report. + +## Non-blocking version check + +At the start of analysis, compare the skill version in the local package metadata with the version in: + +`https://raw.githubusercontent.com/microsoft/CSS-Exchange/main/Calendar/RBA/exo-rba-troubleshooting/manifest.json` + +Use available read-only web retrieval and make at most one retrieval attempt. Do not ask the administrator to enable network access, retry, wait, or provide remote metadata. + +- `Current`: local and remote versions were parsed and the remote preview version is not newer. No version warning is required. +- `UpdateAvailable`: the remote preview version is newer. Mention the latest-release Markdown download, but do not download or install it. +- `Unavailable`: local package metadata is unavailable, or retrieval, parsing, or comparison fails. Mention once that the version check was unavailable, then proceed immediately with the supplied report. + +Version-check status must never be reported as an RBA finding, failed collector, report evidence gap, or diagnosis blocker. diff --git a/Calendar/RBA/exo-rba-troubleshooting/TSG-Rules.md b/Calendar/RBA/exo-rba-troubleshooting/TSG-Rules.md new file mode 100644 index 0000000000..342d35cf9b --- /dev/null +++ b/Calendar/RBA/exo-rba-troubleshooting/TSG-Rules.md @@ -0,0 +1,144 @@ +# EXO TSG core rules + +These rules define reusable safety and evidence requirements for Exchange Online troubleshooting skills. They are prototyped with the RBA skill and are intended to move to a shared Microsoft Exchange Support Skills location after the pattern is validated. + +## Rule ranges + +| Range | Rule group | +|---|---| +| TSG000–TSG099 | Authority and truthfulness | +| TSG100–TSG199 | Evidence validation and completeness | +| TSG200–TSG299 | Diagnosis and interpretation | +| TSG300–TSG399 | Output and self-correction | +| TSG400–TSG499 | Remediation and data handling | +| TSG500–TSG999 | Reserved for future shared rule groups | + +## Authority and truthfulness (TSG000–TSG099) + +### TSG001 — Deterministic evidence first + +Apply deterministic rules to validated evidence before performing broader interpretation. Interpretive reasoning must not override or contradict a deterministic rule result. + +### TSG002 — No fabrication + +Never invent values, events, settings, identities, commands that were run, or results that are absent from the supplied evidence. + +### TSG003 — Observable claims only + +State only what the available evidence supports. Do not infer intent, corruption, data loss, a product defect, or a service failure unless a documented rule explicitly supports that classification and all required evidence is present. + +### TSG004 — Symptoms are not root causes + +Do not present an observed symptom, warning, missing value, or temporal correlation as a root cause. Label it as an observation unless a deterministic rule establishes causality. + +### TSG005 — Rule authority and scope + +Treat the rules bundled with the installed skill as authoritative only within their documented scope and supported schema versions. Do not extend a rule to an excluded scenario because its wording appears similar. + +## Evidence validation and completeness (TSG100–TSG199) + +### TSG100 — Validate before diagnosis + +Before interpreting evidence, validate that it is readable, uses a supported schema, identifies the target object, and contains the fields required by the applicable rules. Stop interpretation of malformed or unsupported evidence. + +### TSG101 — Verify the evidence target + +Confirm that the report identity and resource type match the administrator's intended target. Do not silently analyze evidence from a different mailbox, tenant object, or run. + +### TSG102 — Current evidence required + +Base every conclusion on evidence available in the current analysis. If an attachment or report is no longer accessible, request it again rather than answering from conversational memory or a previously recalled value. + +### TSG103 — Process all relevant supplied evidence + +Review all relevant sections of the supplied report before selecting a diagnosis. Do not stop after the first warning or cherry-pick a single field when other collected evidence can confirm, contradict, or qualify it. + +### TSG104 — Missing evidence is unknown + +Distinguish "not present in the evidence" from "did not happen" and "not configured." A missing field, failed collector, or `NotEvaluated` result is an evidence gap, not proof of a healthy or unhealthy state. + +### TSG105 — Partial evidence handling + +Continue with independent diagnostic branches when evidence is partial, but identify every affected conclusion and lower confidence accordingly. Prefer recollecting required evidence before recommending a configuration change. + +### TSG106 — Detect stale or incompatible evidence + +Report unsupported schema versions, incompatible collector versions, and evidence that predates the reported incident or relevant configuration change. Do not silently reinterpret an older contract as the current schema. + +## Diagnosis and interpretation (TSG200–TSG299) + +### TSG200 — Evidence citation + +For each finding, cite its rule ID and the exact report fields and observed values that support it. Do not cite a rule without showing how the supplied evidence satisfies it. + +### TSG201 — Defined precedence only + +When evidence conflicts, apply only a precedence rule documented for that evidence type. State which evidence was followed and why. If no precedence is defined, report the conflict as unresolved. + +### TSG202 — Separate observation, interpretation, and recommendation + +Keep these concepts distinct: + +1. **Observation** — the value or event in the evidence. +2. **Interpretation** — the documented meaning of that evidence. +3. **Recommendation** — the next verification, remediation, or escalation step. + +### TSG203 — Confidence must match evidence + +Use `High` confidence only when all deterministic conditions and required evidence are present. Use `Medium` when a documented interpretation is supported but qualifying evidence is incomplete. Use `Low` when collection gaps prevent a reliable classification. Never use confidence wording to disguise speculation. + +### TSG204 — Expected behavior versus issue + +Explicitly distinguish expected configuration consequences from warnings, errors, and known issues. Do not recommend changing expected behavior unless it conflicts with the administrator's stated intent. + +### TSG205 — No unrelated diagnosis + +Stay within the skill's activation scope. Route client-only, transport-only, identity, permissions, or other adjacent symptoms to the appropriate workflow when the current evidence does not support an in-scope diagnosis. + +## Output and self-correction (TSG300–TSG399) + +### TSG300 — Evidence gaps first + +State the report's collection status and material evidence gaps before presenting findings that depend on the incomplete evidence. + +### TSG301 — Rank actionable findings + +Present confirmed findings by documented severity, then informational expected behavior, then unresolved evidence gaps. Do not present `NotDetected` or `NotApplicable` results as problems. + +### TSG302 — Neutral and precise language + +Use factual language such as "the report shows," "the evidence does not contain," and "this setting results in." Avoid terms such as "corrupted," "broken," "Exchange failed," or "bug" unless a documented rule and evidence explicitly authorize the classification. + +### TSG303 — Internal consistency and correction + +Keep later answers consistent with earlier evidence-based conclusions. If new or more complete evidence changes a prior answer, explicitly correct the earlier statement before giving the revised conclusion. + +### TSG304 — Do not hide ambiguity + +Call out contradictory values, unknown identities, sanitized fields, and unresolved branches. Do not collapse multiple plausible interpretations into one asserted cause. + +## Remediation and data handling (TSG400–TSG499) + +### TSG400 — Read-only verification first + +Prefer the smallest read-only command that can confirm a finding or fill an evidence gap before recommending remediation. + +### TSG401 — Safe modifying-command guidance + +For every modifying command, state why it is proposed, the expected user-visible impact, the observed current value, the proposed value, and rollback guidance. Require the administrator to review and run the command; the skill must not execute it. + +### TSG402 — No guessed parameters + +Never invent a mailbox identity, rule name, delegate, organization, or property value for a command. If a required value is sanitized or unavailable, provide a read-only command that lets the administrator resolve it locally. + +### TSG403 — Minimize sensitive data + +Use sanitized evidence when it is sufficient. Do not request full logs, transcripts, identities, message content, or tenant-specific values unless they are required for a documented diagnostic branch. + +### TSG404 — Do not persist customer evidence + +Do not copy customer evidence into durable notes, generated knowledge, or unrelated artifacts. Use it only for the current diagnostic interaction unless the administrator explicitly requests an export suitable for escalation. + +### TSG405 — Escalate instead of guessing + +When required evidence cannot be collected, no documented rule matches, or remediation would exceed the skill's safety boundary, produce a concise evidence summary and escalation recommendation rather than inventing a diagnosis. diff --git a/Calendar/RBA/exo-rba-troubleshooting/manifest.json b/Calendar/RBA/exo-rba-troubleshooting/manifest.json new file mode 100644 index 0000000000..fe7f20c610 --- /dev/null +++ b/Calendar/RBA/exo-rba-troubleshooting/manifest.json @@ -0,0 +1,13 @@ +{ + "id": "exo-rba-troubleshooting", + "version": "0.1.1-preview", + "displayName": "EXO RBA Troubleshooting", + "description": "Diagnoses Exchange Online Resource Booking Assistant policy, routing, mailbox lifecycle, post-processing, and targeted meeting RBA log issues from Get-RBASummary JSON evidence; safely routes adjacent item-propagation and non-RBA requests.", + "displayCollection": "Microsoft Exchange Support Skills", + "entryPoint": "SKILL.md", + "downloadUrl": "https://github.com/microsoft/CSS-Exchange/releases/latest/download/EXO-RBA-Troubleshooting-SKILL.md", + "tsgRulesVersion": "0.1.0-preview", + "reportSchemaVersions": [ + "1.0-preview" + ] +} diff --git a/Calendar/Tests/Get-RBASummary.Tests.ps1 b/Calendar/Tests/Get-RBASummary.Tests.ps1 new file mode 100644 index 0000000000..af6cb022cb --- /dev/null +++ b/Calendar/Tests/Get-RBASummary.Tests.ps1 @@ -0,0 +1,1247 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +BeforeAll { + $Script:calendarPath = Split-Path -Path $PSScriptRoot -Parent + $Script:scriptPath = Join-Path -Path $Script:calendarPath -ChildPath "Get-RBASummary.ps1" + $Script:packageHelperPath = Join-Path -Path $Script:calendarPath -ChildPath "..\.build\BuildFunctions\New-PrototypeSkillFiles.ps1" + $Script:skillPath = Join-Path -Path $Script:calendarPath -ChildPath "RBA\exo-rba-troubleshooting" + + function Get-Mailbox { param($Identity, $SoftDeletedMailbox, $ErrorAction) } + function Get-Place { param($Identity, $ErrorAction) } + function Get-InboxRule { param($Mailbox, $IncludeHidden, $ErrorAction) } + function Get-CalendarProcessing { param($Identity, $ErrorAction) } + function Get-MailboxFolderStatistics { param($Identity, $FolderScope, $ErrorAction) } + function Get-MailboxFolderPermission { param($Identity, $ErrorAction) } + function Get-MailboxPermission { param($Identity, $ErrorAction) } + function Export-MailboxDiagnosticLogs { param($Identity, $ComponentName, $ErrorAction) } + function Get-Recipient { param($Identity, $Organization, $ErrorAction) } + + function Get-TestCalendarProcessing { + [PSCustomObject]@{ + AutomateProcessing = "AutoAccept" + AllowConflicts = $false + AllowDistributionGroup = $true + AllowMultipleResources = $true + MaximumDurationInMinutes = 1440 + MinimumDurationInMinutes = 0 + AllowRecurringMeetings = $true + ScheduleOnlyDuringWorkHours = $false + ProcessExternalMeetingMessages = $false + BookingWindowInDays = 180 + ConflictPercentageAllowed = 0 + MaximumConflictInstances = 0 + MaximumConflictPercentage = 0 + EnforceSchedulingHorizon = $true + EnforceCapacity = $false + RequestOutOfPolicy = @() + AllRequestOutOfPolicy = $false + BookInPolicy = @("allowed@contoso.com") + AllBookInPolicy = $true + RequestInPolicy = @() + AllRequestInPolicy = $true + ResourceDelegates = @("delegate@contoso.com") + AddNewRequestsTentatively = $true + ForwardRequestsToDelegates = $true + AddOrganizerToSubject = $true + DeleteSubject = $true + DeleteComments = $false + DeleteAttachments = $true + RemovePrivateProperty = $true + DeleteNonCalendarItems = $true + RemoveForwardedMeetingNotifications = $false + RemoveCanceledMeetings = $false + EnableAutoRelease = $false + AddAdditionalResponse = $true + AdditionalResponse = "Contact delegate@contoso.com" + } + } + + function Initialize-StandardMocks { + Mock Get-Place { + [PSCustomObject]@{ + City = "Redmond" + Floor = 1 + Capacity = 8 + Localities = @("RoomList@contoso.com") + Street = "1 Microsoft Way" + State = "WA" + PostalCode = "98052" + CountryOrRegion = "US" + Building = "1" + Tags = @("Display") + } + } + Mock Get-InboxRule { @([PSCustomObject]@{ Name = "Default Junk Email" }) } + Mock Get-CalendarProcessing { Get-TestCalendarProcessing } + Mock Get-MailboxFolderStatistics { + [PSCustomObject]@{ + Name = "Calendar" + FolderType = "Calendar" + } + } + Mock Get-MailboxFolderPermission { + @( + [PSCustomObject]@{ + User = "Default" + AccessRights = @("AvailabilityOnly") + SharingPermissionFlags = @() + } + [PSCustomObject]@{ + User = "delegate@contoso.com" + AccessRights = @("Editor") + SharingPermissionFlags = @("Delegate") + } + ) + } + Mock Get-MailboxPermission { + @([PSCustomObject]@{ + User = "NT AUTHORITY\SELF" + AccessRights = @("FullAccess") + IsInherited = $false + Deny = $false + }) + } + Mock Export-MailboxDiagnosticLogs { + [PSCustomObject]@{ + MailboxLog = @( + "2026-08-28T10:00:02Z, Entry Action: Message, LogComment: Action:Accept" + "2026-08-28T10:00:00Z, START - HandleEventInternal Automatic Booking is enabled for resource." + ) -join "`r`n" + } + } + Mock Get-Recipient { + [PSCustomObject]@{ + DisplayName = "Resolved user" + PrimarySmtpAddress = $Identity + } + } + } + + function Invoke-TestRbaSummary { + param( + [switch]$IncludeSensitiveData, + + [string]$Subject, + + [string]$MeetingId + ) + + Push-Location -Path $TestDrive + try { + Get-ChildItem -Path $TestDrive -Filter "RBA-*-For_room_*" -ErrorAction SilentlyContinue | + Remove-Item -Force + Get-ChildItem -Path $TestDrive -Filter "RBA-Logs_room_*" -ErrorAction SilentlyContinue | + Remove-Item -Force + $params = @{ + Identity = "room@contoso.com" + SkipVersionCheck = $true + } + if ($IncludeSensitiveData) { + $params.IncludeSensitiveData = $true + } + if (-not [string]::IsNullOrWhiteSpace($Subject)) { + $params.Subject = $Subject + } + if (-not [string]::IsNullOrWhiteSpace($MeetingId)) { + $params.MeetingId = $MeetingId + } + & $Script:scriptPath @params + $jsonPath = Get-ChildItem -Path $TestDrive -Filter "RBA-Summary-For_room_*.json" | + Sort-Object -Property LastWriteTime | Select-Object -Last 1 + return Get-Content -Path $jsonPath.FullName -Raw | ConvertFrom-Json + } finally { + Pop-Location + } + } +} + +Describe "Get-RBASummary best-effort report" { + BeforeEach { + Initialize-StandardMocks + Mock Get-Mailbox { + [PSCustomObject]@{ + Identity = "room@contoso.com" + DisplayName = "Conference Room" + Alias = "room" + PrimarySmtpAddress = "room@contoso.com" + EmailAddresses = @("SMTP:room@contoso.com", "smtp:old-room@contoso.com") + ExchangeGuid = "11111111-1111-1111-1111-111111111111" + ExternalDirectoryObjectId = "22222222-2222-2222-2222-222222222222" + WhenCreatedUTC = [DateTime]"2025-01-01T00:00:00Z" + WhenChangedUTC = [DateTime]"2026-01-01T00:00:00Z" + RecipientTypeDetails = "RoomMailbox" + ResourceType = "Room" + Database = "DatabaseGroup01" + ServerName = "server" + } + } + } + + It "stops collection when the mailbox cannot be resolved" { + Mock Get-Mailbox { throw "Mailbox unavailable" } + + Push-Location -Path $TestDrive + try { + $output = & $Script:scriptPath -Identity "missing@contoso.com" -SkipVersionCheck *>&1 | Out-String + $jsonFiles = @(Get-ChildItem -Path $TestDrive -Filter "RBA-Summary-For_missing_*.json") + } finally { + Pop-Location + } + + Assert-MockCalled -CommandName Get-Mailbox -Exactly 2 + Assert-MockCalled -CommandName Get-Place -Exactly 0 + Assert-MockCalled -CommandName Get-InboxRule -Exactly 0 + Assert-MockCalled -CommandName Get-CalendarProcessing -Exactly 0 + Assert-MockCalled -CommandName Get-MailboxFolderPermission -Exactly 0 + Assert-MockCalled -CommandName Get-MailboxPermission -Exactly 0 + Assert-MockCalled -CommandName Export-MailboxDiagnosticLogs -Exactly 0 + $output | Should -Match "Unable to resolve 'missing@contoso.com' to a mailbox\. Stopping\." + $jsonFiles.Count | Should -Be 0 + } + + It "stops collection when the identity is not a resource mailbox" { + Mock Get-Mailbox { + [PSCustomObject]@{ + Identity = "user@contoso.com" + PrimarySmtpAddress = "user@contoso.com" + RecipientTypeDetails = "UserMailbox" + ResourceType = $null + } + } + + Push-Location -Path $TestDrive + try { + $output = & $Script:scriptPath -Identity "user@contoso.com" -SkipVersionCheck *>&1 | Out-String + $jsonFiles = @(Get-ChildItem -Path $TestDrive -Filter "RBA-Summary-For_user_*.json") + } finally { + Pop-Location + } + + Assert-MockCalled -CommandName Get-Mailbox -Exactly 1 + Assert-MockCalled -CommandName Get-Place -Exactly 0 + Assert-MockCalled -CommandName Get-InboxRule -Exactly 0 + Assert-MockCalled -CommandName Get-CalendarProcessing -Exactly 0 + Assert-MockCalled -CommandName Get-MailboxFolderPermission -Exactly 0 + Assert-MockCalled -CommandName Get-MailboxPermission -Exactly 0 + Assert-MockCalled -CommandName Export-MailboxDiagnosticLogs -Exactly 0 + $output | Should -Match "The mailbox is not a Room Mailbox / Equipment Mailbox\. RBA will only work with these\. Stopping\." + $jsonFiles.Count | Should -Be 0 + } + + It "detects a recoverable soft-deleted room after the active lookup fails" { + Mock Get-Mailbox { throw "Active mailbox not found" } -ParameterFilter { -not $SoftDeletedMailbox } + Mock Get-Mailbox { + [PSCustomObject]@{ + Identity = "room@contoso.com" + PrimarySmtpAddress = "room@contoso.com" + EmailAddresses = @("SMTP:room@contoso.com") + RecipientTypeDetails = "RoomMailbox" + ResourceType = "Room" + } + } -ParameterFilter { $SoftDeletedMailbox } + + $report = Invoke-TestRbaSummary + + $report.collectors.Mailbox.status | Should -Be "Success" + $report.mailbox.objectState | Should -Be "SoftDeleted" + ($report.findings | Where-Object { $_.ruleId -eq "RBA101" }).status | Should -Be "Detected" + ($report.findings | Where-Object { $_.ruleId -eq "RBA100" }).status | Should -Be "NotApplicable" + Assert-MockCalled -CommandName Get-Mailbox -Exactly 1 -ParameterFilter { -not $SoftDeletedMailbox } + Assert-MockCalled -CommandName Get-Mailbox -Exactly 1 -ParameterFilter { $SoftDeletedMailbox } + } + + It "reports when an old SMTP proxy resolves to the current room mailbox" { + Push-Location -Path $TestDrive + try { + & $Script:scriptPath -Identity "old-room@contoso.com" -SkipVersionCheck + $jsonPath = Get-ChildItem -Path $TestDrive -Filter "RBA-Summary-For_old-room_*.json" | + Sort-Object -Property LastWriteTime | Select-Object -Last 1 + $report = Get-Content -Path $jsonPath.FullName -Raw | ConvertFrom-Json + } finally { + Pop-Location + } + + $report.mailbox.primarySmtpAddress | Should -Be "room@contoso.com" + $report.mailbox.inputIdentityMatch | Should -Be "ProxyAddress" + ($report.findings | Where-Object { $_.ruleId -eq "RBA102" }).status | Should -Be "Detected" + } + + It "continues to skill guidance when the JSON file write fails" { + Mock Set-Content { throw [System.IO.IOException]::new("JSON destination unavailable") } -ParameterFilter { + $Path -like "RBA-Summary-For_room_*.json" + } + + Push-Location -Path $TestDrive + try { + $output = & $Script:scriptPath -Identity "room@contoso.com" -SkipVersionCheck *>&1 | Out-String + } finally { + Pop-Location + } + + Assert-MockCalled -CommandName Set-Content -Exactly 1 -ParameterFilter { + $Path -like "RBA-Summary-For_room_*.json" -and $ErrorAction -eq "Stop" + } + $output | Should -Match "Unable to write RBA JSON output" + $output | Should -Match "Beta Feature" + $output | Should -Match "Tenant admins can install and use the EXO RBA troubleshooting skill" + $output | Should -Match "CalLogFormatterDevs@microsoft.com" + } + + It "prints each generated output file on one line and uses the feedback alias" { + Push-Location -Path $TestDrive + try { + $output = & $Script:scriptPath -Identity "room@contoso.com" -SkipVersionCheck *>&1 | Out-String + $summaryPath = Get-ChildItem -Path $TestDrive -Filter "RBA-Summary-For_room_*.txt" | + Sort-Object -Property LastWriteTime | Select-Object -Last 1 + $summary = Get-Content -Path $summaryPath.FullName -Raw + } finally { + Pop-Location + } + + $output | Should -Match "RBA logs saved as \[RBA-Logs_room_.*\.txt\] in the current directory\." + $output | Should -Match "Text summary: \[RBA-Summary-For_room_.*\.txt\]" + $output | Should -Match "JSON report:\s+\[RBA-Summary-For_room_.*\.json\]" + $output | Should -Match "RBA logs:\s+\[RBA-Logs_room_.*\.txt\]" + $output | Should -Match "Feedback: CalLogFormatterDevs@microsoft.com" + $output | Should -Not -Match "Shanefe@microsoft.com" + $summary | Should -Match "RBA output files:" + $summary | Should -Match "JSON report:\s+\[RBA-Summary-For_room_.*\.json\]" + } + + It "marks a collector failed when post-collection evidence processing throws" { + Push-Location -Path $TestDrive + try { + . $Script:scriptPath -Identity "room@contoso.com" -SkipVersionCheck *>&1 | Out-Null + $collectedEvidence = @($script:RBALog) + $unknownError = ConvertTo-RbaErrorInfo -ErrorRecord ([PSCustomObject]@{}) + + Invoke-RbaCollectorOperation -Name "RbaLog" -Action { + throw [System.InvalidOperationException]::new("RBA log processing failed") + } + $JsonFilename = Join-Path -Path $TestDrive -ChildPath "PostCollectionFailure.json" + Write-RbaJson + $report = Get-Content -Path $JsonFilename -Raw | ConvertFrom-Json + } finally { + Pop-Location + } + + $report.metadata.collectionStatus | Should -Be "Partial" + $report.collectors.RbaLog.status | Should -Be "Failed" + $report.collectors.RbaLog.error | Should -Be "RBA log processing failed" + @($report.collectionErrors | Where-Object { $_.collector -eq "RbaLog" }).Count | Should -Be 1 + $report.rbaLogSummary | Should -BeNullOrEmpty + ($report.findings | Where-Object { $_.ruleId -eq "RBA005" }).status | Should -Be "Detected" + @($script:RBALog) | Should -Be $collectedEvidence + $unknownError.message | Should -Be "Unknown error." + $unknownError.exceptionType | Should -BeNullOrEmpty + $unknownError.category | Should -BeNullOrEmpty + $unknownError.fullyQualifiedErrorId | Should -BeNullOrEmpty + } + + It "emits bounded structured error metadata without diagnostic internals" { + Mock Get-Place { + $innerException = [System.Exception]::new("Inner detail") + $errorMessage = [string]::Join([Environment]::NewLine, @("Place", "unavailable")) + $exception = [System.InvalidOperationException]::new($errorMessage, $innerException) + Write-Error -Exception $exception -Message $exception.Message -Category PermissionDenied ` + -ErrorId "RbaPlaceFailure" -ErrorAction Stop + } + + $report = Invoke-TestRbaSummary + $collector = $report.collectors.Place + $errorEntry = @($report.collectionErrors | Where-Object { $_.collector -eq "Place" })[0] + + $collector.error | Should -Be "Place unavailable" + $collector.exceptionType | Should -Be "System.InvalidOperationException" + $collector.category | Should -Be "PermissionDenied" + $collector.fullyQualifiedErrorId | Should -Match "RbaPlaceFailure" + $collector.innerExceptionMessage | Should -Be "Inner detail" + $collector.error.Length | Should -BeLessOrEqual 2048 + $errorEntry.message | Should -Be $collector.error + $errorEntry.PSObject.Properties.Name | Should -Not -Contain "scriptStackTrace" + $errorEntry.PSObject.Properties.Name | Should -Not -Contain "invocationInfo" + $errorEntry.PSObject.Properties.Name | Should -Not -Contain "targetObject" + $errorEntry.PSObject.Properties.Name | Should -Not -Contain "positionMessage" + } + + It "treats an empty inbox-rule result as successful evidence" { + Mock Get-InboxRule { @() } + + $report = Invoke-TestRbaSummary + + $report.collectors.InboxRules.status | Should -Be "Success" + $report.inboxRules.totalCount | Should -Be 0 + $report.inboxRules.delegateRuleCount | Should -Be 0 + ($report.findings | Where-Object { $_.ruleId -eq "RBA200" }).status | Should -Be "NotDetected" + } + + It "still treats a null scalar collector result as a failure" { + Mock Get-Place { $null } + + $report = Invoke-TestRbaSummary + + $report.collectors.Place.status | Should -Be "Failed" + $report.collectionErrors.collector | Should -Contain "Place" + ($report.findings | Where-Object { $_.ruleId -eq "RBA002" }).status | Should -Be "Detected" + } + + It "captures a Get-Place exception, explains the failure, and continues collection" { + Mock Get-Place { throw "InternalServerError: Error executing cmdlet; token is null" } + + Push-Location -Path $TestDrive + try { + $output = & $Script:scriptPath -Identity "room@contoso.com" -SkipVersionCheck *>&1 | Out-String + $jsonPath = Get-ChildItem -Path $TestDrive -Filter "RBA-Summary-For_room_*.json" | + Sort-Object -Property LastWriteTime | Select-Object -Last 1 + $report = Get-Content -Path $jsonPath.FullName -Raw | ConvertFrom-Json + } finally { + Pop-Location + } + + $output | Should -Match "Get-Place failed to get information from room@contoso.com\. Double-check the setup of the room\." + Assert-MockCalled -CommandName Get-InboxRule -Exactly 1 + Assert-MockCalled -CommandName Get-CalendarProcessing -Exactly 1 + Assert-MockCalled -CommandName Export-MailboxDiagnosticLogs -Exactly 1 + $report.metadata.collectionStatus | Should -Be "Partial" + $report.collectors.Place.status | Should -Be "Failed" + $report.collectors.Place.error | Should -Match "InternalServerError" + $report.collectionErrors.collector | Should -Contain "Place" + } + + It "sanitizes non-target identities and emits the documented finding families by default" { + $report = Invoke-TestRbaSummary + + $report.metadata.privacyMode | Should -Be "Sanitized" + $report.metadata.identity | Should -Be "room@contoso.com" + $report.mailbox.objectState | Should -Be "Active" + $report.mailbox.inputIdentityMatch | Should -Be "PrimarySmtpAddress" + $report.mailbox.PSObject.Properties.Name | Should -Not -Contain "emailAddresses" + $report.mailbox.PSObject.Properties.Name | Should -Not -Contain "exchangeGuid" + $report.calendarProcessing.resourceDelegates | Should -Contain "SanitizedIdentity-2" + $report.calendarProcessing.resourceDelegates | Should -Not -Contain "delegate@contoso.com" + $report.calendarProcessing.PSObject.Properties.Name | Should -Not -Contain "additionalResponse" + $report.PSObject.Properties.Name | Should -Not -Contain "fullRbaLog" + $report.meetingLogSearch.searchSubject | Should -BeNullOrEmpty + $report.meetingLogSearch.status | Should -Be "NotRequested" + $report.meetingLogSearch.sourceOrder | Should -Be "NewestFirst" + $report.meetingLogSearch.eventOrder | Should -Be "NewestFirst" + $report.meetingLogSearch.rawLogChronologicalReadDirection | Should -Be "BottomToTop" + $report.meetingLogSearch.subjectMatchCount | Should -Be 0 + @($report.meetingLogSearch.meetingIds).Count | Should -Be 0 + $report.meetingLogSearch.eventCount | Should -Be 0 + $report.meetingLogSearch.acceptCount | Should -Be 0 + $report.meetingLogSearch.tentativeCount | Should -Be 0 + $report.meetingLogSearch.declineCount | Should -Be 0 + $report.meetingLogSearch.updateCount | Should -Be 0 + $report.meetingLogSearch.cancellationCount | Should -Be 0 + $report.meetingLogSearch.delegateReferralCount | Should -Be 0 + $report.meetingLogSearch.externalSkippedCount | Should -Be 0 + $report.meetingLogSearch.horizonDeclineCount | Should -Be 0 + $report.meetingLogSearch.recurrenceTruncateCount | Should -Be 0 + @($report.meetingLogSearch.events).Count | Should -Be 0 + @($report.findings.ruleId) | Should -Contain "RBA001" + @($report.findings.ruleId) | Should -Contain "RBA100" + @($report.findings.ruleId) | Should -Contain "RBA101" + @($report.findings.ruleId) | Should -Contain "RBA102" + @($report.findings.ruleId) | Should -Contain "RBA200" + @($report.findings.ruleId) | Should -Contain "RBA300" + @($report.findings.ruleId) | Should -Contain "RBA400" + @($report.findings.ruleId) | Should -Contain "RBA500" + @($report.findings.ruleId) | Should -Contain "RBA600" + @($report.findings.ruleId) | Should -Contain "RBA700" + @($report.findings.ruleId) | Should -Contain "RBA703" + @($report.findings.ruleId) | Should -Contain "RBA710" + @($report.findings.ruleId) | Should -Contain "RBA715" + @($report.findings.ruleId) | Should -Contain "RBA801" + @($report.findings.ruleId) | Should -Contain "RBA820" + @($report.findings.ruleId | Sort-Object -Unique).Count | Should -Be @($report.findings).Count + ($report.findings | Where-Object { $_.ruleId -eq "RBA500" }).status | Should -Be "NotApplicable" + ($report.findings | Where-Object { $_.ruleId -eq "RBA411" }).status | Should -Be "Detected" + ($report.findings | Where-Object { $_.ruleId -eq "RBA801" }).status | Should -Be "Detected" + ($report.findings | Where-Object { $_.ruleId -eq "RBA801" }).evidence.accessRights | Should -Contain "AvailabilityOnly" + foreach ($ruleId in @("RBA710", "RBA711", "RBA712", "RBA713", "RBA714", "RBA715")) { + ($report.findings | Where-Object { $_.ruleId -eq $ruleId }).status | Should -Be "NotApplicable" + } + ($report.findings | Where-Object { $_.ruleId -eq "RBA710" }).evidence.searchStatus | Should -Be "NotRequested" + ($report.findings | Where-Object { $_.ruleId -eq "RBA710" }).evidence.subjectMatchCount | Should -Be 0 + ($report.findings | Where-Object { $_.ruleId -eq "RBA711" }).evidence.searchStatus | Should -Be "NotRequested" + ($report.findings | Where-Object { $_.ruleId -eq "RBA711" }).evidence.subjectMatchCount | Should -Be 0 + ($report.findings | Where-Object { $_.ruleId -eq "RBA711" }).evidence.meetingIdCount | Should -Be 0 + ($report.findings | Where-Object { $_.ruleId -eq "RBA711" }).evidence.eventCount | Should -Be 0 + ($report.findings | Where-Object { $_.ruleId -eq "RBA712" }).evidence.updateCount | Should -Be 0 + ($report.findings | Where-Object { $_.ruleId -eq "RBA713" }).evidence.cancellationCount | Should -Be 0 + ($report.findings | Where-Object { $_.ruleId -eq "RBA714" }).evidence.searchStatus | Should -Be "NotRequested" + ($report.findings | Where-Object { $_.ruleId -eq "RBA714" }).evidence.subjectMatchCount | Should -Be 0 + ($report.findings | Where-Object { $_.ruleId -eq "RBA715" }).evidence.declineCount | Should -Be 0 + ($report.findings | Where-Object { $_.ruleId -eq "RBA715" }).evidence.horizonDeclineCount | Should -Be 0 + } + + It "summarizes and writes collected RBA log content after a successful collection" { + $report = Invoke-TestRbaSummary + $logPath = Get-ChildItem -Path $TestDrive -Filter "RBA-Logs_room_*.txt" | + Sort-Object -Property LastWriteTime | Select-Object -Last 1 + + $report.collectors.RbaLog.status | Should -Be "Success" + $report.rbaLogSummary.entryCount | Should -Be 2 + $report.rbaLogSummary.acceptedCount | Should -Be 1 + $logPath | Should -Not -BeNullOrEmpty + $logContent = Get-Content -Path $logPath.FullName -Raw + $logContent | Should -Match "Action:Accept" + $logContent | Should -Match "START - HandleEventInternal Automatic Booking is enabled for resource\." + } + + It "emits delegate-routing and post-processing conditions with minimal evidence" { + Mock Get-CalendarProcessing { + $settings = Get-TestCalendarProcessing + $settings.AllBookInPolicy = $false + $settings.BookInPolicy = @("allowed@contoso.com") + $settings.AddNewRequestsTentatively = $false + $settings.AllRequestOutOfPolicy = $false + $settings.RequestOutOfPolicy = @("exception@contoso.com") + $settings.DeleteComments = $true + $settings + } + + $report = Invoke-TestRbaSummary + + ($report.findings | Where-Object { $_.ruleId -eq "RBA410" }).status | Should -Be "Detected" + ($report.findings | Where-Object { $_.ruleId -eq "RBA412" }).evidence.bookInPolicyCount | Should -Be 1 + ($report.findings | Where-Object { $_.ruleId -eq "RBA421" }).status | Should -Be "Detected" + ($report.findings | Where-Object { $_.ruleId -eq "RBA600" }).status | Should -Be "Detected" + } + + It "does not report no delegates as a fault when all valid requests auto-book" { + Mock Get-CalendarProcessing { + $settings = Get-TestCalendarProcessing + $settings.ResourceDelegates = @() + $settings.AllBookInPolicy = $true + $settings.AllRequestOutOfPolicy = $false + $settings.RequestOutOfPolicy = @() + $settings + } + + $report = Invoke-TestRbaSummary + + ($report.findings | Where-Object { $_.ruleId -eq "RBA400" }).status | Should -Be "Detected" + ($report.findings | Where-Object { $_.ruleId -eq "RBA400" }).severity | Should -Be "Information" + ($report.findings | Where-Object { $_.ruleId -in @("RBA401", "RBA402", "RBA403") -and $_.status -eq "Detected" }) | Should -BeNullOrEmpty + } + + It "reports restrictive booking and post-processing policy consequences" { + Mock Get-CalendarProcessing { + $settings = Get-TestCalendarProcessing + $settings.BookingWindowInDays = 30 + $settings.MaximumDurationInMinutes = 60 + $settings.AllowRecurringMeetings = $false + $settings.ScheduleOnlyDuringWorkHours = $true + $settings.AllowConflicts = $true + $settings.ConflictPercentageAllowed = 25 + $settings.MaximumConflictInstances = 3 + $settings.ProcessExternalMeetingMessages = $false + $settings.RemovePrivateProperty = $true + $settings.DeleteSubject = $true + $settings.AddOrganizerToSubject = $true + $settings.RemoveCanceledMeetings = $false + $settings + } + + $report = Invoke-TestRbaSummary + + ($report.findings | Where-Object { $_.ruleId -eq "RBA302" }).evidence.bookingWindowInDays | Should -Be 30 + ($report.findings | Where-Object { $_.ruleId -eq "RBA303" }).status | Should -Be "Detected" + ($report.findings | Where-Object { $_.ruleId -eq "RBA304" }).status | Should -Be "Detected" + @($report.findings | Where-Object { $_.ruleId -in @("RBA305", "RBA306") -and $_.status -ne "NotApplicable" }) | Should -BeNullOrEmpty + ($report.findings | Where-Object { $_.ruleId -eq "RBA307" }).status | Should -Be "Detected" + ($report.findings | Where-Object { $_.ruleId -eq "RBA308" }).status | Should -Be "Detected" + @($report.findings | Where-Object { $_.ruleId -in @("RBA309", "RBA310") -and $_.status -ne "NotApplicable" }) | Should -BeNullOrEmpty + ($report.findings | Where-Object { $_.ruleId -eq "RBA311" }).status | Should -Be "Detected" + ($report.findings | Where-Object { $_.ruleId -eq "RBA601" }).status | Should -Be "Detected" + ($report.findings | Where-Object { $_.ruleId -eq "RBA602" }).evidence.addOrganizerToSubject | Should -BeTrue + ($report.findings | Where-Object { $_.ruleId -eq "RBA603" }).evidence.deleteSubject | Should -BeTrue + ($report.findings | Where-Object { $_.ruleId -eq "RBA604" }).status | Should -Be "Detected" + } + + It "reports recurring conflict thresholds and the non-enforced horizon behavior" { + Mock Get-CalendarProcessing { + $settings = Get-TestCalendarProcessing + $settings.AllowRecurringMeetings = $true + $settings.AllowConflicts = $false + $settings.ConflictPercentageAllowed = 10 + $settings.MaximumConflictInstances = 2 + $settings.EnforceSchedulingHorizon = $false + $settings + } + + $report = Invoke-TestRbaSummary + + ($report.findings | Where-Object { $_.ruleId -eq "RBA305" }).status | Should -Be "NotDetected" + ($report.findings | Where-Object { $_.ruleId -eq "RBA306" }).status | Should -Be "Detected" + ($report.findings | Where-Object { $_.ruleId -eq "RBA309" }).evidence.conflictPercentageAllowed | Should -Be 10 + ($report.findings | Where-Object { $_.ruleId -eq "RBA310" }).evidence.maximumConflictInstances | Should -Be 2 + } + + It "distinguishes observed recurrence horizon outcomes from configuration" { + Mock Export-MailboxDiagnosticLogs { + [PSCustomObject]@{ + MailboxLog = @( + "2026-08-28, Entry Action: Message, LogComment: Action:Decline" + "2026-08-28, Truncating meeting recurrence end window (endBookingWindowLocal) from X to Y" + "2026-08-28, Recurrence ends is past the booking window. Meeting will be declined." + "2026-08-28, START - HandleEventInternal Automatic Booking is enabled for resource." + ) -join "`r`n" + } + } + + $report = Invoke-TestRbaSummary + + $report.rbaLogSummary.horizonDeclineCount | Should -Be 1 + $report.rbaLogSummary.recurrenceTruncateCount | Should -Be 1 + ($report.findings | Where-Object { $_.ruleId -eq "RBA704" }).status | Should -Be "Detected" + ($report.findings | Where-Object { $_.ruleId -eq "RBA705" }).status | Should -Be "Detected" + } + + It "collects every retained RBA processing block for meeting IDs found by subject" { + $meetingIdWithComma = "040000008,00E00074C5A7101A82E007000000004220FC5BAC74D90100000000000000001000000068B165058D1E2E439252F58379D4FE92" + $expectedMeetingId = $meetingIdWithComma -replace ',', '' + $unrelatedMeetingId = "040000008200E00074C5B7101A82E00800000000FFFFFFFFFFFFFFFF" + Mock Export-MailboxDiagnosticLogs { + [PSCustomObject]@{ + MailboxLog = @( + "2026-08-22T10:00:03Z, Cancellation processing completed." + "MeetingId: $expectedMeetingId" + "It's a meeting cancellation." + "2026-08-22T10:00:00Z, START - HandleEventInternal Automatic Booking is enabled for resource." + "2026-08-21T10:00:04Z, END - Sending the acceptance response to organizer." + "2026-08-21T10:00:03Z, Entry Action: Message, LogComment: Action:Accept" + "2026-08-21T10:00:01Z, Begin ProcessUpdateRequest Goid: $expectedMeetingId" + "2026-08-21T10:00:00Z, START - HandleEventInternal Automatic Booking is enabled for resource." + "2026-08-20T10:00:05Z, END - Sending the acceptance response to organizer." + "2026-08-20T10:00:04Z, PostProcessing completed on ItemId." + "" + "2026-08-20T10:00:03Z, Entry Action: Message, LogComment: Action:Accept" + "2026-08-20T10:00:02Z, Sending approval messages to 1 delegates." + "2026-08-20T10:00:02Z, Forwarding Request To Delegates." + "2026-08-20T10:00:02Z, END - Sending the tentatively acceptance response to organizer." + "2026-08-20T10:00:02Z, Meeting request evaluate returns result Tentative" + "2026-08-20T10:00:02Z, Defaulting to in policy." + "2026-08-20T10:00:02Z, Received Request from: Organizer subject Project Falcon" + "2026-08-20T10:00:01Z, Begin ProcessRequest Goid: $meetingIdWithComma" + "2026-08-20T10:00:00Z, START - HandleEventInternal Automatic Booking is enabled for resource." + "2026-08-19T10:00:03Z, Entry Action: Message, LogComment: Action:Decline" + "Subject: Different meeting" + "2026-08-19T10:00:01Z, Begin ProcessRequest Goid: $unrelatedMeetingId" + "2026-08-19T10:00:00Z, START - HandleEventInternal Automatic Booking is enabled for resource." + ) -join "`r`n" + } + } + + $report = Invoke-TestRbaSummary -Subject "project falcon" + + $report.metadata.privacyMode | Should -Be "TargetedMeeting" + $report.metadata.commandLine | Should -Be ".\Get-RBASummary.ps1 -Identity 'room@contoso.com' -Subject 'project falcon' -SkipVersionCheck:true" + $report.PSObject.Properties.Name | Should -Not -Contain "fullRbaLog" + $report.meetingLogSearch.status | Should -Be "Found" + $report.meetingLogSearch.searchType | Should -Be "Subject" + $report.meetingLogSearch.searchMeetingId | Should -BeNullOrEmpty + $report.meetingLogSearch.subjectMatchCount | Should -Be 1 + $report.meetingLogSearch.meetingIds | Should -Contain $expectedMeetingId + $report.meetingLogSearch.eventCount | Should -Be 3 + $report.meetingLogSearch.updateCount | Should -Be 1 + $report.meetingLogSearch.cancellationCount | Should -Be 1 + $report.meetingLogSearch.declineCount | Should -Be 0 + $report.meetingLogSearch.firstLogTimeText.ToUniversalTime().ToString("o") | Should -Be "2026-08-20T10:00:01.0000000Z" + $report.meetingLogSearch.lastLogTimeText.ToUniversalTime().ToString("o") | Should -Be "2026-08-22T10:00:03.0000000Z" + $report.meetingLogSearch.lastUpdateTimeText.ToUniversalTime().ToString("o") | Should -Be "2026-08-21T10:00:01.0000000Z" + $report.meetingLogSearch.recurrenceStatus | Should -Be "Unknown" + $report.meetingLogSearch.policyResult | Should -Be "InPolicy" + $report.meetingLogSearch.disposition | Should -Be "Multiple" + $report.meetingLogSearch.forwardedToDelegates | Should -BeTrue + $report.meetingLogSearch.delegateMessageCount | Should -Be 1 + $report.meetingLogSearch.tentativeResponseSent | Should -BeTrue + $report.meetingLogSearch.sourceOrder | Should -Be "NewestFirst" + $report.meetingLogSearch.eventOrder | Should -Be "NewestFirst" + $report.meetingLogSearch.rawLogChronologicalReadDirection | Should -Be "BottomToTop" + $report.meetingLogSearch.events[0].cancellationDetected | Should -BeTrue + $report.meetingLogSearch.events[1].updateDetected | Should -BeTrue + $initialEvent = $report.meetingLogSearch.events[2] + $initialEvent.subjectMatched | Should -BeTrue + $initialEvent.startBoundaryFound | Should -BeTrue + $initialEvent.boundaryStatus | Should -Be "BetweenExactStartBoundaries" + $initialEvent.startMarker | Should -Be "2026-08-20T10:00:00Z, START - HandleEventInternal Automatic Booking is enabled for resource." + $initialEvent.startTimeText.ToUniversalTime().ToString("o") | Should -Be "2026-08-20T10:00:00.0000000Z" + $initialEvent.eventTimeText | Should -Be $initialEvent.startTimeText + $initialEvent.rawLogOrder | Should -Be "NewestFirst" + $initialEvent.chronologicalReadDirection | Should -Be "BottomToTop" + $initialEvent.rawLog[0] | Should -Be "2026-08-20T10:00:05Z, END - Sending the acceptance response to organizer." + $initialEvent.rawLog[-1] | Should -Be $initialEvent.startMarker + @($initialEvent.rawLog).Count | Should -Be 11 + $initialEvent.rawLog | Should -Not -Contain "" + $initialEvent.rawLog | Should -Contain "2026-08-20T10:00:04Z, PostProcessing completed on ItemId." + $initialEvent.rawLog | Should -Contain "2026-08-20T10:00:01Z, Begin ProcessRequest Goid: $meetingIdWithComma" + @($report.meetingLogSearch.events.rawLog | Where-Object { $_ -match "Different meeting" }) | Should -BeNullOrEmpty + @($report.meetingLogSearch.events.meetingIds | Where-Object { $_ -eq $unrelatedMeetingId }) | Should -BeNullOrEmpty + ($report.findings | Where-Object { $_.ruleId -eq "RBA711" }).status | Should -Be "Detected" + ($report.findings | Where-Object { $_.ruleId -eq "RBA712" }).status | Should -Be "Detected" + ($report.findings | Where-Object { $_.ruleId -eq "RBA713" }).status | Should -Be "Detected" + ($report.findings | Where-Object { $_.ruleId -eq "RBA715" }).status | Should -Be "NotDetected" + + $summary = Get-ChildItem -Path $TestDrive -Filter "RBA-Summary-For_room_*.txt" | + Sort-Object -Property LastWriteTime | Select-Object -Last 1 + $summaryContent = Get-Content -Path $summary.FullName -Raw + $summaryContent | Should -Match ([regex]::Escape("Command line: .\Get-RBASummary.ps1 -Identity 'room@contoso.com' -Subject 'project falcon' -SkipVersionCheck:true")) + $summaryContent | Should -Match "Targeted meeting search:" + $summaryContent | Should -Match "Search result\s+Found" + $summaryContent | Should -Match ([regex]::Escape($expectedMeetingId)) + $summaryContent | Should -Match "subsequent correlation uses the meeting ID" + $summaryContent | Should -Match "First meeting log\s+2026-08-20T10:00:01Z" + $summaryContent | Should -Match "Last meeting update\s+2026-08-21T10:00:01Z" + $summaryContent | Should -Match "Policy result\s+In policy" + $summaryContent | Should -Match "Disposition\s+Multiple" + $summaryContent | Should -Match "Tentative response sent\s+Yes" + $summaryContent | Should -Match "Forwarded to delegates\s+Yes" + $summaryContent | Should -Match "Delegate approval messages\s+1" + $summaryContent.IndexOf("Last updated") | Should -BeLessThan $summaryContent.IndexOf("Targeted meeting search:") + } + + It "switches from subject discovery to meeting ID-only correlation" { + $expectedMeetingId = "04000000800E00074C5A7101A82E007000000004220FC5BAC74D90100000000000000001000000068B165058D1E2E439252F58379D4FE92" + Mock Export-MailboxDiagnosticLogs { + [PSCustomObject]@{ + MailboxLog = @( + "Subject: ClassicOnly" + "2026-08-22T10:00:00Z, START - HandleEventInternal Automatic Booking is enabled for resource." + "Subject: ClassicOnly" + "2026-08-21T10:00:01Z, Begin ProcessRequest Goid: $expectedMeetingId" + "2026-08-21T10:00:00Z, START - HandleEventInternal Automatic Booking is enabled for resource." + ) -join "`r`n" + } + } + + $report = Invoke-TestRbaSummary -Subject "ClassicOnly" + + $report.meetingLogSearch.status | Should -Be "Found" + $report.meetingLogSearch.subjectMatchCount | Should -Be 2 + $report.meetingLogSearch.eventCount | Should -Be 1 + $report.meetingLogSearch.events[0].meetingIds | Should -Contain $expectedMeetingId + } + + It "separates outcomes when a subject resolves to multiple meeting IDs" { + $newerMeetingId = "040000008200E00074C5B7101A82E0080000000040102B6651CBDC01000000000000000010000000F88270875E4D8C4EAE68086FFC170C60" + $olderMeetingId = "040000008200E00074C5B7101A82E0080000000060043C1FE2C5DC01000000000000000010000000849AA4DF567BE0499C0A21B37BE890E1" + Mock Export-MailboxDiagnosticLogs { + [PSCustomObject]@{ + MailboxLog = @( + "2026-08-22T10:00:05Z, Sending approval messages to 1 delegates." + "2026-08-22T10:00:04Z, Forwarding Request To Delegates." + "2026-08-22T10:00:03Z, Entry Action:Tentative, Subject :Classic newer" + "2026-08-22T10:00:02Z, Defaulting to in policy." + "2026-08-22T10:00:01Z, Begin ProcessRequest Goid: $newerMeetingId" + "2026-08-22T10:00:00Z, START - HandleEventInternal Automatic Booking is enabled for resource." + "2026-08-21T10:00:03Z, Entry Action:Decline, Subject :Classic older" + "2026-08-21T10:00:02Z, Not in policy." + "2026-08-21T10:00:01Z, Begin ProcessRequest Goid: $olderMeetingId" + "2026-08-21T10:00:00Z, START - HandleEventInternal Automatic Booking is enabled for resource." + ) -join "`r`n" + } + } + + $report = Invoke-TestRbaSummary -Subject "Classic" + + $report.meetingLogSearch.meetingIds.Count | Should -Be 2 + $report.meetingLogSearch.meetings.Count | Should -Be 2 + $newerMeeting = $report.meetingLogSearch.meetings | Where-Object { $_.meetingId -eq $newerMeetingId } + $newerMeeting.policyResult | Should -Be "InPolicy" + $newerMeeting.disposition | Should -Be "Tentative" + $newerMeeting.forwardedToDelegates | Should -BeTrue + $newerMeeting.delegateMessageCount | Should -Be 1 + $olderMeeting = $report.meetingLogSearch.meetings | Where-Object { $_.meetingId -eq $olderMeetingId } + $olderMeeting.policyResult | Should -Be "OutOfPolicy" + $olderMeeting.disposition | Should -Be "Decline" + $olderMeeting.forwardedToDelegates | Should -BeFalse + + $summary = Get-ChildItem -Path $TestDrive -Filter "RBA-Summary-For_room_*.txt" | + Sort-Object -Property LastWriteTime | Select-Object -Last 1 + $summaryContent = Get-Content -Path $summary.FullName -Raw + $summaryContent | Should -Match "subject matched 2 meeting IDs" + $summaryContent | Should -Match "Meeting 1 of 2:" + $summaryContent | Should -Match "Meeting 2 of 2:" + $summaryContent | Should -Match ([regex]::Escape($newerMeetingId)) + $summaryContent | Should -Match ([regex]::Escape($olderMeetingId)) + $summaryContent | Should -Match "Policy result\s+In policy" + $summaryContent | Should -Match "Policy result\s+Out of policy" + $summaryContent | Should -Match "Disposition\s+Tentatively accepted" + $summaryContent | Should -Match "Disposition\s+Declined" + } + + It "reports an in-policy tentative meeting forwarded to resource delegates" { + $expectedMeetingId = "040000008200E00074C5B7101A82E0080000000040102B6651CBDC01000000000000000010000000F88270875E4D8C4EAE68086FFC170C60" + Mock Export-MailboxDiagnosticLogs { + [PSCustomObject]@{ + MailboxLog = @( + "04/13/2026 19:26:29, END - Sending the tentatively acceptance response to organizer." + "04/13/2026 19:26:28, Sending approval messages to 1 delegates." + "04/13/2026 19:26:28, Forwarding Request To Delegates." + "04/13/2026 19:26:26, Entry Action:Tentative, Subject :ClassicOnly" + "04/13/2026 19:26:21, Meeting request evaluate returns result Tentative" + "04/13/2026 19:26:20, Sender has RequestInPolicy." + "04/13/2026 19:26:20, Evaluate: Completed IsRequestInPolicy." + "04/13/2026 19:26:20, Defaulting to in policy." + "04/13/2026 19:26:18, Begin ProcessRequest Goid: $expectedMeetingId" + "04/13/2026 19:26:18, START - HandleEventInternal Automatic Booking is enabled for resource." + ) -join "`r`n" + } + } + + $report = Invoke-TestRbaSummary -Subject "ClassicOnly" + + $report.meetingLogSearch.policyResult | Should -Be "InPolicy" + $report.meetingLogSearch.disposition | Should -Be "Tentative" + $report.meetingLogSearch.tentativeResponseSent | Should -BeTrue + $report.meetingLogSearch.forwardedToDelegates | Should -BeTrue + $report.meetingLogSearch.delegateMessageCount | Should -Be 1 + $report.meetingLogSearch.events[0].policyResult | Should -Be "InPolicy" + $report.meetingLogSearch.events[0].disposition | Should -Be "Tentative" + $report.meetingLogSearch.events[0].delegateMessageCount | Should -Be 1 + $report.meetingLogSearch.events[0].tentativeResponseSent | Should -BeTrue + $report.meetingLogSearch.events[0].PSObject.Properties.Name | Should -Contain "policyResult" + $report.meetingLogSearch.events[0].PSObject.Properties.Name | Should -Contain "disposition" + $report.meetingLogSearch.events[0].PSObject.Properties.Name | Should -Contain "delegateMessageCount" + $report.meetingLogSearch.events[0].PSObject.Properties.Name | Should -Contain "tentativeResponseSent" + + $summary = Get-ChildItem -Path $TestDrive -Filter "RBA-Summary-For_room_*.txt" | + Sort-Object -Property LastWriteTime | Select-Object -Last 1 + $summaryContent = Get-Content -Path $summary.FullName -Raw + $summaryContent | Should -Match "Policy result\s+In policy" + $summaryContent | Should -Match "Disposition\s+Tentatively accepted" + $summaryContent | Should -Match "Tentative response sent\s+Yes" + $summaryContent | Should -Match "Forwarded to delegates\s+Yes" + $summaryContent | Should -Match "Delegate approval messages\s+1" + } + + It "accepts a MeetingId and returns all retained blocks for that ID" { + $meetingIdWithComma = "040000008,00E00074C5A7101A82E007000000004220FC5BAC74D90100000000000000001000000068B165058D1E2E439252F58379D4FE92" + $expectedMeetingId = $meetingIdWithComma -replace ',', '' + Mock Export-MailboxDiagnosticLogs { + [PSCustomObject]@{ + MailboxLog = @( + "2026-08-22T10:00:01Z, Begin ProcessUpdateRequest Goid: $expectedMeetingId" + "2026-08-22T10:00:00Z, START - HandleEventInternal Automatic Booking is enabled for resource." + "2026-08-21T10:00:02Z, IsRecurring: True" + "2026-08-21T10:00:01Z, Begin ProcessRequest Goid: $meetingIdWithComma" + "2026-08-21T10:00:00Z, START - HandleEventInternal Automatic Booking is enabled for resource." + ) -join "`r`n" + } + } + + $report = Invoke-TestRbaSummary -MeetingId $meetingIdWithComma + + $report.metadata.privacyMode | Should -Be "TargetedMeeting" + $report.meetingLogSearch.searchType | Should -Be "MeetingId" + $report.meetingLogSearch.searchSubject | Should -BeNullOrEmpty + $report.meetingLogSearch.searchMeetingId | Should -Be $expectedMeetingId + $report.meetingLogSearch.status | Should -Be "Found" + $report.meetingLogSearch.meetingIds | Should -Contain $expectedMeetingId + $report.meetingLogSearch.eventCount | Should -Be 2 + $report.meetingLogSearch.updateCount | Should -Be 1 + $report.meetingLogSearch.recurrenceStatus | Should -Be "Recurring" + $report.meetingLogSearch.firstLogTimeText.ToUniversalTime().ToString("o") | Should -Be "2026-08-21T10:00:01.0000000Z" + $report.meetingLogSearch.lastUpdateTimeText.ToUniversalTime().ToString("o") | Should -Be "2026-08-22T10:00:01.0000000Z" + } + + It "rejects Subject and MeetingId when supplied together" { + { & $Script:scriptPath -Identity "room@contoso.com" -Subject "ClassicOnly" -MeetingId "04000000800E00074C5A7101A82E00700000000" -SkipVersionCheck } | + Should -Throw "Specify either Subject or MeetingId, not both." + } + + It "reports that a subject is not found without claiming the meeting was never processed" { + $report = Invoke-TestRbaSummary -Subject "Missing meeting" + + $report.metadata.privacyMode | Should -Be "TargetedMeeting" + $report.meetingLogSearch.status | Should -Be "NotFound" + $report.meetingLogSearch.eventCount | Should -Be 0 + $report.meetingLogSearch.firstLogTimeText | Should -BeNullOrEmpty + $report.meetingLogSearch.lastUpdateTimeText | Should -BeNullOrEmpty + $report.meetingLogSearch.recurrenceStatus | Should -Be "Unknown" + ($report.findings | Where-Object { $_.ruleId -eq "RBA710" }).status | Should -Be "Detected" + ($report.findings | Where-Object { $_.ruleId -eq "RBA711" }).status | Should -Be "NotDetected" + + $summary = Get-ChildItem -Path $TestDrive -Filter "RBA-Summary-For_room_*.txt" | + Sort-Object -Property LastWriteTime | Select-Object -Last 1 + $summaryContent = Get-Content -Path $summary.FullName -Raw + $summaryContent | Should -Not -Match "First meeting log" + } + + It "accepts MeetingSubject as a compatibility alias for Subject" { + Push-Location -Path $TestDrive + try { + & $Script:scriptPath -Identity "room@contoso.com" -MeetingSubject "Missing meeting" -SkipVersionCheck + $jsonPath = Get-ChildItem -Path $TestDrive -Filter "RBA-Summary-For_room_*.json" | + Sort-Object -Property LastWriteTime | Select-Object -Last 1 + $report = Get-Content -Path $jsonPath.FullName -Raw | ConvertFrom-Json + } finally { + Pop-Location + } + + $report.metadata.privacyMode | Should -Be "TargetedMeeting" + $report.meetingLogSearch.searchSubject | Should -Be "Missing meeting" + } + + It "keeps Calendar access, booking delegates, and post-processing as separate findings" { + Mock Get-MailboxFolderPermission { + @( + [PSCustomObject]@{ + User = "Default" + AccessRights = @("LimitedDetails") + SharingPermissionFlags = @() + } + [PSCustomObject]@{ + User = "calendar.owner@contoso.com" + AccessRights = @("Owner") + SharingPermissionFlags = @() + } + ) + } + + $report = Invoke-TestRbaSummary + + ($report.findings | Where-Object { $_.ruleId -eq "RBA801" }).evidence.accessRights | Should -Contain "LimitedDetails" + ($report.findings | Where-Object { $_.ruleId -eq "RBA802" }).status | Should -Be "Detected" + ($report.findings | Where-Object { $_.ruleId -eq "RBA803" }).status | Should -Be "Detected" + ($report.findings | Where-Object { $_.ruleId -eq "RBA804" }).evidence.relatedRuleIds | Should -Contain "RBA602" + ($report.findings | Where-Object { $_.ruleId -eq "RBA805" }).evidence.relatedRuleIds | Should -Contain "RBA601" + $report.calendarPermissions.entries.principal | Should -Contain "Default" + $report.calendarPermissions.entries.principal | Should -Contain "SanitizedIdentity-3" + $report.calendarPermissions.entries.principal | Should -Not -Contain "calendar.owner@contoso.com" + } + + It "uses stable sanitized identities across processing and permission sections" { + Mock Get-CalendarProcessing { + $settings = Get-TestCalendarProcessing + $settings.RequestOutOfPolicy = @(" Shared.User@Contoso.com ") + $settings.BookInPolicy = @("different.user@contoso.com") + $settings.RequestInPolicy = @("SHARED.USER@CONTOSO.COM") + $settings.ResourceDelegates = @("shared.user@contoso.com") + $settings + } + Mock Get-MailboxFolderPermission { + @( + [PSCustomObject]@{ + User = "Default" + AccessRights = @("AvailabilityOnly") + SharingPermissionFlags = @() + } + [PSCustomObject]@{ + User = "sHaReD.uSeR@cOnToSo.cOm" + AccessRights = @("Editor") + SharingPermissionFlags = @("Delegate") + } + [PSCustomObject]@{ + User = "Anonymous" + AccessRights = @("None") + SharingPermissionFlags = @() + } + ) + } + Mock Get-MailboxPermission { + @([PSCustomObject]@{ + User = "SHARED.USER@CONTOSO.COM" + AccessRights = @("FullAccess") + IsInherited = $false + Deny = $false + }) + } + + $report = Invoke-TestRbaSummary + + $sharedIdentity = @($report.calendarProcessing.requestOutOfPolicy)[0] + $differentIdentity = @($report.calendarProcessing.bookInPolicy)[0] + $sharedIdentity | Should -Match "^SanitizedIdentity-\d+$" + $differentIdentity | Should -Match "^SanitizedIdentity-\d+$" + $differentIdentity | Should -Not -Be $sharedIdentity + @($report.calendarProcessing.requestInPolicy)[0] | Should -Be $sharedIdentity + @($report.calendarProcessing.resourceDelegates)[0] | Should -Be $sharedIdentity + ($report.calendarPermissions.entries | Where-Object { $_.principal -like "SanitizedIdentity-*" }).principal | Should -Be $sharedIdentity + @($report.mailboxPermissions.grantees)[0] | Should -Be $sharedIdentity + $report.calendarPermissions.entries.principal | Should -Contain "Default" + $report.calendarPermissions.entries.principal | Should -Contain "Anonymous" + $report.PSObject.Properties.Name | Should -Not -Contain "SanitizedIdentityMap" + } + + It "assigns separate placeholders when an identity has no stable key" { + Mock Get-CalendarProcessing { + $settings = Get-TestCalendarProcessing + $settings.RequestOutOfPolicy = @($null, "") + $settings + } + + $report = Invoke-TestRbaSummary + + $report.calendarProcessing.requestOutOfPolicy.Count | Should -Be 2 + $report.calendarProcessing.requestOutOfPolicy[0] | Should -Match "^SanitizedIdentity-\d+$" + $report.calendarProcessing.requestOutOfPolicy[1] | Should -Match "^SanitizedIdentity-\d+$" + $report.calendarProcessing.requestOutOfPolicy[0] | Should -Not -Be $report.calendarProcessing.requestOutOfPolicy[1] + } + + It "preserves the target identity in CalendarProcessing recipient wells" { + Mock Get-CalendarProcessing { + $settings = Get-TestCalendarProcessing + $settings.RequestOutOfPolicy = @("ROOM@CONTOSO.COM") + $settings + } + + $report = Invoke-TestRbaSummary + + $report.calendarProcessing.requestOutOfPolicy | Should -Contain "ROOM@CONTOSO.COM" + } + + It "matches a resource delegate through its resolved SMTP identity" { + Mock Get-CalendarProcessing { + $settings = Get-TestCalendarProcessing + $settings.ResourceDelegates = @("Delegate Directory Identity") + $settings + } + Mock Get-Recipient { + [PSCustomObject]@{ + DisplayName = "Resolved delegate" + PrimarySmtpAddress = "delegate@contoso.com" + } + } + + $report = Invoke-TestRbaSummary + + ($report.findings | Where-Object { $_.ruleId -eq "RBA803" }).status | Should -Be "NotDetected" + } + + It "reports explicit Full Access without claiming direct Calendar editing" { + Mock Get-MailboxPermission { + @( + [PSCustomObject]@{ + User = "NT AUTHORITY\SELF" + AccessRights = @("FullAccess") + IsInherited = $false + Deny = $false + } + [PSCustomObject]@{ + User = "operator@contoso.com" + AccessRights = @("FullAccess") + IsInherited = $false + Deny = $false + } + ) + } + + $report = Invoke-TestRbaSummary + + ($report.findings | Where-Object { $_.ruleId -eq "RBA820" }).status | Should -Be "Detected" + ($report.findings | Where-Object { $_.ruleId -eq "RBA820" }).evidence.explicitFullAccessCount | Should -Be 1 + $report.mailboxPermissions.grantees | Should -Contain "SanitizedIdentity-3" + $report.mailboxPermissions.grantees | Should -Not -Contain "operator@contoso.com" + @($report.findings.ruleId) | Should -Not -Contain "RBA830" + } + + It "marks permission findings not evaluated when permission collection fails" { + Mock Get-MailboxFolderPermission { throw "Calendar permissions unavailable" } + Mock Get-MailboxPermission { throw "Mailbox permissions unavailable" } + + $report = Invoke-TestRbaSummary + + $report.metadata.collectionStatus | Should -Be "Partial" + ($report.findings | Where-Object { $_.ruleId -eq "RBA006" }).status | Should -Be "Detected" + ($report.findings | Where-Object { $_.ruleId -eq "RBA007" }).status | Should -Be "Detected" + ($report.findings | Where-Object { $_.ruleId -eq "RBA801" }).status | Should -Be "NotEvaluated" + ($report.findings | Where-Object { $_.ruleId -eq "RBA820" }).status | Should -Be "NotEvaluated" + } + + It "fully enumerates folder statistics before selecting the Calendar folder" { + Mock Get-MailboxFolderStatistics { + [PSCustomObject]@{ Name = "Calendar"; FolderType = "Calendar" } + [PSCustomObject]@{ Name = "Inbox"; FolderType = "Inbox" } + } + + $report = Invoke-TestRbaSummary + + $report.collectors.CalendarFolderPermissions.status | Should -Be "Success" + Assert-MockCalled -CommandName Get-MailboxFolderStatistics -Exactly 1 + Assert-MockCalled -CommandName Get-MailboxFolderPermission -Exactly 1 -ParameterFilter { + $Identity -eq "room@contoso.com:\Calendar" + } + } + + It "keeps Calendar permissions successful when CalendarProcessing fails" { + Mock Get-CalendarProcessing { throw "Calendar processing unavailable" } + + $report = Invoke-TestRbaSummary + + $report.metadata.collectionStatus | Should -Be "Partial" + $report.collectors.CalendarProcessing.status | Should -Be "Failed" + $report.collectors.CalendarFolderPermissions.status | Should -Be "Success" + ($report.findings | Where-Object { $_.ruleId -eq "RBA006" }).status | Should -Be "NotDetected" + ($report.findings | Where-Object { $_.ruleId -eq "RBA803" }).status | Should -Be "NotEvaluated" + ($report.findings | Where-Object { $_.ruleId -eq "RBA803" }).evidence.configuredDelegateCount | Should -Be 0 + ($report.findings | Where-Object { $_.ruleId -eq "RBA803" }).evidence.unmatchedIdentityCount | Should -Be 0 + Assert-MockCalled -CommandName Get-Mailbox -Exactly 1 + Assert-MockCalled -CommandName Get-Place -Exactly 1 + Assert-MockCalled -CommandName Get-InboxRule -Exactly 1 + Assert-MockCalled -CommandName Get-CalendarProcessing -Exactly 1 + Assert-MockCalled -CommandName Get-MailboxFolderPermission -Exactly 1 + Assert-MockCalled -CommandName Get-MailboxPermission -Exactly 1 + Assert-MockCalled -CommandName Export-MailboxDiagnosticLogs -Exactly 1 + } + + It "includes full-fidelity identities, RBA log, and transcript only when requested" { + Mock Get-MailboxPermission { + @([PSCustomObject]@{ + User = "operator@contoso.com" + AccessRights = @("FullAccess") + IsInherited = $false + Deny = $false + }) + } + + $report = Invoke-TestRbaSummary -IncludeSensitiveData + + $report.metadata.privacyMode | Should -Be "Full" + $report.calendarProcessing.resourceDelegates | Should -Contain "delegate@contoso.com" + $report.calendarProcessing.additionalResponse | Should -Be "Contact delegate@contoso.com" + $report.calendarPermissions.entries.principal | Should -Contain "delegate@contoso.com" + $report.mailboxPermissions.grantees | Should -Contain "operator@contoso.com" + $report.mailbox.emailAddresses | Should -Contain "smtp:old-room@contoso.com" + $report.mailbox.exchangeGuid | Should -Be "11111111-1111-1111-1111-111111111111" + $report.mailbox.externalDirectoryId | Should -Be "22222222-2222-2222-2222-222222222222" + @($report.fullRbaLog).Count | Should -BeGreaterThan 0 + $report.transcript | Should -Not -BeNullOrEmpty + } + + It "writes full-fidelity output without room lists when Place collection fails" { + Mock Get-Place { throw "Place unavailable" } + + $report = Invoke-TestRbaSummary -IncludeSensitiveData + + $report.metadata.privacyMode | Should -Be "Full" + $report.metadata.collectionStatus | Should -Be "Partial" + $report.collectors.Place.status | Should -Be "Failed" + $report.place | Should -BeNullOrEmpty + $report.evaluationErrors | Should -BeNullOrEmpty + } +} + +Describe "RBA log processing block extraction" { + BeforeEach { + Initialize-StandardMocks + Mock Get-Mailbox { + [PSCustomObject]@{ + Identity = "room@contoso.com" + PrimarySmtpAddress = "room@contoso.com" + EmailAddresses = @("SMTP:room@contoso.com") + RecipientTypeDetails = "RoomMailbox" + ResourceType = "Room" + } + } + } + + It "uses only the exact RBA START marker as a boundary" { + Mock Export-MailboxDiagnosticLogs { + [PSCustomObject]@{ + MailboxLog = @( + "2026-08-28T10:00:03Z, END - Sending the acceptance response to organizer." + "2026-08-28T10:00:02Z, START - Retry checkpoint" + "2026-08-28T10:00:01Z, Subject: Boundary test" + "2026-08-28T10:00:00Z, START - HandleEventInternal Automatic Booking is enabled for resource." + "2026-08-27T10:00:02Z, Older retained result" + "2026-08-27T10:00:01Z, START - Generic older marker" + ) -join "`r`n" + } + } + + $report = Invoke-TestRbaSummary -Subject "Boundary test" + $report.meetingLogSearch.eventCount | Should -Be 1 + $report.meetingLogSearch.events[0].startBoundaryFound | Should -BeTrue + $report.meetingLogSearch.events[0].rawLog | Should -Contain "2026-08-28T10:00:02Z, START - Retry checkpoint" + $report.meetingLogSearch.events[0].rawLog.Count | Should -Be 4 + $report.meetingLogSearch.events[0].rawLog | Should -Not -Contain "2026-08-27T10:00:02Z, Older retained result" + } + + It "retains a log with no exact START boundary as one incomplete block" { + Mock Export-MailboxDiagnosticLogs { + [PSCustomObject]@{ + MailboxLog = @( + "2026-08-28T10:00:04Z, Action:Accept" + "2026-08-28T10:00:03Z, Subject: Missing boundary test" + "2026-08-28T10:00:02Z, Begin ProcessRequest Goid: 040000008,00E00074C5A7101A82E007000000004220FC5BAC74D90100000000000000001000000068B165058D1E2E439252F58379D4FE92" + "2026-08-28T10:00:01Z, START - Generic marker" + ) -join "`r`n" + } + } + + $report = Invoke-TestRbaSummary -Subject "Missing boundary test" + $report.meetingLogSearch.eventCount | Should -Be 1 + $report.meetingLogSearch.events[0].startBoundaryFound | Should -BeFalse + $report.meetingLogSearch.events[0].boundaryStatus | Should -Be "MissingStartBoundary" + $report.meetingLogSearch.events[0].startMarker | Should -BeNullOrEmpty + $report.meetingLogSearch.events[0].rawLog.Count | Should -Be 4 + $report.meetingLogSearch.events[0].meetingIds | Should -Contain "04000000800E00074C5A7101A82E007000000004220FC5BAC74D90100000000000000001000000068B165058D1E2E439252F58379D4FE92" + } +} + +Describe "EXO RBA troubleshooting skill package" { + It "combines the skill, package metadata, and all rules into one Markdown file" { + . $Script:packageHelperPath + $destinationPath = Join-Path -Path $TestDrive -ChildPath "EXO-RBA-Troubleshooting-SKILL.md" + + New-RbaTroubleshootingSkillFile ` + -SkillPath (Join-Path -Path $Script:skillPath -ChildPath "SKILL.md") ` + -TsgRulesPath (Join-Path -Path $Script:skillPath -ChildPath "TSG-Rules.md") ` + -RulesPath (Join-Path -Path $Script:skillPath -ChildPath "RBA-Rules.md") ` + -ManifestPath (Join-Path -Path $Script:skillPath -ChildPath "manifest.json") ` + -DestinationPath $destinationPath + + $content = Get-Content -Path $destinationPath -Raw + $content | Should -Match "^---\r?\nname: exo-rba-troubleshooting\r?\n" + $content | Should -Match "Skill version: ``0\.1\.1-preview``" + $content | Should -Match "TSG rules version: ``0\.1\.0-preview``" + $content | Should -Match "Canonical download: https://github.com/microsoft/CSS-Exchange/releases/latest/download/EXO-RBA-Troubleshooting-SKILL\.md" + $content | Should -Match "## TSG core rules" + $content | Should -Match "### TSG002 — No fabrication" + $content | Should -Match "### TSG405 — Escalate instead of guessing" + $content | Should -Match "## RBA finding rules" + $content | Should -Match "## Explicit out-of-scope routing" + $content | Should -Match "## Lifecycle and item propagation triage" + $content | Should -Match "\| Soft-deleted room mailbox \|" + $content | Should -Match "\| Duplicate or stale room object \|" + $content | Should -Match "\| Room rename or SMTP change \|" + $content | Should -Match "\| Former organizer reservations \|" + $content | Should -Match "\| Stale room calendar items \|" + $content | Should -Match "\| Cancellation or update not reflected \|" + $content | Should -Match "\| Orphaned recurring meetings \|" + $content | Should -Match "\| Teams Rooms device health \|" + $content | Should -Match "\| Microsoft Places and PlaceV3 \|" + $content | Should -Match "\| Graph and Power Automate \|" + $content | Should -Match "\| Non-resource calendar sharing and delegation \|" + $content | Should -Match "\| Generic calendar-event repair \|" + $content | Should -Match "\| Copilot Rebook or automated room booking \|" + $content | Should -Match "\| Service availability or processing delay \|" + $content | Should -Match "\| Reporting and audit \|" + $content | Should -Match "A supplied RBA JSON report does not make an otherwise out-of-scope question an RBA question" + $content | Should -Match "\| RBA001 \|" + $content | Should -Match "\| RBA101 \|" + $content | Should -Match "\| RBA102 \|" + $content | Should -Match "\| RBA302 \|" + $content | Should -Match "\| RBA311 \|" + $content | Should -Match "\| RBA604 \|" + $content | Should -Match "\| RBA703 \|" + $content | Should -Match "\| RBA705 \|" + $content | Should -Match "\| RBA710 \|" + $content | Should -Match "\| RBA715 \|" + $content | Should -Match "## Targeted meeting log analysis" + $content | Should -Match "START - HandleEventInternal Automatic Booking is enabled for resource\." + $content | Should -Match "newest-first" + $content | Should -Match "bottom-up" + $content | Should -Match "single-threaded" + $content | Should -Match "## Future decline evidence contract" + $content | Should -Match "\| RBA801 \|" + $content | Should -Match "RBA830" + $content | Should -Not -Match "\[TSG-Rules\.md\]\(TSG-Rules\.md\)" + $content | Should -Not -Match "\[RBA-Rules\.md\]\(RBA-Rules\.md\)" + } +} diff --git a/docs/Calendar/Get-RBASummary.md b/docs/Calendar/Get-RBASummary.md index 251f58f2cb..22aab40e8a 100644 --- a/docs/Calendar/Get-RBASummary.md +++ b/docs/Calendar/Get-RBASummary.md @@ -2,32 +2,193 @@ Download the latest release: [Get-RBASummary.ps1](https://github.com/microsoft/CSS-Exchange/releases/latest/download/Get-RBASummary.ps1) +Download the adjacent analysis skill: [EXO-RBA-Troubleshooting-SKILL.md](https://github.com/microsoft/CSS-Exchange/releases/latest/download/EXO-RBA-Troubleshooting-SKILL.md) -This script runs the Get-CalendarProcessing cmdlet and returns the output with more details in clear English, highlighting the key settings that affect RBA and some of the common errors in configuration. +`Get-RBASummary.ps1` collects Resource Booking Assistant (RBA) configuration and recent processing evidence for one room, equipment, or Workspace mailbox. It produces a readable text summary and a structured JSON report that can be analyzed with the adjacent EXO RBA troubleshooting skill. -The script will also validate the mailbox is the correct type for RBA to interact with (via the Get-Mailbox cmdlet) as well as check for any Delegate rules that would interfere with RBA functionality (via the Get-InboxRules cmdlet). +The script validates mailbox type, booking policy, request routing, delegate configuration, post-processing, room properties, permissions, and recent RBA log activity. It first resolves the identity with `Get-Mailbox`, including a targeted soft-deleted-mailbox fallback. Collection stops if no mailbox is resolved or the resolved object is not a room or equipment mailbox. After this prerequisite validation, collection is best effort: it independently attempts `Get-Place`, `Get-InboxRule`, `Get-CalendarProcessing`, Calendar and mailbox permissions, and `Export-MailboxDiagnosticLogs`. If one of those collectors fails, the remaining collectors still run and evaluations that require unavailable evidence are safely skipped. +## Requirements -#### Syntax: +- Windows PowerShell 5.1 or PowerShell 7 or later. +- The Exchange Online PowerShell module and an active `Connect-ExchangeOnline` session. +- Permission to read the target mailbox, CalendarProcessing configuration, mailbox diagnostic logs, and applicable permissions. +- A room or equipment mailbox. Workspace-specific validation applies when the mailbox resource type is `Workspace`. -Example to display the setting of room mailbox. -```PowerShell +## Syntax + +```powershell +.\Get-RBASummary.ps1 -Identity [-Subject | -MeetingId ] [-IncludeSensitiveData] [-SkipVersionCheck] [-Verbose] +``` + +| Parameter | Required | Description | +|---|---|---| +| `Identity` | Yes | Resource mailbox identity. An SMTP address is recommended. | +| `Subject` | No | Case-insensitive literal subject substring used to discover meeting IDs. After discovery, only blocks carrying those IDs are correlated. This adds sensitive meeting evidence to the JSON report. The former `MeetingSubject` name remains available as an alias. Cannot be combined with `MeetingId`. | +| `MeetingId` | No | Clean global object ID used to select all matching retained RBA processing blocks directly. A comma in the documented `040000008,` prefix is normalized. Cannot be combined with `Subject`. | +| `IncludeSensitiveData` | No | Includes full identities, target mailbox identifiers, the complete RBA log, and the text transcript in JSON. | +| `SkipVersionCheck` | No | Skips the automatic script update check. | +| `Verbose` | No | Displays additional policy and post-processing explanations. | + +Examples: + +```powershell +# Standard sanitized report .\Get-RBASummary.ps1 -Identity Room1@Contoso.com -.\Get-RBASummary.ps1 -Identity Room1 -Verbose +# Include verbose policy explanations +.\Get-RBASummary.ps1 -Identity Room1@Contoso.com -Verbose + +# Collect targeted evidence for one meeting subject +.\Get-RBASummary.ps1 -Identity Room1@Contoso.com -Subject "Quarterly planning" + +# Re-run targeted analysis directly with the meeting ID discovered above +.\Get-RBASummary.ps1 -Identity Room1@Contoso.com -MeetingId "04000000800E00074C5A7101A82E00700000000..." + +# Include all sensitive evidence +.\Get-RBASummary.ps1 -Identity Room1@Contoso.com -IncludeSensitiveData ``` -##### High-level steps for RBA processing:
+## Output files + +Every run that passes the script update self-check attempts to create timestamp-correlated text and JSON reports. The text transcript and JSON metadata record a canonical command line with the resolved values of every supplied parameter. The JSON report also contains a schema version, collection status, per-collector status, collection errors, minimal configuration and log-summary evidence, and findings with stable rule IDs. A status of `Partial` or `Failed` means the collection errors and `NotEvaluated` findings should be reviewed before drawing conclusions. `NotApplicable` means that evidence was available but the rule does not apply to the resource or its routing configuration. + +A collector is successful only when collection and its immediate evidence processing both finish successfully. If processing fails after evidence was retrieved, that collector is marked `Failed`, the overall status cannot be `Complete`, and other successfully collected evidence remains in the report. Collector and evaluation errors retain their existing `error` or `message` strings and include bounded `exceptionType`, `category`, `fullyQualifiedErrorId`, and `innerExceptionMessage` metadata when available. Stack traces, invocation details, target objects, and remote position details are not exported. + +Output is written to the current working directory. All files from one run share the same timestamp. + +| File | Contents | +|---|---| +| `RBA-Summary-For__.txt` | Human-readable transcript and configuration summary. | +| `RBA-Summary-For__.json` | Structured evidence, collector status, errors, and stable findings for skill analysis. | +| `RBA-Logs__.txt` | Readable retained RBA diagnostic log when log evidence is available. | + +For example, running the script for `Room1@Contoso.com` creates a filename similar to `RBA-Summary-For_Room1_2026-08-28_15-42-10.json`. The timestamp matches the associated text summary produced by the same run. + +The JSON uses schema version `1.0-preview` and records an overall `Complete`, `Partial`, or `Failed` collection status. Review `collectors`, `collectionErrors`, `evaluationErrors`, and `NotEvaluated` findings before drawing conclusions from a partial report. `NotApplicable` means that evidence was available but the finding does not apply to the resource or routing configuration. + +## Privacy modes + +The report identifies its handling mode in `metadata.privacyMode`: + +| Mode | Trigger | Included evidence | +|---|---|---| +| `Sanitized` | Default | Keeps the target mailbox identity and summary but replaces other identities with placeholders. Omits complete logs, transcript content, stable identifiers, and sensitive response text. | +| `TargetedMeeting` | `-Subject` or `-MeetingId` without `-IncludeSensitiveData` | Adds only RBA blocks correlated by an extracted or supplied meeting ID. If a subject matches but has no extractable ID, only its matching blocks are added. These blocks can contain subjects, identities, and processing details. | +| `Full` | `-IncludeSensitiveData` | Adds full identities, target proxy addresses and stable identifiers, complete RBA log, transcript content, and additional response text. | + +Treat `TargetedMeeting` and `Full` reports as sensitive customer data. + +## Targeted meeting log search + +With `-Subject`, the script searches the retained RBA log, extracts meeting IDs from matching processing blocks, and then switches to meeting-ID correlation. Once at least one ID is discovered, only retained blocks carrying those IDs are included; an additional subject-only block is not treated as the same meeting. If a subject resolves to multiple IDs, the text output reports each meeting independently and warns the operator to rerun with `-MeetingId` for focused analysis. The JSON `meetings` array likewise contains one outcome summary per ID; do not treat the top-level targeted counts as one meeting in that case. With `-MeetingId`, discovery is skipped and all retained blocks carrying the supplied ID are selected directly. The text summary states whether the requested meeting was found and prints every resolved ID so it can be reused with `-MeetingId` or downstream Calendar Diagnostic Log tools. + +The decoder recognizes the existing ID labels plus the exact `Begin ProcessRequest Goid:` and `Begin ProcessUpdateRequest Goid:` formats. For correlation, it normalizes the documented comma after the `040000008` GOID prefix; the complete unchanged source line remains in the raw block. This allows the skill to follow separate initial request, update, and cancellation processing even when later blocks omit the subject. If the same subject resolves to multiple meeting IDs, their timelines remain separate. + +Exported RBA logs are newest-first: the newest result or END lines are at the top, and a processing unit's oldest START line is at the bottom. Because RBA processing is single-threaded, targeted extraction treats each processing unit as one contiguous range. The only recognized boundary is the exact line ending: + +`START - HandleEventInternal Automatic Booking is enabled for resource.` + +A block includes every source line after the preceding newer exact START boundary through and including its own exact START boundary. Generic `START -` text does not split a block. The `events` array remains in top-down, newest-first source order, and each event's complete `rawLog` also remains newest-first. Read an individual `rawLog` **bottom-up** for chronological processing: START, entry/classification, policy evaluation, decision, optional post-processing, and result or END. For an oldest-to-newest history across events, traverse the event array from highest `sequence` to lowest. + +Each targeted event reports `startMarker`, `startTimeText`, `startBoundaryFound`, and `boundaryStatus`. `startMarker` is the complete exact START row, and `startTimeText` is the timestamp printed on that row; the compatibility field `eventTimeText` has the same value and is not a completion time. `SourceStartToExactStart` means the newest block begins at the top of the export. `BetweenExactStartBoundaries` means both contiguous source boundaries are known. `MissingStartBoundary` identifies retained lines that cannot be closed by an exact START row; treat that evidence as partial and do not attach it to a neighboring block. The absence of an END marker alone does not prove incomplete processing because the manual does not document one universal END marker for every outcome. + +Possible search statuses are: + +- `NotRequested`: neither a meeting subject nor meeting ID was supplied, so counts and event collections are empty; +- `Found`: a subject resolved to at least one meeting ID, or the supplied meeting ID occurs in at least one retained block; +- `FoundWithoutMeetingId`: the subject matched, but other processing blocks cannot be correlated safely; +- `NotFound`: no subject match or supplied meeting ID exists in the retained log; and +- `LogUnavailable`: RBA log collection failed. + +`NotRequested` remains in the default `Sanitized` privacy mode. `NotFound` does not mean the meeting was never processed. The RBA log retains only bounded recent history, and older processing can roll off. Targeted raw blocks can contain meeting subjects, identities, and processing details and must be handled as sensitive customer evidence. + +For a resolved meeting, `firstLogTimeText` is the timestamp on the oldest retained `Begin ProcessRequest` row, `lastLogTimeText` is the newest recognized meeting-processing timestamp in the correlated evidence, and `lastUpdateTimeText` is the newest retained `Begin ProcessUpdateRequest` or `End ProcessUpdateRequest` timestamp. `recurrenceStatus` is `Recurring` or `NotRecurring` only when an explicit supported marker exists; otherwise it is `Unknown`. The report also summarizes explicit policy, disposition, tentative-response, and delegate-forwarding markers in `policyResult`, `disposition`, `tentativeResponseSent`, `forwardedToDelegates`, and `delegateMessageCount`. The human-readable report prints these meeting-specific values after the aggregate last-activity lines. It does not print meeting-specific detail rows for `NotFound`. + +The targeted event fields identify exact retained markers: + +| JSON field | RBA log marker or meaning | +|---|---| +| `actions` | `Action:Accept`, `Action:Decline`, or `Action:Tentative` | +| `policyResult` | `Defaulting to in policy.` or `Not in policy.` | +| `disposition` | `Meeting request evaluate returns result ` or an `Action:` marker | +| `updateDetected` | `Begin ProcessUpdateRequest` | +| `cancellationDetected` | `It's a meeting cancellation.` | +| `delegateReferralDetected` | `Forwarding Request To Delegates` | +| `delegateMessageCount` | `Sending approval messages to delegates.` | +| `tentativeResponseSent` | `END - Sending the tentatively acceptance response to organizer.` | +| `externalProcessingSkipped` | External processing was skipped because the corresponding setting was false. | +| `horizonDeclineDetected` | A recurring request exceeded the booking window and was explicitly declined. | +| `recurrenceTruncateDetected` | A recurring request was explicitly truncated at the booking window. | + +These observations establish what the retained RBA log recorded. They do not establish message delivery, final calendar state, responsible actor, or a decline reason unless an approved exact reason marker occurs in the same processing block. + +## Troubleshooting skill usage flow + +1. Connect to Exchange Online with permission to read the target resource mailbox. +2. Run `Get-RBASummary.ps1`. Add `-Subject` when investigating a recent specific meeting. +3. Note the JSON path displayed at the end of the run. +4. Add the JSON report and `EXO-RBA-Troubleshooting-SKILL.md` to Copilot or another compatible assistant. +5. Ask the assistant to analyze the JSON by using the EXO RBA troubleshooting skill. The skill validates evidence, ranks detected findings, identifies collection gaps, and recommends tenant-admin commands with impact and rollback guidance. It does not execute configuration changes. + +You can try the RBA troubleshooting skill to get deeper information from the report. When a meeting-specific answer requires final item state, delivery, recurrence exceptions, or actor attribution, collect Calendar Diagnostic Logs from the resource and, when available, the organizer, correlated by the same meeting ID. + +## High-level RBA processing + +1. Determine whether the meeting request is in policy or out of policy. +2. For an out-of-policy request, determine whether the organizer can request approval and, if allowed, route it to resource delegates. +3. For an in-policy request, book it automatically or route it to resource delegates according to the configured recipient wells. +4. If accepted, perform the configured post-processing steps, such as changing the subject or removing attachments. + + +When RBA receives a meeting request, it compares meeting properties with the resource's policy configuration. If all applicable checks pass, the request is in policy; otherwise, it is out of policy. + +For either policy result, RBA reads the request-routing configuration to determine whether to act automatically or involve a resource delegate. By default, out-of-policy requests are rejected and in-policy requests are accepted, but CalendarProcessing supports other routing combinations. + +If the meeting is accepted, RBA formats the resource's calendar item according to its post-processing configuration. + +## Common CalendarProcessing policy findings + +The JSON findings call out these frequently relevant policies: + +- `BookingWindowInDays` sets how far in advance the resource can be reserved; `0` means today and the supported maximum is 1,080 days. +- `MaximumDurationInMinutes` limits each meeting or each instance in a recurring series; `0` means unlimited. +- `AllowRecurringMeetings` controls whether recurring requests are allowed. +- For an allowed recurring series that starts within the booking window but ends beyond it, `EnforceSchedulingHorizon` set to true declines the entire series. When set to false, the series can be accepted but is truncated at the booking-window boundary, and nothing beyond that boundary exists on the resource calendar. Separate log findings report when either behavior was actually observed. +- `ScheduleOnlyDuringWorkHours` rejects meetings outside the resource mailbox's configured work days, hours, and time zone. Those work-hour values come from `Get-MailboxCalendarConfiguration` and aren't collected by this report. +- `AllowConflicts` set to true accepts all conflicts without percentage or count limits, so `ConflictPercentageAllowed` and `MaximumConflictInstances` aren't evaluated. This is required for Workspaces with capacity enforcement, but can permit overlapping reservations on other resources. When conflicts aren't generally allowed, the two thresholds determine how many conflicting occurrences a new recurring series can contain before the series is declined; exceeding either threshold declines the series. If neither threshold is exceeded, the series can be accepted while the conflicting occurrences are declined. +- `ProcessExternalMeetingMessages` set to false prevents RBA from processing meeting requests that Transport classified as external during routing and delivery. A separate RBA-log finding reports when skipped external messages were actually observed. If internal senders are unexpectedly classified as external, validate mail routing before enabling external processing, because enabling it can also permit genuinely external requests. +- `RemovePrivateProperty` set to true clears the private flag from incoming meetings; false preserves it. +- `DeleteSubject` removes the original subject, while `AddOrganizerToSubject` replaces the subject with the organizer's name. These settings apply to `AutoAccept` resource processing. Calendar folder permissions independently control whether a viewer can see subjects. +- `RemoveCanceledMeetings` set to true automatically deletes organizer-canceled meetings from an Exchange Online resource calendar; false retains them. + +These are configuration consequences, not proof that a setting caused a particular historical outcome. RBA reads the current `CalendarProcessing` configuration for each item, while the report captures only the values present at its collection timestamp. Correlate the meeting's recurrence, dates, duration, sender, conflicts, global object identifier, RBA log, response, and calendar-item evidence before assigning causality. Use the EXO RBA troubleshooting skill for deeper analysis and read-only verification guidance. + +## Mailbox lifecycle and item propagation + +The report and troubleshooting skill provide bounded coverage for common lifecycle questions: + +- A targeted fallback lookup identifies when the supplied identity resolves only as a recoverable soft-deleted mailbox. Recovery or purge decisions remain outside RBA troubleshooting. +- The mailbox summary shows whether the supplied identity matches the current primary SMTP address, a proxy address, or another resolvable identity. A proxy match can confirm that an old address still reaches the current mailbox, but it does not prove that or when a rename occurred. +- Current creation and change timestamps are observations only. A change timestamp does not identify the changed property, and one resolved target cannot prove that no duplicate or stale object exists elsewhere in the tenant. +- `RemoveCanceledMeetings` and aggregate RBA update and cancellation counts can explain configured retention and whether the available RBA log contains those operation types. They cannot establish whether a specific update or cancellation changed a specific item. +- Recurrence policy and RBA log findings can identify booking-window declines or truncations. They cannot establish that a current series is orphaned. +- With `-Subject`, targeted RBA blocks can establish that RBA logged an accept, decline, tentative action, update, cancellation, delegate referral, external-message skip, horizon decline, or recurrence truncation for an extracted meeting ID. They still do not establish final calendar state. +- Targeted blocks preserve the exported newest-first order. Read each raw block bottom-up from its exact START boundary; printed timestamps support their own rows but should not reorder equal or ambiguous entries. -1. Determine if the Meeting Request is in policy or out of policy.
-2. If the meeting request is Out of Policy, see if the user has rights to create an Out of Policy request and if so, forward it to the Delegates.
-3. If it is In Policy, then either book it or forward it to the delegate based on the settings.
-4. Lastly the RBA does the configured Post Processing steps to format the meeting (delete attachments, rename meeting, etc.)
+Former-organizer reservations, stale calendar items, specific update or cancellation propagation, and orphaned recurring series require item-level Calendar Diagnostic Log evidence from the resource and, when available, the organizer. Correlate both mailboxes by the same meeting ID; subject and timestamp are secondary correlation values. Tenant-wide duplicate or obsolete room objects require a recipient inventory or object-lifecycle workflow. +An observed `Action:Decline` does not by itself explain why a meeting declined. The skill uses only documented exact reason markers from the same processing block. Current configuration can corroborate expected behavior but cannot establish the historical reason. If no approved marker is present, the result remains "decline observed; reason not established by the current decoder." -When the RBA receives a Meeting Request, the first thing that it will do is to determine if the meeting is in or out of policy. How does the RBA do this? The RBA compares the Meeting properties to the Policy Configuration. If all the checks 'pass', then the meeting request is In Policy, otherwise it is Out of Policy. +## Permissions and visibility boundaries -Whether the meeting is in or out of policy, the RBA will look up the configuration that will tell it what to do with the meeting. By default, all out of policy meetings are rejected, and all in policy meetings are accepted, but there is a larger range of customization that you can do to get the RBA to treat this resource the way you want it to. +The report collects the resource's localized Calendar folder permissions and explicit mailbox Full Access grants. Its findings preserve these separate control planes: -If the meeting is accepted, the RBA will Post Process it based on the Post Processing configuration. +- Calendar folder permissions control who can view or modify items in the resource Calendar folder. The `Default` access level is always reported. +- `ResourceDelegates` controls who can receive booking requests for approval. It is not the same as Calendar visibility or mailbox Full Access. +- The booking-policy recipient lists and all-user switches control who can book automatically or request approval. They do not grant Calendar folder access. +- `DeleteSubject`, `AddOrganizerToSubject`, and `RemovePrivateProperty` are RBA post-processing settings. They change stored meeting properties but do not grant folder access. +- Calendar `Owner` and explicit mailbox Full Access grants are warnings because they permit access outside normal RBA ownership. Their presence does not prove that anyone directly edited a meeting. +- If a configured resource delegate has no matching direct Calendar `Editor` or `Owner` entry, validate effective access separately. The user might receive access through a group or another assignment path. +Direct editing is deliberately not inferred from permissions. Establishing that a user or client modified the resource calendar requires Calendar Diagnostic Log evidence. The `RBA830` identifier is reserved for that future evidence and is not emitted by the current report.