Microsoft Identity Manager and the Partner Center API

Microsoft Identity Manager (MIM) helps manages the users, credentials, policies, and access within a given organization. One of the most common use cases for this piece of software is to synchronize identities throughout a partner or customer environment. With this post I would like to focus on how the Synchronization Service component of MIM can be leverage to synchronize user accounts obtained using the Partner Center API to partner’s multi-tenant Active Directory environment. To learn about the capabilities and feature of MIM visit Identity Manager Documentation.

Before getting into the details of how this can be accomplished it is important to understand why it is needed. It is not supported for any partner to redirect a Microsoft online cloud customer to the partner’s multi-tenant identity management infrastructure for federation or synchronization. Doing this would prevent the customer from being able to utilize some of the core features of Azure Active Directory. However, it is possible to utilize MIM to synchronize a customer’s user accounts to the partners infrastructure so that the account can be used for authorization. The partner would need to utilize Azure AD for authentication in order to keep the solution in a supported configuration.

Definitions

  1. Authentication - The verification of the credentials of the connection attempt. This process consists of sending the credentials from an application to appropriate authority, such as Azure AD, for verification.
  2. Authorization – Happens after a successful authentication attempt. It is the process used by the connected systems to determine what level access the authenticating account has been granted.
  3. Connector Space -A storage area where object additions, deletions, and modifications are written before they are synchronized with the metaverse or the connected data source. A portion of the connector space is dedicated to each management agent.
  4. Management Agent – Link various connected systems to Microsoft Identity Manager, and it is responsible for moving data from a connected data source to MIM.
  5. Metaverse - A set of tables in the SQL Server database that contains the combined identity information for a person or resource.

Walkthrough

The complete source code and the additional require configurations inside MIM can be found here. Prior to testing any of the steps below please perform the configurations documented by the readme found on GitHub.

Creating an Extensible Connectivity Management Agent (ECMA) allows us to customize the management agent for a particular data source, and it can be packaged and distributed to other MIM environment. This type of management agent allows us to utilize the Partner Center API as a data source in order to synchronize customers and users into the metaverse. To create a new ECMA perform the following

  1. Open the Synchronization Service Manager
  2. In the Synchronization Service Manager, at the top, select Management Agents
  3. At the top, select Actions, select Create Extension Projects, and choose Extensible Connectivity 2.0 Extension. This will open the Create Extension Project dialog box
  4. In the Create Extension Project dialog box, next to Programming Language: select Visual C#
  5. In the Create Extension Project dialog box, next to Visual Studio Version: select Visual Studio 2012
  6. In the Create Extension Project dialog box, next to Project Name: specify an appropriate name

MS01
Be sure to uncheck Launch in VS.Net IDE if you do not have Visual Studio installed on the same server as the Synchronization Service or if you do not want it to launch when you click Ok. Once you click Ok a Visual Studio project will be generated that contains the definition for a class named EzmaExtension. There are several interfaces that are commented out in the class definition you will need to uncomment the following and implement the missing methods

IMAExtensible2CallImport
IMAExtensible2GetCapabilities
IMAExtensible2GetParameters
IMAExtensible2GetSchema

IMAExtensible2CallImport

The IMAExtensible2CallImport interface will add the following functions and properties to the class

 
int ImportDefaultPageSize { get; }
int ImportMaxPageSize { get; }

CloseImportConnectionResults CloseImportConnection(CloseImportConnectionRunStep importRunStep);
GetImportEntriesResults GetImportEntries(GetImportEntriesRunStep importRunStep);
OpenImportConnectionResults OpenImportConnection(KeyedCollection<string, ConfigParameter> configParameters, Schema types, OpenImportConnectionRunStep importRunStep);

Let us look at how each of these functions and properties are implemented and called by the Synchronization Service. First we will look at the properties and what they control in respect to the import process. When importing resources into MIM it possible to do so in batches. The ImportDefaultPageSize property controls the default page size for records being imported, and the ImportMaxPageSize controls the maximum page size for records being imported. What all of this means is that MIM will only import N number of resources pre-page at a given time. It recommended for performance purposes that paging be leveraged when importing a large number of records. When implemented these properties should like similar to the following

 
/// <summary>
/// Default size of the import page.
/// </summary>
public int ImportDefaultPageSize => 25;

/// <summary>
/// Maximum size of the import page.
/// </summary>
public int ImportMaxPageSize => 50;

