Annotate role members by querying Microsoft Graph

Last week our CTO Daniel Otykier yet again was involved in a thread on Twitter, where Micah Dail suggested creating a script to annotate Role members with their human readable information instead of the AAD Guids that doesn’t make sense to humans.

Here is the script that we have created for that purpose.
The purpose of the script is to help document the members of a models security roles.
So in addition to having the weird object or application references like here, you’ll also get an annotation of each member with a human readable description:

Unfortunately, the script is not fire-and-forget as it requires some permissions to be able to query the Microsoft Graph in code.
So please follow the instructions from step 1 through step 5 (or stop after step 2, if you only want to run the script once).

Step 1: Configure permissions for Microsoft Graph in Azure AD Application

In order to query the Microsoft Graph through code, we’ll need an Azure AD Application with a specific set of permissions.
This is needed because the code acts on behalf of the user that is logged in, and this gives the nessecary permissions for acting on behalf of you when you run the script.
The needed permissions are:

  • Microsoft Graph (Delegated permissions)
    • Application.Read.All
    • Group.Read.All
    • User.Read
    • User.ReadBasic.All

For this step, you have two options:

Option 1: Use the Azure AD Application supplied from Tabular Editor

For ease of use, we have created a multi-tenant Application in our tenant, that you can use.
It requests the correct set of API permissions, and all you need to do is get someone in IT to consent to this app being used in your Azure Tenant.
To make it easy on them, you can just share a link to this blog, and ask them to visit this link:
https://login.microsoftonline.com/common/adminconsent?client_id=a57d5fb7-7eb6-422d-9c83-5347a7a9194c
This will bring up this dialog, where they can approve the app in your tenant:

NOTE

Doing this does not enable anyone at Tabular Editor to access any of your organizations data.
The requested permissions are so called delegated permissions, explained in detail here Microsoft Learn – Access scenarios and visualized in their documentation like this:

With delegated permissions, the app cannot access anything without a user logging in (in this case as part of execuring the script)

Option 2: Create your own Azure AD Application

You can create your own application by going to the Azure Portal and accessing Azure Active Direktory from there.
You’ll probably need someone from IT to help with this because of limitations most companies have.
The process of creating an application is described here: Quickstart: Register an app (you can skip the sections on redirect URI and credentials).
After creating the application, it needs to be assigned API permissions, which is described here: Delegated permission to Microsoft Graph.
The neeed permissions are:

  • Microsoft Graph (Delegated permissions)
    • Application.Read.All
    • Group.Read.All
    • User.Read
    • User.ReadBasic.All

After creating it, you’ll need the Client (Application) Id that should replace the clientId in the scripts.

Step 1.1: Test the connection to Microsoft Graph

Regardless if you choose option 1 or 2, please validate that the app have been configured correctly and that the neccesary consent has been given.
This short script will give you wither a thumbs up for proceeding or instructions on what you need to fix:

#r "Azure.Identity"

using Azure.Identity;

var clientId = "a57d5fb7-7eb6-422d-9c83-5347a7a9194c";  // Replace with your applications clientId if you use your own app.

try
{
	var options = new DefaultAzureCredentialOptions
	{
		ExcludeAzureCliCredential = true,
		ExcludeAzurePowerShellCredential = true,
		ExcludeEnvironmentCredential = true,
		ExcludeInteractiveBrowserCredential = false,
		ExcludeManagedIdentityCredential = true,
		ExcludeSharedTokenCacheCredential = true,
		ExcludeVisualStudioCodeCredential = true,
		ExcludeVisualStudioCredential = true,
		InteractiveBrowserCredentialClientId = clientId
	};
	var credential = new DefaultAzureCredential(options);
	var tokenRequest = credential.GetTokenAsync(
		new Azure.Core.TokenRequestContext(
			new[] { "https://graph.microsoft.com/Application.Read.All", "https://graph.microsoft.com/Group.Read.All", "https://graph.microsoft.com/User.ReadBasic.All" }
			));
	var token = await tokenRequest.ConfigureAwait(false);
	var accessToken = token.Token;

	Info("Your Azure AD App is configured correctly and you can proceed");
}
catch (Exception ex)
{
	Error("There was an error while validating your Azure AD App.\r\nPlease validate all its settings and try again.\r\n\r\nException was:\r\n" + ex.Message);
}

Step 2: Copy/paste script in to a new C# Script in Tabular Editor

