A multi-tenant App serves many customers from one deployment, and has to keep each customer's data separate. This page covers the model where every tenant shares one database and each row says which tenant owns it, which OrmLite enforces on the database connection so it doesn't depend on every query remembering its tenant.
| Model | How tenants are separated | See |
|---|---|---|
| Shared database | A tenant column on each row, enforced by the connection | This page |
| Database per tenant | Each request uses its tenant's database | Multitenancy with multiple databases |
The risk of the shared model is a query that forgets its tenant: it returns another customer's data and nothing fails. Connection filters and write rules remove that risk by making the tenant a property of the connection:
using var db = dbFactory.Open().ForTenant(tenantId);
var orders = db.Select<Order>(x => x.Total > 100);
SELECT "Id", "TenantId", "CustomerId", "Total"
FROM "Order"
WHERE ("Order"."TenantId" = @0) AND (("Total" > @1))
-- @0 = 1, @1 = 100
What the connection enforces​
| On a connection confined to a tenant | |
|---|---|
| Selects | Only return the tenant's rows, including joins, sub queries and referenced rows |
| Lookups by id | Return null for another tenant's row |
| Updates and deletes | Only change the tenant's rows |
| Inserts | Get the tenant when it isn't set |
| Writes for another tenant | Throw an InvalidOperationException |
| AutoQuery and AutoQuery CRUD | The same, including conditions sent by clients |
It applies to every typed API where OrmLite creates the SQL. See what isn't covered for the exceptions.
Tenant-owned tables​
Mark the tables a tenant owns with an interface, so one filter covers all of them:
public interface IHasTenantId
{
int TenantId { get; }
}
public class Order : IHasTenantId
{
[AutoIncrement]
public int Id { get; set; }
public int TenantId { get; set; }
public int CustomerId { get; set; }
public decimal Total { get; set; }
}
Every table is either owned by tenants or it isn't:
| Kind of table | Tenant column | Example |
|---|---|---|
| Owned by a tenant | Required, never nullable | Orders, files, members |
| Shared by every tenant | None | Plans, countries |
| Run by the platform | None | Webhook inbox, platform audit log |
INFO
Avoid a nullable tenant column. It means a table holds two kinds of row, a tenant's and the platform's, which can't
use Ensure as that sets the tenant on every row. Split it into two tables instead, e.g. a tenant's audit log
and the platform's.
Avoid owning a table indirectly too, e.g. an OrderLine that only has an OrderId. Give it its own TenantId, so
it's filtered directly instead of relying on every query to join its parent.
Confining a connection​
Declare your App's rules once in a FilterSet, and use it for every connection, so every connection is confined the same way:
public static class TenantDb
{
// The tenant's rows are the only rows a connection can read or change, and rows are written with the tenant
public static readonly FilterSet<int> TenantFilters = FilterSet.Create<int>(f =>
f.Ensure<IHasTenantId>(x => x.TenantId, tenantId => tenantId));
public static IDbConnection ForTenant(this IDbConnection db, int tenantId) =>
db.SetItem("TenantId", tenantId).UseFilters(TenantFilters.For(tenantId));
public static int? GetTenantId(this IDbConnection db) => db.GetItem<int?>("TenantId");
}
| API | Purpose |
|---|---|
| Ensure | The rows the connection can read, update and delete, and the tenant of the rows it writes |
| SetItem and GetItem | Lets code that's given a connection ask which tenant it's confined to |
Ensure guards both sides: without filtering, an update could move another tenant's row into the connection's
tenant, and without the write rule an insert could be written for another tenant.
In ServiceStack Apps​
ServiceStack opens the connections used by Db in your Services, AutoQuery, AutoQuery CRUD and the API Keys feature
for the current request. Register a DbConnectionRequestFilter in your AppHost to confine every one of them:
public override void Configure()
{
DbConnectionRequestFilters.Add((db, req) => {
var userId = req.GetUserId(); // the signed in user, or the user of an API Key
if (userId != null)
db.ForTenant(GetTenantId(userId)); // defined by your App
});
}
Your existing Services and AutoQuery APIs don't need to change:
public class OrderServices : Service
{
public object Get(GetOrder request) =>
Db.SingleById<Order>(request.Id) // null if it's another tenant's order
?? throw HttpError.NotFound("Order not found");
}
Return 404 for a row that isn't found rather than 403, as saying a row exists in another tenant leaks information.
Fail closed until the tenant is known​
Confining a connection when it's opened needs the tenant to be known then. Often it isn't: working out which tenant a user is working in needs the database, e.g. to look up their membership. A connection that's left unconfined until then is a connection where a forgotten check returns every tenant's rows.
Use a scope that belongs to the request, which throws until the tenant is resolved:
public class TenantScope
{
public int? TenantId { get; set; }
public int AssertTenantId() => TenantId
?? throw new InvalidOperationException("Resolve the request's tenant before using tables owned by a tenant.");
}
public static readonly FilterSet<TenantScope> TenantFilters = FilterSet.Create<TenantScope>(f =>
f.Ensure<IHasTenantId>(x => x.TenantId, s => s.AssertTenantId()));
public static IDbConnection ForTenant(this IDbConnection db, TenantScope scope) =>
db.SetItem("TenantScope", scope).UseFilters(TenantFilters.For(scope));
Rules read the scope for each statement, so the connection behaves differently before and after:
| When | Tenant-owned tables |
|---|---|
| Before the tenant is resolved | Any query or write throws |
| After it's resolved | Confined to the tenant |
Keep the scope in IRequest.Items, so every connection the request opens shares it, including the one AutoQuery
opens:
public static TenantScope GetTenantScope(this IRequest req)
{
if (req.Items.TryGetValue(nameof(TenantScope), out var existing) && existing is TenantScope scope)
return scope;
req.Items[nameof(TenantScope)] = scope = new TenantScope();
return scope;
}
DbConnectionRequestFilters.Add((db, req) => db.ForTenant(req.GetTenantScope()));
The request says which tenant it's for​
When users can belong to more than one tenant, have APIs for a tenant say which one in their Request DTO. It's then part of your API's contract, and users can work in different tenants in different browser tabs:
public interface IRequireTenant
{
int TenantId { get; set; }
}
public class QueryOrders : QueryDb<Order>, IRequireTenant
{
public int TenantId { get; set; }
}
The TenantId only says which tenant a request is for. The Request DTO is available from IRequest.Dto, so the
first connection that's opened for the request can check the user can use that tenant, before setting the request's
scope which confines every connection it opens:
DbConnectionRequestFilters.Add((db, req) =>
{
var scope = req.GetTenantScope();
db.ForTenant(scope);
if (req.Dto is IRequireTenant requireTenant && scope.TenantId == null)
{
// Whether the user is a member isn't known yet, so it's looked up across tenants
var userId = req.GetUserId();
if (!db.WithoutFilters().Exists<TenantMember>(x => x.TenantId == requireTenant.TenantId && x.UserId == userId))
throw HttpError.Forbidden("You do not have access to this tenant");
scope.TenantId = requireTenant.TenantId;
}
});
The connection is disposed when a filter throws, and the HttpError is returned to the client.
An API that uses a tenant-owned table and forgets IRequireTenant then fails on its first request, instead of
returning another tenant's data in production.
Writing rows​
Rows inserted on a confined connection get its tenant, so your App doesn't set it:
var order = new Order { CustomerId = 1, Total = 100 };
db.Insert(order);
order.TenantId; // 1
The object has the values that were written, so it can be returned from an API without reading it back. Writing a row for another tenant throws instead of being silently changed, as it's a bug in your App:
// Order.TenantId must be '1' on this connection
db.Insert(new Order { TenantId = 2, CustomerId = 1, Total = 100 });
// Rows can't be moved to another tenant either
db.UpdateOnly(() => new Order { TenantId = 2 }, where: x => x.Id == 1);
Working across tenants​
Some code legitimately works on more than one tenant: finding which tenants a user belongs to, a check that must be
unique across tenants, and platform administration. WithoutFilters() returns the same connection and transaction
without its filters and rules:
// Is the address taken by any tenant?
var taken = db.WithoutFilters().Exists<Tenant>(x => x.Slug == slug);
It's the only way to opt-out, so searching your code base for it finds every place tenant isolation is bypassed. Wrap it in a method of your own and limit which files can call it, e.g. with a test, so each new use is reviewed.
The connection it returns can use filter sets of its own, to drop the tenant filter and keep others, e.g. the audit rules:
public static IDbConnection AcrossTenants(this IDbConnection db, string userId) =>
db.WithoutFilters().UseFilters(AuditRules.For(userId));
For platform APIs that act on a single tenant, confine the request to that tenant instead of opting out, so the rest of the API can't reach anyone else's rows. With a scope that's setting the tenant an administrator asked for, after checking they're allowed to:
public object Any(GetCustomer request)
{
RequireAdmin();
Request.GetTenantScope().TenantId = request.TenantId; // every query below is for this customer
//...
}
INFO
A connection is confined once. Confining it again to another tenant throws, as its rows would need to belong to both, see Using a set more than once.
Auditing​
Write rules record who changed a row and when on every write, replacing any value from your App so the columns can be trusted:
public static readonly FilterSet<string> AuditRules = FilterSet.Create<string>(f => {
f.OnInsert<IAudit>(x => x.CreatedBy, userId => userId);
f.OnInsert<IAudit>(x => x.CreatedDate, _ => DateTime.UtcNow);
f.OnWrite<IAudit>(x => x.ModifiedBy, userId => userId);
f.OnWrite<IAudit>(x => x.ModifiedDate, _ => DateTime.UtcNow);
});
db.UseFilters(AuditRules.For(userId));
Add [IgnoreOnUpdate] to the created columns, so an update of every column can't change them.
Background jobs​
A connection a job opens itself isn't confined. Put the tenant in the job's request and confine its connection before it reads anything, so an id for another tenant's row is treated as if it doesn't exist:
public class DeleteFile
{
public int TenantId { get; set; }
public int FileId { get; set; }
}
using var db = dbFactory.Open().ForTenant(request.TenantId);
var file = db.SingleById<StoredFile>(request.FileId); // null if it isn't this tenant's
Jobs that process every tenant either run a statement across tenants with explicit conditions, or loop over tenants opening a confined connection for each, which also bounds their memory by the largest tenant.
API Keys​
API Keys can be bound to a tenant with their RefId or RefIdStr. The API Keys feature opens the
request's connection for its own APIs, so a filter on its table confines them:
f.Filter<ApiKeysFeature.ApiKey>((x, tenantId) => x.RefId == tenantId);
Users then only see and manage the API Keys of their tenant. To let a tenant's API Key call the same APIs as its user, see Allow Authenticated User APIs to API Keys.
Indexes​
Every query on a tenant-owned table is filtered by its tenant, so give each one an index or unique constraint that starts with the tenant column, followed by the columns it's queried by:
[UniqueConstraint(nameof(TenantId), nameof(Reference))]
[CompositeIndex(nameof(TenantId), nameof(Status), nameof(CreatedDate))]
public class Order : IHasTenantId
Make business keys unique within a tenant, not globally. A globally unique key lets one tenant discover or block the values another tenant uses.
What isn't covered​
| Not covered | What to do |
|---|---|
Complete SQL statements in SqlList, SqlScalar and ExecuteSql |
Include the tenant in the statement. SQL fragments in typed APIs are filtered |
Connections you open yourself, e.g. dbFactory.Open() in a job |
Confine them, e.g. with .ForTenant(tenantId) |
| Plugins that open a connection without the request | Check any plugin that stores rows per tenant |
| Files, caches and other stores | Include the tenant in their keys |
| Authorization | Confinement decides which rows are reachable. Roles and permissions are still checked by your App |
See What isn't filtered for the full list.
Testing​
As tenant-owned tables share an interface, one set of tests can cover all of them, including tables added later:
static readonly Type[] TenantTables = typeof(IHasTenantId).Assembly.GetTypes()
.Where(x => x.IsClass && !x.IsAbstract && typeof(IHasTenantId).IsAssignableFrom(x))
.ToArray();
[TestCaseSource(nameof(TenantTables))]
public void A_confined_connection_only_reads_its_tenants_rows(Type table)
{
using var db = dbFactory.Open().ForTenant(1);
Assert.That(SelectTenantIds(db, table), Is.EqualTo(new[] { 1 }));
}
static int[] SelectTenantIds(IDbConnection db, Type table) =>
db.CreateTypedApi(table).Select().Cast<IHasTenantId>().Select(x => x.TenantId).ToArray();
Seed two tenants, then assert that a confined connection can't select, update or delete the other tenant's rows, that inserts get its tenant, and that an unresolved connection throws.
Tests don't catch a Service that forgets to resolve its tenant, which is why failing closed matters: it fails on its first request.
Checklist​
For each tenant-owned table:
- A required tenant column, implementing your tenant interface
- An index or unique constraint that starts with the tenant column
- Business keys that are unique within a tenant
- Included in your export and deletion of a tenant
For the App:
- Request connections are confined with a
DbConnectionRequestFilter, and fail until the tenant is resolved WithoutFilters()is only called from reviewed code- Jobs carry their tenant and confine their connection
- Raw SQL includes the tenant
- Tests run against every tenant-owned table
Next steps​
- Multitenancy Guide walks through how a complete SaaS App applies each of these
- Connection Filters & Write Rules is the reference for every API on this page