Delegated App Admin in Azure AD B2C – Part 1

This post is going to be one of two about a common pattern for Azure AD B2C to support Delegated App Admin. Part one is about how to achieve delegated admin and part two will be focused on how to invite users to your team. The solution to achieve delegated admin is based on using AppRoles, which is described in a previous post Using AppRoles and Azure AD B2C for RBAC.

The source code for this post can be found in github here.

What is Delegated App Admin?

If you generalize, you can say that Azure AD B2C has a only two types of users – IT admins and consumer accounts. The IT admins are the users that can login to portal.azure.com because they have permissions to administrate resources in the B2C tenant. Consumer accounts are users who just login to apps but have no further permissions. All accounts exist in a flat model where you can’t partition them into any type of units. But this doesn’t mean you can’t build an app that has a delegated admin model where a user is appointed the ability to manage his/her set of users in some sort of satellite team.

The model we want to build looks like this.

  • App Admin – can create new teams and invite a manager.
  • Team Manager – can invite new users to the team either as managers or members. A Team Manager must know the email address of the user that is to be invited as ad-hoc searching amongst existing users is not acceptable.
  • Team Member – a user that gets an invitation to join a team by a Team Manager. In a deluxe implementation, a Team Member should also be able to request to join a Team.

Note that users in all three roles above do not to be IT admins. They can be normal consumer users. However, it is likely, but not necessary, that an App Admin is an employee of the company owning the app. How the invitation works will be covered in part 2 of this blog.

Technical solution for Delegated App Admin

The app needs to have two AppRoles defined (see this blog post). One for App Admins and one for Team Managers. The AppRole for team managers will be general and hold all users that is a Team Manager in any role. Having any of these two AppRoles unlocks access to management functionality in the app.

A team will be represented by a group object in the B2C tenant. Being a member of a group makes you a team member. There are multiple solutions on how to track managers, but the easiest is to use the group owners feature, since that makes it query able via Microsoft Graph API and easy to manage. A group can have multiple owners just as a team can have multiple managers.

Operations needed would be:

  • Add App admin – add a user to the group that has the AppRole assignment of AppAdmin
  • Create a new team – create a group, add a user as owner (manager) and add the same user as member of the group. Also, add the user to the group that has the AppRole assignment for DelegatedAppAdmin.
  • Add manager – add a user as owner and add the user as a member of the group
  • Add member – add a user as a member of the group

All the above operations would be implemented as Microsoft Graph API calls and the app would use client credentials for authentication to avoid giving permissions to the end users.

App Admin

For a user that has the app role assignment of AppAdmin, the webapp will display an Admin menu item and a page where you can name the new team and select the manager. For now, we don’t send an invite and the manager must have signed up already

The page does a form post back to the MVC controller who performs the steps of checking that the group with a name entered doesn’t already exist and that the selected manager exists. Notice that we mangle the team name a bit, giving it a prefix of Team_. This is so that we later can separate the team groups from other groups we might have in the B2C tenant.

        [HttpPost]
        [Authorize(Policy = "AppAdmin")]
        public async Task<IActionResult> CreateTeam(string teamName, string managerEmail) {
            string returnView = "~/Views/Home/AppAdmin.cshtml";
            // Add 'Team_' as prefix to distinguish from other groups
            string groupName = "Team_" + teamName.Replace(" ", "").Replace("'", "").Replace("\"", "");
            try {
                var graphClient = GraphHelper.GetGraphClient(_appSettings);
                // check to see that the group name doesn't exist already 
                var groups = graphClient.Groups.Request()
                                                .Select(m => new { m.Id, m.DisplayName })
                                                .Filter($"displayName eq '{groupName}'")
                                                .GetAsync().Result;
                if (groups.Count > 0) {
                    ViewData["Message"] = "A team with that name already exists. Please choose another one";
                    return View(returnView);
                }
                // get the manager (assuming a Local Account)
                var users = graphClient.Users.Request()
                                            .Select(m => new { m.Id, m.DisplayName })
                                            .Filter($"identities/any(c:c/issuerAssignedId eq '{managerEmail}' and c/issuer eq '{_appSettings.Domain}')")
                                            .GetAsync().Result;
                if (users.Count == 0) {
                    ViewData["Message"] = $"Cannot find manager with email {managerEmail}";
                    return View(returnView);
                }

                var group = new Microsoft.Graph.Group {
                    DisplayName = groupName,
                    Description = teamName,
                    SecurityEnabled = true,
                    MailEnabled = false,
                    MailNickname = groupName
                };
                // create the group
                var newGroup = graphClient.Groups.Request().AddAsync(group).Result;
                // add the manager as the owner
                await graphClient.Groups[ newGroup.Id ].Owners.References.Request().AddAsync( users[0] );
                // add the manager as a member of the group
                await graphClient.Groups[ newGroup.Id ].Members.References.Request().AddAsync( users[0] );
                // add the manager as a member of the AppRole group for general Team Managers
                // add the manager as a member of the AppRole group for general Team Managers (could already be a member of another team)
                var alreadyManager = graphClient.Groups[_appSettings.TeamManagerAppRoleGroupId].Members.Request()
                                .Select(m => new { m.Id })
                                .Filter($"id eq '{users[0].Id}'")
                                .GetAsync().Result;
                if ( alreadyManager.Count == 0 ) {
                    await graphClient.Groups[_appSettings.TeamManagerAppRoleGroupId].Members.References.Request().AddAsync(users[0]);
                }
                ViewData["Message"] = $"A team with name {teamName} was created with {users[0].DisplayName} ({managerEmail}) as manager";
            } catch (Exception ex) {
                ViewData["Message"] = $"Technical error - {ex.Message}";
            }
            return View(returnView);
        }

