Unused Teams Audit & Warning with Microsoft Teams & Graph API

Unused Teams Audit & Warning with Microsoft Teams & Graph API

Inspired by my peers using Microsoft Graph API to interact with the Microsoft 365 substrate and in particular Microsoft Teams, I decided to give it a whirl and see what I could break.

I came up with a valid problem that administrators will face as Teams proliferate through their organization. How to clean-up unused Teams. We accept there will be a lot of Teams created for test purposes. There may also be well used Teams created that have expired their usefulness and simply left dormant. What do we do with these?

Built into Microsoft 365 we have the ability to set group expiration policies. Two issues with these policies spring to mind. 1) They required AzureAD Premium P2 licensing and 2) they work on a fixed time duration approval basis. This means every X Days a Team / Group owner will need to reconfirm they still want the Team / Group to remain active. This may get annoying for Owners who have multiple Teams.

Using the Graph API, we have access to lots of information, some of which is inaccessible by any other means. I wanted to investigate if I could audit Teams and figure out if there has been any activity in that Team within a set number of days. If not, then post a message into that Team to warn members, unless they use it, it will be deleted or archived, whatever your policy dictates.

The metrics used are message creation date in any Team channel and any file activity on the Team drive. If there has been activity in either metric within a configured time period, consider these Teams active and ignore. If the last activity exceeds the configured time period, issue a warning to the General channel of the Team by posting a channel message containing the warning.

To get started, I enlisted the help of Lee Ford’s amazing post on getting started with Graph API and PowerShell. I suggest you head on over to his post to discover how to create the required AzureAD App Registration.

Back? Good. There are a few alterations to the AzureAD App permissions needed for the script to work. These are:

  • ChannelMessage.Read.All
  • Group.ReadWrite.All
  • User.Read.All
  • Files.Read.All
  • Directory.Read.All

Delegated User permissions are needed to post messages in the channel, so you need to add the following for these:

  • Group.ReadWrite.All

Unfortunately, we have to use both Application and Delegated permission because we cannot send a message to a Team as an Application. It has to be done by an account with membership to the Team in question. The end result will post a message in the offending Teams like this

The script will run, collect the information and then decide if it should post a message. If it does, then it will temporarily add the delegated user to the Team as an owner, post the message and subsequently remove the user from the Team to maintain information security. At the end the administrator will have the option to export the Teams that were affected to a CSV file for further analysis.

Below is the script:

<###########################################################################

SCRIPT: WARNUNUSEDTEAMS.PS1

THIS SCRIPT CAN BE LAUNCHED IN POWERSHELL AND YOU MUST HAVE AZUREAD POWERSHELL MODULE INSTALLED

TO INSTALL RUN INSTALL-MODULE AZUREAD

YOU MUST CREATE AN AZUREAD APP REGISTRATION FOR THIS SCRIPT. THIS SCRIPT REQUIRES BOTH APPLICATION
AND DELEGATED PERMISSIONS TO RUN

MORE INFORMATION HTTPS://commsverse.blog

OFFERED WITHOUT WARRANTY, SUPPORT OR RESPONSIBILITY. USE AT YOUR OWN RISK

###########################################################################>


<###########################################################################

SCRIPT PARAMETERS - PLEASE MODIFY

###########################################################################>

#SET THE NUMBER OF DAYS FROM TODAY THE SCRIPT WILL CLASSIFY AS MINIMUM ACTIVITY

$days = "60"

#SET YOUR AZUREAD APP ID'S, SECRET AND SUPPORT UPN USED TO RUN AS USER TO POST MESSAGES IN TEAMS HERE

$clientId = ""
$tenantId = ""
$clientSecret = ''
$supportUPN = "itsupport@valeconsulting.co.uk"

<################################################################################################

PLEASE MODIFY THE CONTENT ELEMENT WITH YOUR CHOSEN MESSAGE YOU WANT POSTING IN THE TEAM

#################################################################################################>


$body = @"
{
"body": {
      "contentType": "html",
      "content": "Hello, we have noticed that this Team has not been used for at least $($days) Days. In line with our IT Policy, if there continues to be no activity for a further 30 Days, this team will be automatically deleted. Thank you. IT Support"
    }

}
"@

<#################################################################################################

DO NOT EDIT BELOW THIS LINE

##################################################################################################>

<#################################################################################################

CREDIT TO LEE FORD (WWW.LEE-FORD.CO.UK) FOR THIS SCRIPT ELEMENT

##################################################################################################>
# Construct URI
$uri = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token"

# Construct Body
$body = @{
    client_id     = $clientId
    scope         = "https://graph.microsoft.com/.default"
    client_secret = $clientSecret
    grant_type    = "client_credentials"
}

# Get OAuth 2.0 Token
$tokenRequest = Invoke-WebRequest -Method Post -Uri $uri -ContentType "application/x-www-form-urlencoded" -Body $body -UseBasicParsing

# Access Token
$token = ($tokenRequest.Content | ConvertFrom-Json).access_token

Write-Host "Connected to AzureAD App and Acquired Token" -ForegroundColor Yellow

