Carl Rippon

Building SPAs

Carl Rippon
BlogBooksAbout
This site uses cookies. Click here to find out more

Creating a Multi-Tenant ASP.NET Core Web API with SQL Server RLS

June 25, 2017
dotnet

In this post we’re going to leverage SQL Server Row Level Security (RLS), Entity Framework Core and ASP.NET Core to create a multi-tenant database with a multi-tenant web API …

First, let’s create a database containing a couple of tables - one to hold our tenants and one to hold some real data (products in this example):

CREATE DATABASE ProductDb
GO
USE ProductDb
GO
CREATE TABLE dbo.Tenant
(
	TenantId uniqueidentifier NOT NULL,
	APIKey uniqueidentifier NOT NULL,
	TenantName nvarchar(200) NOT NULL,
	CONSTRAINT PK_Tenant PRIMARY KEY CLUSTERED (TenantId ASC)
)
GO
CREATE TABLE dbo.Product
(
	ProductID uniqueidentifier NOT NULL,
	TenantId uniqueidentifier NOT NULL,
	ProductName nvarchar(50) NOT NULL,
	UnitPrice money NULL,
	UnitsInStock smallint NULL,
	UnitsOnOrder smallint NULL,
	ReorderLevel smallint NULL,
	Discontinued bit NOT NULL,
	CONSTRAINT PK_Product PRIMARY KEY CLUSTERED (ProductID ASC),
	CONSTRAINT FK_Product_Tenant FOREIGN KEY (TenantId) REFERENCES dbo.Tenant(TenantId)
)
GO

We’ll add some test data in there as well. We’ll have 2 tenants - one with 3 products and the other one with 2 products:

INSERT INTO dbo.Tenant(TenantId, APIKey, TenantName)
VALUES('6CB8DE43-2043-4415-B267-7FFFA2EB5AC0', '98D92BF3-0620-4CC4-9C7D-78BC94DD4C55','tenant 1')
INSERT INTO dbo.Tenant(TenantId, APIKey, TenantName)
VALUES('25EA09EF-E24E-494B-911F-F63CE9ED8458', '081FF61A-E688-4DC2-84E7-6CC8FFED4D69','tenant 1')
GO

INSERT dbo.Product(ProductID, TenantId, ProductName, UnitPrice, UnitsInStock, UnitsOnOrder, ReorderLevel, Discontinued)
VALUES ('E897FF55-8F3D-4154-B582-8D37D116347F', '6CB8DE43-2043-4415-B267-7FFFA2EB5AC0', N'Chai', 18.0000, 39, 0, 10, 0)
GO
INSERT dbo.Product(ProductID, TenantId, ProductName, UnitPrice, UnitsInStock, UnitsOnOrder, ReorderLevel, Discontinued)
VALUES ('F5506F2A-5148-44FE-9225-AC75108AA30F', '6CB8DE43-2043-4415-B267-7FFFA2EB5AC0', N'Chang', 19.0000, 17, 40, 25, 0)
GO
INSERT dbo.Product(ProductID, TenantId, ProductName, UnitPrice, UnitsInStock, UnitsOnOrder, ReorderLevel, Discontinued)
VALUES ('775EDB92-32BE-4D46-ABBB-921BC1860514', '6CB8DE43-2043-4415-B267-7FFFA2EB5AC0', N'Aniseed Syrup', 10.0000, 13, 70, 25, 0)
GO
INSERT dbo.Product(ProductID, TenantId, ProductName, UnitPrice, UnitsInStock, UnitsOnOrder, ReorderLevel, Discontinued)
VALUES ('ED3D0EE0-3D02-460A-9659-58C34CCC663F', '25EA09EF-E24E-494B-911F-F63CE9ED8458', N'Chang', 16.0000, 3, 12, 13, 0)
GO
INSERT dbo.Product(ProductID, TenantId, ProductName, UnitPrice, UnitsInStock, UnitsOnOrder, ReorderLevel, Discontinued)
VALUES ('CED038B1-DF1C-4111-9E48-E386A1A7063A', '25EA09EF-E24E-494B-911F-F63CE9ED8458', N'Pavlova', 17.4500, 29, 0, 10, 0)
GO