Team Manager

When the Team Manager signs in to the app, the webapp will show a menu item Team Manager since the user has the app role assignment of Team Manager. In the Team drop down, all teams that the user is manager for will be listed. The Role drop down is just a selection of adding the user as a member or manager. The list of teams the user is manager for can easily be retrieved via making a Graph API query for all owned objects that starts with the name Team_.

[Authorize(Policy = "TeamManager")]
public IActionResult TeamManager() {
    string userObjectId = User.Claims.Where(c => c.Type == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier").Select(c => c.Value).SingleOrDefault();
    var ownedObjects = GraphHelper.GetGraphClient(_appSettings).Users[userObjectId].OwnedObjects.Request().GetAsync().Result;
    Dictionary<string, string> dict = new Dictionary<string, string>();
    foreach (var group in ownedObjects) {
        if ( group is Microsoft.Graph.Group && ((Microsoft.Graph.Group)group).DisplayName.StartsWith("Team_") ) {
            dict.Add(((Microsoft.Graph.Group)group).Id, ((Microsoft.Graph.Group)group).Description);
        }
    }
    ViewBag.Groups = dict;
    return View();
}

The post back for this form does a similar job of adding the user to the team and optionally making the user a manager.

[HttpPost]
[Authorize(Policy = "TeamManager")]
public async Task<IActionResult> AddUserToTeam(string team, string role, string email ) {
    string returnView = "~/Views/Home/TeamManager.cshtml";
    try {
        var graphClient = GraphHelper.GetGraphClient(_appSettings);
        // make sure the group still exists
        var groups = graphClient.Groups.Request()
                                        .Select(m => new { m.Id, m.DisplayName, m.Description })
                                        .Filter($"id eq '{team}'")
                                        .GetAsync().Result;
        if (groups.Count == 0) {
            ViewData["Message"] = "The team does not exist. Please choose another one";
            return View(returnView);
        }
        // get the user (assuming a Local Account)
        var users = graphClient.Users.Request()
                                    .Select(m => new { m.Id, m.DisplayName })
                                    .Filter($"identities/any(c:c/issuerAssignedId eq '{email}' and c/issuer eq '{_appSettings.Domain}')")
                                    .GetAsync().Result;
        if (users.Count == 0) {
            ViewData["Message"] = $"Cannot find user with email {email}";
            return View(returnView);
        }
        // add the user as a member of the group
        await graphClient.Groups[team].Members.References.Request().AddAsync(users[0]);
        // if role is manager, make owner and add to general group
        if ( role.ToLowerInvariant() == "manager" ) {
            await graphClient.Groups[ team ].Owners.References.Request().AddAsync(users[0]);
            // add the manager as a member of the AppRole group for general Team Managers (could already be a member of another team)
            var alreadyManager = graphClient.Groups[_appSettings.TeamManagerAppRoleGroupId].Members.Request()
                            .Select(m => new { m.Id })
                            .Filter($"id eq '{users[0].Id}'")
                            .GetAsync().Result;
            if (alreadyManager.Count == 0) {
                await graphClient.Groups[_appSettings.TeamManagerAppRoleGroupId].Members.References.Request().AddAsync(users[0]);
            }
        }
        ViewData["Message"] = $"User {users[0].DisplayName} ({email}) was added as {role} to team {groups[0].Description}";
    } catch (Exception ex) {
        ViewData["Message"] = $"Technical error - {ex.Message}";
    }
    return View(returnView);
}

Team Member

When the team member logs in, that user will not have any extra functionality in the sample, but there will be a claim in the id- and access_token that the user is member of the team. What your app does then is up to you. The sample app just displays your team membership.

[AllowAnonymous]
public IActionResult Index()
{
    List<string> teams = new List<string>();
    if (User.Identity.IsAuthenticated ) {
        foreach (var group in User.Claims.Where(c => c.Type == "groups")) {
            teams.Add( group.Value );
        }
    }
    ViewBag.Teams = teams;
    return View();
}

Beyond the sample

If you need to build a people picker for users within a team, you would simply enumerate the users in the team’s group. The Team Manager role might be expanded to do various tasks, like create new users in the team, reset passwords for users in the team, etc. All these operations would be carried out with Microsoft Graph API and even though the Team Manager user do not have the permission to do these operations, the client credentials will have it. The trick here is to make sure you only do operations within the team to keep security in order.