WPNinjas HeaderWPNinjas Header

Azure AD Guest Account -Governance and Cleanup

One of the biggest challenges with guest/external accounts in Azure AD is to build a governance process to keep your directory clean. Many companies do not like to have old and unused guest accounts forever in their Azure AD. Without a review functionality and information who has invited them this job is nearly impossible.

I elaborated some possibilities which are available today for a customer:

  1. Block Guest Invite for users and solve lifecycle in separate tool like an existing IAM.
  2. Leave the defaults, everybody can invite, and guests will stay in your directory forever.
  3. Use Azure AD Access Reviews for Guests.
  4. Create a custom solution based on Logic App and Azure Log Analytics

Number one and two are well documented and therefore, I will not further explain them and focus on the last two.

Azure AD Access Review

This functionality is part of Azure AD Premium P2 and you can create recurring or onetime reviews per Azure AD group or Azure AD role. A review can be forced by the group owner, specific person, or the user himself. The actions based on a review are stay in the group/role or be removed from the Azure AD group or role.

This leads to two big issues with guest accounts:

  • I cannot remove them from my Azure AD completely, Access Review is just focused on the group membership. This is good for example to keep review my high security groups clean, but not for the use case to keep the whole directory clean.
  • It’s very restricted who can approve. For example, it’s not possible to let the manager decide of each user or for guest account the person who invited them.

With these two challenges I know it will not provide a working solution in my case.

Create a custom solution

A custom solution always means a lot of flexibility but also higher maintenance and documentation effort. But I will give it a try.

The first thing to solve is, that Azure AD does not store who has invited which guest. After searching the Azure AD logs, I could detect two audit entries which are important for me:

  • Invite external user
    This entry has the reference who has invited the user and the InvitationId.
  • Redeem external user invite
    Only after the redeem does the guest user exist in Azure AD. The entry does also contain the InvitationId.

With this knowledge in mind I will show how a possible solution can look like in the following sections.

Prepare Audit Log forwarding

To be able to query for these entries it’s the best option to forward audit logs to Azure Log Analytics.

You can check if this is already configured or if you have to enable it by navigating to your Azure AD and selecting Audit Log.

Then click on Export Data Settings.

If you have connection to an Azure Log Analytics workspace and access to it, then all is fine. And you can go to the next section Otherwise click on Add diagnostic setting.

In the next screen enable Audit Log forwarding and select a Log Analytics workspace in one of your subscriptions.

Now, it’s good to generate some test data. The simplest way is to invite some personal accounts of you to a Microsoft Teams. You can now try the following query in Log Analytics to check if the data is found:

AuditLogs 
| where TimeGenerated > ago(90d) and OperationName in('Invite external user')
| extend InvitationId = tostring(AdditionalDetails[0].value)
| extend InvitedUserEmailAddress = AdditionalDetails[1].value
| extend InitiatedBy = InitiatedBy.user.userPrincipalName
| project ActivityDateTime, InitiatedBy, InvitationId, InvitedUserEmailAddress
| join kind= leftouter (
   AuditLogs
   | where TimeGenerated > ago(24h) and OperationName in('Redeem external user invite')
   | parse kind=regex TargetResources[0].displayName with * "InvitationId: " InvitationId:string ","
   | parse kind=regex TargetResources[0].displayName with * "UPN: " InvitedUserUPN:string ", Email"
   | project InvitationId,InvitedUserUPN,ActivityDateTimeAccepted = ActivityDateTime
) on $left.InvitationId == $right.InvitationId
| project-away InvitationId1
| where isnotnull(ActivityDateTimeAccepted)

The query returns entities with the necessary information:

Create Azure AD Application Registration

In order to call the Graph, the Logic Apps needs an Azure AD App Registration. This section is not documented in detail to keep this blog post simple.

  1. Login to the Azure Portal and go to Azure Active Directory.
  2. Go to App Registrations and click New Registration
  3. Enter a name (I used “Company LogicApp”)
  4. Choose Single Tenant
  5. Choose Web as the Redirect URI and set the value to https://localhost/myapp (it does not matter what this is, it will not be used).
  6. Click Register

You will now have the basic app registration. The next step is to grant the API permissions that the App requires in the Graph. Our Applications needs the following:

  • User.ReadWrite.All

To add these permissions to your App Registration, follow these steps:

  1. Go to API permissions in your App Registration
  2. Click Add a permission
  3. Choose Microsoft Graph
  4. Choose Application This is because our Logic App runs as a background service and has no specific logged in user.
  5. Find and select the permissions above then click Add Permissions
  6. Now we can grant these permissions to the App Registration as an administrator. If we don’t do this, the Logic App will not work. Under the Grant Consent section, click Grant Admin consent for (your directory name) and click Yes to confirm. When complete you’ll see a green tick next to all the permissions you added.

To be able to use the App Registration in the Logic App, we will need Client Secret or Certificate.

  1. Go to Certificates & secrets for your App Registration
  2. Click New client secret
  3. Add a description to your secret. I used “cs-logicapp”
  4. Choose the expiry. I chose Never but you should align with your company policy if this is allowed.
  5. Click Add
  6. Make a note of the value for your secret. It will only be shown on this page, when you navigate away it will be forever partially hidden

Logic App 1 - Add invitation initiator in a custom extension of the guest account

In this blog I will use an open extension in Azure AD to store the additional information. If you would like to learn more about other possibilities read John Craddock’s blog about the advantages and disadvantages of Azure AD schema and open extensions.

Several improvements should eb done to use the flow in production. I have mentioned them at the end in the possible improvement section.

The following flow is used to do the work:

The following steps are required in detail:

The logic app should be triggered once day because it will check for all invitations accepted in the last 24h.

This step connects to your log analytics workspace and executes the above defined KUSTO query to get the necessary invitation id.

Create a for each loop based on the value of the log analytics step.

Add the Get User step from the Azure AD connector and get the user object of the invitation sender.

Add a second Get User step from the Azure AD connector and get the user object of the guest account.

The next step is a HTTP Request. Use the following values:

Method: Post

Url: https://graph.microsoft.com/v1.0/users/@{body(‘Get_user’)?[‘id’]}/extensions

Headers:

·        Content-typ = application/json

Body:

{

  “@@odata.type”: “microsoft.graph.openTypeExtension”,

  “LastAccessReview”: “@{utcNow()}”,

  “extensionName”: “ch.basevision.guestmanagement”,

  “invitorId”: “@{body(‘Get_Invitation_Sender’)?[‘id’]}”,

  “invitorUPN”: “@{body(‘Get_Invitation_Sender’)?[‘userPrincipalName’]}”

}

Authentication:

·        Active Directory OAuth
Use the values collected in the app registration.

Note: The step will fail if you execute it multiple times per user because in the second run it should use Patch as method.

Now you can try and execute your logic app and if you have a recently added guest it should be executed successful.

Logic App 2 – Custom Access Review

This flow should pick up guest accounts which are not reviewed for a specific time and start an approval workflow. My example is just basic and will only as the person who has invited the user and disable the guest if he is not approved.

The following flow is used to do the work:

The following steps are required in detail:

The logic can be triggered as often you would like to.

The next step is a HTTP Request. Use the following values:

Method: Get

Url: https://graph.microsoft.com/v1.0/users

Query:

·        $expand = extensions

·        $filter = userType eq ‘Guest’

·        $select = id,userPrincipalName,userType

Body:

Authentication:

·        Active Directory OAuth
Use the values collected in the app registration.

Add a Parse Json step and parse the returned body with the following schema:

{

    “properties”: {

        “@@odata.context”: {

            “type”: “string”

        },

        “value”: {

            “items”: {

                “properties”: {

                    “extensions”: {

                        “items”: {

                            “properties”: {

                                “@@odata.type”: {

                                    “type”: “string”

                                },

                                “LastAccessReview”: {

                                    “type”: “string”

                                },

                                “LastAccessReview@odata.type”: {

                                    “type”: “string”

                                },

                                “extensionName”: {

                                    “type”: “string”

                                },

                                “id”: {

                                    “type”: “string”

                                },

                                “invitorId”: {

                                    “type”: “string”

                                },

                                “invitorUPN”: {

                                    “type”: “string”

                                }

                            },

                            “required”: [

                                “@@odata.type”,

                                “LastAccessReview@odata.type”,

                                “LastAccessReview”,

                                “extensionName”,

                                “invitorId”,

                                “invitorUPN”,

                                “id”

                            ],

                            “type”: “object”

                        },

                        “type”: “array”

                    },

                    “extensions@odata.context”: {

                        “type”: “string”

                    },

                    “id”: {

                        “type”: “string”

                    },

                    “userPrincipalName”: {

                        “type”: “string”

                    },

                    “userType”: {

                        “type”: “string”

                    }

                },

                “required”: [

                    “id”,

                    “userPrincipalName”,

                    “userType”

                ],

                “type”: “object”

            },

            “type”: “array”

        }

    },

    “type”: “object”

}

Then we need again a Parse JSON step to parse the single guest item in the HTTP result. The following schema can be used:

{

    “properties”: {

        “extensions”: {

            “items”: {

                “properties”: {

                    “@@odata.type”: {

                        “type”: “string”

                    },

                    “LastAccessReview”: {

                        “type”: “string”

                    },

                    “LastAccessReview@odata.type”: {

                        “type”: “string”

                    },

                    “extensionName”: {

                        “type”: “string”

                    },

                    “id”: {

                        “type”: “string”

                    },

                    “invitorId”: {

                        “type”: “string”

                    },

                    “invitorUPN”: {

                        “type”: “string”

                    }

                },

                “required”: [

                    “@@odata.type”,

                    “LastAccessReview@odata.type”,

                    “LastAccessReview”,

                    “extensionName”,

                    “invitorId”,

                    “invitorUPN”,

                    “id”

                ],

                “type”: “object”

            },

            “type”: “array”

        },

        “extensions@odata.context”: {

            “type”: “string”

        },

        “id”: {

            “type”: “string”

        },

        “userPrincipalName”: {

            “type”: “string”

        },

        “userType”: {

            “type”: “string”

        }

    },

    “type”: “object”

}

Add a condition to check if the extension attribute is not null, which would mean that there is no information about who has invited the guest. The for each step should automatically be added.

If the extension attribute is available, then we can start the Send Approval Step and customize the message as you want.

After the Approval Step you need to add again a condition to be able to handle the result of the approval.

If the approval was rejected, we can now for example disable the guest account or you can start any other deprovisioning process or user information if you like. The deactivation of the guest can look like in the screenshot.

The result

Already with these two basic logic apps the guest handing in Azure AD can be improved significantly. The approval in Outlook or on a mobile look pretty and users should be able to handle it.

Possible Improvements

Already during development of this solution, I found various improvements which I should add to the solution like:

Follow me

2 Comments

Magnus · August 10, 2020 at 00:11

When querying for userType eq ‘Guest’ the parameters should be Queries not Headers. Great solution !

    Thomas Kurth · August 10, 2020 at 07:47

    Thanks, just updated the post!

Leave a Reply

Your email address will not be published. Required fields are marked *

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