· Adam Ferguson  · 7 min read

Implementing Multi-tenancy in SaaS

Discover how to implement robust multi-tenancy in your SaaS application using .NET 8 Web API, Entity Framework Core, and PostgreSQL, ensuring secure data isolation and scalable architecture for cloud-based services

Discover how to implement robust multi-tenancy in your SaaS application using .NET 8 Web API, Entity Framework Core, and PostgreSQL, ensuring secure data isolation and scalable architecture for cloud-based services

Multi-tenancy in SaaS

In the world of Software as a Service (SaaS), ensuring data privacy and security for each client is paramount. One of the most effective ways to achieve this is through the implementation of robust multi-tenancy. This blog post will guide you through the process of implementing secure multi-tenancy using .NET 8 and Entity Framework Core, with a focus on automatic data isolation and enhanced privacy.

Understanding Multi-Tenancy

Multi-tenancy refers to a software architecture where a single instance of an application serves multiple clients or ‘tenants’. Each tenant’s data is isolated from others, despite sharing the same database and application code. This approach offers significant benefits in terms of resource efficiency and maintainability.

The Security Imperative in Multi-Tenant Systems

When dealing with multi-tenant systems, data security and privacy are paramount. Clients entrust their sensitive information to your SaaS platform, and any data leak or cross-tenant access could be catastrophic. This is where a well-implemented multi-tenancy solution becomes crucial.

Implementing Secure Multi-Tenancy with .NET 8 and Entity Framework Core

Let’s dive into the implementation details. We’ll use a step-by-step approach to create a secure, efficient multi-tenant system.

Step 1: Creating the Tenant Model

This model allows us to identify both the organization (our tenant) and the user within that organization. First, let’s define our Tenant class and its interface ITenant:

public class Tenant(Guid? organizationId, string? userId) : ITenant
{
public Guid? OrganizationId { get; set; } = organizationId;
public string? UserId { get; set; } = userId;
}
public interface ITenant
{
public Guid? OrganizationId { get; set; }
public string? UserId { get; set; }
}

Step 2: Creating the Tenant Resolver

TenantResolver is a static class that provides a method to resolve the current tenant’s id and user id from HTTP context in a .NET web API. This method checks the query string, path segments, and other relevant sources to find the tenant id (organizationId). This is used to resolve the scoped inteface ITenant dependency which will be shown later on.

public static class TenantResolver
{
public static Guid? ResolveOrganizationId(HttpContext httpContext)
{
// Check query string
if (httpContext.Request.Query.TryGetValue("organizationId", out var queryValue))
{
if (Guid.TryParse(queryValue, out var orgId))
return orgId;
}
// Check path segments
var pathSegments = httpContext.Request.Path.Value?.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (pathSegments != null)
{
for (int i = 0; i < pathSegments.Length - 1; i++)
{
// The string value to compare against here is determined by your routing. For example, if you have a .NET web API controller with the route "/api/organizations".
if (pathSegments[i].Equals("organizations", StringComparison.OrdinalIgnoreCase) &&
Guid.TryParse(pathSegments[i + 1], out var orgId))
{
return orgId;
}
}
}
// If not found, return null or a default value
return null;
}
}

Step 3: Creating the Multi-Tenant DbContext

using Microsoft.EntityFrameworkCore;
public class MultiTenantDbContext : DbContext
{
public DbSet<Organization> Organization { get; set; }
public DbSet<Employee> Employee { get; set; }
// CompanyId stores the current tenant context
public Guid? OrganizationId { get; set; }
// CurrentUserId stores the current user context - this prevents boilerplate code by not repeatedly checking if the user is a member of the organization in every query
public string? CurrentUserId { get; set; }
public MultiTenantDbContext(DbContextOptions<MultiTenantDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Apply entity configurations
modelBuilder.ApplyOrganizationConfiguration();
modelBuilder.ApplyEmployeeConfiguration();
// Apply global query filters for multi-tenancy
modelBuilder.Entity<Organization>().HasQueryFilter(c =>
(OrganizationId == null || c.Id == OrganizationId) &&
(CurrentUserId == null || c.Employees.Any(e => e.UserId == CurrentUserId)));
modelBuilder.Entity<Employee>().HasQueryFilter(e =>
(OrganizationId == null || e.OrganizationId == OrganizationId) &&
(CurrentUserId == null || e.UserId == CurrentUserId));
}
}

This MultiTenantDbContext implements multi-tenancy using Entity Framework Core’s global query filters. Here’s a breakdown of its key components:

  1. Entity Sets: The context includes DbSet properties for Organizations and Employees.
  2. Tenant and User Identifiers:
    • OrganizationId (Guid?) represents the current tenant.
    • CurrentUserId (string?) represents the current user.

These nullable properties allow for flexible querying scenarios. Meaning, you can query for data that is visible to the current tenant and user, whilst having the flexibility to query all tenants and users and you can use this db context in other applications without a HTTP context.

  1. Constructor: Accepts DbContextOptionsfor dependency injection and configuration.

  2. OnModelCreating Method:

    • Applies separate configuration methods for each entity (e.g., ApplyOrganizationConfiguration()), promoting modularity.
    • Implements global query filters for each entity to enforce data isolation.
  3. Query Filters:

    • Organization Filter: Ensures organization data is only accessible if the current tenant matches the organization’s tenant.
    • Employee Filter: Restricts access to an organization and it’s employee data to the current organization and user.
  4. Multi-Tenancy Strategy:

    • Uses a combination of Organization (tenant) and user-level filtering.
    • OrganizationId ensures data isolation between different tenants.
    • CurrentUserId adds an additional layer of security within each organization.
    • Null checks (e.g., OrganizationId == null || …) allow for bypassing tenant filtering when necessary (e.g., for administrative purposes).

