Overview

I have been exploring various options in the Microsoft suite recently to host a SaaS application I am building targeting small businesses. I have settled on Microsoft Azure as the PaaS, .NET MVC 5 as the framework and front-end and ASP.NET as my authentication mechanism. For the CRM’ers out there, I apologise this not CRM related but thought it was worth posting.

Of course today most modern SaaS applications cater for a multi-tenant environment, meaning we want a dedicated database per customer that signs up to our application or service. In terms of SaaS architecture, we are faced with the following scenarios in terms of the database in a SaaS application:

  • Separate Databases
  • Shared Database, Separate Schema
  • Shared Database, Shared Schema

I am not going to go into the advantage and disadvantages of each as I have limited time to post but Microsoft have documented this quite well over here: https://msdn.microsoft.com/en-us/library/aa479086.aspx#mlttntda_topic3

So to start, we want to achieve the following scenario:

  • Customer logs in via their own subdomain i.e. customer1.myapp.com
  • Customer is then authenticated on their own database and tenant and granted access to the application
  • The db connection is then persisted throughout their journey in the application

To achieve this I have created a MVC  5 web application which has Individual User Accounts authentication (ASP.NET Identity) configured. I have also created two separate customer databases named Customer1 and Customer2 for the purposes of this post and I have also created a Tenant database which keeps record of our customer databases and their connection strings. It is important to note that each customer database has their own set of ASP.NET Identity tables to store users and roles. We will be adding some magic to the ApplicationDbContext.Create method within the IdentityModel to create the connection to relevant database based on the customers URL.

SaaS_Architecture

The Tenant Database

The purpose of the tenant database is to provide the application with a mapping to the database which is associated with the customer tenant. While I am calling this the tenant db it is in fact just a table in an application wide database used for metadata in my case.

Table structure:

Tenant1

Tenant records:

Tenant2

 

The TenantDataProvider Class

The purpose of this class is to provide the DbContext with a database connection string to the associated database for the tenant. The TenantDataProvider comprises of three classes listed below.

TenantDataProvider.cs

using MyApp.TenantProvider.BaseClasses;
using System;
using System.Data.SqlClient;

namespace MyApp.TenantProvider
{
///
/// Provides a connection to the application metadata and tenant informtation
///

public class TenantDataProvider : TenantProviderBase, IDisposable
{
#region Fields

private string _hostName;
#endregion

#region Properties

///
/// The tenant that is related to the current connection
///

public Tenant AppTenant
{
get; protected set;
}

#endregion

#region .Ctor

///
/// Initialises a connection to the metadata provider
///

///The url application is accessing ///The db connectionstring public TenantDataProvider(string hostName, string tenantDbConnection)
: base(tenantDbConnection)
{
_hostName = hostName;
AppTenant = GetTenant();
}

#endregion

#region Public Methods
public void Dispose()
{
this.TenantDbConnection.Close();
}

///
/// Returns the tenant metadata associated with the teneat
///

///
public Tenant GetTenant()
{
Tenant tenant = null;
//Validate the hostname
if (string.IsNullOrEmpty(_hostName))
{
throw new ArgumentNullException("Host");
}

//Strip port from hostname
int index = _hostName.LastIndexOf(':');
if (index > 0)
_hostName = _hostName.Substring(0, index);

var sql = $"SELECT * FROM [dbo].Tenant WHERE SubDomain = '{_hostName}'";

var cmd = new SqlCommand(sql) {Connection = TenantDbConnection};
var sqlReader = cmd.ExecuteReader();

while (sqlReader.Read())
{
tenant = new Tenant()
{
TenantID = Guid.Parse(sqlReader["TenantID"].ToString()),
CompanyName = sqlReader["CompanyName"].ToString(),
ApplicationTitle = sqlReader["ApplicationTitle"].ToString(),
DbConnectionString = sqlReader["DbConnectionString"].ToString(),
SubDomain = sqlReader["SubDomain"].ToString()
};
}

sqlReader.Close();
return tenant;
}
#endregion
}
}

Tenant.cs

using System;

namespace MyApp.TenantProvider
{
[Serializable]
public class Tenant
{
public Guid TenantID { get; set;}
public string CompanyName { get; set; }
public string SubDomain { get; set; }
public string ApplicationTitle { get; set; }
public string DbConnectionString { get; set; }
}
}

TenantProviderBase.cs

using System.Data.SqlClient;

namespace MyApp.TenantProvider.BaseClasses
{
public abstract class TenantProviderBase
{
#region Properties

protected SqlConnection TenantDbConnection{ get; set; }

#endregion

#region .Ctor

///
/// Creates a sql connection to the tenant metabase
///

///protected TenantProviderBase(string tenantDbConnection)
{
try
{
TenantDbConnection = new SqlConnection(tenantDbConnection);
TenantDbConnection.Open();
}
catch(SqlException ex)
{
throw ex;
}
}

#endregion
}
}

Plugging it all up and waving the magic wand

So now that we have a provider to get us the relevant database connection string to the tenant database based on a host url parsed to it, we now have to plug this into the standard ASP.NET Identity ApplicationDbContext which out the box would connect to a database via a connection string name parsed to it in the ApplicationDbContext .Create method. This connection string is usually stored in the web.config.