Before we move on to RLS, let’s create a “super user” that will have access to data in all the tenants (this is useful for debugging). While we are at it, we’ll create a “normal user” that our web API will use to access data:

CREATE LOGIN superuserlogin WITH PASSWORD = 'p@ssw0rd'
GO
CREATE USER superuser FOR LOGIN [superuserlogin]
GO
EXEC sp_addrolemember N'db_owner', N'superuser'
GO
CREATE LOGIN [normaluserlogin] WITH PASSWORD = 'p@ssw0rd'
GO
CREATE USER [normaluser] FOR LOGIN [normaluserlogin]
GO
EXEC sp_addrolemember N'db_datareader', N'normaluser'
EXEC sp_addrolemember N'db_datawriter', N'normaluser'
GO

Now on to the RLS. First we need to create a predicate function that is going to apply a filter when the product table is accessed for the given tenant. The tenant for a given row is passed into the function. The tenant that the API is operatoring for is stored in

CREATE FUNCTION dbo.TenantAccessPredicate (@TenantId uniqueidentifier)
    RETURNS TABLE
    WITH SCHEMABINDING
AS
    RETURN SELECT 1 AS TenantAccessPredicateResult
	WHERE (@TenantId = CAST(SESSION_CONTEXT(N'TenantId') AS uniqueidentifier))
		  OR
		  (DATABASE_PRINCIPAL_ID() = DATABASE_PRINCIPAL_ID('superuser'))
GO

Now we can bind our predicate function to our product table. The FILTER predicate ensures we only read data for a given tenant and the BLOCK predicate ensures we only write data for a given tenant:

CREATE SECURITY POLICY dbo.TenantAccessPolicy
ADD FILTER PREDICATE dbo.TenantAccessPredicate(TenantId) ON dbo.Product,
ADD BLOCK PREDICATE dbo.TenantAccessPredicate(TenantId) ON dbo.Product
GO

So, let’s give this a quick test, connecting as normaluser:

-- connect as tenant 1
EXEC sp_set_session_context @key=N'TenantId', @value='6CB8DE43-2043-4415-B267-7FFFA2EB5AC0'
GO
SELECT * FROM dbo.Product
GO

-- connect as tenant 2
EXEC sp_set_session_context @key=N'TenantId', @value='25EA09EF-E24E-494B-911F-F63CE9ED8458'
GO
SELECT * FROM dbo.Product
GO

We should get following results back (3 rows for the first query and 2 for the 2nd query): multitenant api sql results

If you connect as superuser, SELECT * FROM dbo.Product, you should get all 5 rows.

Cool, we’re done with our database. Let’s move on to our web API …

Let’s create a new ASP.NET Core Web Application in Visual Studio, choosing the Web API project template.

First let’s create our model classes to hold our tenants and products …

public class Tenant
{
    public Guid TenantId { get; set; }
    public Guid APIKey { get; set; }
    public string TenantName { get; set; }
}
public class Product
{
    public Guid ProductId { get; set; }
    public string ProductName { get; set; }
    public Decimal UnitPrice { get; set; }
    public Int16 UnitsInStock { get; set; }
    public Int16 UnitsOnOrder { get; set; }
    public Int16 ReorderLevel { get; set; }
    public bool Discontinued { get; set; }
}

Now, we’ll bring in EF core using nuget (I’m using the stable 1.1.2 packages). We’ll need Microsoft.EntityFrameworkCore and Microsoft.EntityFrameworkCore.SqlServer.

Time to create our EF core data context. Here’s a basic class that maps our tenants and products:

public class DataContext : DbContext
{
    public DbSet<Tenant> Tenants { get; set; }
    public DbSet<Product> Products { get; set; }

