Simple Room Resources: Setting Up Room Calendars in Exchange Online

Simple Room Resources: Setting Up Room Calendars in Exchange Online

Reading Time: 8 minutes

If you are new to Microsoft 365 administration, simple room resources are one of the first things people will ask you to fix. Someone double-booked the big conference room. A meeting got auto-declined for no clear reason. The room shows up in Outlook but nobody can actually book it. All of these come down to one object type: the room mailbox.

A room mailbox is a special kind of resource mailbox in Exchange Online. It has a calendar, it has an email address, and it can accept or decline meeting invites on its own. When you book a room in Outlook, you are really sending a meeting request to that mailbox, and the mailbox decides what to do with it.

This post covers how to create and manage simple room resources two ways: through the Exchange admin center, and through PowerShell. The GUI is fine for getting started. PowerShell is where you get the permission controls that actually solve the messy problems.

What a simple room resource actually is

Strip away the marketing and a room mailbox is three things working together:

  • A mailbox object with its own SMTP address, so it can receive booking requests.
  • A calendar that holds the bookings.
  • A set of booking rules, called calendar processing, that decide how the mailbox responds to requests.

There is a sibling object called an equipment mailbox. It works the same way but is meant for things that are not tied to a location, like a projector, a loaner laptop, or a company vehicle. Everything below applies to both. The only real difference is the resource type you pick when you create it.

One rule to memorize early: never make a room mailbox the organizer of a meeting. A room is something you invite, not something that runs the meeting. Add it to the location or attendee field of the invite and let it respond.

Creating simple room resources in the Exchange admin center

The Exchange admin center, or EAC, is the web console for Exchange Online. You get to it at admin.exchange.microsoft.com, or through the main Microsoft 365 admin center by going to Show all > Exchange. You need to be a Global Administrator or have the Exchange recipient management role to do any of this.

Create a room mailbox

In the EAC, go to Recipients > Resources. This page lists every room and equipment mailbox in the tenant.

Click Add a resource. A panel opens on the right and walks you through four steps:

  1. Resource setup. Choose Room or Equipment. For a conference room, pick Room.
  2. General information. Give it a display name, set the email alias, and add the capacity. Capacity matters more than it looks. Outlook’s Room Finder uses it to filter rooms by how many people you are inviting.
  3. Booking options. This is where you decide whether the room accepts meetings automatically, whether it allows conflicting bookings, and how far out people can book it.
  4. Review resource. Confirm the settings and click Create.

Give Exchange a few minutes after you create it. The mailbox and its calendar do not always appear instantly.

Edit booking behavior

Back on the Recipients > Resources page, click the room you just made. A details pane opens. The settings you care about live in two spots:

  • Under General, you can edit the resource details, capacity, and contact information for the room.
  • Under Booking, click Manage booking settings. This is the heart of room behavior.

Inside booking settings you can control:

  • Whether to auto-accept meeting requests or send them to a delegate for approval.
  • Whether to allow conflicting meetings (almost always leave this off for a real room).
  • The booking window, which is how many days in advance someone can reserve the room.
  • The maximum duration for a single booking.
  • Whether to allow recurring meetings.

Set delegates in the GUI

If you want a person to approve every booking, look for the delegate option inside booking settings. Add the user there, and the room switches from auto-accept to manual approval. Every request will sit in a pending state until that delegate says yes.

That covers the everyday work. The GUI is clean and fast for one room at a time. The trouble starts when you need rules that are more specific than “auto-accept or ask a delegate.” That is where you open PowerShell.

Managing simple room resources with PowerShell

The GUI gives you the common switches. PowerShell gives you all of them, plus the ability to do the same thing to fifty rooms in one command. For permissions especially, PowerShell exposes controls the web console simply does not show you.

Connect to Exchange Online PowerShell

You only need to install the module once.

Install-Module ExchangeOnlineManagement -Scope CurrentUser

Then connect each session:

Connect-ExchangeOnline -UserPrincipalName admin@contoso.com

A browser window opens for sign-in, including multifactor auth if you have it on, which you should. When you are done, run Disconnect-ExchangeOnline to close the session cleanly.

Create a room mailbox

New-Mailbox -Name "Conf Room - Building A" -Room

You can set the capacity and other properties right after with Set-Mailbox:

Set-Mailbox -Identity "Conf Room - Building A" -ResourceCapacity 12

Room lists are PowerShell only

Here is a detail that catches new admins off guard. A room list is a special distribution group that powers the Room Finder in Outlook. It lets users browse rooms by building. You cannot create a room list in the GUI. You have to use PowerShell.

New-DistributionGroup -Name "Building A Rooms" -RoomList
Add-DistributionGroupMember -Identity "Building A Rooms" -Member "Conf Room - Building A"

If your simple room resources are not showing up grouped by building in Room Finder, a missing room list is usually why.

Calendar processing is where the real control lives

Every booking decision a room makes comes from its calendar processing settings. You read them with Get-CalendarProcessing and change them with Set-CalendarProcessing. Run the Get command first so you can see the current state before you change anything.

Get-CalendarProcessing -Identity "Conf Room - Building A" | Format-List

A solid baseline for a normal conference room looks like this:

Set-CalendarProcessing -Identity "Conf Room - Building A" `
  -AutomateProcessing AutoAccept `
  -AllowConflicts $false `
  -BookingWindowInDays 180 `
  -MaximumDurationInMinutes 480 `
  -AllowRecurringMeetings $true

A few of these are worth explaining.

  • AutomateProcessing has three values. AutoAccept books and declines on its own. AutoUpdate adds tentative holds but does not commit. None turns the automation off entirely.
  • AllowConflicts does what it says. For a physical room, keep it $false so two teams cannot claim the same space.
  • BookingWindowInDays stops someone from blocking the room a year out.

Make the meeting details readable

By default, a room strips the subject line off accepted meetings and replaces it with the organizer’s name. So the calendar fills up with entries that just say “Booked” or a person’s name, and nobody can tell what is happening in the room. These settings fix that:

Set-CalendarProcessing -Identity "Conf Room - Building A" `
  -DeleteSubject $false `
  -AddOrganizerToSubject $false `
  -DeleteComments $false `
  -RemovePrivateProperty $false

DeleteSubject $false keeps the real meeting title. DeleteComments $false keeps the body of the invite. Run these on a shared room and the calendar suddenly becomes useful to look at.

Advanced permission control through PowerShell

This is the part that makes PowerShell worth learning. The GUI gives you “auto-accept” or “send to a delegate.” PowerShell lets you decide who can book the room, who needs approval, and who can see what on the calendar. There are three layers, and they do different jobs.

Layer one: who can book without approval

These settings answer the question “can this person book the room directly, or does it need a sign-off?”

  • BookInPolicy is a list of users or groups whose in-policy requests get accepted automatically.
  • AllBookInPolicy set to $true lets everyone book directly as long as the request follows the rules.
  • RequestInPolicy sends in-policy requests to a delegate for approval instead of auto-accepting.
  • RequestOutOfPolicy lets specific people submit requests that break the rules (too long, too far out) and have a delegate decide.

A common real-world setup: the whole company can book the room normally, but only the leadership group can book it for longer than the usual limit or outside the normal window.

Set-CalendarProcessing -Identity "Conf Room - Building A" `
  -AllBookInPolicy $true `
  -RequestOutOfPolicy "Leadership Team" `
  -AllRequestOutOfPolicy $false

The GUI cannot express that. PowerShell does it in one command.

Layer two: delegates who approve bookings

When you want a person to approve requests, set them as a resource delegate:

Set-CalendarProcessing -Identity "Conf Room - Building A" `
  -ResourceDelegates "assistant@contoso.com" `
  -AutomateProcessing AutoUpdate

Set AutomateProcessing to AutoUpdate here. If you leave it on AutoAccept, the room books everything on its own and the delegate never gets a say. The delegate then receives the pending requests and approves or rejects each one from their own Outlook.

You can list more than one delegate. Keep in mind that delegates approve, they do not own the calendar.

Layer three: who can see and edit the calendar

This is a different kind of permission, and it confuses people because it does not live in calendar processing at all. It lives on the calendar folder itself, and you manage it with the mailbox folder permission commands.

Use this when an executive assistant needs to actually open the room calendar, move meetings around, or see full meeting details instead of just free or busy time.

Add-MailboxFolderPermission -Identity "Conf Room - Building A:\Calendar" `
  -User "assistant@contoso.com" `
  -AccessRights Editor

To change an existing permission, use Set-MailboxFolderPermission. To remove one, use Remove-MailboxFolderPermission. The access levels run from least to most access:

  • AvailabilityOnly shows free or busy time and nothing else.
  • LimitedDetails adds the subject and location.
  • Reviewer lets the person read full meeting details.
  • Editor lets them read, create, and change items on the calendar.

For most people, Reviewer is enough. Hand out Editor only to the person who genuinely needs to rearrange the room’s schedule.

A note on Full Access

You can grant Full Access to a room mailbox with Add-MailboxPermission, but think twice before you do. Full Access means the user can open the entire mailbox, not just the calendar. For room management, a calendar folder permission is almost always the right and safer choice. Give people the least access that solves the problem.

Putting your simple room resources together

A clean setup usually follows the same path. Create the mailbox, set its capacity, drop it into a room list so Room Finder works, set sensible calendar processing so it books well and keeps readable subject lines, then layer on permissions only where a real person needs them.

Start in the GUI while you are learning the objects. Move to PowerShell the moment you need more than one room handled the same way, or any permission setup more specific than auto-accept. The web console is the front door. PowerShell is the whole house.

What we can learn as a person

There is something worth sitting with here, past the cmdlets and the click paths.

A room mailbox works because it has limits. It knows its capacity. It only takes so many bookings, only so far out, only for so long. When a request breaks the rules, it does not feel bad about it. It declines, or it hands the decision to someone else and moves on.

We are worse at this than a conference room.

Most of us run with auto-accept turned on for everything. Every request, every favor, every late “quick thing” gets booked straight into the calendar, conflicts and all. We say yes past our own capacity, then wonder why we feel scraped out by the middle of the week.

You are allowed to set a booking window, have a maximum duration, and decline a request that falls out of policy, and you do not owe anyone a long apology for it.

You are also allowed delegates. The reason we hand approval to someone else is that one person was never meant to carry every decision. Asking for help is not the system failing. It is the system working the way it was built to.

So take some of the load off your shoulders. Figure out what you can actually hold, set the rules that protect it, and let the rest go to someone who has room. The person who guards their capacity is still standing next year. The one who accepts every booking burns out by Thursday.

The room does not run the meeting. It was never supposed to. Neither are you.

FAQ

What is the difference between a room mailbox and a regular shared mailbox?

A room mailbox is built to accept and decline meeting invites through calendar processing rules. A shared mailbox is built for people to read and send mail together. They are different recipient types, and you should not try to use one as the other.

Why does my room calendar only show “Booked” instead of the meeting name?

By default the room deletes the subject and adds the organizer’s name instead. Set DeleteSubject $false and AddOrganizerToSubject $false with Set-CalendarProcessing to keep the real titles.

Why can people see the room in Outlook but not book it?

Check the calendar processing. If AutomateProcessing is set to None, the room will not respond to requests. Also confirm AllBookInPolicy or BookInPolicy actually includes the people trying to book.

Can I create a room list in the Exchange admin center?

No. Room lists exist only in PowerShell. Use New-DistributionGroup with the -RoomList switch, then add your rooms with Add-DistributionGroupMember.

Should I give someone Full Access to manage simple room resources?

Usually not. Use Add-MailboxFolderPermission on the calendar folder with Reviewer or Editor rights instead. Full Access opens the whole mailbox, which is more than calendar management needs.

Get Mailbox Rules Using PowerShell

Get Mailbox Rules Using PowerShell

Reading Time: 2 minutes

Ever found yourself tangled in the web of Exchange Online mailbox rules? Yeah, me too. It’s like trying to find a needle in a haystack, especially if you’re managing multiple mailboxes. Thankfully, I stumbled upon a nifty PowerShell script that makes this task a breeze. Let’s Get Mailbox Rules Using PowerShell.

Let’s Talk About Our Script

Before we jump into the script, let’s understand what it does. The PowerShell script Get-RASMailboxRules helps you retrieve mailbox rules for specific email addresses in Exchange Online. Whether you’re an IT admin juggling a dozen tasks or just someone who likes things neat and tidy, this script can save you a ton of time.

Breaking Down the Script

Here’s the full script for reference:

function Get-RASMailboxRules {
    [cmdletbinding()]
    param (
        [Parameter(
            ValueFromPipeline = $True,
            ValueFromPipelineByPropertyName = $True,
            HelpMessage = "Email Addresses",
            Mandatory = $true)][Alias('Mailbox','EmailAddress')][String[]]$Mailboxes
    )
    begin {
        # Checks if Exchange Online is connected
        if ($null -eq (Get-ConnectionInformation)) {Connect-ExchangeOnline}

        # Pulls all mailboxes from the $Mailboxes parameter and checks if they exist
        $Boxes = @()
        foreach ($box in $mailboxes) {
            Try {
                $Boxes += Get-Mailbox $box
            } catch {
                Write-Error "Error getting mailbox"
            }
        }
    }
    process {
        foreach ($mailbox in $Boxes) {
            $Rules = Get-InboxRule -Mailbox $mailbox.Name

            foreach ($Rule in $Rules) {
                $ruleDescription = $Rule.Description -join "`n"
                $Description = (($ruleDescription -split 'If the message:')[1] -split 'Take the following actions:')
                $ifMessage = ($Description[0].Trim() -replace "`t", "") -replace "\s*`n", ""
                $actions = ($Description[1].Trim() -replace "`t", "") -replace "\s*`n", ""

                [PSCustomObject]@{
                    MailboxName = $Mailbox.Name
                    Mailbox = $Mailbox.UserPrincipalName
                    RuleName = $Rule.Name
                    Enabled = $Rule.Enabled
                    ID = $Rule.RuleIdentity
                    IfMessage = $ifMessage
                    Actions = $actions
                }
            }
        }
    }
    end {
        Disconnect-ExchangeOnline -Confirm:$false
    }
}

What’s Happening Here?

Let’s break it down:

  1. Parameters and Initialization:
    • The script takes email addresses as input through the $Mailboxes parameter.
    • It checks if Exchange Online is connected. If not, it connects using Connect-ExchangeOnline.
  2. Fetching Mailboxes:
    • It loops through the provided mailboxes and tries to fetch their details using Get-Mailbox.
    • Any errors encountered during this process are caught and reported.
  3. Processing Mailbox Rules:
    • For each mailbox, it retrieves the inbox rules using Get-InboxRule.
    • It parses the rules to extract the conditions (IfMessage) and actions (Actions).
  4. Output:
    • It creates a custom PowerShell object for each rule, which includes details like mailbox name, rule name, enabled status, and more.
    • Finally, it disconnects from Exchange Online to clean up.

Key Points to Remember

  • Mandatory Parameter: The script requires at least one email address to be provided.
  • Error Handling: It gracefully handles errors when fetching mailbox details.
  • Custom Output: The output is a clean, readable list of mailbox rules with all the necessary details.

Wrapping up “Get Mailbox Rules using PowerShell”

And there you have it! A super handy PowerShell script to get mailbox rules using PowerShell in order. It’s efficient, straightforward, and takes the hassle out of managing mailbox rules. So next time you’re knee-deep in inbox rules, you know which script to pull out.

Happy scripting, folks! If you run into any issues or have questions, drop them in the comments below. Let’s keep the conversation going!

Additional Reading

    O365 Mobile Devices

    O365 Mobile Devices

    Reading Time: 2 minutes

    I needed to grab all the mobile devices that had emails attached to them not too long ago. I could have gone through the GUI, and spent a few days trying my best to document everything, or I could use Powershell to pull the O365 Mobile Devices. Can you guess which one I did? Yep, PowerShell is a way of thinking nowadays in the system admin world.

    The Script

    Connect-ExchangeOnline
    $Mailboxes = get-mailbox -all
    $Devices = foreach ($Mail in $Mailboxes) {
        $MobileDevices = Get-MobileDevice -Mailbox $mail.userprincipalname
        foreach ($Device in $MobileDevices) {
            $Stats = Get-MobileDeviceStatistics -Identity $Device.Identity
            [pscustomobject]@{
                User = $mail.userprincipalname
                FriendlyName                  = $Device.FriendlyName
                name                          = $Device.name
                Identity                      = $Device.Identity
                DeviceID                      = $Device.DeviceId
                DeviceOS                      = $Device.DeviceOS
                DeviceType                    = $Device.DeviceType
                DeviceModel                   = $Device.DeviceModel 
                ClientType                    = $Stats.ClientType
                FirstSyncTime                 = $Device.FirstSyncTime
                WhenChanged                   = $Device.WhenChanged
                LastPolicyUpdateTime          = $Stats.LastPolicyUpdateTime
                LastSyncAttemptTime           = $Stats.LastSyncAttemptTime
                LastSuccessSync               = $stats.LastSuccessSync
                DeviceAccessState             = $Device.DeviceAccessState
                DeviceAccessStateReason       = $Device.DeviceAccessStateReason
                IsValid                       = $Device.IsValid
                Status                        = $Stats.Status
                DevicePolicyApplied           = $stats.DevicePolicyApplied
                DevicePolicyApplicationStatus = $stats.DevicePolicyApplicationStatus
            }
        }
    }
    $Devices
    
    

    The Break Down

    This script is pretty straightforward. We first connect to exchange online. Currently, this script is designed to work with MFA. Then we grab all the mailboxes from O365.

    Connect-ExchangeOnline
    $Mailboxes = get-mailbox -all
    

    Next, we loop through these mailboxes. We drop all the information get gather from this loop into a variable. I called my devices. Once again, nothing great or grand.

    $Devices = foreach ($Mail in $Mailboxes) {}
    

    For each mailbox we have, we pull all of the devices from that mailbox. Since a single mailbox can have more than one device, we loop through these devices. For each loop, we want to grab that device’s stats to gain more insights.

    $MobileDevices = Get-MobileDevice -Mailbox $mail.userprincipalname
        foreach ($Device in $MobileDevices) {
            $Stats = Get-MobileDeviceStatistics -Identity $Device.Identity
    

    Finally, from there we gather the information into a single object. These commands produce a lot of information. It’s best to parse it down some. That’s what I did with the next part. That’s how I am able to get all the O365 Mobile Devices using PowerShell.

    Additional Reading

    Exchange Mailbox Rules

    Exchange Mailbox Rules

    Reading Time: 8 minutes

    At one of my previous Jobs, I had to write a script to help me understand rules. Well, I pulled that script out of the vault to do the same thing recently. The following script will allow you to select one user or multiple users to grab their rules. Or you can just forget the flag and get all of the users at once. The idea of the script is to get basic information from the rule, not all the information as it’s not all useful. So let’s look at the script then we will do a breakdown. Here comes Exchange Mailbox Rules through PowerShell.

    The script

    function Get-MailboxInboxRules {
        [cmdletbinding()]
        param (
            [string[]]$UserPrincipalName,
            [switch]$StayConnected
        )
        begin {
            #Begining tests if the module is installed, loaded, and connected. Correcting each one at each level. 
    
            #Installs required modules
            Write-Verbose "Installing required modules"
            if (!(Get-InstallEdModule ExchangeOnlineManagement)) { 
                try {
                    Install-Module ExchangeOnlineManagement
                }catch {
                    Write-Error $_
                    exit
                } 
            }
     
            Write-Verbose "Checking and importing required modules"
            # Starts importanting required modules
            if (!(Get-Command Connect-ExchangeOnline)) { 
                try {
                    Import-Module ExchangeOnlineManagement
                } catch {
                    Write-Error $_
                    exit
                }
            }
    
            #Tests if Exchange Online is connected, If not, we trigger a connection
            if (!(Get-PSSession | Where-Object { $_.Name -match 'ExchangeOnline' -and $_.Availability -eq 'Available' })) { 
                try {
                    Connect-ExchangeOnline}
                catch {
                    Write-Error $_
                    exit
                } 
            }
            if (!(Get-PSSession | Where-Object { $_.Name -match 'ExchangeOnline' -and $_.Availability -eq 'Available' })) {
                Write-Error "Connection failed"
                exit
            }
        }
        Process {
            $mailboxes = @()
            if (!($PSBoundParameters.ContainsKey("UserPrincipalName"))) {
                $Mailboxes = Get-mailbox
            }
            else {
                foreach ($Username in $UserPrincipalName) {
                    try {
                        $Mailboxes += get-mailbox $username -ErrorAction SilentlyContinue
                    }
                    catch {
                        Write-Warning "$username has no mailbox"
                    }
                }
            }
    
            #It has been proven to speed up the script if we drop the output into a return value
            $RulesReturn = Foreach ($Mail in $Mailboxes) {
                
                Write-Verbose "Scanning: $($Mail.UserPrincipalName)"
                #We are now trying to catch the rule in question. 
                #if the mailbox has no rules, which will be presented as an error, then we null out the rules. 
                try {
                    $Rules = Get-InboxRule -Mailbox $Mail.Userprincipalname
                }
                catch {
                    $Rules = $null  
                }
    
                #Now that the rules either have value or null, we can test. 
                if ($null -ne $Rules) {
                    #Write-Host "$($mail.userprincipalname)"
    
                    #Now we know we have rules, it's time to start working with those rules
                    foreach ($Rule in $Rules) {
    
                        #From my testing I discover that some rules will be null but the rule itself isn't null. 
                        #Thus we need to test if this is the case. 
                        if ($null -ne $rule) {
    
                            #Now we have confirmed the rule is not empty, we need to test the form
                            #This is because some rules are based on subjects and such and not from people. 
                            if ($null -ne $rule.from) {
                                
                                #since form is not empty we split the string and we get the from information. 
                                $froms = $Rule.from.split('[')[0]
                            }
                            else {
    
                                #if it is, we just blank string the value. 
                                $froms = ""
                            }
    
                            #Next we want the description to be on a single line. this way we can export to a csv. 
                            if ($null -ne $rule.description) {
    
                                #This is programmed for the standard 5.1 powershell. Join-String was introduced in 6.2
                                #to combat this, we create a null return
                                #Then we  split the description rule. 
                                #Then we for each that list and push it back into the return.
                                
                                $dereturn = $Null
                                $rule.description.split("`n") | foreach-object { $dereturn = "$dereturn $($_.trim())" }
                                $description = $dereturn.trim()
                            }
                            else {
                                $description = ""
                            }
                            #Next we create our ps object with items we need for basic level audits. 
                            [pscustomobject]@{
                                Username    = $Mail.Userprincipalname
                                Identity    = $Rule.Identity
                                Enabled     = $Rule.Enabled
                                Name        = $Rule.Name
                                from        = $froms
                                Description = $description
                            } 
                        }
                    }
                }
            }
        }
        end {
    
            #At the end we return
            $RulesReturn
    
            #Then we disconnect if the user didn't say stay connected. 
            if (!($StayConnected)) { Disconnect-ExchangeOnline -Confirm:$false }
        }
    }
    

    The Breakdown

    Before we begin, I want to point out, at this point, there is no graph API calls for exchange. Not yet, its promised, but as of right now, it’s not reality yet. Security is a big thing to think about with scripts like these. MFA is a big piece of security. We always want some mfa going. This is why you will not see credentials in the parameters.

    Parameters

    Our parameters are simple. We have a list of strings of user principal names and we have a switch to determine if we need to stay connected. If we leave the user principal name blank, then we are targeting every mailbox. It may take some time. The stay connected is designed to keep exchange connected. This way once you have the exchange mailbox rules, you can run other commands. You can also leave this blank, if you do, it disconnects from exchange. Good security thinking here.

    param (
            [string[]]$UserPrincipalName,
            [switch]$StayConnected
    )
    

    Begin

    Next is our begin. We are doing some house keeping here. First, We need to know if the exchange online management is installed. if it’s not, we then install it. Next, test if the exchange online management is imported. if not, we import the module. Finally we test if we have an exchange online connection, if not, we use connect-exchange. We use the connect-exchangeonline because it will do the MFA for us. Which is nice.

    To test if the module is installed, we use the Get-InstalledModule. This command searches your modules and lets you know if it is installed. Older Powershells do not know this command. If the powershell is not elevated, then this code will error out and exits with the exit code.

    Write-Verbose "Installing required modules"
    if (!(Get-InstallEdModule ExchangeOnlineManagement)) { 
            try {
                Install-Module ExchangeOnlineManagement
            }catch {
                Write-Error $_
                exit
            } 
    }
    

    If we haven’t exited out yet, we then see if the module is loaded by using the get-command command. We are looking for the command Connect-ExchangeOnline. If the command exists, we will continue on, if not, we will use the import-module command and import the exchangeonlinemanagement module. Of course, if we run into an error, we exit with the error.

    Write-Verbose "Checking and importing required modules"
    # Starts importanting required modules
    if (!(Get-Command Connect-ExchangeOnline)) { 
        try {
            Import-Module ExchangeOnlineManagement
        } catch {
            Write-Error $_
            exit
        }
    }
    

    Finally, we are going to pull all the PowerShell Sessions currently on this computer. This is where we will gain the ability to pull Exchange Mailbox Rules. The first command is get-pssession. We search the output for a session with the name of ExchangeOnline that is available. If we don’t find one, we connect to exchange using the connect-exchangeonline. Next, if connection errors out, we present the error and disconnect. Now, if the connection is successful, we test once more for a connection. If it’s just not there, we give up and say bye.

    #Tests if Exchange Online is connected, If not, we trigger a connection
            if (!(Get-PSSession | Where-Object { $_.Name -match 'ExchangeOnline' -and $_.Availability -eq 'Available' })) { 
                try {
                    Connect-ExchangeOnline}
                catch {
                    Write-Error $_
                    exit
                } 
            }
            if (!(Get-PSSession | Where-Object { $_.Name -match 'ExchangeOnline' -and $_.Availability -eq 'Available' })) {
                Write-Error "Connection failed"
                exit
            }
    

    Process – Exchange Mailbox Rules

    Check Mailboxes

    Now we are connected to exchange online, it’s time to start grabbing the needed information. Before we start, we need to establish an array for possible mailboxes. We will call these mailboxes to make life easy.

    $mailboxes = @()
    

    Now, whether or not the user used the “UserPrincipalName” parameter or not, we are ready. The next step is to test if we used the parameter and grab those mailboxes. We do this by using the $PSBoundParameters variable. Searching the Contains key allows you to see what parameters are being passed to the script. in our case we will be searching for the user principal name. If there is no UPN, we just grab all the mailboxes and dump it into the $mailboxes variable we made.

    Now, if we did use the upn, we will loop through the UPN and grab each mailbox accordingly. if there is an issue, we let the user know that that username was an issue. This will give us the mailbox information we need. We do this separately because it causes fewer errors. Best way to describe it is using this method is the bouncer. It bounces issues out the door before we start looking at the inbox rules.

    $mailboxes = @()
            if (!($PSBoundParameters.ContainsKey("UserPrincipalName"))) {
                $Mailboxes = Get-mailbox
            }
            else {
                foreach ($Username in $UserPrincipalName) {
                    try {
                        $Mailboxes += get-mailbox $username -ErrorAction SilentlyContinue
                    }
                    catch {
                        Write-Warning "$username has no mailbox"
                    }
                }
            }
    

    Exchange Mailbox Rules

    The next step is to grab the rules themselves. We start off by making a for each loop. The loop of course is going to drop into a variable. We do this because it has been found that dropping the output of a foreach loop into a variable is a faster method than rebuilding or appending an array.

    $RulesReturn = Foreach ($Mail in $Mailboxes) {}
    

    Now, it’s time to hang onto your hats. The rules are tricky as they error out. We see errors because the end user will create a rule, and abandon it. It has nothing to do with your code. It is purely what it is.

    The first thing we need to do inside our loop is grab the rules for the mailbox. The command is Get-InboxRule. We will be placing the rules into a variable called Rules. Now here is the catch. This command will produce something. So throw it in a try catch. So if it produces an error, which happens, you can set the Rules to null.

    Inbox Rules

    try {
        $Rules = Get-InboxRule -Mailbox $Mail.Userprincipalname
    } catch {
        $Rules = $null  
    }
    

    Next, we test if the Rules are null, if not, we start the loop. Here is another fun part… If the rule is misconfigured, it will present as null from time to time. So we need to test for the null again per rule.

    if ($null -ne $Rules) {
        Write-Verbose "$($mail.userprincipalname)"
        foreach ($Rule in $Rules) {
            if ($null -ne $rule) {}
        }
    }
    

    Parsing the data

    Some rules are based on subject, some rules are based on email. It’s best that we grab useful information. Things like the description, and from are presented in string format with unique structures. However, sometimes those can be null strings and that will explode the results as well. So we have to test them. First we will test the from. if the from is not null, we want to split the from, from the bracket and select the first item. However, if the from is null, we want to give the from a blank string as a psobject doesn’t like null.

    if ($null -ne $rule.from) {
        $froms = $Rule.from.split('[')[0]
    } else {
        $froms = ""
    }
    

    After the From, we need to grab the description. However, I need this description on a single line. As most people are using PowerShell 5, Join-String is not available. Which is sad. So, I built my own join-string. To do this, first create a null return. Then split the Rules description by the enter, `n. Next we do a foreach-object loop, taking that null return value and added itself to itself with a nice trim. Finally, I dropped that information into the description. If the description was null to begin with we drop a blank string into the description variable.

    if ($null -ne $rule.description) {
        $dereturn = $Null
        $rule.description.split("`n") | foreach-object { $dereturn = "$dereturn $($_.trim())" }
        $description = $dereturn.trim()
    } else {
        $description = ""
    }
    

    Bringing it together

    Finally, we have all the information we need. It’s time to create the PS Custom Object. Here we will be presenting the username, the ID of the rule, if the rule is enabled, the from, and the description. Since we joined the strings of the description before, this output can be exported to a csv file. Remember at the beginning of the main loop, we are placing all the output of that loop into a variable called Rules Return. Well, this is what will be going into that variable.

    [pscustomobject]@{
        Username    = $Mail.Userprincipalname
        Identity    = $Rule.Identity
        Enabled     = $Rule.Enabled
        Name        = $Rule.Name
        from        = $froms
        Description = $description
    } 
    

    End

    Finally, we reach the end of this script. We are doing two things at the end of this script. First, we are presenting the data. Then we are testing if we need to stay connected. We present the variable Rules Return. Then we check if Stay Connected is true. If it isn’t we disconnect from exchange with a confirmation of false. If you set the flag to stay connected when you executed the code, then this part only shows the rules. No need to disconnect. I always love having this option as exchange online is a pain with multiple MFA layers.

    $RulesReturn
    if (!($StayConnected)) { Disconnect-ExchangeOnline -Confirm:$false }
    

    Continue Reading:

    As always, I like to present some items at the end to encourage you to continue reading.

    Find Forwarding Rules

    Find Forwarding Rules

    Reading Time: 4 minutes

    Not too long ago, I needed to do some rule auditing for forwarders in a client’s exchange online. They believed someone had a rule in their exchange account that was forwarded to a spammer. They believed this because new employees were receiving emails within a few days of creation. So, it’s time for some PowerShell magic to save the day. It’s time to Find Forwarding Rules in your mailboxes with PowerShell.

    The Script

    Connect-ExchangeOnline
    $Mailboxes = Get-Mailbox -ResultSize Unlimited
    $ForwarderRules = foreach ($Mailbox in $Mailboxes) {
        $rules = Get-InboxRule -mailbox $Mailbox.Alias
        foreach ($rule in $rules) {
            if (($null -ne $rule.ForwardTo) -or ($null -ne $rule.ForwardAsAttachmentTo)) {
                [pscustomobject][ordered]@{
                    Username = $Mailbox.Alias
                    Rule = $Rule.name
                    ID = $Rule.RuleIdentity
                    Enabled = $rule.enabled
                    ForwardTo = $rule.ForwardTo | where-object {$_ -like "*@*"}
                    ForwardAsAttachmentTo = $rule.ForwardAsAttachmentTo | where-object {$_ -like "*@*"}
                }
            }
        }
    }
    $ats = $ForwarderRules | where-object {($null -ne $_.ForwardTo) -or ($null -ne $_.ForwardAsAttachmentTo)}
    $ats
    

    The Breakdown

    The script today requires the Exchange Online Module to be installed. If you don’t have it, go install it. Once you have it, you will need to connect using the Connect-ExchangeOnline commandlet.

    Connect-ExchangeOnline
    

    By doing it this way, MFA will be triggered and we want MFA to be at this level. Security first yall. This brings me to my next point, soon exo 3 will come out and security will be improved greatly.

    Once you are connected, we need now to pull all the mailboxes from the system. This command can take some time if you have a large company. In fact, this script with only 300 users took around an hour. The Larger your company is, the longer it will take. Plan ahead accordingly.

    $Mailboxes = Get-Mailbox -ResultSize Unlimited
    

    Now we have all the mailboxes, we need to go through each mailbox and get the inbox rules for that mailbox. We start a for each loop of the mailboxes.

    $ForwarderRules = foreach ($Mailbox in $Mailboxes) { 
    
    }
    

    Next, we will need to grab the inbox rules for that mailbox. We do this with the Get-InboxRule commandlet and we feed it the mailbox alias.

    $ForwarderRules = foreach ($Mailbox in $Mailboxes) { 
        $rules = Get-InboxRule -mailbox $Mailbox.Alias
    }
    

    Normally a mailbox has more than one rule. Thus, we need to make another for each loop for the rules inside our main foreach loop.

    $ForwarderRules = foreach ($Mailbox in $Mailboxes) { 
        $rules = Get-InboxRule -mailbox $Mailbox.Alias
        foreach ($rule in $rules) {
        
        }
    }
    

    Afterward, we need to pull the data out of the rules and make it useful. The amount of output is large, breaking it down and making it useful is important. That’s the whole goal of this. We want to find out who has forwarders and we want to know if those forwarders are forwarding out to someone else. I want to break it up as well so I can look at all the forwarders and just the ones with email addresses.

    Gathering Information

    Firstly, we need to ask the question, Are we forwarding to someone as an email or an attachment? The properties we want to look at are, forwardto and forwardasattachmentto. If either of these are not null, then we want to look at that information. This allows us to Find Forwarding Rules.

    $ForwarderRules = foreach ($Mailbox in $Mailboxes) { 
        $rules = Get-InboxRule -mailbox $Mailbox.Alias
        foreach ($rule in $rules) {
            if (($null -ne $rule.ForwardTo) -or ($null -ne $rule.ForwardAsAttachmentTo)) {
            
            }
        }
    }
    

    Now we are looking at a rule object that has a forwarder of some sort. It’s time to let the end user know. Next, we will create a PowerShell Custom Object. Almost every get command I have come across has produced one of these objects.

    $ForwarderRules = foreach ($Mailbox in $Mailboxes) { 
        $rules = Get-InboxRule -mailbox $Mailbox.Alias
        foreach ($rule in $rules) {
            if (($null -ne $rule.ForwardTo) -or ($null -ne $rule.ForwardAsAttachmentTo)) {
                [pscustomobject][ordered]@{
    
                }
            }
        }
    }
    

    The object is ready for us. It’s time to fill it in with useful information. We need the mailbox name, the rule name, the rule’s id, if it’s enabled, and finally the forwarder information. The forwarder information is broken up into two. The “ForwardTo” and the “ForwardAsAttachmentTo”. The first forwards the email to a person. The second wraps up the email into an attachment and sends it to the person. We need to see both.

    These items are arrays of email addresses and references. If the forwarder points to an external email address it will contain the @ symbol like most email addresses do. If the forwarder points to an internal address like bob in accounting, then it will not have an @ symbol unless told otherwise. This is useful. We can use a where object to pull out the lines with an @ symbol.

    $ForwarderRules = foreach ($Mailbox in $Mailboxes) {
        $rules = Get-InboxRule -mailbox $Mailbox.Alias
        foreach ($rule in $rules) {
            if (($null -ne $rule.ForwardTo) -or ($null -ne $rule.ForwardAsAttachmentTo)) {
                [pscustomobject][ordered]@{
                    Username = $Mailbox.Alias
                    Rule = $Rule.name
                    ID = $Rule.RuleIdentity
                    Enabled = $rule.enabled
                    ForwardTo = $rule.ForwardTo | where-object {$_ -like "*@*"}
                    ForwardAsAttachmentTo = $rule.ForwardAsAttachmentTo | where-object {$_ -like "*@*"}
                }
            }
        }
    }
    

    Sorting the Sorted Information

    Now it’s time to sort the sorted information. First why? Why not add it to the loop above? Two reasons. First is the time it takes to process. Second, I want to run $ForwarderRules to get information and I want to run the next line of code to see the more focused information. I like having options. Now we will take the forwarder rules we created and filter out the nulls of the forwarders. Finally, we want to display the information.

    $ats = $ForwarderRules | where-object {($null -ne $_.ForwardTo) -or ($null -ne $_.ForwardAsAttachmentTo)}
    $ats
    

    Finally, you have all the email addresses and rules that have a forwarder that forwards to a real email address. You can run through each one and audit them for security.

    Future Reading

    Images created with Mid Journey AI

    Share Point File Audit

    Share Point File Audit

    Reading Time: 8 minutes

    While reading on Reddit, I found a common thread. People need a quick way to do a Share Point File Audit. I have a PowerShell function for this in my toolbox. This tool heavily uses the Search-UnifiedAuditLog command let. The most common items I tend to audit are file modifications and deletions. This function goes through, modified, moved, renamed, downloaded, uploaded, accessed, synced, malware detection, restored from trash, locked, and finally unlocked. The Search-UnifiedAuditLog is an exchange online command at the time of this writing. Thus, you need to connect to exchange online. In this function, I am using the switch command. I will follow that structure for the breakdown. Lets first jump in with the function.

    The Function

    function Invoke-SharePointFileAudit {
        [cmdletbinding()]
        param (
            [Parameter(Mandatory = $true)][validateset("Deleted", "Modified", "Moved", "Renamed", "Downloaded", "Uploaded", "Synced", "Accessed", "MalwareDetected", "Restored", "Locked", "unLocked")][string]$Type,
            [parameter(Mandatory = $false)][switch]$KeepAlive,
            [switch]$SharePointOnline,
            [switch]$OneDrive, 
            [Nullable[DateTime]]$StartDate,
            [Nullable[DateTime]]$EndDate,
            [string]$Outfile,
            [int]$ResultSize = 5000
        )
        Begin {
            $Module = Get-Module ExchangeOnlineManagement -ListAvailable
            if ($Module.count -eq 0) {Install-Module ExchangeOnlineManagement -Repository PSGallery -AllowClobber -Force}
    
            $getsessions = Get-PSSession | Select-Object -Property State, Name
            $isconnected = (@($getsessions) -like '@{State=Opened; Name=ExchangeOnlineInternalSession*').Count -gt 0
            If ($isconnected -ne "false") {
                try { 
                    Connect-ExchangeOnline
                }
                catch {
                    Write-Error "Exchange Online Failed. Ending"
                    end
                }
            }
            #Auto Generates Start and Finish dates
            if ($Null -eq $StartDate) { $StartDate = ((Get-Date).AddDays(-89)).Date }
            if ($Null -eq $EndDate) { $EndDate = (Get-Date).Date }
            #Tests if end date is before start date.
            if ($EndDate -lt $StartDate) { $StartDate = ((Get-Date).AddDays(-89)).Date }
            if ($EndDate -gt (Get-Date).Date) { $EndDate = (Get-Date).Date }
            
        }
        Process {
            switch ($Type) {
                "Deleted" {
                    $DeletedRecords = Search-UnifiedAuditLog -StartDate $StartDate -EndDate $EndDate -Operations "FileDeleted,FileDeletedFirstStageRecycleBin,FileDeletedSecondStageRecycleBin,FileVersionsAllDeleted,FileRecycled" -SessionId deleted -SessionCommand ReturnLargeSet -ResultSize 5000
                    $Return = foreach ($DeletedRecord in $DeletedRecords) {
                        $JSONInfo = $DeletedRecord.AuditData | convertfrom-json
                        [pscustomobject][ordered]@{
                            TimeStampe  = ($JSONInfo.creationtime).tolocaltime()
                            UserName    = $DeletedRecord.UserIds
                            ClientIP    = $JSONInfo.ClientIP
                            Source      = $JSONInfo.EventSource
                            Workload    = $JSONInfo.Workload
                            Operation   = $JSONInfo.Operation
                            SiteURL     = $JSONInfo.SiteURL
                            RelativeURL = $JSONInfo.SourceRelativeUrl
                            FileName    = $JSONInfo.SourceFileName
                            ObjectID    = $JSONInfo.ObjectId
                        }
                    }
                }
                "Modified" {
                    $ModifiedRecords = Search-UnifiedAuditLog -StartDate $StartDate -EndDate $EndDate -Operations "FileModified,FileModifiedExtended" -SessionId Modified -SessionCommand ReturnLargeSet -ResultSize 5000
                    $Return = foreach ($ModifiedRecord in $ModifiedRecords) {
                        $JSONInfo = $ModifiedRecord.AuditData | convertfrom-json
                        [pscustomobject][ordered]@{
                            TimeStamp   = ($JSONInfo.creationtime).tolocaltime()
                            UserName    = $ModifiedRecord.UserIds
                            ClientIP    = $JSONInfo.ClientIP
                            Source      = $JSONInfo.EventSource
                            Workload    = $JSONInfo.Workload
                            Operation   = $JSONInfo.Operation
                            SiteURL     = $JSONInfo.SiteURL
                            RelativeURL = $JSONInfo.SourceRelativeUrl
                            FileName    = $JSONInfo.SourceFileName
                            ObjectID    = $JSONInfo.ObjectId
                        }
                    }
                }
                "Moved" {
                    $MovedRecords = Search-UnifiedAuditLog -StartDate $StartDate -EndDate $EndDate -Operations "FileMoved" -SessionId Moved -SessionCommand ReturnLargeSet -ResultSize 5000
                    $Return = foreach ($MovedRecord in $MovedRecords) {
                        $JSONInfo = $MovedRecord.AuditData | convertfrom-json
                        [pscustomobject][ordered]@{
                            TimeStamp              = ($JSONInfo.creationtime).tolocaltime()
                            UserName               = $MovedRecord.UserIds
                            ClientIP               = $JSONInfo.ClientIP
                            Source                 = $JSONInfo.EventSource
                            Workload               = $JSONInfo.Workload
                            Operation              = $JSONInfo.Operation
                            SiteURL                = $JSONInfo.SiteURL
                            SourceRelativeURL      = $JSONInfo.SourceRelativeUrl
                            DestinationRelativeURL = $JSONInfo.DestinationRelativeURL
                            FileName               = $JSONInfo.SourceFileName
                            ObjectID               = $JSONInfo.ObjectId
                        }
                    }
                }
                "Renamed" {
                    $RenamedRecords = Search-UnifiedAuditLog -StartDate $StartDate -EndDate $EndDate -Operations "FileRenamed" -SessionId Renamed -SessionCommand ReturnLargeSet -ResultSize 5000
                    $Return = foreach ($RenamedRecord in $RenamedRecords) {
                        $JSONInfo = $RenamedRecord.AuditData | convertfrom-json
                        [pscustomobject][ordered]@{
                            TimeStamp           = ($JSONInfo.creationtime).tolocaltime()
                            UserName            = $RenamedRecord.UserIds
                            ClientIP            = $JSONInfo.ClientIP
                            Source              = $JSONInfo.EventSource
                            Workload            = $JSONInfo.Workload
                            Operation           = $JSONInfo.Operation
                            SiteURL             = $JSONInfo.SiteURL
                            SourceRelativeURL   = $JSONInfo.SourceRelativeUrl
                            SourceFileName      = $JSONInfo.SourceFileName
                            DestinationFileName = $JSONInfo.DestinationFileName
                            ObjectID            = $JSONInfo.ObjectId
                        }
                    }
                }
                "Downloaded" {
                    $DownloadedRecords = Search-UnifiedAuditLog -StartDate $StartDate -EndDate $EndDate -Operations "FileDownloaded" -SessionId Downloaded -SessionCommand ReturnLargeSet -ResultSize 5000
                    $Return = foreach ($DownloadedRecord in $DownloadedRecords) {
                        $JSONInfo = $DownloadedRecord.AuditData | convertfrom-json
                        [pscustomobject][ordered]@{
                            TimeStamp         = ($JSONInfo.creationtime).tolocaltime()
                            UserName          = $DownloadedRecord.UserIds
                            ClientIP          = $JSONInfo.ClientIP
                            Source            = $JSONInfo.EventSource
                            Workload          = $JSONInfo.Workload
                            Operation         = $JSONInfo.Operation
                            SiteURL           = $JSONInfo.SiteURL
                            SourceRelativeURL = $JSONInfo.SourceRelativeUrl
                            SourceFileName    = $JSONInfo.SourceFileName
                            ObjectID          = $JSONInfo.ObjectId
                        }
                    }
                }
                "Uploaded" {
                    $UploadedRecords = Search-UnifiedAuditLog -StartDate $StartDate -EndDate $EndDate -Operations "FileUploaded" -SessionId Uploaded -SessionCommand ReturnLargeSet -ResultSize 5000
                    $Return = foreach ($UploadedRecord in $UploadedRecords) {
                        $JSONInfo = $UploadedRecord.AuditData | convertfrom-json
                        [pscustomobject][ordered]@{
                            TimeStamp         = ($JSONInfo.creationtime).tolocaltime()
                            UserName          = $UploadedRecord.UserIds
                            ClientIP          = $JSONInfo.ClientIP
                            Source            = $JSONInfo.EventSource
                            Workload          = $JSONInfo.Workload
                            Operation         = $JSONInfo.Operation
                            SiteURL           = $JSONInfo.SiteURL
                            SourceRelativeURL = $JSONInfo.SourceRelativeUrl
                            SourceFileName    = $JSONInfo.SourceFileName
                            ObjectID          = $JSONInfo.ObjectId
                        }
                    }
                }
                "Synced" {
                    $SyncedRecords = Search-UnifiedAuditLog -StartDate $StartDate -EndDate $EndDate -Operations "FileSyncDownloadedFull,FileSyncUploadedFull" -SessionId Synced -SessionCommand ReturnLargeSet -ResultSize 5000
                    $Return = foreach ($SyncedRecord in $SyncedRecords) {
                        $JSONInfo = $SyncedRecord.AuditData | convertfrom-json
                        [pscustomobject][ordered]@{
                            TimeStamp         = ($JSONInfo.creationtime).tolocaltime()
                            UserName          = $SyncedRecord.UserIds
                            ClientIP          = $JSONInfo.ClientIP
                            Source            = $JSONInfo.EventSource
                            Workload          = $JSONInfo.Workload
                            Operation         = $JSONInfo.Operation
                            SiteURL           = $JSONInfo.SiteURL
                            SourceRelativeURL = $JSONInfo.SourceRelativeUrl
                            SourceFileName    = $JSONInfo.SourceFileName
                            ObjectID          = $JSONInfo.ObjectId
                        }
                    }
                }
                "Accessed" {
                    $AccessedRecords = Search-UnifiedAuditLog -StartDate $StartDate -EndDate $EndDate -Operations "FileAccessed,FileAccessedExtended" -SessionId Accessed -SessionCommand ReturnLargeSet -ResultSize 5000
                    $Return = foreach ($AccessedRecord in $AccessedRecords) {
                        $JSONInfo = $AccessedRecord.AuditData | convertfrom-json
                        [pscustomobject][ordered]@{
                            TimeStamp         = ($JSONInfo.creationtime).tolocaltime()
                            UserName          = $AccessedRecord.UserIds
                            ClientIP          = $JSONInfo.ClientIP
                            Source            = $JSONInfo.EventSource
                            Workload          = $JSONInfo.Workload
                            Operation         = $JSONInfo.Operation
                            SiteURL           = $JSONInfo.SiteURL
                            SourceRelativeURL = $JSONInfo.SourceRelativeUrl
                            SourceFileName    = $JSONInfo.SourceFileName
                            ObjectID          = $JSONInfo.ObjectId
                        }
                    }
                }
                "MalwareDetected" {
                    $MalewareRecords = Search-UnifiedAuditLog -StartDate $StartDate -EndDate $EndDate -Operations "FileMalwareDetected" -SessionId MalewareRecords -SessionCommand ReturnLargeSet -ResultSize 5000
                    $Return = foreach ($MalewareRecord in $MalewareRecords) {
                        $JSONInfo = $MalewareRecord.AuditData | convertfrom-json
                        [pscustomobject][ordered]@{
                            TimeStamp   = ($JSONInfo.creationtime).tolocaltime()
                            UserName    = $MalewareRecord.UserIds
                            ClientIP    = $JSONInfo.ClientIP
                            Source      = $JSONInfo.EventSource
                            Workload    = $JSONInfo.Workload
                            Operation   = $JSONInfo.Operation
                            SiteURL     = $JSONInfo.SiteURL
                            RelativeURL = $JSONInfo.SourceRelativeUrl
                            FileName    = $JSONInfo.SourceFileName
                            ObjectID    = $JSONInfo.ObjectId
                        }
                    }
                }
                "Restored" {
                    $RestoredRecords = Search-UnifiedAuditLog -StartDate $StartDate -EndDate $EndDate -Operations "FileRestored" -SessionId RestoredRecords -SessionCommand ReturnLargeSet -ResultSize 5000
                    $Return = foreach ($RestoredRecord in $RestoredRecords) {
                        $JSONInfo = $RestoredRecord.AuditData | convertfrom-json
                        [pscustomobject][ordered]@{
                            TimeStamp   = ($JSONInfo.creationtime).tolocaltime()
                            UserName    = $RestoredRecord.UserIds
                            ClientIP    = $JSONInfo.ClientIP
                            Source      = $JSONInfo.EventSource
                            Workload    = $JSONInfo.Workload
                            Operation   = $JSONInfo.Operation
                            SiteURL     = $JSONInfo.SiteURL
                            RelativeURL = $JSONInfo.SourceRelativeUrl
                            FileName    = $JSONInfo.SourceFileName
                            ObjectID    = $JSONInfo.ObjectId
                        }
                    }
                }
                "Locked" {
                    $LockedRecords = Search-UnifiedAuditLog -StartDate $StartDate -EndDate $EndDate -Operations "LockRecord" -SessionId Locked -SessionCommand ReturnLargeSet -ResultSize 5000
                    $Return = foreach ($LockedRecord in $LockedRecords) {
                        $JSONInfo = $LockedRecord.AuditData | convertfrom-json
                        [pscustomobject][ordered]@{
                            TimeStamp   = ($JSONInfo.creationtime).tolocaltime()
                            UserName    = $LockedRecord.UserIds
                            ClientIP    = $JSONInfo.ClientIP
                            Source      = $JSONInfo.EventSource
                            Workload    = $JSONInfo.Workload
                            Operation   = $JSONInfo.Operation
                            SiteURL     = $JSONInfo.SiteURL
                            RelativeURL = $JSONInfo.SourceRelativeUrl
                            FileName    = $JSONInfo.SourceFileName
                            ObjectID    = $JSONInfo.ObjectId
                        }
                    }
                }
                "unLocked" {
                    $unLockedRecords = Search-UnifiedAuditLog -StartDate $StartDate -EndDate $EndDate -Operations "UnlockRecord" -SessionId UnlockRecord -SessionCommand ReturnLargeSet -ResultSize 5000
                    $Return = foreach ($unLockedRecord in $unLockedRecords) {
                        $JSONInfo = $unLockedRecord.AuditData | convertfrom-json
                        [pscustomobject][ordered]@{
                            TimeStamp   = ($JSONInfo.creationtime).tolocaltime()
                            UserName    = $unLockedRecord.UserIds
                            ClientIP    = $JSONInfo.ClientIP
                            Source      = $JSONInfo.EventSource
                            Workload    = $JSONInfo.Workload
                            Operation   = $JSONInfo.Operation
                            SiteURL     = $JSONInfo.SiteURL
                            RelativeURL = $JSONInfo.SourceRelativeUrl
                            FileName    = $JSONInfo.SourceFileName
                            ObjectID    = $JSONInfo.ObjectId
                        }
                    }
                }
            }
            
        }
        end {
            if (!($SharePointOnline -and $OneDrive) -or ($SharePointOnline -and $OneDrive)) {
                if ($PSBoundParameters.ContainsKey("OutFile")) {
                    $Return | Export-Csv ./$Outfile.CSV
                }
                else {
                    $Return
                }
            }
            elseif ($SharePointOnline) {
                if ($PSBoundParameters.ContainsKey("OutFile")) {
                    $Return | where-object { $_.workload -like "SharePoint" } | Export-Csv ./$Outfile.CSV
                }
                else {
                    $Return | where-object { $_.workload -like "SharePoint" }
                }
            }
            elseif ($OneDrive) { 
                if ($PSBoundParameters.ContainsKey("OutFile")) {
                    $Return | where-object { $_.workload -like "OneDrive" } | Export-Csv ./$Outfile.CSV
                }
                else {
                    $Return | where-object { $_.workload -like "OneDrive" }
                }
            }
            if (!($KeepAlive)) {
                Disconnect-ExchangeOnline -Confirm:$false -InformationAction Ignore -ErrorAction SilentlyContinue
            }
        }
    }
    
    

    The Breakdown of Share Point File Audit

    I’m glad you came to the breakdown. It means you want to know how the code works. This means you truly care about learning. Thank you. This code repeats itself a few times in different ways. So, I will call out the differences, but not the likes after the first time explaining something. The first section is our Parameters.

    Parameters

    We have 8 Parameters, and only one of them is mandatory. Firstly, we have the Type parameter. This mandatory validate set allows you to select from a list of commands we will be using in this function.

    • Deleted
    • Modified
    • Created
    • Moved
    • Renamed
    • Downloaded
    • Uploaded
    • Synced
    • Accessed
    • MalwareDetected
    • Restored
    • Locked
    • UnLocked

    Afterward, we have Keep Alive. This allows us to run the command multiple times without signing back into the system. So, if you want to keep your session alive, flip that flag. Next, we have two switches. The first Switch is to pull only items edited in SharePoint itself. The next is for one drive. They are named accordingly. After that, we have a start date and an end date. These values are nullable. Basically, you don’t need them. The outfile is asking for just the name of the file. We are using the “./” to save it wherever you run the command from. Finally, we have the result size. If you want the max number of results, 5000. However, you can make this number smaller.

    Begin

    In our begin section, we want to test the Exchange Online Management Module. Secondly, we want to validate exchange connectivity. After that, we want to gather the date information for the start and end dates. Let’s take a look at the exchange part first.

    $Module = Get-Module ExchangeOnlineManagement -ListAvailable
    

    The Get-Module command works with PowerShell 5.1. However, I have seen PowerShell flak with this command failing to pull the information. I am going to assume your PowerShell is up to date with your current version.

    if ($Module.count -eq 0) {
        Install-Module ExchangeOnlineManagement -Repository PSGallery -AllowClobber -Force
    }
    

    Afterward, we want to install the exchange online management module if we don’t detect the module. We are using the count to see how many objects are inside our module variable. If it’s 0, it’s time to install. We install it from the PSGallery.

    $getsessions = Get-PSSession | Select-Object -Property State, Name
    $isconnected = (@($getsessions) -like '@{State=Opened; Name=ExchangeOnlineInternalSession*').Count -gt 0
    

    Now, we test exchange connections. We use the Get-PSSession to review the current connections. Next, we test if the connections with the name “ExchangeOnlineInternalSession” is greater than zero. “isconnected” will produce a true or false statement.

    If ($isconnected -ne "false") {
        try { 
            Connect-ExchangeOnline
        } catch {
            Write-Error "Exchange Online Failed. Ending"
            end
        }
    }
    

    After which, we can test with. False, we try to connect. However, if there is an error, we end the script and let the user know. We are not using a credential object to authenticate because MFA should always be a thing.

    #Auto Generates Start and Finish dates
    if ($Null -eq $StartDate) { $StartDate = ((Get-Date).AddDays(-89)).Date }
    if ($Null -eq $EndDate) { $EndDate = (Get-Date).Date }
    #Tests if end date is before start date.
    if ($EndDate -lt $StartDate) { $StartDate = ((Get-Date).AddDays(-89)).Date }
    if ($EndDate -gt (Get-Date).Date) { $EndDate = (Get-Date).Date }
    

    Afterward, we need to get the dates right. If the start date is null, we are going to pull 90 days back. We do this by using the standard. We do the same with the end date. If it’s null, we grab today’s date. Now to prevent errors, we check the start date and end date. The end date can’t be before the start date. This is similar to the end date. The end date can’t be greater than the current date. We use the if statement to resolve this.

    Process

    We begin the process by looking directly at our “Type” variable by using a switch command. The switch allows us to go through each “Type” and run the commands accordingly. Let’s look at one of the switch processes.

    $DeletedRecords = Search-UnifiedAuditLog -StartDate $StartDate -EndDate $EndDate -Operations "FileDeleted,FileDeletedFirstStageRecycleBin,FileDeletedSecondStageRecycleBin,FileVersionsAllDeleted,FileRecycled" -SessionId deleted -SessionCommand ReturnLargeSet -ResultSize 5000
    $Return = foreach ($DeletedRecord in $DeletedRecords) {
        $JSONInfo = $DeletedRecord.AuditData | convertfrom-json
        [pscustomobject][ordered]@{
            TimeStampe  = ($JSONInfo.creationtime).tolocaltime()
            UserName    = $DeletedRecord.UserIds
            ClientIP    = $JSONInfo.ClientIP
            Source      = $JSONInfo.EventSource
            Workload    = $JSONInfo.Workload
            Operation   = $JSONInfo.Operation
            SiteURL     = $JSONInfo.SiteURL
            RelativeURL = $JSONInfo.SourceRelativeUrl
            FileName    = $JSONInfo.SourceFileName
            ObjectID    = $JSONInfo.ObjectId
        }
    }
    

    The data that search-unifiedauditlog produces a section called “AuditData”. This section has almost every piece of information you will need. The difference between each “Type” will be the Operations, and session id. The operations target the required logs. This creates the backbone of the Share Point File Audit. The graph below will show which operations I am using. Once you gather the operation information, we need to pull the AuditData. This data will be in JSON format. We start off by looping the records with a for each loop. Then we pull the auditdata and pipe it into convertfrom-json. Next, we create our PS Custom Object. Other than Moved, the output of the other logs contains almost the same information. See the script for the information.

    Operation Filters

    • Deleted
      • FileDeleted
      • FileDeletedFirstStageRecycleBin
      • FileDeletedSecondStageRecycleBin
      • FileVersionsAllDeleted
      • FileRecycled
    • Modified
      • FileModified
      • FileModifiedExtended
    • Moved
      • FileMoved
    • Renamed
      • FileRenamed
    • Downloaded
      • FileDownloaded
    • Uploaded
      • FileUploaded
    • Synced
      • FileSyncDownloadedFull
      • FileSyncUploadedFull
    • Accessed
      • FileAccessed
      • FileAccessedExtended
    • MalwareDetected
      • FileMalwareDetected
    • Restored
      • FileRestored
    • Locked
      • LockRecord
    • UnLocked
      • UnlockRecord

    End

    Finally, it’s time for the end block. This is where we will present the data we have gathered. Firstly, we need to determine if the SharePoint or Onedrives were flipped or not.

    if (!($SharePointOnline -and $OneDrive) -or ($SharePointOnline -and $OneDrive)) {
        if ($PSBoundParameters.ContainsKey("OutFile")) {
            $Return | Export-Csv ./$Outfile.CSV
        } else {
            $Return
        }
    }
    

    Here we checking if both flags are not checked or if both flags are checked. Then we check if the user gave us a filename. If they did, we export our report to a csv file wherever we are executing the function from. However, if the user didn’t give us a filename, we just dump all the results.

    elseif ($SharePointOnline) {
                if ($PSBoundParameters.ContainsKey("OutFile")) {
                    $Return | where-object { $_.workload -like "SharePoint" } | Export-Csv ./$Outfile.CSV
                }
                else {
                    $Return | where-object { $_.workload -like "SharePoint" }
                }
            }
            elseif ($OneDrive) { 
                if ($PSBoundParameters.ContainsKey("OutFile")) {
                    $Return | where-object { $_.workload -like "OneDrive" } | Export-Csv ./$Outfile.CSV
                }
                else {
                    $Return | where-object { $_.workload -like "OneDrive" }
                }
            }
            if (!($KeepAlive)) {
                Disconnect-ExchangeOnline -Confirm:$false -InformationAction Ignore -ErrorAction SilentlyContinue
            }
    

    Now, if the user selected either or, we present that information. We present those infos by using a where-object. Like before we ask if the user produced an outfile. Finally, we ask if keep alive was set. If it wasn’t we disconnect from the exchange.

    Conclusion

    In conclusion, auditing shouldn’t be difficult. We can quickly pull the info we need. I hope you enjoy this powerful little tools.