How to create and filter Graph API users by custom user type

What represents a user entity

When working with the Microsoft Graph API and users entities, the userType property allows you to distinguish between guest users and members of your organization.

The real-world challenge that many administrators face is that members can be real users, service accounts, rooms, equipment, shared mailboxes, etc. The userType property doesn't provide enough granularity to distinguish between these different types of users.

The solution to this problem is easy. Extend the user entity with a custom property that will held more granular information about the user type.

How to find out what the real user type is?

The users have mailbox settings with a property called the userPurpose. It defines the purpose of the mailbox with the possible values:

  • user: A user account with a mailbox
  • linked: A mailbox linked to a user account in another fores
  • shared: A mailbox shared by two or more user accounts
  • room: A mailbox that represents a conference room.
  • equipment: A mailbox that represents a piece of equipment.
  • others: A mailbox was found but the user purpose is different from the ones specified in the previous scenarios.

The mailbox settings is accessible via the endpoint:

GET v1.0/users/{id}/mailboxSettings

Extending the user entity with a custom user type

You can extend the user entity with open extensions, extension attributes, schema extensions, or directory extensions. What extension is the best in this case? It should be filterable, discoverable and easy to define.

Open extensions

Open extensions are not filterable, so they are not suitable for this scenario.

Extension attributes

The user entity has predefined 15 extension attributes to store custom data. They are filterable, but you can't set a custom name for them and in a big tenant, they can be already used for other purposes.

Schema extensions

Schema extensions are filterable, which is good. However, they have a quite complex lifecycle and every schema extension is represented as a complex object instead of a simple property which makes it more difficult to work with. Schema extensions also don't support creating dynamic membership rules.

Directory extensions

The best option in this case is to use a directory extension. Directory extensions are filterable, strongly types, discoverable, easy to register and assign to users.

Directory extensions

Let's focus more on a directory extension. Suppose that we will use application permissions to manage the directory extension.

Registering a directory extension

To register a directory extension, you need to send a POST request to the extensionProperties endpoint of your application registration with the following payload: The calling application must have at least the Application.ReadWrite.All application permission.

POST https://graph.microsoft.com/v1.0/applications/{appObjectId}/extensionProperties
Content-type: application/json

{
    "name": "entityType",
    "dataType": "String",
    "isMultiValued": false,
    "targetObjects": [
        "User"
    ]
}

Simply define the name of the property, its data type and the target object to which it applies. In this case, we want to add a property called entityType to the user entity.

The response will contain the name of the extension property with the format extension_{appClientId}_{propertyName}. You will need to use this name to set the value for users and filter them by this property.

Assigning a directory extension to users

To assign a directory extension to a user, you need to send a PATCH request to the user endpoint.

PATCH https://graph.microsoft.com/v1.0/users/{userId}
Content-type: application/json

{
    "extension_{appClientId}_entityType": "user"
}

Filtering users by directory extension

To filter users by a directory extension, you need to send a GET request to the users endpoint with the $filter query parameter.

GET https://graph.microsoft.com/v1.0/users?$filter=extension_{appClientId}_entityType eq 'user'

.NET example

Now, let's see how to implement this in .NET. I will batch requests where possible to minimize the number of calls to the Graph API and to improve the performance.

The steps are the following:

  1. Ensure that the directory extension exists. If not, create it.
  2. Update new guests users with the custom user type.
  3. Update custom guests users converted to members with the custom user type.
  4. Update new members users with the custom user type.

Method to ensure that the directory extension exists. The method takes the application id as a parameter and returns the name of the extension property. If the extension already exists, it returns its name. If not, it creates a new extension and returns its name.

public async Task<string> EnsureExtensionExistsAsync(string appId)
{
    var app = await client.ApplicationsWithAppId(appId).GetAsync();
    var extensions = await client.Applications[app.Id].ExtensionProperties.GetAsync();

    if (extensions.Value.Count == 0)
    {
        var extension = await client.Applications[app.Id].ExtensionProperties.PostAsync(new ExtensionProperty
        {
            Name = "entityType",
            DataType = "String",
            TargetObjects = ["User"],
            IsMultiValued = false
        });

        return extension.Name;
    }

    return extensions.Value[0].Name;
}

Method to update users with the custom user type. The method takes the name of the extension and a dictionary with user ids and their corresponding user types. It returns a tuple with a set of successfully updated user ids and a dictionary with failed user ids and their corresponding errors.