# Base URL
$uri = "https://graph.microsoft.com/beta/"
$headers = @{Authorization = "Bearer $token"}
$ctype = "application/json"
<#################################################################################################

END CREDIT

##################################################################################################>


#Get Support UPN Object ID
Write-Host "Getting Azure Object ID of Support UPN" -ForegroundColor Yellow
$objID = Invoke-WebRequest -Method GET -Uri "$($uri)users/$($supportUPN)" -ContentType $ctype -Headers $headers | ConvertFrom-Json

 
#Get all Teams
Write-Host "Getting All O365 Groups that are Teams Enabled" -ForegroundColor Yellow
$graph = Invoke-WebRequest -Method GET -Uri "$($uri)groups?`$filter=resourceProvisioningOptions/Any(x:x eq 'Team')" -ContentType $ctype -Headers $headers | ConvertFrom-Json

#For each Team now find their channels, last message and last modified file.

$results = @()
Write-Host "Analyzing Teams Activity. Please Wait..." -ForegroundColor Yellow

ForEach ($team in $graph.value){

        #Get files activity

            $drive = Invoke-WebRequest -Method GET -Uri "$($uri)groups/$($team.id)/drive" -ContentType $ctype -Headers $headers | ConvertFrom-Json

            $activity = Invoke-WebRequest -Method GET -Uri "$($uri)drives/$($drive.id)/activities?`$top=1" -ContentType $ctype -Headers $headers | ConvertFrom-Json

            $lastTime = $activity.value.times.recordedDateTime

        #Get Teams Owners

            $owners = Invoke-WebRequest -Method Get -Uri "$($uri)groups/$($team.id)/owners" -ContentType $ctype -Headers $headers | ConvertFrom-Json       

             
        #Get Channels from Team

           $channels = Invoke-WebRequest -Method GET -Uri "$($uri)teams/$($team.id)/channels" -ContentType $ctype -Headers $headers | ConvertFrom-Json

        #Loop through channels and get last message by date      

                ForEach ($ch in $channels.value){

                    $chmsg = Invoke-WebRequest -Method GET -Uri "$($uri)teams/$($team.id)/channels/$($ch.id)/messages?`$top=1" -ContentType $ctype -Headers $headers | ConvertFrom-Json

                    #if there was a message and it was posted over the time period set warning flag to true
                    
                    if ($chmsg.value.createdDateTime -ne $null){
                        
                        $time = New-TimeSpan -Start $chmsg.value.createdDateTime -End (Get-Date)

                        if ($time.Days -gt $days){

                            $warn = $true

                        }else{
                            
                            $warn = $false

                        }

                    }else{
                        
                        $warn = $true

                    }

                    #if there has been no chat activity, check file activity

                    if ($warn -eq $true){
                            
                            if ($lastTime -ne $null){
                                    
                                    $ftime = New-TimeSpan -Start $lastTime -End (Get-Date)                                    

                                    if ($ftime.Days -le $days){

                                        #if there has been file activity within the configured number of days, turn the warning flag off

                                        $warn = $false    
                                    }

                            }

                    }

                    #store all results in array
                    $results += New-Object -TypeName psobject -Property @{TeamID=$team.id;TeamName=$team.displayName;ChannelID=$ch.id;ChannelName=$ch.displayName;MessageDate=$chmsg.value.createdDateTime;MessageDaysOld=$time.Days;FileDate=$lastTime;FileDaysOld=$ftime.Days;Owners=$owners.value.mail;Warn=$warn}


                }
}

#filter the results so that only teams with warning flags set to true are used

$teamfilter = $results | Where {$_.Warn -eq $true -and $_.ChannelName -eq "General"}

Write-Host "Require User Authentication for Message Sending..." -ForegroundColor Yellow

<##################################################################################################

CREDIT TO LEE FORD (WWW.LEE-FORD.CO.UK) FOR THIS SCRIPT ELEMENT

###################################################################################################>

#authenticate with the IT user account to post a message to the general channel of these groups.

# Azure AD OAuth User Token for Graph API
# Get OAuth token for a AAD User (returned as $token)

# Add required assemblies
Add-Type -AssemblyName System.Web, PresentationFramework, PresentationCore

# Application (client) ID, tenant ID and redirect URI
$clientId = "08549aa5-f9e9-4297-a1cc-6c776c0c3a17"
$tenantId = "e7a63b11-d255-40fd-b586-9daaa76de185"
$redirectUri = "https://login.microsoftonline.com/common/oauth2/nativeclient"


# Scope - Needs to include all permisions required separated with a space
$scope = "User.Read.All Group.ReadWrite.All" # This is just an example set of permissions

# Random State - state is included in response, if you want to verify response is valid
$state = Get-Random

# Encode scope to fit inside query string 
$scopeEncoded = [System.Web.HttpUtility]::UrlEncode($scope)

# Redirect URI (encode it to fit inside query string)
$redirectUriEncoded = [System.Web.HttpUtility]::UrlEncode($redirectUri)

