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 Dundas BI application database. In this sample you will learn the following:

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

2. About multi-tenancy

Dundas BI has built-in support 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.

4. Getting started

The following prerequisites must be installed on your computer:

  • Visual Studio 2017 or higher
  • Microsoft .NET Framework 4.7.2
  • A deployed instance of Dundas BI - this article details extensions for version 7 and higher

4.1. Downloading sample solution

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

(A sample solution is also available for Dundas BI 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 Dundas BI 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 this example we leave it blank until populated by Dundas BI.
  • 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 read by Dundas BI, it needs to contain a class that extends the ExtensionPackageInfo2 class. This class contains the extension package information.

/// <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()
        {
            // System.Diagnostics.Debugger.Launch();

            if (System.Web.HttpContext.Current == null)
            {
                return;
            }

            string tenantInformationSourceFilePath =
                System.Web.HttpContext.Current.Server.MapPath("wwwroot/ExtensionResources/CustomTenantsProviderSample/TenantInformation.csv");

            string tenantInformationDestinationFilePath =
                System.Web.HttpContext.Current.Server.MapPath("App_Data/TenantInformation.csv");

            if (!File.Exists(tenantInformationDestinationFilePath))
            {
                File.Copy(
                    tenantInformationSourceFilePath,
                    tenantInformationDestinationFilePath
                    );
            }
        }
}
        

5.2. Publish extension template

This sample has a mechanism to automatically publish the extension when building, which is the Dundas.BI.PublishExtension NuGet package. 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 a Dundas BI 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 Dundas BI instance using the Extensions screen in the administration UI. After building the solution, this .zip file can be found within the bin subfolder of your solution.

This sample will copy the TenantInformation.csv file to the application data folder. This is accomplished by using ApplicationResources and the OnLoaded event in the CustomTenantProviderSamplePackageInfo class. This is done only to make changes to the provider visible to the user. One should not use text files to store tenant data.

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, Dundas BI 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

This example demonstrates 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 new Collection<Guid>()
	{
		GetAllRecords().FirstOrDefault(tenantItem => tenantItem.Name.Equals("tenant1")).Id
	};
}

/// <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

The custom tenants provider needs to be enabled in Dundas BI's configuration settings.

Log on to Dundas BI using a system administrator account, and choose Admin in the main menu on the left.

Click to expand the Setup section, and click Config.

Config button
Config button

Select Show Advanced Settings and edit the Custom Tenants Provider configuration setting.

Edit Custom Tenants Provider
Edit Custom Tenants Provider

In the Configure Custom Tenants Provider dialog, select Edit value and choose your accounts provider from the available values in 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.
500-250 Ferrand Drive
Toronto, ON, Canada
M3C 3G8

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