The OpenImportConnection function is called at the beginning of either a Delta Import or a Full Import run of a management agent, and it is used to configure the import session. This is the function we use to initialize any class level objects that will be utilized for obtain resources from the connected system. In this scenario the connected system is the Partner Center API so this function is utilized to initialize the enumerators that will be leveraged for obtain a list of customers from the Partner Center Center API. Below is the completed implementation of the function

 
/// <summary>
/// Used to configure the import session and is called once at the beginning of the import.
/// </summary>
/// <param name="configParameters">A collection of <see cref="ConfigParameter"/> objects.</param>
/// <param name="types"<Contains a <see cref="Schema"/> that defines the management agent's schema</param>
/// <param name="importRunStep">Contains an <see cref="OpenImportConnectionRunStep"/> object.</param>
/// <returns></returns>
public OpenImportConnectionResults OpenImportConnection(KeyedCollection<string, ConfigParameter> configParameters,
    Schema types, OpenImportConnectionRunStep importRunStep)
{
    InitializeConnector(configParameters);

    _setupCustomerIdsEnumerator = true;
    _setupUsersEnumerators = true;

    // This dictionary will contain a complete list of all customer identifiers that are processed. These
    // identifiers will be used to obtain the users that belong to the customers that were processed.
    _customersInfo = new Dictionary<string, string>(); 
    // Obtain the collection of customer broken out by page size. This is required because the synchronization 
    // will only import a set size of entries at once. The page size is controlled by the ImportDefaultPageSize
    // and ImportMaxPageSize properties. 
    _customers = _operations.Customers.Query(QueryFactory.Instance.BuildIndexedQuery(ImportDefaultPageSize));
    // Create a customer enumerator which will be used to traverse the pages of customers.
    _customersEnumerator = _operations.Enumerators.Customers.Create(_customers);

    return new OpenImportConnectionResults();
}

The reason enumerators are utilized to retrieve the list of customers is because they are required to leverage paging with the Partner Center SDK. Other than initializing various class levels variables this function obtains the first page of customers and setups the _customersEnumerator which will be utilized to progress to any additional pages.

The GetImportEntries function is called after the OpenImportConnection function and it persists a batch of entries from the connected system into the connector space of the management agent. It can be invoked multiple times, in order to ensure that all pages have been processed. An instance of GetImportEntriesResults is returned from this function and the moreToImport flag controls whether or not the it is invoked again. The following is the complete implementation of this function and the supporting functions

 
/// <summary>
/// Persists a batch of entries in the connected system. Called for multiple entries that are imported. 
/// </summary>
/// <param name="importRunStep">A <see cref="GetImportEntriesRunStep"/> object that contains import information.</param>
/// <returns>
/// An instance of <see cref="GetImportEntriesResults"/> that contains custom data, whether there are more objects
/// to import, and a list of <see cref="GetImportEntriesRunStep"/> objects.
/// </returns>
public GetImportEntriesResults GetImportEntries(GetImportEntriesRunStep importRunStep)
{
    if (_customersEnumerator.HasValue)
    {
        return GetCustomerImportEntries();
    }
    if (_setupCustomerIdsEnumerator)
    {
        _customersInfoEnumerator = _customersInfo.GetEnumerator();
        _customersInfoEnumerator.MoveNext();
        _setupCustomerIdsEnumerator = false;
    }
    if (!_setupUsersEnumerators)
    {
        return GetUserImportEntries();
    }

    ConfigureUserEnumerator();

    return GetUserImportEntries();
}

private CSEntryChange GetCsEntryChange<T>(T item, string dn, string objectType, Dictionary<string, string> extraAttributes = null)
{
    if (item == null)
    {
        throw new ArgumentNullException(nameof(item));
    }
    if (string.IsNullOrEmpty(dn))
    {
        throw new ArgumentNullException(nameof(dn));
    }
    if (string.IsNullOrEmpty(objectType))
    {
        throw new ArgumentNullException(nameof(objectType));
    }

    CSEntryChange csEntryChange = CSEntryChange.Create();

    csEntryChange.DN = dn;
    csEntryChange.ObjectModificationType = ObjectModificationType.Add;
    csEntryChange.ObjectType = objectType;

    csEntryChange.AttributeChanges.Add(
        AttributeChange.CreateAttributeAdd("objectID", dn));

    if (extraAttributes != null)
    {
        foreach (var element in extraAttributes)
        {
            csEntryChange.AttributeChanges.Add(
                AttributeChange.CreateAttributeAdd(element.Key, element.Value));
        }
    }

    AddAttributeChanges(ref csEntryChange, item);

    return csEntryChange;
}