Now you are ready to begin working with the actual script. It can be run as-is or saved as a macro, which is covered in next step.
The script will run through all selected roles if any are selected or if not, all roles in your model, go through all the members of those roles and attempt to annotate them with object type (Service Principal, Group or User) and DisplayName.
You can copy the script below with the button in top-right hand corner, and paste it into a new C# script in Tabular Editor 3.

#r "Azure.Identity"

using Azure.Identity;
using System.Threading.Tasks;
using System.Net.Http.Headers;
using System.Net.Http;
using Newtonsoft.Json.Linq;
using System.Text.RegularExpressions;

// We need an Azure AD App to be able to connect to Microsoft Graph.
// You can either stick with this one, which is created in the Tabular Editor tenant as a multi tenant app,
// or you can create your own in your tenant.  It needs the following API permissions:
//         Microsoft Graph:
//            Application.Read.All
//            Group.Read.All
//            User.Read
//            User.ReadBasic.All
var clientId = "a57d5fb7-7eb6-422d-9c83-5347a7a9194c";
// What do you want the annotation name to be?
var annotationName = "MemberDescription";

// You probably shouldn't edit anything below this line...
class MicrosoftGraphHelper
{
    HttpClient client;
    string _clientId;

    public MicrosoftGraphHelper(string clientId)
    {
        _clientId = clientId;
    }

    private async Task AuthenticateAsync()
    {
        // If not already authenticate, get a token by using interactive authentication
        if (client == null)
        {
            var options = new DefaultAzureCredentialOptions
                {
                    ExcludeAzureCliCredential = true,
                    ExcludeAzurePowerShellCredential = true,
                    ExcludeEnvironmentCredential = true,
                    ExcludeInteractiveBrowserCredential = false,
                    ExcludeManagedIdentityCredential = true,
                    ExcludeSharedTokenCacheCredential = true,
                    ExcludeVisualStudioCodeCredential = true,
                    ExcludeVisualStudioCredential = true,
                    InteractiveBrowserCredentialClientId = _clientId
                };
            var credential = new DefaultAzureCredential(options);
            var tokenRequest = credential.GetTokenAsync(
                new Azure.Core.TokenRequestContext(
                    new[] { "https://graph.microsoft.com/.default" }));
            var token = await tokenRequest.ConfigureAwait(false);
            var accessToken = token.Token;

            // Append token to httpclient to be able to call the Microsoft Graph API
            client = new HttpClient();
            client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
            client.DefaultRequestHeaders.Accept.Add( new MediaTypeWithQualityHeaderValue("application/json"));
        }
    }

    private async Task<HttpResponseMessage> CallRestApiAsync(string url)
    {
        // Helper function for invoking REST API
        await AuthenticateAsync();
        return await client.GetAsync(url).ConfigureAwait(false);
    }

    public async Task<JObject> GetAADUserAsync(string objectId)
    {
        var url = $"https://graph.microsoft.com/v1.0/users/{objectId}";
        var response = await CallRestApiAsync(url).ConfigureAwait(false);
        var jsonResult = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
        return JObject.Parse(jsonResult);
    }

    public async Task<JObject> GetAADGroupAsync(string objectId)
    {
        var url = $"https://graph.microsoft.com/v1.0/groups/{objectId}";
        var response = await CallRestApiAsync(url).ConfigureAwait(false);
        var jsonResult = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
        return JObject.Parse(jsonResult);
    }

    public async Task<JObject> GetAADApplicationAsync(string objectId)
    {
        var url = $"https://graph.microsoft.com/v1.0/applications/{objectId}";
        var response = await CallRestApiAsync(url).ConfigureAwait(false);
        var jsonResult = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
        return JObject.Parse(jsonResult);
    }