    private SqlConnection connection;

    public DataContext(DbContextOptions<DataContext> options) : base(options)
    {
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        modelBuilder.Entity<Tenant>().ToTable("Tenant");
        modelBuilder.Entity<Product>().ToTable("Product");
    }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        var connectionString = "<your connection string>";
        connection = new SqlConnection(connectionString);

        optionsBuilder.UseSqlServer(connection);

        base.OnConfiguring(optionsBuilder);
    }
}

We’re not done with DataContext yet - we need to set SESSION_CONTEXT('TenantId') for every query that EF core does … we’ll come back to this after we’ve got the tenant from the API.

public class TenantFinder
{
    private readonly RequestDelegate next;
    private DataContext dataContext;
    public TenantFinder(RequestDelegate next, DataContext dataContext)
    {
        this.next = next;
        this.dataContext = dataContext;
    }

    public async Task Invoke(HttpContext context)
    {
        var apiKey = context.Request.Headers["X-API-Key"].FirstOrDefault();
        if (string.IsNullOrEmpty(apiKey))
        {
            context.Response.StatusCode = 400;
            await context.Response.WriteAsync("Invalid API key");
            return;
        }
        Guid apiKeyGuid;
        if (!Guid.TryParse(apiKey, out apiKeyGuid))
        {
            context.Response.StatusCode = 400;
            await context.Response.WriteAsync("Invalid API key");
            return;
        }
        var tenant = dataContext.Tenants.Where(t => t.APIKey == apiKeyGuid).FirstOrDefault();
        if (tenant == null)
        {
            context.Response.StatusCode = 401;
            await context.Response.WriteAsync("Invalid API key");
            return;
        }
        else
        {
            // add tenant to http context for use when the conection to the data is opened
            context.Items["TENANT"] = tenant;        }

        await next.Invoke(context);
    }
}

public static class TenantFinderExtension
{
    public static IApplicationBuilder UseTenantFinder(this IApplicationBuilder app)
    {
        app.UseMiddleware<TenantFinder>();
        return app;
    }
}

Please note the highlighted line in the above code where we place the tenant in a dictionary in theHttpContext. This is how we are going to get the tenant inDataContext.

Before we go back to DataContext, let’s go and do all our registrations in Startup:

public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>(); // we need this to access HttpContext in DataContext    services.AddDbContext<DataContext>(ServiceLifetime.Scoped);
    services.AddMvc();
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
    loggerFactory.AddConsole(Configuration.GetSection("Logging"));
    loggerFactory.AddDebug();

    app.UseTenantFinder();
    app.UseMvc();
}

Time now to go back to DataContext. We can set the tenant in our constructor:

private Guid tenantId;

public DataContext(DbContextOptions<DataContext> options, IHttpContextAccessor httpContextAccessor) : base(options)
{
    if (httpContextAccessor.HttpContext != null)
    {
        var tenant = (Tenant)httpContextAccessor.HttpContext.Items["TENANT"];
        tenantId = tenant.TenantId;
    }
}

We can then set SESSION_CONTEXT(‘TenantId’) every time a connection is opened before any queries are run by EF core.

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    var connectionString = "<your connection string>";
    connection = new SqlConnection(connectionString);
    connection.StateChange += Connection_StateChange;
    optionsBuilder.UseSqlServer(connection);

    base.OnConfiguring(optionsBuilder);
}

private void Connection_StateChange(object sender, System.Data.StateChangeEventArgs e){    if (e.CurrentState == ConnectionState.Open)    {        var cmd = connection.CreateCommand();        cmd.CommandText = @"exec sp_set_session_context @key=N'TenantId', @value=@TenantId";        cmd.Parameters.AddWithValue("@TenantId", tenantId);        cmd.ExecuteNonQuery();    }}

Ok, we’re done with DataContext now. Let’s create a simple API now to get some products:

[Route("api/[controller]")]
public class ProductsController : Controller
{
    private DataContext dataContext;
    public ProductsController(DataContext dataContext)
    {
        this.dataContext = dataContext;
    }

    [HttpGet]
    public IActionResult Get()
    {
        return Ok(dataContext.Products.ToList());
    }
}

Let’s check this is working in Postman with our 2 API keys. We should get 3 products for the 1st key and 2 products for the 2nd key: multitenant api get1 multitenant api get2

Cool, we are good. Let’s quickly add an action method to get a product by its id:

[HttpGet("{productId}", Name = "ProductGet")]
public IActionResult Get(Guid productId)
{
    var product = dataContext.Products.Where(p => p.ProductId == productId).FirstOrDefault();
    if (product == null) return NotFound();

    return Ok(product);
}

Now let’s create the action method in our API to post new products:

[HttpPost]
public IActionResult Post([FromBody]Product product)
{
    product.ProductId = Guid.NewGuid();
    dataContext.Products.Add(product);
    dataContext.SaveChanges();

    var url = Url.Link("ProductGet", new { productId = product.ProductId });
    return Created(url, product);
}

… and let’s give it a try: multitenant api post … it blows up! multitenant api post error

We obviously have a problem because we are not submitting the tenant id in the product.

I lied when I said we were done with our database! Let’s add a default value for the tenant id that picks up SESSION_CONTEXT(‘TenantId’):

[HttpPost]
public IActionResult Post([FromBody]Product product)
{
    product.ProductId = Guid.NewGuid();
    dataContext.Products.Add(product);
    dataContext.SaveChanges();

    var url = Url.Link("ProductGet", new { productId = product.ProductId });
    return Created(url, product);
}

Now, when you post a product in Postman, we should be ok:

multitenant api post ok

To finish off our controller we’ll quickly implement a PUT and DELETE:

[HttpPut("{productId}")]
public IActionResult Put(Guid productId, [FromBody]Product product)
{
    var existingProduct = dataContext.Products.Where(p => p.ProductId == productId).FirstOrDefault();
    if (existingProduct == null) return NotFound();

    existingProduct.ProductName = product.ProductName;
    existingProduct.UnitPrice = product.UnitPrice;
    existingProduct.UnitsInStock = product.UnitsInStock;
    existingProduct.UnitsOnOrder = product.UnitsOnOrder;
    existingProduct.ReorderLevel = product.ReorderLevel;
    existingProduct.Discontinued = product.Discontinued;

    dataContext.SaveChanges();

    return Ok(existingProduct);
}

[HttpDelete("{productId}")]
public IActionResult Delete(Guid productId, [FromBody]Product product)
{
    var existingProduct = dataContext.Products.Where(p => p.ProductId == productId).FirstOrDefault();
    if (existingProduct == null) return NotFound();

    dataContext.Products.Remove(existingProduct);

    dataContext.SaveChanges();

    return NoContent();
}

Pretty straightforward and they both work. If you try to PUT and DELETE products that belong to a tenant that your API key is not for then you get a 404 - just as we want.

The great thing about this approach is that there is no reference to the tenant in our controller code - the multi-tenancy is taken care for us lower down in the stack which reduces our development costs and risk of leaking data.


Comments

Vicent November 15, 2017

You’ll have problems with this if EF Core does some connection pooling, as I think it does in v2… Instead you can use the new feature called “Global query filters” that allow to do all this without having to tweak the database.

Carl December 11, 2017

Thanks for the comment Vicent – this is a great point and solution!


Vikas December 4, 2017

Great Article! From the code above I understand that the products would be listed per tenant based on the api key being passed. However I am curious about the fact that if we have a superuser and that person was to login in, how would the tenant finder work in such a scenario?

Carl December 11, 2017

Thanks for the comment Vikas!

The superuser wouldn’t be used by the app (unless there is a feature that needs access to all the tenant data). I find the superuser really useful whilst developing and debugging