IdentityModel.cs

So if we head over to the IdentityModel class in the Models folder of our solution and focus on the ApplicationDBContext class

public class ApplicationDbContext : IdentityDbContext
{
public static string HostUrl { get; set; }
public ApplicationDbContext(string dbConnString)
: base(dbConnString)
{
}

public static ApplicationDbContext Create()
{
var tenantProvider = new TenantDataProvider(HostUrl,
ConfigurationManager.ConnectionStrings[ApplicationConstants.Tenant_ConnectionString_Name].ConnectionString);
return new ApplicationDbContext(tenantProvider.AppTenant.DbConnectionString);
}
}

You will see that I have added a HostUrl property to the class, which is the URL that the customer is accessing the application from and you will also see that before returning the ApplicationDbContext I create new instance of the TenantDataProvider that we created earlier on and parse the host URL and tenant database connection string (stored in the web.config). The ApplicationDbContext is then created with this tenant db connectionstring and returned as it would normally be.

The host URL

So how do we get and set the host URL then? Well if we go to the Startup.cs class and focus on the ConfigureAuth method, which is an extendable vanilla ASP.NET Identity method that creates our ApplicationDbContext instance and DB connection, we can set the new HostUrl property on the ApplicationDbContext class to the URL by getting the URL from the HttpContext.

public void ConfigureAuth(IAppBuilder app)
{
// Configure the db context, user manager and signin manager to use a single instance per request1
ApplicationDbContext.HostUrl = HttpContext.Current.Request.Url.Host;
app.CreatePerOwinContext(ApplicationDbContext.Create);
app.CreatePerOwinContext(ApplicationUserManager.Create);
app.CreatePerOwinContext(ApplicationSignInManager.Create);

// Enable the application to use a cookie to store information for the signed in user
// and to use a cookie to temporarily store information about a user logging in with a third party login provider
// Configure the sign in cookie
app.UseCookieAuthentication(new CookieAuthenticationOptions
{
AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
LoginPath = new PathString("/Account/Login"),
Provider = new CookieAuthenticationProvider
{
// Enables the application to validate the security stamp when the user logs in.
// This is a security feature which is used when you change a password or add an external login to your account.
OnValidateIdentity = SecurityStampValidator.OnValidateIdentity<ApplicationUserManager, ApplicationUser>(
validateInterval: TimeSpan.FromMinutes(30),
regenerateIdentity: (manager, user) => user.GenerateUserIdentityAsync(manager))
}
});
app.UseExternalSignInCookie(DefaultAuthenticationTypes.ExternalCookie);

// Enables the application to temporarily store user information when they are verifying the second factor in the two-factor authentication process.
app.UseTwoFactorSignInCookie(DefaultAuthenticationTypes.TwoFactorCookie, TimeSpan.FromMinutes(5));

// Enables the application to remember the second login verification factor such as phone or email.
// Once you check this option, your second step of verification during the login process will be remembered on the device where you logged in from.
// This is similar to the RememberMe option when you log in.
app.UseTwoFactorRememberBrowserCookie(DefaultAuthenticationTypes.TwoFactorRememberBrowserCookie);

// Uncomment the following lines to enable logging in with third party login providers
//app.UseMicrosoftAccountAuthentication(
// clientId: "",
// clientSecret: "");

//app.UseTwitterAuthentication(
// consumerKey: "",
// consumerSecret: "");

//app.UseFacebookAuthentication(
// appId: "",
// appSecret: "");

//app.UseGoogleAuthentication(new GoogleOAuth2AuthenticationOptions()
//{
// ClientId = "",
// ClientSecret = ""
//});
}

 

Firing it up!

 

So I am going to launch the debugger in Visual Studio and stop at various break points all in the code listed above so we can inspect the connection strings. For the purpose of this post I am going to manually set the HostUrl to a set string as the URL in my case whilst debugging is the localhost.

Customer1.myapp.com

SCreen1

Customer2.myapp.com

SCreen2

Conclusion!

As you can see above, the TenantDataProvider is returning the matching tenant db connection string based on the given URL used to access the application. In turn the ASP.NET Identity ApplicationDbContext is created with a connection to the relevant tenant database providing the customer with a separate database SaaS solution. Nice and simple!

Please follow and like us:
0

4 thoughts on “SAAS multi-tenancy with MVC5 and ASP.NET Identity

  1. Nice post, I’m facing problem in getting the host Name instead of Host Url.
    ApplicationDbContext.HostUrl = HttpContext.Current.Request.Url.Host;
    Can you please help me, is there any other way to get hostName Like(client.myapp.com).
    Thanks in advance

  2. Great post! Thanks!

    I’m trying to get something similar up, but I cannot use the hostname, so I figures to change the route config to {tenant}/{controller}/{action}/{id}. What I’m struggling with, is that the HttpContext isn’t fully “loaded” upon app start. The request url isn’t available, so I’m unable to retrieve the tenant from the request.

    Any thoughts on how to attack this issue?

Leave a Reply

Your email address will not be published. Required fields are marked *