    public async Task AnnotateRoleMembersAsync(string annotationName)
    {
        if (ScriptHost.Model.Roles.Count == 0)
        {
            Info("Your model doesn't contain any roles");
            return;
        }

        if (!ScriptHost.Model.Roles.Any(r => r.Members.Count > 0))
        {
            Info("None of your roles contains members");
            return;
        }

        if (!ScriptHost.Model.Roles.Any(r => !r.Members.Any(m => m.HasAnnotation(annotationName))))
        {
            Info("All of your role members are already annotated");
            return;
        }

        // Now we start to work through roles and their members
        int roleCount = 0;
        int roleMemberCount = 0;
        int roleMemberEligableCount = 0;
        int roleMemberAnnotatedCount = 0;

        var objRegEx = "obj:(?<objId>[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12})@(?<tenenatId>[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12})";
        var appRegEx = "app:(?<appId>[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12})@(?<tenenatId>[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12})";

        // If invoked as a macro on one/more role(s), only process those, otherwise process all of models roles
        var rolesToProcess = ScriptHost.Selected.Roles.Count != 0 ? ScriptHost.Selected.Roles.ToList() : ScriptHost.Model.Roles.ToList();

        foreach (var role in rolesToProcess)
        {
            roleCount++;
            foreach (var member in role.Members)
            {
                roleMemberCount++;

                // Don't attempt to annotate members, that already have the annotation
                var existingAnnotation = member.GetAnnotation(annotationName);
                if (existingAnnotation == null)
                {
                    roleMemberEligableCount++;
                    if (member.MemberName.StartsWith("app:"))
                    {
                        var appMatch = Regex.Match(member.MemberName, appRegEx);
                        if (appMatch.Success == true)
                        {
                            var appNameObject = await GetAADApplicationAsync(appMatch.Groups["appId"].Value).ConfigureAwait(false);
                            if (appNameObject.ContainsKey("displayName"))
                            {
                                member.SetAnnotation(annotationName, "Azure AD Service Principal: " + appNameObject["displayName"].ToString().Trim());
                                roleMemberAnnotatedCount++;
                            }
                        }
                    }
                    else
                    {
                        var searchValue = member.MemberName;
                        var objMatch = Regex.Match(member.MemberName, objRegEx);
                        if (objMatch.Success == true)
                        {
                            searchValue = objMatch.Groups["objId"].Value;
                        }

                        var groupNameObject = await GetAADGroupAsync(searchValue).ConfigureAwait(false);
                        if (groupNameObject.ContainsKey("displayName"))
                        {
                            member.SetAnnotation(annotationName, "Azure AD Group: " + groupNameObject["displayName"].ToString().Trim());
                            roleMemberAnnotatedCount++;
                        }
                        else
                        {
                            var userNameObject = await GetAADUserAsync(searchValue).ConfigureAwait(false);
                            if (userNameObject.ContainsKey("displayName"))
                            {
                                member.SetAnnotation(annotationName, "Azure AD User: " + userNameObject["displayName"].ToString().Trim());
                                roleMemberAnnotatedCount++;
                            }
                        }
                    }
                }
            }
        }

        // Report result
        Info(
            "Role member annotating completed\r\n" +
            "Processed:\r\n" +
            $"\t{roleCount} Roles with " + $"{roleMemberCount} members.\r\n" +
            $"\t{roleMemberEligableCount} of those members wasn't already annotated.\r\n" +
            $"\t{roleMemberAnnotatedCount} was annotated, while the rest was unsuccesful."
        );
    }

    public void AnnotateRoleMembers(string annotationName)
    {
        AnnotateRoleMembersAsync(annotationName).Wait();
    }
}

var graphHelper = new MicrosoftGraphHelper(clientId);
graphHelper.AnnotateRoleMembersAsync(annotationName);

If you just want to run the script once, you are now ready to do so, and you do not need to complete the rest of the steps.

Step 3: Save the script as a macro

To save the script as a reusable macro, click C# Script > Save as Macro.

This will make it available in the context menu for the selected types.
By selecting both Model and Role, you’ll be able to right click one or more roles to do a selective processing or the model to just iterate over all roles.

NOTE

We have a known issue running asynchronous code as a macro, therefore the last line of the script is not awaited.
This means that the script will appear finished before it actually is. Only when you receive a popup with a message is the script done executing.
We’ll address this issue in an upcoming release of Tabular Editor.

Step 4: Testing the Macro

To test the script, I have created a model with 3 roles. Each role contains 1 member that is either a group, a user or a service principal:

When I invoke the macro by right clicking on my model

I am prompted to login (to be able to query the Microsoft Graph) and after logging in the roles and role members are processed.
In the end, you either get an error message or a nice summary of what was fixed:

The annotations can be saved back to the model now.

Step 5: Validating the result

To validate the result, I use save-to-folder to write my changes into a git enabled folder and check the changes and all of my role members have been annotated with a description:

Wrapping up

Hope you enjoyed this post, the potential for using Microsoft Graph through C# Scripting is vast, and this is just a small example of what can be achieved.
Please feel free to comment below with questions or comments.

1 thought on “Annotate role members by querying Microsoft Graph”

Leave a Comment

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

Scroll to Top