Create a custom tenants provider

1. Overview

This sample shows how to create a custom tenants provider. A custom tenants provider allows for creating, and managing of multiple tenants from outside the application database. In this sample you will learn the following:

  • What is multi-tenancy
  • What is a custom tenants provider
  • How to create an extension
  • How to create a tenant provider

2. About multi-tenancy

There is support built-in for multi-tenant deployment scenarios, allowing you to easily create and manage tenants that are isolated from each other, add accounts for specific tenants, and customize the license seats for each tenant individually.

For more information on multi-tenancy, see the article Multi-Tenancy.

3. What is a custom tenants provider

The custom tenant provider will be responsible for the following:

  • Creating, managing, and storing of tenants.
  • Handling of data connector overrides.
  • Storing the user group information for each tenant. Each tenant has an administrators group and a members group.
  • Storing the license seat information for each tenant.

Note
In the following example, a simple CSV file is used as the backend for the custom tenant provider for demonstration purposes.

4. Getting started

The current version of the sample solution targets both .NET Framework and .NET Core so that the packaged extension will work for version 7 of the application and higher on all platforms.

The following prerequisites must be installed on your computer to build the provided sample without modifications:

  • Visual Studio 2017 or higher
  • Microsoft .NET Framework 4.7.2
  • Microsoft .NET Core 3.1

You can modify the sample project to target different versions of .NET if preferred depending on the version used by your application instance.

The NuGet packages referenced as dependencies are initially set to version 7. If your version of the application is newer and the APIs you are using may have changed, you can update the version of this package reference.

4.1. Downloading sample solution

To download the custom tenants provider sample solution, click here.

This sample was updated on April 8, 2022 to add .NET Core support.

(A sample solution is also available for version 6.0, and 5.0 and older.)

4.2. Opening solution

Extract CustomTenantsProviderSample.zip to a folder and open the solution in Microsoft Visual Studio to build the extension.

To use the option to publish directly to your application instance, run Visual Studio as an administrator before opening the solution. For example, you can right-click Visual Studio in the start menu and find the Run as administrator option.

The solution file is located at:
[Extracted folder]\CustomTenantsProviderSample\CustomTenantsProviderSample.sln

5. The project

The project is a class library.

  • TenantInformation.csv - Comma separated values containing tenant information. This is used as the back end of the tenants provider for demonstration purposes.
  • CustomTenantsProvider.cs - This class contains the implementation of the custom tenant provider.
  • CustomTenantsProviderSamplePackageInfo.cs - This class contains the package information about the extension package.
  • PublishExtensionTemplate.props - Used for auto publishing the extension after the build succeeds, and defines extension properties, and files.

5.1. ExtensionPackageInfo class

In order for a tenants provider to be recognized, it needs to contain a class that extends the ExtensionPackageInfo2 class. This class contains the extension package information.

In this sample, the OnLoaded method is also overridden to copy the CSV file resource used for demonstration purposes to the application data folder where it can be modified. Tenant data should not be stored in text files.

/// <summary>
/// This class contains the package information about the extension package.
/// </summary>
public class CustomTenantsProviderSamplePackageInfo : ExtensionPackageInfo2
{
    /// <summary>
    /// Initializes a new instance of the <see cref="CustomTenantsProviderSamplePackageInfo"/> class.
    /// </summary>
    /// <param name="extensionManifest">The extension manifest.</param>
    public CustomTenantsProviderSamplePackageInfo(ExtensionManifest extensionManifest)
        : base(extensionManifest)
    {
    }

    /// <summary>
    /// Called after the extension package is loaded during engine startup.
    /// </summary>
    public override void OnLoaded()
    {
        ...
    }
}

5.2. Publish extension template

This sample is set up to use the Dundas.BI.PublishExtension NuGet package to automatically publish the extension when the project is built. When this package is added to the project, it creates a PublishExtensionTemplate.props file containing MSBuild property and item groups, which define how to create and publish the extension.