private CSEntryChange GetCsEntryChange<T, V>(T item1, V item2, string dn, string objectType)
{
    if (item1 == null)
    {
        throw new ArgumentNullException(nameof(item1));
    }
    if (item2 == null)
    {
        throw new ArgumentNullException(nameof(item2));
    }
    if (string.IsNullOrEmpty(dn))
    {
        throw new ArgumentNullException(nameof(dn));
    }
    if (string.IsNullOrEmpty(objectType))
    {
        throw new ArgumentNullException(nameof(objectType));
    }

    CSEntryChange csEntryChange = CSEntryChange.Create();

    csEntryChange.DN = dn;
    csEntryChange.ObjectModificationType = ObjectModificationType.Add;
    csEntryChange.ObjectType = objectType;

    csEntryChange.AttributeChanges.Add(
        AttributeChange.CreateAttributeAdd("objectID", dn));

    AddAttributeChanges(ref csEntryChange, item1);
    AddAttributeChanges(ref csEntryChange, item2);

    return csEntryChange;
}

private GetImportEntriesResults GetCustomerImportEntries()
{
    List<CSEntryChange> csentries = new List<CSEntryChange>();
    bool moreToImport;

    // Add the connector space objects for customers obtained using the Partner Center API.
    csentries.AddRange(
        _customersEnumerator.Current.Items.Select(c => GetCsEntryChange(c, c.CompanyProfile, c.Id, "customer")));
    // Add all of the customer identifiers from the current page of records to the collection.
    foreach (Customer c in _customersEnumerator.Current.Items)
    {
        _customersInfo.Add(c.CompanyProfile.CompanyName, c.Id);
    }
    // Move to the next page of customers. 
    _customersEnumerator.Next();

    moreToImport = _setupCustomerIdsEnumerator || _customersEnumerator.HasValue;

    return new GetImportEntriesResults(string.Empty, moreToImport, csentries);
}

private GetImportEntriesResults GetUserImportEntries()
{
    Dictionary<string, string> extraAttributes = new Dictionary<string, string>();
    List<CSEntryChange> csentries = new List<CSEntryChange>();
    bool moreToImport = false;

    extraAttributes.Add("company",
        _customersInfoEnumerator.Current.Key);

    csentries.AddRange(_usersEnumerator.Current.Items.Select(u => GetCsEntryChange(u, u.Id, "user", extraAttributes)));

    _usersEnumerator.Next();

    if (_usersEnumerator.HasValue)
    {
        moreToImport = true;
    }
    else
    {
        if (_customersInfoEnumerator.MoveNext())
        {
            _setupUsersEnumerators = true;
            moreToImport = true;
        }
    }

    return new GetImportEntriesResults(string.Empty, moreToImport, csentries);
}

Since our goal is to import customers and users we need to have a bit of complexity. Upon the first execution of the above code all customers will be imported, after that all users that belong to the customers that were imported will be imported as well. All of this is accomplished using the class level enumerators that were initialized in the OpenImportConnection function. It important to note that reflection is leveraged to extract the public properties that are of the string type from the Customer and CustomerUser object types. This is done to configure the attributes for the corresponding object type. Additional attributes can be configured by adding the attribute name and value to a Dictionary object that is passed to the GetCsEntryChange functions.

The CloseImportConnection is invoked after all resources have been import, and it is used to perform any code cleanup. Also, it is worth noting that this function will only be invoked if the OpenImportConnection function was successfully called. The following is the complete implementation of this function

 
/// <summary>
/// Used by the management agent to allow for code cleanup. 
/// </summary>
/// <param name="importRunStep"></param>
/// <returns></returns>
public CloseImportConnectionResults CloseImportConnection(CloseImportConnectionRunStep importRunStep)
{
    _customersEnumerator = null;
    _customersInfo = null;
    _customers = null;
    _users = null;
    _usersEnumerator = null;

    _customersInfoEnumerator.Dispose();

    return new CloseImportConnectionResults();
}

All class level variables are either disposed or set to null in order to release the allocated resources in an appropriate manner.

IMAExtensible2GetCapabilities

This interface is used to define the capabilities of the management agent. When implemented it will add the following property

 
MACapabilities Capabilities { get; }