Dev Raj Gautam December 6, 2017

this is the most convincing article i have read concept clear! 🙂 thanks. How would we implement user wise security in this principal say there is a user of tenant a and i only want him to edit or delete the records he inputted.

Carl December 11, 2017

Thanks for the kind comment!

Good question on the only allowing certain users to change certain records. I would look into using the BLOCK PREDICATE and instead of using the same predicate function as the FILTER PREDICATE, I would associate it with a new function, dbo.UserAccessPredicate() say, that checks that the user is allowed to edit / delete the record. In addition to this I would pass the user id or role id from the code into SQL in another SESSION_CONTEXT() variable – the rule in dbo.UserAccessPredicate() would do a check against this …

Hope this helps!


Anand February 1, 2018

Nice explanation, can you explain how to implement in ASP.NET Web API not in ASP.NET Core Web API.

Harry March 6, 2018

Hi Carl,

First of all thanks for sharing your knowledge I have read almost all your posts and have learned a lot!

I have a question regarding the technique you are using to achieve to set spsetsession_context.

Since your are crating a new connection object inside the db contact class. How will this get disposed and closed if my db context is using Scoped lifetimes?

Cheers, Harry

Carl March 6, 2018

Great question Harry! Yes, I think we’ll have a memory leak in the above DataContext class. However, if we remove the ConnectionStateChange event handler in ConnectionStateChange when the connection is closed I think it will dispose ok.

private void Connection_StateChange(object sender, System.Data.StateChangeEventArgs e)
{
    if (e.CurrentState == ConnectionState.Open)
    {
        var cmd = connection.CreateCommand();
        cmd.CommandText = @"exec sp_set_session_context @key=N'TenantId', @value=@TenantId";
        cmd.Parameters.AddWithValue("@TenantId", tenantId);
        cmd.ExecuteNonQuery();
    }
    else if (e.CurrentState == ConnectionState.Closed)
	{
	    connection.StateChange -= Connection_StateChange;
	}
}

Shawn November 5, 2019

Hey Carl,

Thanks for the post. When I try to implement this using EF Core 2.2, it closes the connection before I run any LINQ queries, causing my session_context value to be lost and not being used for RLS.

I tried to add connection.Open() before UseSqlServer(connection), but that’s a big no as EF Core no longer closes the connections.

I appreciate any thought you have on this.

Thanks, Shawn


Maxim Markelow March 30, 2018

Hi! In case of multitenant application, is it better to pass tenant id to DAO explicitly and filter by this id? Becase such implicit solution will be broken, if we will change database or ORM. Also we mix business and data access layer. And this approach is often considered as a bad practice. But it is high performance. And what about more sophisticated cases? For example, access for specific entity depands on user rights. Can you advice how fetch data with such restriction without filtering entities in application memory and with pagination support?

Carl March 31, 2018

Thanks for the question Maxim. I think you are asking a couple of questions here:

  • Should we be doing the filtering outside of database and data access layers? If the app needs to work on different types of databases, this is going to be a challenge! Unless your db is very small, I always do the filtering in database queries – pulling all the data into memory and then doing the filtering in memory won’t scale very well
  • Should we use RLS to implement data authorisation rules? – shouldn’t we implement these rules in the business layer? From an architectural point of view you can argue that. However, this won’t scale very well if you are dealing with lots of data. The other benefit of using RLS in the database is that the risk of data leaks reduced because the logic is centralised in database filter predicates rather than having to do this all over your code

Mark Arnold September 11, 2018

Thanks Carl. Very timely article for my team. One question though, are you confident that under load the API won’t in some way reuse connections (from a pool for instance) such that the wrong tenantId winds up in the session_context?

Carl September 13, 2018

Cheers Mark. I’m fairly sure this will be fine under load. I’ve used this approach for a web app that gets lots of traffic and it has no issues.


Interested in learning more about ASP.NET Core and React? My book is available now!
ASP.NET Core 3 and React