Thursday, November 22, 2018

Create Azure AD groups with owners and members using single API call with Graph API client library

In one of my previous posts I showed example how to create Azure AD groups with owners which were added right after group has been created: Create Azure AD group and set group owner using Microsoft Graph Client library. This approach works but on some tenants it may cause slowness and performance problems during group’s creation. You may have the following error when use this approach:

"code": "ResourceNotFound"
"message": "Resource provisioning is in progress. Please try again."

This issue is also reported on github: After office 365 group is created, the group site provisioning is pending. Also if you will try to create group using PnP PowerShell or OfficeDevPnP library you may face with the same issue. PnP uses UnifiedGroupsUtility.CreateUnifiedGroup method to create groups. Let’s check it’s code:

public static UnifiedGroupEntity CreateUnifiedGroup(string displayName, string description, string mailNickname,
 string accessToken, string[] owners = null, string[] members = null, Stream groupLogo = null,
 bool isPrivate = false, int retryCount = 10, int delay = 500)
{
 UnifiedGroupEntity result = null;

 if (String.IsNullOrEmpty(displayName))
 {
  throw new ArgumentNullException(nameof(displayName));
 }

 if (String.IsNullOrEmpty(description))
 {
  throw new ArgumentNullException(nameof(description));
 }

 if (String.IsNullOrEmpty(mailNickname))
 {
  throw new ArgumentNullException(nameof(mailNickname));
 }

 if (String.IsNullOrEmpty(accessToken))
 {
  throw new ArgumentNullException(nameof(accessToken));
 }

 try
 {
  // Use a synchronous model to invoke the asynchronous process
  result = Task.Run(async () =>
  {
   var group = new UnifiedGroupEntity();

   var graphClient = CreateGraphClient(accessToken, retryCount, delay);

   // Prepare the group resource object
   var newGroup = new Microsoft.Graph.Group
   {
    DisplayName = displayName,
    Description = description,
    MailNickname = mailNickname,
    MailEnabled = true,
    SecurityEnabled = false,
    Visibility = isPrivate == true ? "Private" : "Public",
    GroupTypes = new List<string> { "Unified" },
   };

   Microsoft.Graph.Group addedGroup = null;
   String modernSiteUrl = null;

   // Add the group to the collection of groups (if it does not exist
   if (addedGroup == null)
   {
    addedGroup = await graphClient.Groups.Request().AddAsync(newGroup);

    if (addedGroup != null)
    {
     group.DisplayName = addedGroup.DisplayName;
     group.Description = addedGroup.Description;
     group.GroupId = addedGroup.Id;
     group.Mail = addedGroup.Mail;
     group.MailNickname = addedGroup.MailNickname;

     int imageRetryCount = retryCount;

     if (groupLogo != null)
     {
      using (var memGroupLogo = new MemoryStream())
      {
       groupLogo.CopyTo(memGroupLogo);

       while (imageRetryCount > 0)
       {
        bool groupLogoUpdated = false;
        memGroupLogo.Position = 0;

        using (var tempGroupLogo = new MemoryStream())
        {
         memGroupLogo.CopyTo(tempGroupLogo);
         tempGroupLogo.Position = 0;

         try
         {
          groupLogoUpdated = UpdateUnifiedGroup(addedGroup.Id, accessToken, groupLogo: tempGroupLogo);
         }
         catch
         {
          // Skip any exception and simply retry
         }
        }

        // In case of failure retry up to 10 times, with 500ms delay in between
        if (!groupLogoUpdated)
        {
         // Pop up the delay for the group image
         await Task.Delay(delay * (retryCount - imageRetryCount));
         imageRetryCount--;
        }
        else
        {
         break;
        }
       }
      }
     }

     int driveRetryCount = retryCount;

     while (driveRetryCount > 0 && String.IsNullOrEmpty(modernSiteUrl))
     {
      try
      {
       modernSiteUrl = GetUnifiedGroupSiteUrl(addedGroup.Id, accessToken);
      }
      catch
      {
       // Skip any exception and simply retry
      }

      // In case of failure retry up to 10 times, with 500ms delay in between
      if (String.IsNullOrEmpty(modernSiteUrl))
      {
       await Task.Delay(delay * (retryCount - driveRetryCount));
       driveRetryCount--;
      }
     }

     group.SiteUrl = modernSiteUrl;
    }
   }

   #region Handle group's owners

   if (owners != null && owners.Length > 0)
   {
    await UpdateOwners(owners, graphClient, addedGroup);
   }

   #endregion

   #region Handle group's members

   if (members != null && members.Length > 0)
   {
    await UpdateMembers(members, graphClient, addedGroup);
   }

   #endregion

   return (group);

  }).GetAwaiter().GetResult();
 }
 catch (ServiceException ex)
 {
  Log.Error(Constants.LOGGING_SOURCE, CoreResources.GraphExtensions_ErrorOccured, ex.Error.Message);
  throw;
 }
 return (result);
}

As you can see it basically uses the same approach: at first creates group and then adds owners/members using UpdateOwners/UpdateMembers methods.

Workaround for this problem is to not use Graph API client library and use plain REST calls and special OData bind syntax for owners and members like described here: Create a Group in Microsoft Graph API with a Owner

{
...
  "owners@odata.bind": [
    "https://graph.microsoft.com/v1.0/users/{id1}"
  ],
  "members@odata.bind": [
    "https://graph.microsoft.com/v1.0/users/{id1}",
    "https://graph.microsoft.com/v1.0/users/{id2}"
  ]
}

This approach works i.e. groups are created with owners and members from beginning and you don’t have to call another methods to add them separately. But is it possible to do the same with Graph API .Net client library (it would be good because it is more convenient to use client library than raw REST calls). The answer is yes it is possible and below it is shown how to do it.

Need to say that if you use only Graph API .Net client library classes it is not possible to do it. If you check property Group.Owners you will see that it has IGroupOwnersCollectionWithReferencesPage type:

image

In Graph API library there is only one class which implements this interface GroupOwnersCollectionWithReferencesPage and you can’t create instance of this class with owners specified and pass to Group.Owners property – it has to be used with Groups[].Request.Owners.References when you read group owners with pagination. So my first attempt was to create custom class which inherits IGroupOwnersCollectionWithReferencesPage interface which would allow list of user in constructor and then pass it’s instance to Group.Owners property before creation:

public class LightOwners : CollectionPage<DirectoryObject>, IGroupOwnersCollectionWithReferencesPage
{
    public LightOwners()
    {
    }

    public LightOwners(List<User> owners)
    {
        if (owners != null)
        {
            owners.ForEach(o => this.Add(o));
        }
    }

    public void InitializeNextPageRequest(IBaseClient client, string nextPageLinkString)
    {
    }

    public IGroupOwnersCollectionWithReferencesRequest NextPageRequest { get; }
}

This approach didn’t work: group object was serialized to JSON when client library made POST request to https://graph.microsoft.com/v1.0/groups for creating the group with property “owners” and all users’ properties were serialized as well – while we need "owners@odata.bind" and "https://graph.microsoft.com/v1.0/users/{id1}" string instead of fully serialized user object.

After that I tried another approach which worked: at first created new class GroupExtended which inherits Group class from Graph API library:

public class GroupExtended : Group
{
 [JsonProperty("owners@odata.bind", NullValueHandling = NullValueHandling.Ignore)]
 public string[] OwnersODataBind { get; set; }
 [JsonProperty("members@odata.bind", NullValueHandling = NullValueHandling.Ignore)]
 public string[] MembersODataBind { get; set; }
}

As you can see it adds 2 new properties OwnersODataBind and MembersODataBind which are serialized to "owners@odata.bind" and "members@odata.bind" respectively. Then I modified UnifiedGroupsUtility.CreateUnifiedGroup method to create groups with owners and members from beginning using single API call instead of adding them after group was created:

public static UnifiedGroupEntity CreateUnifiedGroup(string displayName, string description, string mailNickname,
 string accessToken, string[] owners = null, string[] members = null, Stream groupLogo = null,
 bool isPrivate = false, int retryCount = 10, int delay = 500)
{
 UnifiedGroupEntity result = null;

 if (String.IsNullOrEmpty(displayName))
 {
  throw new ArgumentNullException(nameof(displayName));
 }

 if (String.IsNullOrEmpty(description))
 {
  throw new ArgumentNullException(nameof(description));
 }

 if (String.IsNullOrEmpty(mailNickname))
 {
  throw new ArgumentNullException(nameof(mailNickname));
 }

 if (String.IsNullOrEmpty(accessToken))
 {
  throw new ArgumentNullException(nameof(accessToken));
 }

 try
 {
  // Use a synchronous model to invoke the asynchronous process
  result = Task.Run(async () =>
  {
   var group = new UnifiedGroupEntity();

   var graphClient = CreateGraphClient(accessToken, retryCount, delay);

   // Prepare the group resource object
   var newGroup = new GroupExtended
   {
    DisplayName = displayName,
    Description = description,
    MailNickname = mailNickname,
    MailEnabled = true,
    SecurityEnabled = false,
    Visibility = isPrivate == true ? "Private" : "Public",
    GroupTypes = new List<string> { "Unified" }
   };

   if (owners != null && owners.Length > 0)
   {
    var users = GetUsers(graphClient, owners);
    if (users != null)
    {
     newGroup.OwnersODataBind = users.Select(u => string.Format("https://graph.microsoft.com/v1.0/users/{0}", u.Id)).ToArray();
    }
   }

   if (members != null && members.Length > 0)
   {
    var users = GetUsers(graphClient, members);
    if (users != null)
    {
     newGroup.MembersODataBind = users.Select(u => string.Format("https://graph.microsoft.com/v1.0/users/{0}", u.Id)).ToArray();
    }
   }

   Microsoft.Graph.Group addedGroup = null;
   String modernSiteUrl = null;

   // Add the group to the collection of groups (if it does not exist
   if (addedGroup == null)
   {
    addedGroup = await graphClient.Groups.Request().AddAsync(newGroup);

    if (addedGroup != null)
    {
     group.DisplayName = addedGroup.DisplayName;
     group.Description = addedGroup.Description;
     group.GroupId = addedGroup.Id;
     group.Mail = addedGroup.Mail;
     group.MailNickname = addedGroup.MailNickname;

     int imageRetryCount = retryCount;

     if (groupLogo != null)
     {
      using (var memGroupLogo = new MemoryStream())
      {
       groupLogo.CopyTo(memGroupLogo);

       while (imageRetryCount > 0)
       {
        bool groupLogoUpdated = false;
        memGroupLogo.Position = 0;

        using (var tempGroupLogo = new MemoryStream())
        {
         memGroupLogo.CopyTo(tempGroupLogo);
         tempGroupLogo.Position = 0;

         try
         {
          groupLogoUpdated = UnifiedGroupsUtility.UpdateUnifiedGroup(addedGroup.Id, accessToken, groupLogo: tempGroupLogo);
         }
         catch
         {
          // Skip any exception and simply retry
         }
        }

        // In case of failure retry up to 10 times, with 500ms delay in between
        if (!groupLogoUpdated)
        {
         // Pop up the delay for the group image
         await Task.Delay(delay * (retryCount - imageRetryCount));
         imageRetryCount--;
        }
        else
        {
         break;
        }
       }
      }
     }

     int driveRetryCount = retryCount;

     while (driveRetryCount > 0 && String.IsNullOrEmpty(modernSiteUrl))
     {
      try
      {
       modernSiteUrl = UnifiedGroupsUtility.GetUnifiedGroupSiteUrl(addedGroup.Id, accessToken);
      }
      catch
      {
       // Skip any exception and simply retry
      }

      // In case of failure retry up to 10 times, with 500ms delay in between
      if (String.IsNullOrEmpty(modernSiteUrl))
      {
       await Task.Delay(delay * (retryCount - driveRetryCount));
       driveRetryCount--;
      }
     }

     group.SiteUrl = modernSiteUrl;
    }
   }

//                    #region Handle group's owners
//
//                    if (owners != null && owners.Length > 0)
//                    {
//                        await UpdateOwners(owners, graphClient, addedGroup);
//                    }
//
//                    #endregion

//                    #region Handle group's members
//
//                    if (members != null && members.Length > 0)
//                    {
//                        await UpdateMembers(members, graphClient, addedGroup);
//                    }
//
//                    #endregion

   return (group);

  }).GetAwaiter().GetResult();
 }
 catch (ServiceException ex)
 {
  //Log.Error(Constants.LOGGING_SOURCE, CoreResources.GraphExtensions_ErrorOccured, ex.Error.Message);
  throw;
 }
 return (result);
}

private static List<User> GetUsers(GraphServiceClient graphClient, string[] owners)
{
 if (owners == null)
 {
  return new List<User>();
 }
 var result = Task.Run(async () =>
 {
  var usersResult = new List<User>();
  var users = await graphClient.Users.Request().GetAsync();
  while (users.Count > 0)
  {
   foreach (var u in users)
   {
    if (owners.Any(o => u.UserPrincipalName.ToLower().Contains(o.ToLower())))
    {
     usersResult.Add(u);
    }
   }

   if (users.NextPageRequest != null)
   {
    users = await users.NextPageRequest.GetAsync();
   }
   else
   {
    break;
   }
  }

  return usersResult;
 }).GetAwaiter().GetResult();
 return result;
}

private static GraphServiceClient CreateGraphClient(String accessToken, int retryCount = 10, int delay = 500)
{
 // Creates a new GraphServiceClient instance using a custom PnPHttpProvider
 // which natively supports retry logic for throttled requests
 // Default are 10 retries with a base delay of 500ms
 var result = new GraphServiceClient(new DelegateAuthenticationProvider(
  async (requestMessage) =>
  {
   if (!String.IsNullOrEmpty(accessToken))
   {
    // Configure the HTTP bearer Authorization Header
    requestMessage.Headers.Authorization = new AuthenticationHeaderValue("bearer", accessToken);
   }
  }), new PnPHttpProvider(retryCount, delay));

 return (result);
}

And after that groups were created successfully with owners and members. At first code resolves specified users by their emails and then fills OwnersODataBind and MembersODataBind properties with strings like "https://graph.microsoft.com/v1.0/users/{id1}" (we need to resolve uses from Azure AD first in order to get their ids to build these strings). After that it creates group with single call and it contains specified owners and members. So this approach allows to create groups with owners and members set from beginning.

3 comments:

  1. Hello,

    GroupExtended approach originally implemented in PNP lib )

    ReplyDelete
    Replies
    1. ToPa3b, it was added to PnP Sites Core after I proposed it there: https://github.com/pnp/PnP-Sites-Core/issues/1987

      Delete