The implementation of this property defines what features this management agent is capable of performing. The completed implementation can be found below

 
/// <summary>
/// Gets the capabilities of the management agent.
/// </summary>
/// <value>
/// The capabilities of the management agent.
/// </value>
public MACapabilities Capabilities => new MACapabilities
{
    ConcurrentOperation = true,
    DeleteAddAsReplace = true,
    DeltaImport = true,
    DistinguishedNameStyle = MADistinguishedNameStyle.Generic,
    ExportPasswordInFirstPass = false,
    ExportType = MAExportType.AttributeReplace,
    FullExport = false,
    NoReferenceValuesInFirstExport = true,
    Normalizations = MANormalizations.None,
    ObjectConfirmation = MAObjectConfirmation.Normal,
    ObjectRename = false
};

With this solution we only need the ability to import resources and synchronize them with the connector space and metaverse. Given these needs the instance of MACapabilities return by this property only enables the import and synchronization abilities for the management agent.

 

IMAExtensible2GetParameters

This interface is used to define parameters used by the management agent during executing any of the run profiles. When implemented it will add the following functions

 
IList<ConfigParameterDefinition> GetConfigParameters(KeyedCollection<string, ConfigParameter> configParameters, ConfigParameterPage page);
ParameterValidationResult ValidateConfigParameters(KeyedCollection<string, ConfigParameter> configParameters, ConfigParameterPage page);

The GetConfigParameters controls what parameters most be configured during the creation of the management agent in the Synchronization Service Manager. With this solution we need a parameter for an application identifier, application secret, username, and password. These parameters are used to generate the necessary access tokens to interface with the Partner Center API. The calls we are making with that API require the app + user authentication, and that is why all four values are required. The full implementation for this function is found below

 
/// <summary>
/// Get an array of values indicating the configuration parameter definitions supported by the 
/// management agent. This method is called to display the parameters user interface page for 
/// configuring Connectivity, Global, Partitions, and Run-Step parameters.
/// </summary>
/// <param name="configParameters">A collection of <see cref="ConfigParameter"/> objects.</param>
/// <param name="page">The <see cref="ConfigParameterPage"/> which contains the parameters.</param>
/// <returns>A list of child <see cref="ConfigParameterDefinition"/> objects.</returns>
public IList<ConfigParameterDefinition> GetConfigParameters(
    KeyedCollection<string, ConfigParameter> configParameters, ConfigParameterPage page)
{
    List<ConfigParameterDefinition> parameterDefinitionList = new List<ConfigParameterDefinition>();

    if (page == ConfigParameterPage.Connectivity)
    {
        parameterDefinitionList.Add(
            ConfigParameterDefinition.CreateStringParameter("AppId", string.Empty));
        parameterDefinitionList.Add(
            ConfigParameterDefinition.CreateEncryptedStringParameter("AppSecret", string.Empty));
        parameterDefinitionList.Add(
            ConfigParameterDefinition.CreateStringParameter("Username", string.Empty));
        parameterDefinitionList.Add(
            ConfigParameterDefinition.CreateEncryptedStringParameter("Password", string.Empty));
    }

    return parameterDefinitionList;
}

Above you will notice we created a list of ConfigParameterDefinition objects, and in that list we include all parameters. The parameters can be several types, however, in our case we simply need two string and and two encrypted strings. Encrypted strings are used to protect the application secret and the password because both of those values are considered to be privileged. This particular code is invoked when the configuring the management agent. The figure below shows what this code actually produces

MS02

All parameters defined by the GetConfigParameters are validated by the ValidateConfigParameters function. With this function you can perform any necessary validation to ensure appropriate values were specified for the parameters in question. This was implemented by using the following code

 
/// <summary>
/// Ensure the configuration parameters are valid.
/// </summary>
/// <param name="configParameters">Contains a collection of <see cref="ConfigParameter"/> objects.</param>
/// <param name="page">The <see cref="ConfigParameterPage"/> which contains the parameters</param>
/// <returns>An aptly populated instance of <see cref="ParameterValidationResult"/>.</returns>
public ParameterValidationResult ValidateConfigParameters(KeyedCollection<string, ConfigParameter> configParameters, ConfigParameterPage page)
{
    InitializeConnector(configParameters);

    return new ParameterValidationResult();
}

private void InitializeConnector(KeyedCollection<string, ConfigParameter> configParameters)
{
    SecureString appSecret = GetEncryptedParameterValue(configParameters, "AppSecret");
    SecureString password = GetEncryptedParameterValue(configParameters, "Password");
    string appId = GetParameterValue(configParameters, "AppId");
    string username = GetParameterValue(configParameters, "Username");

    if (_operations == null)
    {
        _operations = new PartnerCenterContext(
            appId, appSecret, username, password).GetOperations();
    }
}