private async Task<(HashSet<string>, Dictionary<string, ODataError>)> UpdateUsersAsync(string extensionName, Dictionary<string, string> users)
{
    var batchRequestContent = new BatchRequestContentCollection(client);

    foreach (var user in users)
    {
        var request = client.Users[user.Key].ToPatchRequestInformation(new User
        {
            AdditionalData = new Dictionary<string, object>
            {
                { extensionName, user.Value }
            }
        });

        await batchRequestContent.AddBatchRequestStepAsync(request, user.Key);
    }

    var batchResponse = await client.Batch.PostAsync(batchRequestContent);
    var responsesStatusCodes = await batchResponse.GetResponsesStatusCodesAsync();

    var successfulIds = new HashSet<string>();
    var failedIds = new Dictionary<string, ODataError>();

    foreach (var status in responsesStatusCodes)
    {
        if (status.Value == HttpStatusCode.NoContent)
        {
            successfulIds.Add(status.Key);
        }
        else
        {
            var error = await batchResponse.GetResponseByIdAsync<ODataError>(status.Key);
            failedIds[status.Key] = error;
        }
    }

    return (successfulIds, failedIds);
}

Method to get users' purpose. The method takes a list of user ids as a parameter and returns a tuple with a dictionary of user ids and their corresponding user purposes and a set of failed user ids.

private async Task<(Dictionary<string, UserPurpose> userPurposes, HashSet<string> failedUsers)> GetUsersPurposeAsync(List<string> userIds)
{
    var userPurposes = new Dictionary<string, UserPurpose>();
    var failedUsers = new HashSet<string>();
    var batchRequestContent = new BatchRequestContentCollection(client);

    try
    {
        foreach (var userId in userIds)
        {
            var request = client.Users[userId].MailboxSettings.ToGetRequestInformation(rc =>
            {
                rc.QueryParameters.Select = ["userPurpose"];
            });

            await batchRequestContent.AddBatchRequestStepAsync(request, userId);
        }

        var batchResponse = await client.Batch.PostAsync(batchRequestContent);
        var responsesStatusCodes = await batchResponse.GetResponsesStatusCodesAsync();

        foreach (var status in responsesStatusCodes)
        {
            if (status.Value == HttpStatusCode.OK)
            {
                var settings = await batchResponse.GetResponseByIdAsync<MailboxSettings>(status.Key);
                userPurposes[status.Key] = settings.UserPurpose.Value;
            }
            else
            {
                failedUsers.Add(status.Key);
            }
        }
    }
    catch (ODataError e)
    {
        Console.WriteLine($"{e.ResponseStatusCode} {e.Message}");
    }
    catch (Exception ex)
    {
        Console.WriteLine(ex.Message);
    }

    return (userPurposes, failedUsers);
}

Method to update new guest users with the custom user type. The method takes the name of the extension as a parameter and updates all new guest users with the custom user type "Guest".

public async Task UpdateNewGuestUsersAsync(string extensionName)
{
    var newGuests = await GetNewGuestUsersAsync(extensionName);
    var usersCustomTypes = newGuests.ToDictionary(x => x.Id, x => "Guest");

    await UpdateUsersAsync(extensionName, usersCustomTypes);
}

Method to update custom guest users converted to members with the custom user type. The method takes the name of the extension as a parameter and updates all custom guest users converted to members with the custom user type based on their user purpose.

public async Task UpdateCustomGuestUsersAsync(string extensionName)
{
    var customGuests = await GetCustomGuestUsersAsync(extensionName);

    // guests converted to members
    var convertedMembers = customGuests.Where(cg => cg.UserType == "Member").ToList();

    var (userPurposes, failedUsers) = await GetUsersPurposeAsync([.. convertedMembers.Select(x => x.Id)]);

    var usersCustomTypes = ConvertUsersPurposeToCustomTypes(userPurposes);

    await UpdateUsersAsync(extensionName, usersCustomTypes);
}

Method to update new member users with the custom user type. The method takes the name of the extension as a parameter and updates all new member users with the custom user type based on their user purpose.

public async Task UpdateNewUsersAsync(string extensionName)
{
    var newUsers = await GetNewUsersAsync(extensionName);
    var (userPurposes, failedUsers) = await GetUsersPurposeAsync([.. newUsers.Select(x => x.Id)]);
    var usersCustomTypes = ConvertUsersPurposeToCustomTypes(userPurposes);
    await UpdateUsersAsync(extensionName, usersCustomTypes);
}

Once the custom user type is updated for all users, you can filter user by this property:

You can find the full code in this GitHub repository.

Conclusion

In this article, I showed how to deal with the lack of granularity of the userType property in the Microsoft Graph API by extending the user entity with a custom directory extension. I also provided a .NET implementation to register the directory extension, update users with the custom user type and filter users by this property.

0
Buy Me a Coffee at ko-fi.com
An error has occurred. This application may no longer respond until reloaded. Reload x