When the DtFilePath property is set to the file path of the dt tool of an application instance, it will then publish the extension directly to that instance when you build the solution. It will also touch the web.config file to force the web application to reset.

If the DtFilePath property is not set, it will create a .zip file you can add to your application instance using the Extensions screen in the administration UI. After building the solution with default settings while set to the Release solution configuration, this .zip file can be found in the bin\Release\netcoreapp3.1 subfolder of your solution. It targets both .NET Framework and .NET Core.

The provided sample also defines the CSV file used for demonstration purposes as a file resource in the PublishExtensionTemplate.props file. For more details on using this package to automate publishing extensions, see Using the Dundas.BI.PublishExtension NuGet package.

5.3. Defining the custom provider

To create a custom tenant provider, implement the ITenantsProvider Interface.

using Dundas.BI.AccountServices.Extensibility;
using Dundas.BI.AccountServices.MultiTenancy;
using Dundas.BI.Licensing;
using Dundas.BI.Utility;
using Newtonsoft.Json;
  ...
namespace MyCompany.Sample.CustomTenantsProviderSample
{
    /// <summary>
    /// This class represents a simple tenant provider that uses a csv file.
    /// </summary>
    public class CustomTenantsProvider : ITenantsProvider
    {
      ...
    }
}

5.4. Implementing the ITenantsProvider interface

5.4.1. Defining the supported query operations

The supported query operation specifies one or more operations available in the tenants provider. In the following example, we return the Count operation. This means we should throw a System.NotSupportedException for the Query method on the interface, and we should implement the QueryCount method.

/// <summary>
/// Gets the supported query operations.
/// </summary>
/// <value>
/// The supported query operations.
/// </value>
public Dundas.BI.Utility.QueryApiOperations SupportedQueryOperations
{
	get { return Dundas.BI.Utility.QueryApiOperations.Count; }
}

/// <summary>
/// Queries for tenant records.
/// </summary>
/// <param name="pageNumber">The page number.</param>
/// <param name="pageSize">The number of results in each page.</param>
/// <param name="orderBy">The sort order of the result, or <see langword="null" /> if the order
/// does not matter.</param>
/// <param name="filter">The filter rules which should be applied to the query, or
/// <see langword="null" /> if no filters are required.</param>
/// <returns>
/// The records matching the search criteria.
/// </returns>
/// <exception cref="System.NotSupportedException"></exception>
public IList<Guid> Query(
    int pageNumber,
    int pageSize,
    IList<Tuple<TenantQueryField, SortDirection>> orderBy,
    ICollection<TenantQueryFilterRule> filter
)
{
	throw new NotSupportedException();
}

/// <summary>
/// Queries for the number of tenants matching a filter criteria.
/// </summary>
/// <param name="filter">The filter rules which should be applied to the query, or
/// <see langword="null"/> if the total number of tenants should be returned.</param>
/// <returns>
/// The number of tenants matching the filter.
/// </returns>
public int QueryCount(ICollection<TenantQueryFilterRule> filter)
{
	return GetAllRecords().Count();
}

5.4.2. Defining if modify is supported

The IsModifySupported property indicates whether the provider supports saving changes. If true, all the methods that change the tenant should be implemented. Otherwise, they should throw a System.NotSupportedException.

Each Tenant will have two groups associated with it: Tenant Members and Tenant Administrators.

With a read-only tenant, it is the tenant's responsibility to manage the special group ID's: these are GUIDs and should be the same for a specific tenant. It does not matter if the groups actually exist. In this case, the UpdateSpecialGroupIds method will never be called.

If the tenant provider is read/write, when the SaveRecord method on the Tenant is called, the AdministratorsGroupId and MembersGroupId values should be left as empty GUIDs. After SaveRecord is called on the provider for a new tenant, the application will auto-create the special groups, and then call UpdateSpecialGroupIds so that the provider can save those special group IDs with the tenant.

This example demonstrates how to set the custom tenant provider to modify the tenants:

/// <summary>
/// Gets a value indicating whether the provider supports saving changes.
/// </summary>
public bool IsModifySupported
{
	get { return true; }
}