internal static string GetParameterValue(KeyedCollection<string, ConfigParameter> configParameters,
    string keyName)
{
    if (configParameters.Contains(keyName) && !configParameters[keyName].IsEncrypted)
    {
        return configParameters[keyName].Value;
    }

    throw new ExtensibleExtensionException($"Expected parameter was not found: {keyName}");
}

internal static SecureString GetEncryptedParameterValue(KeyedCollection<string, ConfigParameter> configParameters,
    string keyName)
{
    if (configParameters.Contains(keyName) && configParameters[keyName].IsEncrypted)
    {
        return configParameters[keyName].SecureValue;
    }

    throw new ExtensibleExtensionException($"Expected encrypted parameter was not found: {keyName}");
}

In order to validate the parameters for this solution an access token for the Partner Center API is obtained. This is done when the InitializeConnector function is invoked. It important to note that there are two supporting functions for retrieving the configuration parameters. The first will return a string literal and the second will return an instance of SecureString.

 

IMAExtensible2GetSchema

When this interface is implemented it adds a function used to obtain the schema for the connected system. The function that is added can be found below

 
Schema GetSchema(KeyedCollection<string, ConfigParameter> configParameters);

When implemented the GetSchema function looks like the following

 
/// <summary>
/// Gets the schema for the connected system (Partner Center).
/// </summary&amp;gt;
/// <param name="configParameters">The configuration parameters.</param>
/// <returns></returns>
public Schema GetSchema(KeyedCollection<string, ConfigParameter> configParameters)
{
    Schema schema = Schema.Create();

    schema.Types.Add(GetCustomerType());
    schema.Types.Add(GetPersonType());

    return schema;
}

private static SchemaType GetCustomerType()
{
    SchemaType customerType = SchemaType.Create("customer", false);

    customerType.Attributes.Add(SchemaAttribute.CreateAnchorAttribute("objectID", AttributeType.String));
    customerType.Attributes.Add(SchemaAttribute.CreateSingleValuedAttribute("companyName", AttributeType.String));
    customerType.Attributes.Add(SchemaAttribute.CreateSingleValuedAttribute("domain", AttributeType.String));
    customerType.Attributes.Add(SchemaAttribute.CreateSingleValuedAttribute("tenantId", AttributeType.String));

    return customerType;
}

private static SchemaType GetPersonType()
{
    SchemaType personType = SchemaType.Create("user", false);

    personType.Attributes.Add(SchemaAttribute.CreateAnchorAttribute("objectID", AttributeType.String));
    personType.Attributes.Add(SchemaAttribute.CreateSingleValuedAttribute("company", AttributeType.String));
    personType.Attributes.Add(SchemaAttribute.CreateSingleValuedAttribute("displayName", AttributeType.String));
    personType.Attributes.Add(SchemaAttribute.CreateSingleValuedAttribute("firstName", AttributeType.String));
    personType.Attributes.Add(SchemaAttribute.CreateSingleValuedAttribute("lastName", AttributeType.String));
    personType.Attributes.Add(SchemaAttribute.CreateSingleValuedAttribute("usageLocation", AttributeType.String));
    personType.Attributes.Add(SchemaAttribute.CreateSingleValuedAttribute("userPrincipalName", AttributeType.String));

    return personType;
}

As you can see the GetSchema function defines to objects, one for customers and the other for users. All of the attributes that we will be synchronizing from the Partner Center API must be defined within the schema. So it is important to ensure that all of the appropriate values have been included, otherwise you will be unable to synchronize the data.

 

Final Thoughts

One of the primary use cases for this management agent is for service providers looking to synchronize information from their customer’s Azure AD tenant back to their multi-tenant infrastructure. There are numerous reason why a partner would like to do this, however, they need to ensure they are doing so in a supported manner. With this approach the customer’s Azure AD tenant is not being connected to the partners infrastructure, which allows the customer to take advantage of all the capabilities and features that Microsoft provides (e.g. synchronizing from the customer’s AD to AAD using AAD Connect). This solution will only provide a mechanism where authorization can be provided which means in order to take full advantage of it the dependent application must be able authenticate using SAML.

In a future post I will show how to configure Active Directory Federation Services to obtain the necessary token, for authentication, from a customers instance of Azure Active Directory.