diff --git a/Calendar/Get-RBASummary.ps1 b/Calendar/Get-RBASummary.ps1 index dd1d470064..90cffa60ba 100644 --- a/Calendar/Get-RBASummary.ps1 +++ b/Calendar/Get-RBASummary.ps1 @@ -1,98 +1,401 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +# cspell:ignore Goid +# .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 ) -$BuildVersion = "" +function ConvertTo-RbaCommandLineValue { + param( + [AllowNull()] + [object]$Value + ) -. $PSScriptRoot\..\Shared\ScriptUpdateFunctions\Test-ScriptVersion.ps1 + if ($null -eq $Value) { + return '$null' + } -if (Test-ScriptVersion -AutoUpdate) { - # Update was downloaded, so stop here. - Write-Host "Script was updated. Please rerun the command." -ForegroundColor Yellow - return + if ($Value -is [array]) { + $values = @($Value | ForEach-Object { ConvertTo-RbaCommandLineValue -Value $_ }) + return "@($($values -join ', '))" + } + + return "'$(([string]$Value).Replace("'", "''"))'" } -Write-Verbose "Script Versions: $BuildVersion" +function Write-RbaPhaseVerbose { + param( + [Parameter(Mandatory)] + [string]$Message + ) -$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 -Write-Host "`r`n" + Write-Verbose "[$($script:RunStopwatch.ElapsedMilliseconds)ms] $Message" +} -function ValidateMailbox { - Write-Host -NoNewline "Running : "; Write-Host -ForegroundColor Cyan "Get-Mailbox -Identity $Identity" - $script:Mailbox = Get-Mailbox -Identity $Identity +function ConvertTo-RbaFileNameStem { + param( + [Parameter(Mandatory)] + [string]$Value + ) + + $stem = ($Value.Split('@')[0] -replace '[<>:"/\\|?*\x00-\x1F]', '_').Trim([char[]]@(' ', '.')) + if ([string]::IsNullOrWhiteSpace($stem) -or $stem -in @('.', '..')) { + return "ResourceMailbox" + } + return $stem +} + +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-RbaPlainString { + param( + [AllowNull()] + [object]$Value + ) + + if ($null -eq $Value) { + return $null + } + + try { + return [string]$Value + } catch { + Write-Verbose "Unable to convert a report value of type '$($Value.GetType().FullName)' to a string." + return $null + } +} + +function ConvertTo-RbaPlainStringList { + param( + [AllowNull()] + [object[]]$Value + ) + + return @($Value | ForEach-Object { ConvertTo-RbaPlainString -Value $_ }) +} + +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 { + $activeLookupError = $_ + Write-Verbose -Message "Active mailbox lookup failed. Checking for a recoverable soft-deleted mailbox." + try { + $mailbox = Get-Mailbox -Identity $Identity -SoftDeletedMailbox -ErrorAction Stop + } catch { + $fallbackErrorInfo = ConvertTo-RbaErrorInfo -ErrorRecord $_ + Write-Verbose -Message "Soft-deleted mailbox lookup failed: $($fallbackErrorInfo.message) (Category: $($fallbackErrorInfo.category); fully qualified error ID: $($fallbackErrorInfo.fullyQualifiedErrorId))." + throw $activeLookupError + } + if ($null -eq $mailbox) { + Write-Verbose -Message "Soft-deleted mailbox lookup returned null." + throw $activeLookupError + } + $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] } + + $placeObjects = @($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 ($placeObjects.Count -eq 0) { throw "Get-Place returned no place objects." } + if ($placeObjects.Count -gt 1) { Write-Verbose "Get-Place returned $($placeObjects.Count) results; using the first entry." } + return $placeObjects[0] + } 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)" + Write-Host -ForegroundColor Yellow "To gain PII access, Mailbox is located on $($script:Mailbox.Database) on server $($script:Mailbox.ServerName)" if ($null -eq $rules.count -or $rules.count -eq 1) { Write-Host -ForegroundColor Yellow "Warning: One rule has been found, which is likely the default Junk Mail rule." Write-Host -ForegroundColor Yellow "Warning: You should verify that this is not a Delegate Rule setup on this resource mailbox. Delegate rules will block RBA functionality. Please remove the rule via Remove-InboxRule cmdlet and re-run this script." @@ -106,28 +409,103 @@ 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,16 +513,13 @@ 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." } } -# RBA processing logic -function ProcessingLogic { +function Write-RbaProcessingLogic { Write-DashLineBoxColor @("RBA Processing Logic") -DashChar = @" The RBA first evaluates a request against all the policy configuration constraints assigned in the calendar @@ -175,7 +550,6 @@ function RBACriteria { `t BookingWindowInDays: $($RbaSettings.BookingWindowInDays) `t ConflictPercentageAllowed: $($RbaSettings.ConflictPercentageAllowed) `t MaximumConflictInstances: $($RbaSettings.MaximumConflictInstances) - `t MaximumConflictPercentage: $($RbaSettings.MaximumConflictPercentage) `t EnforceSchedulingHorizon: $($RbaSettings.EnforceSchedulingHorizon) "@ Write-Host -NoNewline "`r`nIf all the above criteria are met, the request is " @@ -188,12 +562,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 +602,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,12 +614,11 @@ 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 } -# RBA processing settings function RBAProcessingValidation { Write-DashLineBoxColor @("Policy Processing:") -DashChar = @@ -265,29 +638,29 @@ 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." } } -# Write out a list of Mailboxes -# We get CN from the cmdlet and want Display Name and Primary SMTP Address -function OutputMBList { +function Write-RbaRecipientList { param ( [Parameter(Mandatory)] [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]" } } } @@ -300,7 +673,7 @@ function InPolicyProcessing { Write-Host "`t BookInPolicy: {$($RbaSettings.BookInPolicy)}" } else { Write-Host "`t BookInPolicy: These $($RbaSettings.BookInPolicy.count) accounts do not require the delegate approval." - OutputMBList($RbaSettings.BookInPolicy) + Write-RbaRecipientList -MBList $RbaSettings.BookInPolicy } Write-Host "`t AllBookInPolicy: "$RbaSettings.AllBookInPolicy Write-Host "`t RequestInPolicy: {$($RbaSettings.RequestInPolicy)}" @@ -313,7 +686,7 @@ function InPolicyProcessing { } else { if ($RbaSettings.BookInPolicy.Count -gt 0) { Write-Host "- The RBA will process (auto-book / accept) in-policy requests from this list of Users:" - OutputMBList($RbaSettings.BookInPolicy) + Write-RbaRecipientList -MBList $RbaSettings.BookInPolicy } Write-Host "- RBA will forward all in-policy meetings to the resource delegates." @@ -326,12 +699,11 @@ function InPolicyProcessing { } } -# Out-of-policy request processing function OutOfPolicyProcessing { Write-DashLineBoxColor @(" Out-of-Policy request processing:") -Color DarkYellow if ($RbaSettings.RequestOutOfPolicy.Count -gt 0) { Write-Host "`t RequestOutOfPolicy: These {$($RbaSettings.RequestOutOfPolicy.Count)} accounts are allowed to submit out-of-policy requests (that require approval by a resource delegate)." - OutputMBList($RbaSettings.RequestOutOfPolicy) + Write-RbaRecipientList -MBList $RbaSettings.RequestOutOfPolicy } else { Write-Host "`t RequestOutOfPolicy: {$($RbaSettings.RequestOutOfPolicy)}" } @@ -352,7 +724,6 @@ function OutOfPolicyProcessing { } } -# RBA Delegate Settings function RBADelegateSettings { Write-DashLineBoxColor @("Resource Delegate Settings") -Color White @@ -360,7 +731,7 @@ function RBADelegateSettings { Write-Host "`t ResourceDelegates: "$RbaSettings.ResourceDelegates } else { Write-Host "`t ResourceDelegates: $($RbaSettings.ResourceDelegates.Count) Resource Delegate`(s`) have been configured." - OutputMBList($RbaSettings.ResourceDelegates) + Write-RbaRecipientList -MBList $RbaSettings.ResourceDelegates } Write-Host "`t AddNewRequestsTentatively: "$RbaSettings.AddNewRequestsTentatively @@ -380,7 +751,7 @@ function RBADelegateSettings { Write-Host -ForegroundColor White "Information: Delegate(s) will not receive any In Policy requests as they will be AutoApproved." } elseif ($RbaSettings.BookInPolicy.Count -gt 0 ) { Write-Host -ForegroundColor White "Information: Delegate(s) will not receive requests from users in the BookInPolicy as they will be AutoApproved." - OutputMBList($RbaSettings.BookInPolicy) + Write-RbaRecipientList -MBList $RbaSettings.BookInPolicy } if ($RbaSettings.AllRequestOutOfPolicy -eq $false) { @@ -388,7 +759,7 @@ function RBADelegateSettings { Write-Host -ForegroundColor Yellow "Warning: Delegate(s) will not receive any Out of Policy requests as they will all be AutoDenied." } else { Write-Host -ForegroundColor Yellow "Warning: Delegate(s) will only receive any Out of Policy requests from the below list of users." - OutputMBList($RbaSettings.RequestOutOfPolicy) + Write-RbaRecipientList -MBList $RbaSettings.RequestOutOfPolicy } } else { Write-Host -ForegroundColor Yellow "Warning: All users can send Out of Policy requests to be approved by the Resource Delegates." @@ -407,7 +778,6 @@ function RBADelegateSettings { } } -# RBA PostProcessing Steps function RBAPostProcessing { Write-DashLineBoxColor @("PostProcessing Setup") -Color Cyan -DashChar = Write-Host -ForegroundColor Cyan "The RBA will format the meeting based on the following settings." @@ -433,7 +803,6 @@ function RBAPostProcessing { } } -# RBA Verbose PostProcessing Steps function VerbosePostProcessing { Write-Verbose "`t`r`n AdditionalResponse: `r`n$($RbaSettings.AdditionalResponse)`r`n`r`n" @@ -511,128 +880,748 @@ function VerbosePostProcessing { Write-Verbose $RbaFormattingString } -#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-Host -ForegroundColor Yellow "`tExport-MailboxDiagnosticLogs $Identity -ComponentName RBA" +function Write-RbaNextSteps { + 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 $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 ',', '')) + } + + 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 Test-RbaLogLinesContainSubject { + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [AllowEmptyString()] + [string[]]$Lines, + + [Parameter(Mandatory)] + [string]$Text + ) + + foreach ($line in $Lines) { + $subjectText = $null + if ($line -match '(?i)\bReceived Request from:.*?\bsubject\s+(?.+)$') { + $subjectText = $Matches['Subject'] + } elseif ($line -match '(?i)(?:^|,\s*)Subject\s*:\s*(?.+)$') { + $subjectText = $Matches['Subject'] + } + + if ($null -ne $subjectText -and + $subjectText.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 + } +} - 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" +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 + acceptCount = 0 + tentativeCount = 0 + updateCount = 0 + cancellationCount = 0 + declineCount = 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 = @() + } + } + + $blocks = @(Split-RbaLogProcessingBlocks -Lines @($script:RBALog)) + $completeBlocks = @($blocks | Where-Object { $_.startBoundaryFound }) + if ($searchType -eq "MeetingId") { + $selectedBlocks = @($completeBlocks | Where-Object { + @($_.meetingIds) -contains $normalizedRequestedMeetingId + }) + $ambiguousBlockMatch = @($blocks | Where-Object { + -not $_.startBoundaryFound -and + @($_.meetingIds) -contains $normalizedRequestedMeetingId + }).Count -gt 0 + $events = @($selectedBlocks | ForEach-Object { + Get-RbaTargetedLogBlockObject -Block $_ -SubjectMatched $false + }) + $meetingDetails = Get-RbaTargetedMeetingDetails -Events $events + [string[]]$matchedMeetingIds = @() + [object[]]$meetingSummaries = @() + if ($events.Count -gt 0) { + $matchedMeetingIds = @($normalizedRequestedMeetingId) + $meetingSummaries = @(Get-RbaTargetedMeetingSummaries -MeetingIds $matchedMeetingIds -Events $events) + } + + return [PSCustomObject]@{ + searchType = $searchType + searchSubject = $null + searchMeetingId = $normalizedRequestedMeetingId + status = $(if ($events.Count -gt 0) { "Found" } elseif ($ambiguousBlockMatch) { "AmbiguousBoundary" } else { "NotFound" }) + sourceOrder = "NewestFirst" + eventOrder = "NewestFirst" + rawLogChronologicalReadDirection = "BottomToTop" + subjectMatchCount = 0 + meetingIds = $matchedMeetingIds + 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 = @($completeBlocks | Where-Object { + Test-RbaLogLinesContainSubject -Lines $_.lines -Text $Subject + }) + $ambiguousSubjectBlocks = @($blocks | Where-Object { + -not $_.startBoundaryFound -and + (Test-RbaLogLinesContainSubject -Lines $_.lines -Text $Subject) + }) + if ($subjectBlocks.Count -eq 0) { + return [PSCustomObject]@{ + searchType = $searchType + searchSubject = $Subject + searchMeetingId = $null + status = $(if ($ambiguousSubjectBlocks.Count -gt 0) { "AmbiguousBoundary" } else { "NotFound" }) + sourceOrder = "NewestFirst" + eventOrder = "NewestFirst" + rawLogChronologicalReadDirection = "BottomToTop" + subjectMatchCount = $ambiguousSubjectBlocks.Count + meetingIds = @() + eventCount = 0 + acceptCount = 0 + tentativeCount = 0 + updateCount = 0 + cancellationCount = 0 + declineCount = 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 = @() + } + } + + $meetingIds = @($subjectBlocks | ForEach-Object { + @($_.meetingIds) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + } | Sort-Object -Unique) + $selectedBlocks = if ($meetingIds.Count -gt 0) { + @($completeBlocks | 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-RbaLogLinesContainSubject -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." + } elseif ($script:MeetingLogSearch.status -eq "AmbiguousBoundary") { + Write-Warning "The requested meeting text was found only in RBA log content without an exact processing boundary. No raw targeted evidence was exported." + } } 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_$outputFileStem`_$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 + Write-RbaNextSteps } else { Write-Warning "No RBA Logs found. Send a test meeting invite to the room and try again if this is a newly created room mailbox." } } -#Validate Workspace settings function ValidateWorkspace { Write-DashLineBoxColor @("Workspace Settings") -Color White Write-Host -ForegroundColor White "`tIs Resource [$Identity] a Workspace: $(if ($script:Workspace) {"TRUE"} else {"False - Skipping additional Workspace Checks"})." @@ -670,7 +1659,6 @@ function ValidateWorkspace { } } -# Validate Setting for the New Room List functionality function ValidateRoomListSettings { Write-DashLineBoxColor @("Room List Settings") -Color White Write-Host -ForegroundColor White "`tThe new Room Finder uses the City and other properties to help users find the right room for their meeting." @@ -753,20 +1741,986 @@ 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 + ) + + $effectiveEvidence = if ($Status -eq "NotEvaluated") { $null } else { $Evidence } + $Findings.Add([PSCustomObject]@{ + ruleId = $RuleId + severity = $Severity + status = $Status + title = $Title + evidence = $effectiveEvidence + }) +} + +function Get-RbaReportErrorMessage { + param( + [AllowNull()] + [string]$Message + ) + + if ($IncludeSensitiveData -or [string]::IsNullOrWhiteSpace($Message)) { + return $Message + } + return "Error details omitted in sanitized mode." +} + +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 @{ error = Get-RbaReportErrorMessage -Message $script:collectorStatuses["Mailbox"].error } + + Add-RbaFinding -Findings $findings -RuleId "RBA002" -Severity Error ` + -Status $(if ($placeAvailable) { "NotDetected" } else { "Detected" }) ` + -Title "Place evidence unavailable" -Evidence (Get-RbaReportErrorMessage -Message $script:collectorStatuses["Place"].error) + + Add-RbaFinding -Findings $findings -RuleId "RBA003" -Severity Error ` + -Status $(if ($rulesAvailable) { "NotDetected" } else { "Detected" }) ` + -Title "Inbox rule evidence unavailable" -Evidence (Get-RbaReportErrorMessage -Message $script:collectorStatuses["InboxRules"].error) + + Add-RbaFinding -Findings $findings -RuleId "RBA004" -Severity Error ` + -Status $(if ($settingsAvailable) { "NotDetected" } else { "Detected" }) ` + -Title "Calendar processing evidence unavailable" -Evidence (Get-RbaReportErrorMessage -Message $script:collectorStatuses["CalendarProcessing"].error) + + Add-RbaFinding -Findings $findings -RuleId "RBA005" -Severity Warning ` + -Status $(if ($logAvailable) { "NotDetected" } else { "Detected" }) ` + -Title "RBA log evidence unavailable" -Evidence (Get-RbaReportErrorMessage -Message $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 (Get-RbaReportErrorMessage -Message $script:collectorStatuses["CalendarFolderPermissions"].error) + + Add-RbaFinding -Findings $findings -RuleId "RBA007" -Severity Warning ` + -Status $(if ($mailboxPermissionsAvailable) { "NotDetected" } else { "Detected" }) ` + -Title "Mailbox permission evidence unavailable" -Evidence (Get-RbaReportErrorMessage -Message $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 @{ capacity = $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 + processedEventCountRepresents = "ProcessingBlocks" + markerCountCategoryRelationship = "IndependentNonMutuallyExclusive" + markerCountCategoriesMayOverlapWithinBlock = $true + 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 { + Write-RbaPhaseVerbose -Message "Building JSON collector metadata." + $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" + } + + $jsonCollectorStatuses = [ordered]@{} + foreach ($collectorName in $script:collectorStatuses.Keys) { + $collectorStatus = $script:collectorStatuses[$collectorName] + $jsonCollectorStatuses[$collectorName] = [PSCustomObject]@{ + status = $collectorStatus.status + error = Get-RbaReportErrorMessage -Message $collectorStatus.error + exceptionType = $collectorStatus.exceptionType + category = $collectorStatus.category + fullyQualifiedErrorId = $(if ($IncludeSensitiveData) { $collectorStatus.fullyQualifiedErrorId } else { $null }) + innerExceptionMessage = $(if ($IncludeSensitiveData) { $collectorStatus.innerExceptionMessage } else { $null }) + } + } + $jsonCollectionErrors = @($script:collectionErrors | ForEach-Object { + [PSCustomObject]@{ + collector = $_.collector + message = Get-RbaReportErrorMessage -Message $_.message + exceptionType = $_.exceptionType + category = $_.category + fullyQualifiedErrorId = $(if ($IncludeSensitiveData) { $_.fullyQualifiedErrorId } else { $null }) + innerExceptionMessage = $(if ($IncludeSensitiveData) { $_.innerExceptionMessage } else { $null }) + } + }) + $jsonEvaluationErrors = @($script:evaluationErrors | ForEach-Object { + [PSCustomObject]@{ + evaluation = $_.evaluation + message = Get-RbaReportErrorMessage -Message $_.message + exceptionType = $_.exceptionType + category = $_.category + fullyQualifiedErrorId = $(if ($IncludeSensitiveData) { $_.fullyQualifiedErrorId } else { $null }) + innerExceptionMessage = $(if ($IncludeSensitiveData) { $_.innerExceptionMessage } else { $null }) + } + }) + Write-RbaPhaseVerbose -Message "JSON collector metadata completed." + + Write-RbaPhaseVerbose -Message "Building JSON evidence summaries." + $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 @(ConvertTo-RbaPlainStringList -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 + 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 (ConvertTo-RbaPlainString -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 } + + Write-RbaPhaseVerbose -Message "Building JSON findings." + $jsonFindings = @(Get-RbaFindings) + Write-RbaPhaseVerbose -Message "JSON findings completed." + + Write-RbaPhaseVerbose -Message "Assembling JSON report." + $data = [ordered]@{ + metadata = [ordered]@{ + schemaVersion = "1.1-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 = $jsonCollectorStatuses + 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 = $jsonFindings + collectionErrors = $jsonCollectionErrors + evaluationErrors = $jsonEvaluationErrors + } + + if ($IncludeSensitiveData) { + Write-RbaPhaseVerbose -Message "Attaching sensitive JSON evidence." + if ($null -ne $data.place) { + $data.place | Add-Member -MemberType NoteProperty -Name roomLists ` + -Value @(ConvertTo-RbaPlainStringList -Value $script:Place.Localities) + } + $data.fullRbaLog = @(ConvertTo-RbaPlainStringList -Value $script:RBALog) + if (Test-Path -Path $SummaryFilename) { + $data.transcript = ConvertTo-RbaPlainString -Value (Get-Content -Path $SummaryFilename -Raw) + } + } + + Write-RbaPhaseVerbose -Message "Serializing JSON report." + $json = $data | ConvertTo-Json -Depth 8 -ErrorAction Stop + Write-RbaPhaseVerbose -Message "Writing JSON report file." + $jsonFilePath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($JsonFilename) + [System.IO.File]::WriteAllText($jsonFilePath, $json, [System.Text.UTF8Encoding]::new($false)) + Write-RbaPhaseVerbose -Message "JSON report file written." +} + +if (-not [string]::IsNullOrWhiteSpace($Subject) -and -not [string]::IsNullOrWhiteSpace($MeetingId)) { + throw "Specify either Subject or MeetingId, not both." +} + +$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 (-not $SkipVersionCheck -and (Test-ScriptVersion -AutoUpdate)) { + # Update was downloaded, so stop here. + Write-Host "Script was updated. Please rerun the command." -ForegroundColor Yellow + return +} + +Write-Verbose "Script Versions: $BuildVersion" + +$runTimestamp = (Get-Date).ToString('yyyy-MM-dd_HH-mm-ss') +$outputFileStem = ConvertTo-RbaFileNameStem -Value $Identity +$SummaryFilename = "RBA-Summary-For_$outputFileStem`_$runTimestamp.txt" +$JsonFilename = "RBA-Summary-For_$outputFileStem`_$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:MeetingLogSearch = $null +$script:ResourceDelegateIdentitySets = @() +$script:ResourceDelegateIdentitySetsAvailable = $false +$script:SanitizedIdentityMap = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase) +$script:SanitizedIdentitySequence = 0 +$script:RunStopwatch = [System.Diagnostics.Stopwatch]::StartNew() +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" + +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 + if ($script:collectorStatuses["RbaLog"].status -eq "Success") { + $script:MeetingLogSearch = Get-RbaMeetingLogSearchObject + } + } + if ($null -eq $script:MeetingLogSearch) { + $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 } + } + Write-RbaProcessingLogic + 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) { + Write-RbaPhaseVerbose -Message "Stopping transcript." + Stop-Transcript | Out-Null + $script:TranscriptStarted = $false + Write-RbaPhaseVerbose -Message "Transcript stopped." + } +} + +try { + Write-RbaPhaseVerbose -Message "Starting JSON report generation." + Write-RbaJson + Write-RbaPhaseVerbose -Message "JSON report generation completed." + Write-Host -ForegroundColor Cyan "`r`nRBA JSON Output saved as [$JsonFilename] in the current directory." +} catch { + Write-Verbose "JSON report failure location: $($_.InvocationInfo.PositionMessage)" + Write-Verbose "JSON report failure stack: $($_.ScriptStackTrace)" + $errorInfo = ConvertTo-RbaErrorInfo -ErrorRecord $_ + Write-Warning "Unable to write RBA JSON output '$JsonFilename': $($errorInfo.message)" +} + +Write-RbaPhaseVerbose -Message "Building final output file list." +$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) { + Write-RbaPhaseVerbose -Message "Updating text summary with output file list." + Add-Content -Path $SummaryFilename -Value ([Environment]::NewLine + ($outputFileLines -join [Environment]::NewLine)) -Encoding utf8 + Write-RbaPhaseVerbose -Message "Text summary update completed." +} diff --git a/Calendar/Tests/Get-RBASummary.Tests.ps1 b/Calendar/Tests/Get-RBASummary.Tests.ps1 new file mode 100644 index 0000000000..4c7fd2a7be --- /dev/null +++ b/Calendar/Tests/Get-RBASummary.Tests.ps1 @@ -0,0 +1,1446 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# cspell:ignore Goid + +BeforeAll { + $Script:calendarPath = Split-Path -Path $PSScriptRoot -Parent + $Script:scriptPath = Join-Path -Path $Script:calendarPath -ChildPath "Get-RBASummary.ps1" + + function Get-Mailbox { + param( + [string]$Identity, + [switch]$SoftDeletedMailbox, + [System.Management.Automation.ActionPreference]$ErrorAction + ) + } + function Get-Place { + param( + [string]$Identity, + [System.Management.Automation.ActionPreference]$ErrorAction + ) + } + function Get-InboxRule { + param( + [string]$Mailbox, + [switch]$IncludeHidden, + [System.Management.Automation.ActionPreference]$ErrorAction + ) + } + function Get-CalendarProcessing { + param( + [string]$Identity, + [System.Management.Automation.ActionPreference]$ErrorAction + ) + } + function Get-MailboxFolderStatistics { + param( + [string]$Identity, + [string]$FolderScope, + [System.Management.Automation.ActionPreference]$ErrorAction + ) + } + function Get-MailboxFolderPermission { + param( + [string]$Identity, + [System.Management.Automation.ActionPreference]$ErrorAction + ) + } + function Get-MailboxPermission { + param( + [string]$Identity, + [System.Management.Automation.ActionPreference]$ErrorAction + ) + } + function Export-MailboxDiagnosticLogs { + param( + [string]$Identity, + [string]$ComponentName, + [System.Management.Automation.ActionPreference]$ErrorAction + ) + } + function Get-Recipient { + param( + [string]$Identity, + [string]$Organization, + [System.Management.Automation.ActionPreference]$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 + 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 "preserves the active mailbox error when the soft-deleted fallback also fails" { + Mock Get-Mailbox { throw "Active mailbox access denied" } -ParameterFilter { -not $SoftDeletedMailbox } + Mock Get-Mailbox { throw "Soft-deleted mailbox not found" } -ParameterFilter { $SoftDeletedMailbox } + + Push-Location -Path $TestDrive + try { + $output = @(& $Script:scriptPath -Identity "missing@contoso.com" -SkipVersionCheck -Verbose *>&1) + $jsonFiles = @(Get-ChildItem -Path $TestDrive -Filter "RBA-Summary-For_missing_*.json") + } finally { + Pop-Location + } + + $warningMessages = @($output | Where-Object { + $_ -is [System.Management.Automation.WarningRecord] + } | ForEach-Object { $_.Message }) + $verboseMessages = @($output | Where-Object { + $_ -is [System.Management.Automation.VerboseRecord] + } | ForEach-Object { $_.Message }) + + Assert-MockCalled -CommandName Get-Mailbox -Exactly 1 -ParameterFilter { -not $SoftDeletedMailbox } + Assert-MockCalled -CommandName Get-Mailbox -Exactly 1 -ParameterFilter { $SoftDeletedMailbox } + $warningMessages | Should -Contain "Mailbox collection failed: Active mailbox access denied" + $warningMessages | Should -Not -Contain "Mailbox collection failed: Soft-deleted mailbox not found" + $verboseMessages -join [Environment]::NewLine | Should -Match "Soft-deleted mailbox lookup failed: Soft-deleted mailbox not found" + $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 "reports a JSON write failure without hiding generated text output" { + Mock Get-Date { [DateTime]"2026-09-11T09:00:00" } + $jsonDirectoryPath = Join-Path -Path $TestDrive -ChildPath "RBA-Summary-For_room_2026-09-11_09-00-00.json" + New-Item -Path $jsonDirectoryPath -ItemType Directory | Out-Null + + Push-Location -Path $TestDrive + try { + $output = & $Script:scriptPath -Identity "room@contoso.com" -SkipVersionCheck -Verbose *>&1 | Out-String + } finally { + Pop-Location + } + + $output | Should -Match "Writing JSON report file\." + $output | Should -Not -Match "JSON report file written\." + $output | Should -Match "JSON report failure location:" + $output | Should -Match "Unable to write RBA JSON output" + $output | Should -Match "Text summary: \[RBA-Summary-For_room_.*\.txt\]" + } + + 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\]" + $summary | Should -Not -Match "MaximumConflictPercentage" + } + + It "emits ordered verbose phase boundaries after the feedback line" { + Push-Location -Path $TestDrive + try { + $output = & $Script:scriptPath -Identity "room@contoso.com" -SkipVersionCheck -Verbose *>&1 | Out-String + } finally { + Pop-Location + } + + $phaseMessages = @( + "Stopping transcript." + "Transcript stopped." + "Starting JSON report generation." + "JSON report generation completed." + "Building final output file list." + "Updating text summary with output file list." + "Text summary update completed." + ) + $previousIndex = -1 + foreach ($phaseMessage in $phaseMessages) { + $phasePattern = "\[\d+ms\] $([regex]::Escape($phaseMessage))" + $phaseIndex = $output.IndexOf(($output | Select-String -Pattern $phasePattern).Matches[0].Value) + $phaseIndex | Should -BeGreaterThan $previousIndex + $previousIndex = $phaseIndex + } + } + + 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 "Error details omitted in sanitized mode." + @($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 -IncludeSensitiveData + $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 "omits remote error details from sanitized JSON" { + Mock Get-Place { throw "Lookup failed for private.user@contoso.com object 11111111-1111-1111-1111-111111111111" } + + $report = Invoke-TestRbaSummary + $serializedReport = $report | ConvertTo-Json -Depth 8 + + $report.collectors.Place.error | Should -Be "Error details omitted in sanitized mode." + $report.collectors.Place.fullyQualifiedErrorId | Should -BeNullOrEmpty + $report.collectors.Place.innerExceptionMessage | Should -BeNullOrEmpty + ($report.findings | Where-Object { $_.ruleId -eq "RBA002" }).evidence | Should -Be "Error details omitted in sanitized mode." + $serializedReport | Should -Not -Match "private\.user@contoso\.com" + $serializedReport | Should -Not -Match "11111111-1111-1111-1111-111111111111" + } + + 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 -Be "Error details omitted in sanitized mode." + $report.collectionErrors.collector | Should -Contain "Place" + ($report.findings | Where-Object { $_.ruleId -eq "RBA510" }).status | Should -Be "NotEvaluated" + ($report.findings | Where-Object { $_.ruleId -eq "RBA510" }).evidence | Should -BeNullOrEmpty + ($report.findings | Where-Object { $_.ruleId -eq "RBA511" }).status | Should -Be "NotEvaluated" + ($report.findings | Where-Object { $_.ruleId -eq "RBA511" }).evidence | Should -BeNullOrEmpty + } + + It "sanitizes non-target identities and emits the documented finding families by default" { + $report = Invoke-TestRbaSummary + + $report.metadata.schemaVersion | Should -Be "1.1-preview" + $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 -Contain "conflictPercentageAllowed" + $report.calendarProcessing.PSObject.Properties.Name | Should -Not -Contain "maximumConflictPercentage" + $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 "RBA001" }).evidence.PSObject.Properties.Name | Should -Contain "error" + ($report.findings | Where-Object { $_.ruleId -eq "RBA001" }).evidence.error | Should -BeNullOrEmpty + ($report.findings | Where-Object { $_.ruleId -eq "RBA500" }).status | Should -Be "NotApplicable" + ($report.findings | Where-Object { $_.ruleId -eq "RBA500" }).evidence.PSObject.Properties.Name | Should -Contain "capacity" + ($report.findings | Where-Object { $_.ruleId -eq "RBA500" }).evidence.capacity | Should -Be 8 + ($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 "writes JSON as UTF-8 without a byte order mark" { + $null = Invoke-TestRbaSummary + $jsonPath = Get-ChildItem -Path $TestDrive -Filter "RBA-Summary-For_room_*.json" | + Sort-Object -Property LastWriteTime | Select-Object -Last 1 + $bytes = [System.IO.File]::ReadAllBytes($jsonPath.FullName) + + $bytes.Length | Should -BeGreaterThan 3 + @($bytes[0], $bytes[1], $bytes[2]) -join "," | Should -Not -Be "239,187,191" + } + + It "summarizes and writes collected RBA log content after a successful collection" { + Mock Export-MailboxDiagnosticLogs { + [PSCustomObject]@{ + MailboxLog = @( + "2026-08-28T10:00:03Z, Entry Action: Message, LogComment: Action:Accept" + "2026-08-28T10:00:02Z, Begin ProcessUpdateRequest" + "2026-08-28T10:00:01Z, It's a meeting cancellation." + "2026-08-28T10:00:00Z, START - HandleEventInternal Automatic Booking is enabled for resource." + ) -join "`r`n" + } + } + + $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 4 + $report.rbaLogSummary.processedEventCount | Should -Be 1 + $report.rbaLogSummary.processedEventCountRepresents | Should -Be "ProcessingBlocks" + $report.rbaLogSummary.markerCountCategoryRelationship | Should -Be "IndependentNonMutuallyExclusive" + $report.rbaLogSummary.markerCountCategoriesMayOverlapWithinBlock | Should -BeTrue + $report.rbaLogSummary.acceptedCount | Should -Be 1 + $report.rbaLogSummary.updatedCount | Should -Be 1 + $report.rbaLogSummary.cancellationCount | Should -Be 1 + ($report.rbaLogSummary.acceptedCount + $report.rbaLogSummary.updatedCount + + $report.rbaLogSummary.cancellationCount) | Should -BeGreaterThan $report.rbaLogSummary.processedEventCount + $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 + ([DateTimeOffset]$report.meetingLogSearch.firstLogTimeText).UtcDateTime.ToString("o") | Should -Be "2026-08-20T10:00:01.0000000Z" + ([DateTimeOffset]$report.meetingLogSearch.lastLogTimeText).UtcDateTime.ToString("o") | Should -Be "2026-08-22T10:00:03.0000000Z" + ([DateTimeOffset]$report.meetingLogSearch.lastUpdateTimeText).UtcDateTime.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." + ([DateTimeOffset]$initialEvent.startTimeText).UtcDateTime.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" + ([DateTimeOffset]$report.meetingLogSearch.firstLogTimeText).UtcDateTime.ToString("o") | Should -Be "2026-08-21T10:00:01.0000000Z" + ([DateTimeOffset]$report.meetingLogSearch.lastUpdateTimeText).UtcDateTime.ToString("o") | Should -Be "2026-08-22T10:00:01.0000000Z" + } + + It "returns empty collections when a MeetingId is not found" { + $meetingId = "04000000800E00074C5A7101A82E007000000004220FC5BAC74D90100000000000000001000000068B165058D1E2E439252F58379D4FE92" + + $report = Invoke-TestRbaSummary -MeetingId $meetingId + + $report.meetingLogSearch.status | Should -Be "NotFound" + $report.meetingLogSearch.eventCount | Should -Be 0 + @($report.meetingLogSearch.meetingIds).Count | Should -Be 0 + @($report.meetingLogSearch.events).Count | Should -Be 0 + @($report.meetingLogSearch.meetings).Count | Should -Be 0 + } + + 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.acceptCount | Should -Be 0 + $report.meetingLogSearch.tentativeCount | 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.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 "does not match subject text in unrelated log fields" { + Mock Export-MailboxDiagnosticLogs { + [PSCustomObject]@{ + MailboxLog = @( + "2026-08-28T10:00:03Z, Delegate: Project Falcon" + "2026-08-28T10:00:02Z, Subject: Different meeting" + "2026-08-28T10:00:01Z, Begin ProcessRequest Goid: 04000000800E00074C5A7101A82E007000000004220FC5BAC74D90100000000000000001000000068B165058D1E2E439252F58379D4FE92" + "2026-08-28T10:00:00Z, START - HandleEventInternal Automatic Booking is enabled for resource." + ) -join "`r`n" + } + } + + $report = Invoke-TestRbaSummary -Subject "Project Falcon" + + $report.meetingLogSearch.status | Should -Be "NotFound" + $report.meetingLogSearch.subjectMatchCount | Should -Be 0 + $report.meetingLogSearch.events | Should -BeNullOrEmpty + } + + It "returns the complete empty search schema when the RBA log is unavailable" { + Mock Export-MailboxDiagnosticLogs { throw "RBA log unavailable" } + + $report = Invoke-TestRbaSummary -Subject "Project Falcon" + + $report.metadata.collectionStatus | Should -Be "Partial" + $report.collectors.RbaLog.status | Should -Be "Failed" + $report.meetingLogSearch.status | Should -Be "LogUnavailable" + foreach ($propertyName in @( + "acceptCount", "tentativeCount", "declineCount", "updateCount", "cancellationCount", + "delegateReferralCount", "externalSkippedCount", "horizonDeclineCount", "recurrenceTruncateCount" + )) { + $report.meetingLogSearch.PSObject.Properties.Name | Should -Contain $propertyName + $report.meetingLogSearch.$propertyName | Should -Be 0 + } + } + + It "creates filename-safe output stems from supported identity formats" { + Push-Location -Path $TestDrive + try { + & $Script:scriptPath -Identity "..\room@contoso.com" -SkipVersionCheck *>&1 | Out-Null + $jsonFiles = @(Get-ChildItem -Path $TestDrive -Filter "RBA-Summary-For__room_*.json" -File) + $unexpectedDirectories = @(Get-ChildItem -Path $TestDrive -Directory) + } finally { + Pop-Location + } + + $jsonFiles.Count | Should -Be 1 + $unexpectedDirectories | Should -BeNullOrEmpty + } + + 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 | Should -BeNullOrEmpty + 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 "normalizes full-fidelity Exchange values before JSON serialization" { + $ruleName = [PSCustomObject]@{ NestedRuleValue = [PSCustomObject]@{ Secret = "rule-secret" } } + $ruleName | Add-Member -MemberType ScriptMethod -Name ToString -Value { "Rich rule name" } -Force + $roomList = [PSCustomObject]@{ NestedRoomListValue = [PSCustomObject]@{ Secret = "room-list-secret" } } + $roomList | Add-Member -MemberType ScriptMethod -Name ToString -Value { "RoomList@contoso.com" } -Force + $additionalResponse = [PSCustomObject]@{ NestedResponseValue = [PSCustomObject]@{ Secret = "response-secret" } } + $additionalResponse | Add-Member -MemberType ScriptMethod -Name ToString -Value { "Contact the delegate" } -Force + + Mock Get-InboxRule { @([PSCustomObject]@{ Name = $ruleName }) } + Mock Get-Place { + [PSCustomObject]@{ + City = "Redmond" + Floor = 1 + Capacity = 8 + Localities = @($roomList) + Street = "1 Microsoft Way" + State = "WA" + PostalCode = "98052" + CountryOrRegion = "US" + Building = "1" + Tags = @("Display") + } + } + Mock Get-CalendarProcessing { + $settings = Get-TestCalendarProcessing + $settings.AdditionalResponse = $additionalResponse + $settings + } + + $report = Invoke-TestRbaSummary -IncludeSensitiveData + $serializedReport = $report | ConvertTo-Json -Depth 8 + + $report.inboxRules.ruleNames | Should -Contain "Rich rule name" + $report.place.roomLists | Should -Contain "RoomList@contoso.com" + $report.calendarProcessing.additionalResponse | Should -Be "Contact the delegate" + $serializedReport | Should -Not -Match "rule-secret|room-list-secret|response-secret" + } + + 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 "does not export targeted evidence without an exact START boundary" { + 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:02Z, Subject: Unrelated private meeting" + "2026-08-28T10:00:02Z, Organizer: private.user@contoso.com" + "2026-08-28T10:00:01Z, START - Generic marker" + ) -join "`r`n" + } + } + + $report = Invoke-TestRbaSummary -Subject "Missing boundary test" + $report.meetingLogSearch.status | Should -Be "AmbiguousBoundary" + $report.meetingLogSearch.subjectMatchCount | Should -Be 1 + $report.meetingLogSearch.eventCount | Should -Be 0 + $report.meetingLogSearch.events | Should -BeNullOrEmpty + $report.meetingLogSearch.meetingIds | Should -BeNullOrEmpty + ($report | ConvertTo-Json -Depth 8) | Should -Not -Match "private\.user@contoso\.com" + } +} diff --git a/docs/Calendar/Get-RBASummary.md b/docs/Calendar/Get-RBASummary.md index 251f58f2cb..39a98ec03e 100644 --- a/docs/Calendar/Get-RBASummary.md +++ b/docs/Calendar/Get-RBASummary.md @@ -1,33 +1,205 @@ # Get-RBASummary + + Download the latest release: [Get-RBASummary.ps1](https://github.com/microsoft/CSS-Exchange/releases/latest/download/Get-RBASummary.ps1) +`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 for further analysis. + +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. If the active lookup fails, the script tries `Get-Mailbox -SoftDeletedMailbox`. If that fallback also fails or returns null, the original active-mailbox error remains the collector failure and user-facing warning; details from the fallback attempt are available with `-Verbose`. Collection stops before downstream collection and JSON generation 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. -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. +## Requirements -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). +- 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`. +## Syntax + +```powershell +.\Get-RBASummary.ps1 -Identity [-Subject | -MeetingId ] [-IncludeSensitiveData] [-SkipVersionCheck] [-Verbose] +``` -#### Syntax: +| Parameter | Required | Description | +|---|---|---| +| `Identity` | Yes | Resource mailbox identity. An SMTP address is recommended. | +| `Subject` | No | Case-insensitive literal substring matched only against recognized RBA subject fields. After discovery, only complete blocks carrying those meeting 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. | -Example to display the setting of room mailbox. -```PowerShell +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 + +The script first attempts to start the text transcript and then resolves and validates the mailbox. An unresolved or unsupported mailbox can therefore leave a text transcript but stops before JSON generation. After successful prerequisite validation, JSON generation is attempted independently. If writing JSON fails, the text summary remains available, and the final output list includes only files that exist. + +The text transcript and JSON metadata record a canonical command line with the bound 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. Mailbox prerequisite failures normally produce no JSON, so `Failed` remains a defined schema status rather than the normal output for an unresolved mailbox. `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. An empty result from a collection-valued collector, such as Inbox Rules, is successful evidence and produces zero counts. A null result from a required scalar collector is a collection failure. 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. In `Sanitized` and `TargetedMeeting` modes, remote error messages, fully qualified error IDs, and inner-exception messages are omitted to prevent identity or object details from leaking into JSON. Bounded exception type and category remain available. `Full` mode includes the bounded error details. Stack traces, invocation details, target objects, and remote position details are never exported. + +Output is written to the current working directory. All files from one run share the same timestamp. Invalid filename characters in the supplied identity are replaced so aliases and distinguished-name-style inputs cannot create unintended paths. + +| 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 further analysis. | +| `RBA-Logs__.txt` | Readable retained RBA diagnostic log when collection succeeds and contains more than one non-empty log entry. | + +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.1-preview` and is encoded as UTF-8 without a byte order mark. Compared with `1.0-preview`, `calendarProcessing.maximumConflictPercentage` is removed; use `calendarProcessing.conflictPercentageAllowed`. The transcript and readable RBA-log files do not carry the same BOM-free encoding guarantee. + +Every finding contains `ruleId`, `severity`, `status`, `title`, and `evidence`. When `status` is `NotEvaluated`, `evidence` is always JSON `null`; consumers must not interpret unavailable evidence as a negative result. For evaluated findings, evidence is rule-specific and can be a scalar or object. `RBA001` uses an object with an `error` member, while `RBA002` through `RBA007` use a scalar error string or null. In sanitized modes, those error values can contain the fixed text `Error details omitted in sanitized mode.` + +The JSON 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. + +`rbaLogSummary.processedEventCount` counts retained `START -` processing blocks. The accepted, declined, tentative, updated, cancellation, delegate-referral, skipped-external, horizon-decline, and recurrence-truncation values count their respective retained markers independently. These categories are not mutually exclusive: one processing block can increment multiple marker counts, so their sum must not be expected to equal `processedEventCount`. The fields `processedEventCountRepresents`, `markerCountCategoryRelationship`, and `markerCountCategoriesMayOverlapWithinBlock` encode these semantics for consumers. + +## 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 complete RBA blocks correlated by an extracted or supplied meeting ID. If a subject matches in a complete block but has no extractable ID, only that block is added. Content without an exact boundary is never added. Included 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 recognized subject fields in complete retained RBA processing blocks, extracts meeting IDs from matching blocks, and then switches to meeting-ID correlation. Text in organizer, delegate, or diagnostic fields does not count as a subject match. Once at least one ID is discovered, only complete 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 complete 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 report 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. Such content is not exported as a targeted event because it cannot be separated safely from neighboring meetings. 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. All count fields are `0`; `meetingIds`, `meetings`, and `events` are empty arrays. +- `Found`: a subject resolved to at least one meeting ID, or the supplied meeting ID occurs in at least one complete retained block. +- `FoundWithoutMeetingId`: the subject matched one or more complete blocks, but no meeting ID could be extracted. Only the directly subject-matched complete blocks are returned. +- `AmbiguousBoundary`: the requested subject or meeting ID appeared only in content without an exact processing boundary. Counts are `0`, and no meeting IDs, meeting summaries, events, or raw targeted evidence are returned. +- `NotFound`: no subject match or supplied meeting ID exists in the retained log. Counts are `0`, and `meetingIds`, `meetings`, and `events` are empty arrays. +- `LogUnavailable`: RBA log collection failed. The complete search schema remains present, with zero counts and empty `meetingIds`, `meetings`, and `events`. + +No-result search objects use null timestamps, `Unknown` recurrence, policy, and disposition values, false Boolean summaries, and a null delegate-message count. + +`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 workflow + +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. Review the collection status and failed collectors before interpreting findings. +4. Review detected findings by severity and keep `NotEvaluated` findings separate as evidence gaps. +5. For a targeted search, correlate each meeting ID independently and cite the exact retained RBA markers that support the conclusion. + +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. + +## Mailbox lifecycle and item propagation + +The report provides 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. Use 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.