/// <summary>
/// Saves a tenant.
/// </summary>
/// <param name="tenant">The tenant to be saved.</param>
/// <returns>
/// The ID of the tenant.
/// </returns>
public Guid SaveRecord(TenantData tenant)
{
	TenantData[] listToSave;
	if (tenant.Id.Equals(Guid.Empty))
	{
		// Record is new
		tenant.Id = Guid.NewGuid();
		tenant.CreatedTime = DateTime.Now;
		listToSave = GetAllRecords().Concat(
			new TenantData[] { tenant }
		).ToArray();
	}
	else
	{
		// Existing record
		listToSave = GetAllRecords().ToArray();
		for (int index = 0; index < listToSave.Count(); index++)
		{
			if (listToSave[index].Id.Equals(tenant.Id))
			{
				listToSave[index] = tenant;
				break;
			}
		}
	}
	SaveAllTenantDataToFile(listToSave);
	return tenant.Id;
}

/// <summary>
/// Deletes a tenant specified by its ID.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
public void DeleteRecord(Guid tenantId)
{
	SaveAllTenantDataToFile(GetAllRecords().Where(record => !record.Id.Equals(tenantId)));
}

/// <summary>
/// Update the special groups associated with the tenant.
/// </summary>
/// <remarks>
/// After a tenant is created, two groups are also created (Tenant Admin, and Member groups).
/// </remarks>
/// <param name="tenantId">ID of the tenant.</param>
/// <param name="adminGroupId">The tenant admin group ID.</param>
/// <param name="memberGroupId">The tenant group ID.</param>
public void UpdateSpecialGroupIds(Guid tenantId, Guid adminGroupId, Guid memberGroupId)
{
	TenantData[] listToSave = GetAllRecords().ToArray();

	for (int index = 0; index < listToSave.Count(); index++)
	{
		if (listToSave[index].Id.Equals(tenantId))
		{
			listToSave[index].AdministratorsGroupId = adminGroupId;
			listToSave[index].MembersGroupId = memberGroupId;
			break;
		}
	}

	SaveAllTenantDataToFile(listToSave);
}

/// <summary>
/// Saves all tenant data to file.
/// </summary>
/// <param name="tenantDataList">The tenant data list.</param>
private void SaveAllTenantDataToFile(IEnumerable<TenantData> tenantDataList)
{
	StringBuilder updatedFileContent = new StringBuilder();

	foreach (TenantData tenantData in tenantDataList)
	{
		updatedFileContent.AppendFormat(
			CultureInfo.CurrentCulture,
			"{0}•{1}•{2}•{3}•{4}•{5}•{6}•{7}•{8}{9}",
			tenantData.Id,
			tenantData.Name,
			tenantData.AdministratorsGroupId,
			tenantData.CreatedTime,
			tenantData.MembersGroupId,
			tenantData.AccountNamePattern,
			tenantData.BrandingSettingOverrides,
			tenantData.DataConnectorPropertyOverrides,
			SerializeLicenseSeatAllocation(tenantData.LicenseSeatAllocation),
			Environment.NewLine
		);
	}
	File.WriteAllText(this.TenantInformationFilePath, updatedFileContent.ToString());
}

5.4.3. Implementing of the rest of the tenant interface

These methods demonstrate one example of how to implement the other methods in the ITenantsProvider Interface.

/// <summary>
/// Retrieves a list of tenants having the specified IDs.
/// </summary>
/// <param name="tenantIds">The IDs of the tenants to retrieve.</param>
/// <returns>
/// The requested tenants.
/// </returns>
public ICollection<TenantData> GetById(ICollection<Guid> tenantIds)
{
	Collection<TenantData> tenantDataCollection = new Collection<TenantData>();
	var allRecords = GetAllRecords();
	foreach (Guid id in tenantIds)
	{
		TenantData foundItem = allRecords.FirstOrDefault(
			accountDataItem => accountDataItem.Id.Equals(id)
		);
		if (foundItem != null)
		{
			tenantDataCollection.Add(foundItem);
		}
	}
	return tenantDataCollection;
}