This approach provides a robust and flexible multi-tenancy solution, suitable for complex business applications with varying data access requirements. It ensures data isolation at the database level while allowing for scenarios where global access might be needed.

The global query filters are the key to automatic data isolation. They’re applied to every query executed against these entities, ensuring that data from other tenants is never accidentally accessed.

Step 4: Implementing Pooled DB Context Scoped Factory

  1. Pooled DB Context Factory Wrapping: This factory class implements the interface IDbContextFactory<MultiTenantDbContext> acting as a provider of scoped pooled DB contexts.
  2. Tenant Information: ITenant is injected into the MultiTenantDbContextScopedFactory constructor to pass tenant context into the created pooled DB context when the method CreateDbContext() is called.
using Microsoft.EntityFrameworkCore;
public class MultiTenantDbContextScopedFactory : IDbContextFactory<MultiTenantDbContext>
{
private readonly IDbContextFactory<MultiTenantDbContext> _pooledFactory;
private readonly Guid? _organizationId;
private readonly string? _userId;
public MultiTenantDbContextScopedFactory(
IDbContextFactory<MultiTenantDbContext> pooledFactory,
ITenant tenant)
{
_pooledFactory = pooledFactory;
_organizationId = tenant.OrganizationId;
_userId = tenant.UserId;
}
public MultiTenantDbContext CreateDbContext()
{
var context = _pooledFactory.CreateDbContext();
context.OrganizationId = _organizationId;
context.UserId = _userId;
return context;
}
}

Step 3: Configuring Services

Finally, let’s look at how to configure these services in your application:

services.AddScoped<ITenant>(sp =>
{
var httpContext = sp.GetRequiredService<IHttpContextAccessor>().HttpContext;
if (httpContext is null)
{
throw new ArgumentNullException(nameof(httpContext));
}
var organizationId = TenantResolver.ResolveOrganizationId(httpContext);
var userId = httpContext.GetUserIdForDbContext(); // I am using a static class extension to extract the user id from the the user's claims in the HTTP context. You can do this however you want.
return new Tenant(organizationId, userId);
});
var connectionString = Environment.GetEnvironmentVariable("DB_CONNECTION_STRING")
?? config["PostgresSQL:ConnectionString"];
services.AddPooledDbContextFactory<MultiTenantDbContext>((options) =>
{
options.UseNpgsql(connectionString);
});
services.AddScoped<MultiTenantDbContextScopedFactory>();
services.AddScoped(sp => sp.GetRequiredService<MultiTenantDbContextScopedFactory>().CreateDbContext());

Firstly, we resolve a scoped ITenant instance from the service collection by obtaining the current HTTP context and calling the methods that we created earlier to retrieve the tenant’s organizationId and userId.

Next, we get the connection string from either the environment variable or the configuration file (appsettings.json) to configure the context to connect to a postgreSQL database. Note: If you’re uing a different database, your code will be different here.

Lastly, we register the MultiTenantDbContextScopedFactory as a scoped service and then we get the factory to create a DB context by calling the CreateDbContext() method in MultiTenantDbContextScopedFactory.

The Power of Automatic Data Isolation

  1. Developer-Friendly Approach: Developers can write queries as if working with a single-tenant system, while the multi-tenancy layer works silently in the background.
  2. Consistent Security: This approach enforces data isolation at the database level, reducing the risk of accidental data leaks due to overlooked filtering in application code.

Benefits of This Approach

  1. Enhanced Data Security: By default, users can only access data from their own tenant.
  2. Simplified Development: Developers don’t need to constantly worry about filtering data by tenant.
  3. Reduced Error Risk: Automatic filtering minimizes the chance of accidentally exposing data across tenants.
  4. Improved Performance: Global filters are optimized at the database level, ensuring efficient queries.
  5. Scalability: This approach scales well as you add more tenants and data.

Best Practices for Secure Multi-Tenancy

  • Encrypt Sensitive Data: Use encryption for storing sensitive information in the database.
  • Regular Security Audits: Conduct thorough audits to ensure the integrity of your multi-tenant system.
  • Tenant Isolation Testing: Implement comprehensive tests to verify that tenant data remains isolated.
  • Logging and Monitoring: Keep detailed logs of all data access attempts for auditing purposes.

Conclusion

Implementing secure multi-tenancy is crucial for any SaaS application. By leveraging .NET 8 and Entity Framework Core’s features, we can create a system that automatically ensures data privacy and security. This approach not only protects your clients’ data but also simplifies development and maintenance of your SaaS platform.

Remember, in the world of SaaS, data security is not just a feature – it’s a fundamental requirement. By implementing robust multi-tenancy, you’re not just building an application; you’re building trust with your clients.

Back to Blog

Related Posts

View All Posts »
Inspiration behind Alertu

Inspiration behind Alertu

Alertu was born from firsthand experience with the challenges of cloud monitoring at major enterprises like ASOS, where the flood of email alerts and resulting alert fatigue inspired the creation of a streamlined, centralized solution for more effective cloud infrastructure management

Creating and publishing a .NET NuGet package

Creating and publishing a .NET NuGet package

Learn how to build a .NET library, pack for distribution, and seamlessly publish to your choice of public or private NuGet feeds. Learn how to leverage the power of GitHub Actions and Git tags to automate your workflow, ensuring efficient and reliable deployment of your .NET packages