Inactive External Users Report Logic App
This one is an old logic app which I have made somewhere between 2019-2020 but more modern & simplified version of it.
Basically this will create a report of all guest users which have not been active for 1 year. The report is generated as HTML table and posted to a teams channel message.
Solution Overview
The logic is fairly simple:
- Get all guest users and include signInActivity poperty.
- Checks if guest user had last succesfull sign in over 365 days ago, if yes, append the information to a string variable as an HTML table row.
- At the end, send the HTML table via Teams channel message.
Let’s get started with the guide.
Requirements
- Azure subscription
- Resource group
- Microsoft Graph PowerShell module
- Automation/service user account with Teams license
Create a logic app
- Start by creating a new Logic App in your Azure subscription.
- Select the resource group and region of your choice.
- Name your Logic App according to your naming convention. For this example, we’ll use LA-Entra-Report-Inactive-Guests.
After the Logic App has been created, enable the Managed Identity:
- Open the Logic App in the Azure portal.
- From the left navigation menu, go to Settings → Identity.
- Enable the System-assigned identity.
- Take note of the Object (Principal) ID because you’ll need it in the next step to assign the necessary permissions.
Assign Permissions to Managed Identity
For this automation, we need AuditLog.Read.All and User.Read.All permissions.
Optionally you could assing User.ReadWrite.All If you want to use this automation for deleting the inactive users. Refer to the official documentation on Microsoft Learn for more details.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# Add the correct 'Object (principal) ID' for the Managed Identity
$ObjectId = "MIObjectID"
# Add the correct Graph scopes to grant (multiple scopes)
$graphScopes = @(
"AuditLog.Read.All",
"User.Read.All"
)
# Connect to Microsoft Graph
Connect-MgGraph -Scope AppRoleAssignment.ReadWrite.All
# Get the Graph Service Principal
$graph = Get-MgServicePrincipal -Filter "AppId eq '00000003-0000-0000-c000-000000000000'"
# Loop through each scope and assign the role
foreach ($graphScope in $graphScopes) {
# Find the corresponding AppRole for the current scope
$graphAppRole = $graph.AppRoles | Where-Object { $_.Value -eq $graphScope }
if ($graphAppRole) {
# Prepare the AppRole Assignment
$appRoleAssignment = @{
"principalId" = $ObjectId
"resourceId" = $graph.Id
"appRoleId" = $graphAppRole.Id
}
# Assign the role
New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $ObjectId -BodyParameter $appRoleAssignment | Format-List
Write-Host "Assigned $graphScope to Managed Identity $ObjectId"
} else {
Write-Warning "AppRole for scope '$graphScope' not found."
}
}
Create the Logic
Open your Logic App in Edit mode, and let’s begin building the workflow.
Step 1: Variables Setup
- Select a trigger, schedule works for this reportin automation
- Create a “Get past time” action
- Set your desired timeframe to look back on. For this example, I’ll use 12 months. This interval represents what is the treshold for considering as inactive user.
- Initialize a string variable
- Name the variable tableRows.
- This variable will be used to store the HTML table row data for the list of inactive users.
- Initialize an boolean variable
- Name the variable breakLoop.
- This variable will track when HTTP - Get Guest users have succesfully got the results (HTTP Code: 200).
At this point, your Logic App should look like this:
Step 2: Get guest users
This section is more complicated than it should be due to instability or issues with the API.
For some reason, the GET request to https://graph.microsoft.com/v1.0/users
results in the error “Insufficient privileges to complete the operation,” even though the app has the required permissions. You can find more details about this error at the end of the post.
To work around this issue, we are using a Do Until loop to repeatedly send the HTTP request until it succeeds or reaches a maximum of 60 attempts.
- Create a Until loop
- Create HTTP action with following configurations
- URI:
https://graph.microsoft.com/v1.0/users
- Method:
GET
- Headers:
ConsistencyLevel
:Eventual
- Queries:
$filter
:userType eq 'Guest'
$select
:displayName,userPrincipalName,signInActivity
- Advanced parameters:
- URI:
- Create Set Variable and select breakLoop for variable and true for the value.
- Create a paraller branch in between of HTTP request and Set variable.
- Add Delay action to paraller branch with 5 seconds delay.
- Go to Settings tab for Delay action.
- Untick Is successful and tick rest of the options.
Your Do Until should be similar to this:
Outside of do-until add Parse JSON action where you add body from HTTP - Get Guest users and following schema:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
{
"type": "object",
"properties": {
"statusCode": {
"type": "integer"
},
"headers": {
"type": "object",
"properties": {
"Cache-Control": {
"type": [
"string",
"null"
]
},
"Transfer-Encoding": {
"type": [
"string",
"null"
]
},
"Vary": {
"type": [
"string",
"null"
]
},
"Strict-Transport-Security": {
"type": [
"string",
"null"
]
},
"request-id": {
"type": [
"string",
"null"
]
},
"client-request-id": {
"type": [
"string",
"null"
]
},
"x-ms-ags-diagnostic": {
"type": [
"string",
"null"
]
},
"x-ms-resource-unit": {
"type": [
"string",
"null"
]
},
"OData-Version": {
"type": [
"string",
"null"
]
},
"Date": {
"type": [
"string",
"null"
]
},
"Content-Type": {
"type": [
"string",
"null"
]
},
"Content-Length": {
"type": [
"string",
"null"
]
}
}
},
"body": {
"type": "object",
"properties": {
"@@odata.context": {
"type": [
"string",
"null"
]
},
"value": {
"type": "array",
"items": {
"type": "object",
"properties": {
"displayName": {
"type": [
"string",
"null"
]
},
"userPrincipalName": {
"type": [
"string",
"null"
]
},
"id": {
"type": [
"string",
"null"
]
},
"signInActivity": {
"type": "object",
"properties": {
"lastSignInDateTime": {
"type": [
"string",
"null"
]
},
"lastSignInRequestId": {
"type": [
"string",
"null"
]
},
"lastNonInteractiveSignInDateTime": {
"type": [
"string",
"null"
]
},
"lastNonInteractiveSignInRequestId": {
"type": [
"string",
"null"
]
},
"lastSuccessfulSignInDateTime": {
"type": [
"string",
"null"
]
},
"lastSuccessfulSignInRequestId": {
"type": [
"string",
"null"
]
}
}
}
}
}
}
}
}
}
}
Step 3: Check for inactive users
Create a condition where you compare
lastSignInDateTime
property from Parse JSON action. The condition islastSignInDateTime
is less thanPast Time
action. TechnicallylastSignInDateTime
isitems('For_each')?['signInActivity']?['lastSignInDateTime']
. The for each is created automatically when the lastSignInDateTime is selected.
- In the true section create Convert time zone action with following properties
- Create Append to string variable which appends to tableRows variable with following content:
1
2
3
4
5
6
<tr>
<td>item()?['displayName']</td>
<td>item()?['userPrincipalName']</td>
<td>item()?['id']</td>
<td>body('Convert_time_zone')</td>
</tr>
First three are from Parse JSON action and the last one is from Convert Time Zone action you created in the previous step.
Your logic for checking inactive guest users should look similar to this:
Step 4: The message
Now there’s two more actions left.
Second to last action is Compose with following content:
1
2
3
4
5
6
7
8
9
10
11
12
13
<table style="width:100%; border-collapse: collapse; border: 1px solid #ddd; font-family: Arial, sans-serif;">
<thead>
<tr style="background-color: #f2f2f2; color: #333;">
<th style="border: 1px solid #ddd; padding: 8px; text-align: left;">Display Name</th>
<th style="border: 1px solid #ddd; padding: 8px; text-align: left;">User Principal Name</th>
<th style="border: 1px solid #ddd; padding: 8px; text-align: left;">ID</th>
<th style="border: 1px solid #ddd; padding: 8px; text-align: left;">Last Interractive Sign-In</th>
</tr>
</thead>
<tbody>
variables('tableRows')
</tbody>
</table>
Make sure you added tableRows variable in between of tbody tags.
For the last action, create a “Post Message in a Chat or Channel”
- Post As: User
- Post In: Channel
- Team: Select your team
- Channel: Select your channel
- Message: Use the Outputs of your previously created Compose action.
- You can optionally add a subject for the message under Advanced Parameters if needed.
And finally, that’s it! Your notification logic is now complete. You should receive Teams message when there are inactive guest users.
Here’s an example of teams message:
Overview of full logic app
Possible Modifications
You could add an HTTP request to delete users within the checking logic (where the “Append to string” action is). However, I have personally created a separate Logic App for deleting users, which you can find on my GitHub.
Closing thoughts
This Logic App provides an alternative approach to checking inactive users compared to the PowerShell scripts I have commonly seen in customer environments.
Developing this Logic App was a bit challenging. During the process, I encountered severe authorization issues, which required creating workarounds to handle random “unauthorized” request errors. Other than that, the overall implementation was fairly straightforward.
This post serves as a walkthrough of the reporting Logic App. I have created a separate Logic App specifically for deleting inactive users. Both Logic Apps, along with deployment scripts and Bicep templates, are available on my GitHub, complete with detailed instructions on how to deploy them to your environment.
This post is kind of a walkthrough of report logic app. I made delete inactive user logic to another logic app on the side. Both logic apps can be found on my github with deployment script and bicep templates with detailed descriptions how to deploy them to your environment. GitHub
Hopefully this helps you manage your guest users.
-Anssi
Sources
- List users - Microsoft Learn
- Delete a user - Microsoft Learn
- How to manage inactive user accounts - Microsoft Learn
- Github, apaivinen/Entra-ID-Logic-Apps
Extra: Unauthorized Error in Detail for Those Who Are Wondering
I have assigned the following permissions to my user-managed identity:
- User.ReadWrite.All (I used this with the delete Logic App as well, which is why it includes ReadWrite access)
- AuditLog.Read.All
It does not matter whether the managed identity is user-assigned or system-assigned.
I also tried adding the User Administrator role to the managed identity. During development, I went overboard and even assigned the Global Administrator role to see if it would have any effect. No, none at all.
The HTTP request in question:
- URI:
https://graph.microsoft.com/v1.0/users
- Method:
GET
- Queries:
$filter
:userType eq 'Guest'
$select
:displayName,userPrincipalName,signInActivity
- Authentication:
- The managed identity
- Audience:
https://graph.microsoft.com
Despite having the permissions mentioned above, there is a high chance of encountering the following error during the HTTP request:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{
"statusCode": 403,
"headers": {
"Cache-Control": "no-cache",
"Transfer-Encoding": "chunked",
"Vary": "Accept-Encoding",
"Strict-Transport-Security": "max-age=31536000",
"request-id": "f428ca8a-d83c-44e7-9939-5fb35e669716",
"client-request-id": "f428ca8a-d83c-44e7-9939-5fb35e669716",
"x-ms-ags-diagnostic": "{\"ServerInfo\":{\"DataCenter\":\"West Europe\",\"Slice\":\"E\",\"Ring\":\"5\",\"ScaleUnit\":\"010\",\"RoleInstance\":\"AM4PEPF000278F7\"}}",
"x-ms-resource-unit": "1",
"Date": "Sun, 26 Jan 2025 18:06:23 GMT",
"Content-Type": "application/json",
"Content-Length": "266"
},
"body": {
"error": {
"code": "Authorization_RequestDenied",
"message": "Insufficient privileges to complete the operation.",
"innerError": {
"date": "2025-01-26T18:06:23",
"request-id": "f428ca8a-d83c-44e7-9939-5fb35e669716",
"client-request-id": "f428ca8a-d83c-44e7-9939-5fb35e669716"
}
}
}
}
Debugging this issue was a nightmare, as the error appeared randomly. I spent a considerable amount of time searching for information related to the problem but couldn’t find any relevant posts or mentions within a reasonable timeframe.
Even assigning the User Administrator and Global Administrator roles on top of the necessary Graph permissions did not resolve the issue. Eventually, I decided to leave it as is and implemented a do-until workaround. It’s not the most elegant solution, but it works.