# Construct URI
$uri = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/authorize?client_id=$clientId&response_type=code&redirect_uri=$redirectUriEncoded&response_mode=query&scope=$scopeEncoded&state=$state"

# Create Window for User Sign-In
$windowProperty = @{
    Width  = 500
    Height = 700
}

$signInWindow = New-Object System.Windows.Window -Property $windowProperty
    
# Create WebBrowser for Window
$browserProperty = @{
    Width  = 480
    Height = 680
}

$signInBrowser = New-Object System.Windows.Controls.WebBrowser -Property $browserProperty

# Navigate Browser to sign-in page
$signInBrowser.navigate($uri)
    
# Create a condition to check after each page load
$pageLoaded = {

    # Once a URL contains "code=*", close the Window
    if ($signInBrowser.Source -match "code=[^&]*") {

        # With the form closed and complete with the code, parse the query string

        $urlQueryString = [System.Uri]($signInBrowser.Source).Query
        $script:urlQueryValues = [System.Web.HttpUtility]::ParseQueryString($urlQueryString)

        $signInWindow.Close()

    }
}

# Add condition to document completed
$signInBrowser.Add_LoadCompleted($pageLoaded)

# Show Window
$signInWindow.AddChild($signInBrowser)
$signInWindow.ShowDialog()

# Extract code from query string
$authCode = $script:urlQueryValues.GetValues(($script:urlQueryValues.keys | Where-Object { $_ -eq "code" }))

if ($authCode) {

    # With Auth Code, start getting token

    # Construct URI
    $uri = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token"

    # Construct Body
    $body = @{
        client_id    = $clientId
        scope        = $scope
        code         = $authCode[0]
        redirect_uri = $redirectUri
        grant_type   = "authorization_code"
        client_secret = $clientSecret
    }

    # Get OAuth 2.0 Token
    $tokenRequest = Invoke-WebRequest -Method Post -Uri $uri -ContentType "application/x-www-form-urlencoded" -Body $body

    # Access Token
    $2token = ($tokenRequest.Content | ConvertFrom-Json).access_token

}
else {

    Write-Error "Unable to obtain Auth Code!"

}
<##############################################################################################################

END CREDIT

###############################################################################################################>

# Base URL
$headers = @{Authorization = "Bearer $2token"}
$uri = "https://graph.microsoft.com/beta/"


Write-Host "Posting Messages in Affected Teams..." -ForegroundColor Yellow

ForEach ($team in $teamfilter){
         
         
        #Add IT Support Account to the Team as an Owner

         $userbody = @"
                    { 
                    "@odata.id": "https://graph.microsoft.com/beta/users/$($objID.id)" 
                    }
"@
        
        try{
            Invoke-WebRequest -Method POST -Uri "$($uri)groups/$($team.TeamID)/owners/`$ref" -Body $userbody -Headers $headers -ContentType $ctype -ErrorAction Stop
        }catch{

        }

        #send message in Team Channel
        
        try{
            Invoke-WebRequest -Method POST -Uri "$($uri)teams/$($team.TeamID)/channels/$($team.ChannelID)/messages" -ContentType $ctype -Headers $headers -Body $body -ErrorAction Stop
        }catch{

        }

        
       
        #Remove IT Support UPN from Team

        try{
            Invoke-WebRequest -Method Delete -Uri "$($uri)groups/$($team.TeamID)/owners/$($objID.id)/`$ref" -Headers $headers -ContentType $ctype -body $userbody -ErrorAction Stop
        }catch{

        }

        

       
}

Write-Host "Cleaned Up Team Membership" -ForegroundColor Yellow

$report = Read-Host "Do you want to export the results to a CSV (y/n)"

if ($report -ieq "y"){

    $results | Export-Csv -Path C:\Temp\Teamsreport.csv -NoTypeInformation -Force

}

Write-Host "Finished" -ForegroundColor Green

The script can be downloaded here as well

Advertisements

One thought on “Unused Teams Audit & Warning with Microsoft Teams & Graph API

  1. Dear Mark,

    First of all thank you so much for your script it’s help a lot to others who is working governance activities.

    coming to the point in the script, we are facing the issue at call the channel message

    $chmsg = Invoke-WebRequest -Method GET -Uri “$($uri)teams/$($team.id)/channels/$($ch.id)/messages?`$top=1” -ContentType $ctype -Headers $headers | ConvertFrom-Json

    Error message :

    Invoke-WebRequest : The remote server returned an error: (403) Forbidden.
    At line:120 char:30
    + … $chmsg = Invoke-WebRequest -Method GET -Uri “$($uri)teams/$($team. …
    + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo : InvalidOperation: (System.Net.HttpWebRequest:HttpWebRequest) [Invoke-WebRequest], WebException
    + FullyQualifiedErrorId : WebCmdletWebResponseException,Microsoft.PowerShell.Commands.InvokeWebRequestCommand

    We have provided all api permissions mentioned by you both delegated and applications even we geeting this access denied message i.e.403
    Can you please help us to solve this issue?

    Thanks

    Best regards,
    Muniraja

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.

%d bloggers like this: