How to add licensed users to VS Team Services via the API

Buck

NOTE (Sept. 16, 2016): I need to update the solution file to work with VS 2015 and test the code again.

A while back someone asked a question about how to use the API to add licensed users to a VSTS account and change licenses for existing users (instead of the web UI to invite and assign licenses to users). Abhijeet, a dev on my team, gave me some code to get me started, as the identity APIs are not easy to figure out.

Licensing is different on VSTS than it is with Team Foundation Server. With VSTS, every user who is added to an account must have a license assigned. We need to be able to add a new user and also to change the license for an existing user. Let’s take a look at how the code to do this works.

Shared Platform Services

The first thing to notice is that the code is operating on the account that is stored in what we call Shared Platform Services (SPS). SPS is a core set of services, such as identity, account, and profile, that are used by every service in VSTS. The notion of a collection, which is a concept that was introduced in TFS 2010, doesn’t exist in SPS. That’s because not every service in VSTS has to have the notion of a collection. Collection is a TFS concept that is used by version control, work item tracking, etc.

Since we need to make a change to the account, we are going to be talking to SPS. Rather than making calls to https://MyAccount.visualstudio.com as you are used to seeing, we are going to be calling https://MyAccount.vssps.visualstudio.com. The “vssps” part of the URL takes us to SPS, and the account name in the URL gives SPS the context.

You can see SPS in some scenarios if you watch the address bar in your browser. For example, go to http://visualstudio.com and sign in. Then click on your name to go to your profile page. You will see that the URL is https://app.vssps.visualstudio.com/profile/view?mkt=en-us (or you can directly click that URL). In that context, you are asking SPS for your profile and the list of accounts that you have across the system. SPS is responsible for that information.

Once we’ve connected to SPS using the same credentials that you normally use as administrator of your account, we get the client representations of the identity and account services in SPS that we’ll use. The next thing we do is to determine if the user is new or an existing user.

Adding a New User with a License

New users have to be added to the system. We call that process “bind pending.” What we mean by that is that we will add a reference to a user – nothing but the email address. That email address is not bound to anything. Once a user logs into VSTS with an email address that matches a bind-pending entry, we’ll create a full Identity object that references the unique identifier of that user. We’ll also have enter basic info for a profile (e.g., display name) and link that to the identity.

The Identity API calls for this operation are not at all obvious, and this is something we need to improve. I’ve added a lot of comments to the code to help explain what’s going on. At a high level, we first have to construct a descriptor for this bind pending identity. Each account in VSTS is either an MSA-backed account (i.e., it only uses MSAs, also known as LiveIDs) or AAD-backed (i.e., all identities reside in an Azure Active Directory tenant). We make that determination using the properties of the administrative user. Then we first have to add a new user to a group in order for the user to be part of the system – for there to be a reference to the identity. Since every user must have a license, we add the user to the licensed users group.

Changing the License for an Existing User

This case is much simpler. If the user is already in the account, the code just makes a call to set the license with licensingClient.AssignEntitlementAsync.

Notes on Licensing

The names for the licenses match what you’ll find in our documentation for pricing levels except AccountLicense.Express. That’s the name that the code uses for Basic (at one time it was going to be called Express, and the code didn’t get updated). If you look at the AccountLicense enum, you’ll also find AccountLicense.EarlyAdopter. That was only valid until VSTS became GA, so it can no longer be used.

The MSDN benefits license is different than the other licenses because it is dependent upon a user’s MSDN license level. While you could explicitly set the license to a particular MSDN benefits level, you’d only cause yourself problems. Setting it to MsdnLicense.Eligible as the code does below means that the service will handle setting the user’s MSDN benefits level properly upon logging in.

The licensing API right now uses an enum rather than a string for the licenses. The result is that AccountLicense.Stakeholder doesn’t exist in the client API prior to Update 4. You’ll see in the code that I commented out Stakeholder so that it builds with Update 3, which is the latest RTM update at the time this post is being written. In the future the API will allow for a string so that as new license types are added the API will still be able to use them.

There are limits for licenses based on what you have purchased. For example, if you try to add a sixth user licensed for Basic, you will get an error message. Here’s how to add licenses to your VSTS account.

One other thing that I’ll mention is that you may have to search around for the credential dialog prompt. That dialog ends up parented to the desktop, so it’s easy for it to get hidden by another window.

I’ve attached the VS solution to this blog post, in addition to including the code in the post. I recommend downloading the solution, which will build with VS 2013 Update 3 or newer (it may work with earlier versions of 2013, but I didn’t try it).

Enjoy!

Follow me on Twitter at twitter.com/tfsbuck

using System;
using System.Linq;
using Microsoft.TeamFoundation;
using Microsoft.TeamFoundation.Framework.Common;
using Microsoft.VisualStudio.Services.Client;
using Microsoft.VisualStudio.Services.Identity;
using Microsoft.VisualStudio.Services.Identity.Client;
using Microsoft.VisualStudio.Services.Licensing;
using Microsoft.VisualStudio.Services.Licensing.Client;

namespace AddUserToAccount
{
    public class Program
    {
        public static void Main(string[] args)
        {
            if (args.Length == 0)
            {
                // For ease of running from the debugger, hard-code the account and the email address if not supplied
                // The account name here is just the name, not the URL.
                //args = new[] { "Awesome", "example@outlook.com", "basic" };
            }

            if (!Init(args))
            {
                Console.WriteLine("Add a licensed user to a Visual Studio Account");
                Console.WriteLine("Usage: [accountName] [userEmailAddress] <license>");
                Console.WriteLine("  accountName - just the name of the account, not the URL");
                Console.WriteLine("  userEmailAddress - email address of the user to be added");
                Console.WriteLine("  license - optional license (default is Basic): Basic, Professional, or Advanced");
                return;
            }

            AddUserToAccount();
        }

        private static void AddUserToAccount()
        {
            try
            {
                // Create a connection to the specified account.
                // If you change the false to true, your credentials will be saved.
                var creds = new VssClientCredentials(false);
                var vssConnection = new VssConnection(new Uri(VssAccountUrl), creds);

                // We need the clients for two services: Licensing and Identity
                var licensingClient = vssConnection.GetClient<LicensingHttpClient>();
                var identityClient = vssConnection.GetClient<IdentityHttpClient>();

                // The first call is to see if the user already exists in the account.
                // Since this is the first call to the service, this will trigger the sign-in window to pop up.
                Console.WriteLine("Sign in as the admin of account {0}. You will see a sign-in window on the desktop.",
                                  VssAccountName);
                var userIdentity = identityClient.ReadIdentitiesAsync(IdentitySearchFilter.AccountName, 
                                                                      VssUserToAddMailAddress).Result.FirstOrDefault();

                // If the identity is null, this is a user that has not yet been added to the account.
                // We'll need to add the user as a "bind pending" - meaning that the email address of the identity is 
                // recorded so that the user can log into the account, but the rest of the details of the identity 
                // won't be filled in until first login.
                if (userIdentity == null)
                {
                    Console.WriteLine("Creating a new identity and adding it to the collection's licensed users group.");

                    // We are adding the user to a collection, and at the moment only one collection is supported per
                    // account in VSTS.
                    var collectionScope = identityClient.GetScopeAsync(VSSAccountName).Result;

                    // First get the descriptor for the licensed users group, which is a well known (built in) group.
                    var licensedUsersGroupDescriptor = new IdentityDescriptor(IdentityConstants.TeamFoundationType, 
                                                                              GroupWellKnownSidConstants.LicensedUsersGroupSid);

                    // Now convert that into the licensed users group descriptor into a collection scope identifier.
                    var identifier = String.Concat(SidIdentityHelper.GetDomainSid(collectionScope.Id),
                                                   SidIdentityHelper.WellKnownSidType,
                                                   licensedUsersGroupDescriptor.Identifier.Substring(SidIdentityHelper.WellKnownSidPrefix.Length));

                    // Here we take the string representation and create the strongly-type descriptor
                    var collectionLicensedUsersGroupDescriptor = new IdentityDescriptor(IdentityConstants.TeamFoundationType, 
                                                                                        identifier);


                    // Get the domain from the user that runs this code. This domain will then be used to construct
                    // the bind-pending identity. The domain is either going to be "Windows Live ID" or the Azure 
                    // Active Directory (AAD) unique identifier, depending on whether the account is connected to
                    // an AAD tenant. Then we'll format this as a UPN string.
                    var currUserIdentity = vssConnection.AuthorizedIdentity.Descriptor;
                    var directory = "Windows Live ID"; // default to an MSA (fka Live ID)
                    if (currUserIdentity.Identifier.Contains('\\'))
                    {
                        // The identifier is domain\userEmailAddress, which is used by AAD-backed accounts.
                        // We'll extract the domain from the admin user.
                        directory = currUserIdentity.Identifier.Split(new char[] { '\\' })[0];
                    }
                    var upnIdentity = string.Format("upn:{0}\\{1}", directory, VssUserToAddMailAddress);

                    // Next we'll create the identity descriptor for a new "bind pending" user identity.
                    var newUserDesciptor = new IdentityDescriptor(IdentityConstants.BindPendingIdentityType, 
                                                                  upnIdentity);

                    // We are ready to actually create the "bind pending" identity entry. First we have to add the
                    // identity to the collection's licensed users group. Then we'll retrieve the Identity object
                    // for this newly-added user. Without being added to the licensed users group, the identity 
                    // can't exist in the account.
                    bool result = identityClient.AddMemberToGroupAsync(collectionLicensedUsersGroupDescriptor, 
                                                                       newUserDesciptor).Result;
                    userIdentity = identityClient.ReadIdentitiesAsync(IdentitySearchFilter.AccountName, 
                                                                      VssUserToAddMailAddress).Result.FirstOrDefault();
                }

                Console.WriteLine("Assigning license to user.");
                var entitlement = licensingClient.AssignEntitlementAsync(userIdentity.Id, VssLicense).Result;

                Console.WriteLine("Success!");
            }
            catch (Exception e)
            {
                Console.WriteLine("\r\nSomething went wrong...");
                Console.WriteLine(e.Message);
                if (e.InnerException != null)
                {
                    Console.WriteLine(e.InnerException.Message);
                }
            }
        }

        private static bool Init(string[] args)
        {
            if (args == null || args.Length < 2)
            {
                return false;
            }

            if (string.IsNullOrWhiteSpace(args[0]))
            {
                Console.WriteLine("Error: Invalid accountName");
                return false;
            }

            VssAccountName = args[0];

            // We need to talk to SPS in order to add a user and assign a license.
            VssAccountUrl = "https://" + VssAccountName + ".vssps.visualstudio.com/";

            if (string.IsNullOrWhiteSpace(args[1]))
            {
                Console.WriteLine("Error: Invalid userEmailAddress");
                return false;
            }

            VssUserToAddMailAddress = args[1];

            VssLicense = AccountLicense.Express; // default to Basic license
            if (args.Length == 3)
            {
                string license = args[2].ToLowerInvariant();
                switch (license)
                {
                    case "basic":
                        VssLicense = AccountLicense.Express;
                        break;
                    case "professional":
                        VssLicense = AccountLicense.Professional;
                        break;
                    case "advanced":
                        VssLicense = AccountLicense.Advanced;
                        break;
                    case "msdn":
                        // When the user logs in, the system will determine the actual MSDN benefits for the user.
                        VssLicense = MsdnLicense.Eligible;
                        break;
                    // Uncomment the code for Stakeholder if you are using VS 2013 Update 4 or newer.
                    //case "Stakeholder":
                    //    VssLicense = AccountLicense.Stakeholder;
                    //    break;
                    default:
                        Console.WriteLine("Error: License must be Basic, Professional, Advanced, or MSDN");
                        //Console.WriteLine("Error: License must be Stakeholder, Basic, Professional, Advanced, or MSDN");
                        return false;
                }
            }

            return true;
        }

        public static string VssAccountUrl { get; set; }
        public static string VssAccountName { get; set; }
        public static string VssUserToAddMailAddress { get; set; }
        public static License VssLicense { get; set; }
    }
}

AddUserApp.zip

0 comments

Leave a comment