/// <summary>
/// Gets all records.
/// </summary>
/// <returns>
/// All the records managed by the provider.
/// </returns>
public IEnumerable<TenantData> GetAllRecords()
{
	List<TenantData> tenantDataList = new List<TenantData>();
	foreach (string line in File.ReadAllLines(this.TenantInformationFilePath))
	{
		string[] splitLine = line.Split('•');
		tenantDataList.Add(new TenantData()
		{
			Id = new Guid(splitLine[0]),
			Name = splitLine[1],
			AdministratorsGroupId = new Guid(splitLine[2]),
			CreatedTime = DateTime.Parse(splitLine[3]),
			MembersGroupId = new Guid(splitLine[4]),
			AccountNamePattern = splitLine[5],
			BrandingSettingOverrides = splitLine[6],
			DataConnectorPropertyOverrides = splitLine[7],
			LicenseSeatAllocation = DeserializeLicenseSeatAllocation(splitLine[8])
		});
	}
	return tenantDataList;
}

/// <summary>
/// Gets the IDs of any tenants which draw the specified seat kind from the global pool.
/// </summary>
/// <param name="seatKind">The kind of license seat.</param>
/// <param name="seatCountProperty">The seat count property.</param>
/// <returns>
/// IDs of the tenant which draw from global pool.
/// </returns>
public ICollection<Guid> GetIdsOfTenantsWhichDrawFromGlobalPool(
    LicenseSeatKind2 seatKind,
    SeatCountProperty seatCountProperty
)
{
	// Only Tenant1 draws from global pool in this example.
	return GetAllRecords().Where(t => t.Name.Equals("tenant1")).Take(1).Select(t => t.Id).ToArray();
}

/// <summary>
/// Gets the combined license usage for all tenants.
/// </summary>
/// <returns>
/// A dictionary containing the total number of seats allocated to all tenants, grouped by seat kind.
/// </returns>
public IDictionary<LicenseSeatKind2, SeatCount> GetLicenseAllocation()
{
	return new Dictionary<LicenseSeatKind2, SeatCount>()
	{
		{ LicenseSeatKind2.ReservedDeveloper, new SeatCount(10) },
		{ LicenseSeatKind2.ReservedPowerUser, new SeatCount(10) },
		{ LicenseSeatKind2.ReservedStandardUser, new SeatCount(10) }
	};
}

/// <summary>
/// Imports a tenant to the underlying storage mechanism.
/// </summary>
/// <param name="tenant">The tenant to be imported.</param>
/// <returns>
///   <see langword="true" /> if a new tenant was imported; otherwise <see langword="false" />.
/// </returns>
public bool ImportRecord(TenantData tenant)
{
	IEnumerable<TenantData> allTenants = GetAllRecords();  
	TenantData tenantData = allTenants.FirstOrDefault(tenantItem => tenantItem.Id.Equals(tenant.Id));
	if(tenantData != null)
	{
		// Overwrite existin record.
		tenantData = tenant;
		SaveAllTenantDataToFile(allTenants);
		return false;
	}
	else
	{
		// New record.
		SaveAllTenantDataToFile(allTenants.Concat(new TenantData[] { tenant } ));
		return true;
	}
}

5.5. Enabling the custom tenants provider

After installing the extension, the custom tenants provider needs to be enabled in the configuration settings.

Select the option to show advanced settings and edit the Custom Tenants Provider configuration setting, choosing your custom provider from the drop-down menu.

6. Debugging

You can use the following to debug your custom accounts provider:

System.Diagnostics.Debugger.Launch();

This pops up a window that will allow you to attach the debugger.

Debugging popup
Debugging popup

7. See also

Dundas Data Visualization, Inc.
400-15 Gervais Drive
Toronto, ON, Canada
M3C 1Y8

North America: 1.800.463.1492
International: 1.416.467.5100

Dundas Support Hours:
Phone: 9am-6pm, ET, Mon-Fri
Email: 7am-6pm, ET, Mon-Fri