Multi-tenancy — one database, each customer's rows kept apart
In practice: Rask.Data · authentication · cheat sheet.
A product sold to companies usually keeps every company's data in one database and makes sure no company
ever sees another's rows. Each company is a tenant. Getting that right by hand means a Where(x => x.TenantId == …) on every query, a stamp on every insert and a tenant column at the front of every unique
index, and one forgotten predicate is a data leak. Rask does all three from one declaration on the table.
It is opt-in. A table that says nothing is one table for everybody, and an app with no tenant-scoped table has nothing to configure.
Declaring a tenant-scoped table
One trait on the base partitions a table by tenant — PerTenant, one of the family that
chooses what a table carries:
public sealed class Invoice : Aggregate<Guid, Timestamps, PerTenant>
{
public string Reference { get; private set; } = "";
}
That gives the table a TenantId column and a query filter no read can compose away; its indexes are
what you write. A table that does not name it is shared. A child entity
takes its root's answer and needs no tenant column — see A child belongs to its root's tenant.
The column is the framework's, so the entity does not mention it. One that wants to read its tenant declares the property, and Rask maps the column to it:
public sealed class Invoice : Aggregate<Guid, Timestamps, PerTenant>
{
public Guid? TenantId { get; private set; }
}
A read needs no declaration: the read face of a tenant-scoped ROOT carries TenantId, read-only and in the
column's own type (Guid?, or int?/long? for numbered tenants), so a read
across tenants can say whose row each one is. A child's face has none — it belongs to its root's tenant.
using (Tenant.Across())
{
var account = await User.Where(u => u.Id == id).Select(u => new Account(u.IsAdmin, u.TenantId)).First();
}
Why a type argument. Like the other traits, it is read at compile time rather than reflected over, so a trimmed publish cannot lose it and quietly fall back to "shared".
Tenants that are numbered
An app whose tenants are rows with an integer key names PerTenant<int> (or PerTenant<long>), and that type is
the column's, the filter's and the stamp's. The column is a shadow int? until the aggregate declares it, and a
declared TenantId must have the same type:
public sealed class Destination : Aggregate<int, PerTenant<int>>
{
public int? TenantId { get; private set; } // optional — to read it; long? under PerTenant<long>
public string Name { get; private set; } = "";
}
using (Tenant.Use(42))
{
await Destination.Named("Budapest").Save(); // TenantId = 42
var mine = await Destination.OrderBy(d => d.Name); // WHERE TenantId = 42
}
Guid?, int? and long? are the three types a tenant is kept in — nullable, because a row has none until
it is saved — and any other is refused when the model is built. An aggregate that names plain PerTenant and declares nothing keeps a shadow
Guid?. A child needs no column at all: it is read through its root, so
it follows the root's number without saying anything.
Inside the framework the tenant is still a Guid — a job's row, a cache key — while Current.Tenant.Id is your
number again (your tenant row); a number
travels inside it by a fixed rule: the first eight bytes are zero and the last eight are the number,
big-endian. Tenant 42 is 00000000-0000-0000-0000-00000000002a, readable by eye on a job row or in a log, and
it is what lets a job enqueued in tenant 42 run in tenant 42. No generated Guid has those eight zero bytes,
so a real identifier is never taken for a number. Two consequences:
- A tenant that is not a number — a
Guidfrom a claim — met by a table that keeps a number is refused with an exception, on a read as on a write, rather than compared with something. So is a number too large for anintcolumn, which would otherwise truncate into another tenant's. - Tenant
0isGuid.Empty, which the batteries read as "no tenant". Number tenants from one.
Tenants kept as text
A table made by another tool may keep its tenant — still a Guid — as TEXT, in a column of its own name. Declare
the Guid? and say how it is stored; Rask filters and stamps in the column's own text, on the entity and on its
read face alike:
public sealed class Company : Aggregate<string, PerTenant>
{
public Guid? TenantId { get; private set; }
public static void Configure(EntityTypeBuilder<Company> builder)
{
builder.ToTable("Company");
builder.Property(c => c.TenantId).HasColumnName("tenantId").HasMaxLength(1000).HasConversion<string>();
}
}
The text is the Guid's lower-case form (0f9d5a3c-7c1e-4d52-9a54-2b6f1f0c8e11). A column that already holds
upper-case text is found only where text compares without regard to case — SQL Server's default collation does;
on SQLite or PostgreSQL either make the column's collation say so or store the lower-case form. The filter binds
the text as a parameter, so an index on the column is used.
A child belongs to its root's tenant
Only the aggregate root carries the tenant. Every entity under it — a child in the root's collection, a
grandchild in the child's — is part of that aggregate and belongs to the root's tenant, so it needs no tenant
column and you write nothing for it: no TenantId, no trait, no configuration, no filter. Its own read face
(Chip.Where(…)) is filtered through the root, along the path the aggregate's own collections already make —
an EXISTS on the parent's key and tenant, one per level for a grandchild.
public sealed class Driver : Aggregate<string, PerTenant>
{
public Guid? TenantId { get; private set; } // tenantId, text
public IReadOnlyList<Chip> Chips => _chips; // by driverId
}
public sealed class Vehicle : Aggregate<string, PerTenant>
{
public Guid? TenantId { get; private set; }
public IReadOnlyList<Chip> Chips => _chips; // by vehicleId
}
public sealed class Chip : Entity<string>
{
public string Code { get; private set; } = "";
public string? DriverId { get; private set; } // one of the two is set,
public string? VehicleId { get; private set; } // the other is NULL
}
using (Tenant.Use(acme))
{
var chips = await Chip.Where(c => c.Code.StartsWith("A")); // a driver's AND a vehicle's — Acme's only
}
WHERE EXISTS (SELECT 1 FROM Driver AS d
WHERE d.TenantId IS NOT NULL AND d.TenantId = @tenant AND d.Id = c.DriverId)
OR EXISTS (SELECT 1 FROM Vehicle AS v
WHERE v.TenantId IS NOT NULL AND v.TenantId = @tenant AND v.Id = c.VehicleId)
One type under two roots. A table that serves two parents is ONE child type, held by both collections; its
filter has an EXISTS for each tenant-scoped parent, OR'd, so Chip.Where(…) returns both kinds and only the
current tenant's. Each key is optional — a chip holds one and NULL in the other — and the read face has a
navigation back to each (chip.Driver, chip.Vehicle), null on the side it does not belong to. A chip taken
out of its parent's collection is deleted at the save, rather than left a row no parent holds.
What follows from reading through the root:
- Every read of the child —
Where,Count, a projection, anIncludefrom the root — asks the parent with anEXISTSthat seeks the parent's key, andTenant.Across()drops the question, as it does for any table. - "Through the root" means the parents' own filters: a child whose parent is soft-deleted is not read on its own
either. A row whose parent keys are all NULL belongs to nobody, and only
Tenant.Across()reads it. - A child is written only under a parent of the tenant in flight. A parent the context holds answers from its
own tenant; one it does not hold is looked up through the tenant filter, once per save. A child added to, moved
to, changed or deleted under another tenant's parent is refused with an
InvalidOperationException. - A unique index on the child names no tenant, so — like any index without
TenantId— it holds across every tenant, and its declared rule is asked about every tenant's rows. Put the parent's key in the index ((DriverId, Code)) when unique within the parent is the rule you mean. - The path is read from the model. A tenant-scoped child that a context maps with no relationship up to its aggregate is refused when the model is built, naming the entity — never read unfiltered.
A child that already keeps a column. An existing table may hold the tenant on its children too. Declare it
(public Guid? TenantId { get; private set; }, or int?/long?) and it is kept: stamped with the root's
tenant — inside Tenant.Across() as well — and refused at the save if it disagrees with the root's. It is NOT
filtered on: the child is still read through its root, so a legacy row whose column is NULL is its parent's, and
editing one is not refused for it. A member's name never turns tenant filtering on — public or internal,
declared or shadow, a TenantId on a child is data; only PerTenant on the aggregate root partitions. Before #1251 every child got a shadow TenantId column; one that relied on it and declares nothing now
reads through its root, and the next migration Rask makes drops that column. Read the migration, and declare
the property if the column should stay.
Where the tenant comes from
The signed-in user's. The tenant is an outcome of authentication rather than an input to routing: signing
in finds the user, and the user says which tenant they belong to. It travels on the ClaimsPrincipal as the
rask:tenant claim (Tenant.ClaimType), and the data layer reads it back — which is what lets a page do this
with nothing passed to it:
var invoices = await Invoice.OrderByDescending(i => i.CreatedAt).Take(20);
A restored session carries the claim too, so a reconnect comes back in the same tenant. The same holds for every HTTP request — a minimal API, a controller, a CQRS endpoint — because the host makes the request's services ambient for the data layer after authentication has run.
Which tenant the work is for is answered in this order: an explicit Tenant.Use scope first, then the signed-in
user's claim — or, in an app that registered one, its resolver in the claim's place
— and none inside Tenant.Across() or Tenant.None(). Your code reads it as your own tenant
row, Current.Tenant. See Current for the
user it answers alongside.
Putting a user in a tenant
Rask.Auth records the tenant on the account row but does not decide it — your app does, usually when an
invitation is accepted or a company signs up. Authenticatable has a protected RecordTenant, so your User
can say what joining a tenant means, and the registration lambda sets it in the same insert:
public sealed class User : Authenticatable
{
public void JoinTenant(Guid tenant) => RecordTenant(tenant);
}
await auth.Register(model.Email, model.Password, (User user) => user.JoinTenant(invite.TenantId), ReturnUrl);
A user with no tenant — an administrator — carries no claim, and is covered below.
From the request: a resolver
An app that knows its tenant some other way — a host name per customer, a header, a route value, a request service it already has — says so once, and that replaces the claim as the source:
builder.Services.AddRaskTenant(sp => sp.GetRequiredService<ICurrentRequest>().TenantId); // int?, long? or Guid?
The function is handed the services of the work in flight and returns its tenant, or null when it belongs to
none. A request asks it once, as it starts. A live session asks it at every event — as a connection
attaches (one that attaches while a handler runs, when that handler has finished: a handler works in one tenant
from start to end), before each handler, and as the page's address moves — so the tenant a page works in is the
one its user has now, as it would be if each update were a request. Current.Tenant, every filter and every stamp
then use the answer.
| With a resolver registered | |
|---|---|
Tenant.Use(id) / Tenant.Across() |
still win. A background job goes on running in the tenant its row recorded, and there the resolver is not even asked |
| a signed-in user with no tenant claim | works in the resolved tenant — an administrator on a customer's host, or anybody in an app that signs people in itself |
| a signed-in user whose claim names another tenant | refused: reading the tenant throws ForbiddenException |
the resolver returns null |
no tenant: reads are empty, writes are refused |
What sp is. Under a Rask host — RaskApp, or a host wired by hand with
AddRaskData<TContext>(o => …) and app.UseRaskData():
| The work | sp |
When it is asked |
|---|---|---|
| an HTTP request — an endpoint, a controller, the GET that renders a page | that request's RequestServices |
as the request reaches UseRaskData(), after authentication |
| a live session — a page's handlers, its re-renders | the session's own scope, which lives as long as the page is open | on the session's first render, inside the request that opened the page; then again as each connection attaches, before each handler and as the address moves. A render the server starts itself works in the tenant the last of those named |
Where a live page's resolver can read the tenant from. At an event the request in flight is the page's
connection — the socket's upgrade, or a post of the HTTP fallback — not the page's own GET:
| The tenant lives in | Read it from | At an event |
|---|---|---|
| the signed-in user | Current.Principal |
the session's principal as it is now: after a revalidator replaced it, after a reconnect brought a new token |
| the host name, or a cookie | IHttpContextAccessor |
the connection's request carries both, as the page's did |
the page's address — /t/{tenant}/… |
sp.GetRequiredService<RouteState>().Path |
the session's own route, which follows the page as it navigates in place. Not the request's path or its route values: the connection's request is /rask/ws |
a header only the page's request carried, a route value of the GET |
— | is not there. See below |
builder.Services.AddRaskTenant(sp => TenantIn(sp.GetRequiredService<RouteState>().Path)); // /t/7/orders → 7
builder.Services.AddRaskTenant(_ => PartnerOf(Current.Principal)); // the token's claim
A resolver that stops answering is told so. One that named a tenant when the page opened and names none at
an event — for the same user, on the same page — has not learned anything new: it read something only the
page's own request carried. Reading nothing then would look exactly like an empty table, so the first read of
the tenant throws a MissingTenantException that says so and names the two places above. A user who signed
out, or a page that navigated to an address with no tenant in it, is taken at the resolver's word: no tenant.
Three more things to know when writing one:
- A live session's scope is not a request's. A scoped object a middleware filled in for the request is a
different, empty instance in the session's scope — read the request through
IHttpContextAccessor, or resolve a singleton that does. - It also runs for background work that recorded no tenant — a recurring job the host scheduled — where
there is no request. Return
nullthen; do not assume one. - It must not read the tenant it is being asked for. Resolve it from the request, or from a table that is
not tenant-scoped (the table of tenants itself); a resolver that reads
Current.Tenantis told so.
What a session's queries read for the tenant before is dropped when the answer changes, and read again for the new one.
AddRaskTenant is called once; a second registration is refused at startup.
When the resolver names no tenant
await Invoice.Count(); // 0
await Invoice.Find(id); // null
await Invoice.For("A-1").Save(); // MissingTenantException
A request on a host no customer owns is an ordinary thing in an app that resolves its tenant from the request,
so it is not an error to read there: a tenant-scoped table is empty. It is never every tenant's rows, and
never the rows that belong to no tenant either. Every write to one — create, update, delete — is refused with
a MissingTenantException (an InvalidOperationException), because a row saved for nobody is a row nobody
can read. This is the one place the rule below gives way, and only for an app
that registered a resolver.
Your tenant row: Current.Tenant
Say which of your aggregates is the tenant, once, and Current.Tenant is that tenant's own row — as Rails'
Current.tenant and Laravel's tenant() are:
public sealed class Tenant : Aggregate<int, Deletable>, ITenant
{
public string Name { get; private set; } = "";
public string Currency { get; private set; } = "HUF";
public void Rename(string name) => Name = name;
}
var currency = Current.RequiredTenant.Currency; // TenantRead — the tenant's read face
if (Current.Tenant is { } tenant) title = tenant.Name; // null when the work belongs to no tenant
| Member | What it answers |
|---|---|
Current.Tenant |
the tenant's read face (TenantRead?), or null with no tenant, inside Tenant.Across() or Tenant.None() |
Current.RequiredTenant |
the same row, or an InvalidOperationException when there is no tenant |
Current.Tenant.Id |
your key, in its own type — int here; a Guid, a long or a string elsewhere |
The build generates both from the ITenant declaration. Its key is a Guid, an int, a long, or a string
holding a Guid (kept as text), and it must be the key your PerTenant tables keep in
TenantId — RASK114 otherwise. Two aggregates that say ITenant are
RASK113; a project with PerTenant tables and no ITenant is warned with the line to
add (RASK115).
It is not a query. The row is read once per tenant and held for the whole process: the framework reads it as
each request, live event, job, mail and outbox message starts, so Current.Tenant in a render, a handler or a
factory is a lookup. Read inside your own Tenant.Use(id) before anything has, it is read there and then — one
synchronous query, counted on the rask.data.current_row.queries metric (meter Rask.Data) — and held after.
It is always the tenant in flight. The row is looked up by the tenant the work is for now, so a nested
Tenant.Use(other) reads other's row and the outer one comes back when the scope ends. A tenant whose row is gone
— deleted, or never written — throws a MissingTenantException rather than reading as no tenant, and nothing is
held for it.
It is read-only. It is the read face, with nothing to save. To change the tenant, load the aggregate:
var tenant = await Tenant.Find(Current.RequiredTenant.Id);
tenant!.Rename(name);
await tenant.Save();
The save drops what every process-local copy held, so the next Current.Tenant reads the new row. Another server
sees it within 60 seconds, which is how long a held row is trusted.
Current.Tenant used to be the tenant's Guid. The framework keeps that internally — a job's row, a cache key, a
stored file still record it — and your code reads the row's Id instead.
Saying which tenant explicitly
using (Tenant.Use(acmeId)) // work as this tenant
{
await Invoice.Create(model);
}
using (Tenant.Across()) // deliberately span tenants — an admin tool, a migration
{
var total = await Invoice.Count();
}
An explicit scope always beats the principal, which is what a background job relies on. Tenant.None() clears
the tenant for its scope — the signed-in user's and the resolver's too — for a test, or for code that has to find
a user before it can know their tenant. Inside it Current.Tenant is null and a tenant-scoped read throws.
Administrators
An administrator belongs to no tenant, so they carry no tenant claim, and a tenant-scoped read throws until they say which tenant they are acting in. That is intended: an admin should choose a tenant, not silently read across all of them.
Acting in a tenant
With Rask.Auth, an administrator chooses once and every page works in it:
Ui.Button.OnClick(() => auth.ActInTenant(partner.Id, returnUrl: "/orders"))["Work in this partner"] // Guid or a number
Ui.Button.OnClick(() => auth.LeaveTenant(returnUrl: "/tenants"))["Back to all partners"]
The choice is kept on the sign-in — the Session row the cookie names (Session.ActingInTenant) — and comes
back as the rask:tenant claim each time the principal is rebuilt from it, so Current.Tenant answers it with
nothing else to configure:
| The chosen tenant | |
|---|---|
the next request, the page's first GET |
is Current.Tenant — the cookie resumes the session, and the session names it |
| a reconnect, a bearer token of the same sign-in | still is: both resume the same session |
| the user's other open live pages | work in it from their next event, and render again for it (below) |
| another device, a new sign-in | chooses for itself: the choice belongs to one session |
| signing out | ends the session, and the choice with it |
Who may choose is the role AuthOptions.ActInTenantRole — admin by default, null turns choosing off — and
only a user who belongs to no tenant: somebody signed in to one customer is refused rather than let into
another. Both are read from the user's row at every request, so taking the role away takes the chosen tenant
with it. On the Server host the click stores the choice and loads returnUrl as a fresh page; in WebAssembly
and from any other client it is POST /api/auth/tenant with { "tenant": "…" } (null leaves), which
needs the X-Rask-Auth header and is refused (403) when the browser says another site sent it
(Sec-Fetch-Site other than same-origin).
Pages already open follow; they are not ended. A live page of the same sign-in in another tab asks about its session before each event, finds a principal in another tenant, and renders again in it — every read is made afresh, as for a resolver's answer that changed. What a component holds in its fields stays, and cannot leak into the new tenant: a row belongs to the tenant it was loaded in, so saving one loaded before the switch is refused.
With a resolver, the resolver still decides. The chosen tenant is the
principal's claim, so a resolver that reads Current.Principal sees it; one that names a different tenant
refuses the request, as for any claim that disagrees with it.
With your own sign-in
An app that signs its users in itself — a refresh-token cookie, a JWT — has no Rask session row to keep the
choice on, so it says where the choice goes and Rask does the rest. Map the choice before MapRask:
// A cookie tied to the sign-in (read back by your scheme or a resolver)
app.MapRaskTenantChoice(
find: (Guid id, HttpContext http) => Partners.Find(http, id), // null: no such tenant (400)
onChosen: (http, partner) =>
{
PartnerCookie.Write(http, partner); // null partner: the admin left
return Task.CompletedTask;
});
// A numbered tenant in a reissued token
app.MapRaskTenantChoice(
find: (long id, HttpContext http) => Partners.Find(http, (int)id),
onChosen: async (http, partner) => TokenCookie.Write(http, await Tokens.Issue(http.User, partner)),
configure: o => o.BelongsToTenant = user => user.HasClaim(c => c.Type == "ownTenant"));
Rask owns the endpoint — POST /api/tenant (o.Path) with { "tenant": "…" }, the X-Rask-Auth header,
403 when the browser says another site sent it — and who may choose, by the same rules as Rask.Auth: a signed-in
user who holds o.Role (admin; read with IsInRole) and does not o.BelongsToTenant (nobody, by default —
answer from the account, not the tenant chosen now). The id is a Guid or a number; find turns it into your
tenant, and onChosen runs only for an allowed choice. Leaving posts null and hands onChosen a null tenant.
A live page chooses exactly as with Rask.Auth — Auth.ActInTenant(partner.Id, returnUrl: "/orders"),
Auth.LeaveTenant(returnUrl: "/tenants") — and the choice is handed to onChosen on the browser's own request
(the sign-in relay's single-use ticket), which then loads returnUrl in the new tenant. From WebAssembly or any
other client, post to the path yourself.
A scope of your own
An admin page that works in a tenant for one piece of work, rather than for the whole sign-in, opens a scope around it:
private Guid _tenant; // chosen from a list of tenants
private async Task Load()
{
using (Tenant.Use(_tenant))
{
_invoices = await Invoice.OrderByDescending(i => i.CreatedAt);
}
}
A report over every tenant is Tenant.Across() — one greppable thing a reviewer can find.
A read with no tenant throws
await Invoice.All; // InvalidOperationException, when nothing says which tenant
Deliberately, and it is the decision most worth understanding. Returning nothing would be safe against leaks and indistinguishable from an empty database — the failure that costs the most time to find. Returning everything would be the leak itself. So it refuses, and says how to say which tenant.
An app that registered a resolver has told Rask that "no tenant" is an answer it gives on purpose, and there the read is empty instead — see When the resolver names no tenant.
IgnoreQueryFilters() does not cross tenants
await Invoice.IgnoreQueryFilters(); // includes soft-deleted; SAME tenant
It means "include soft-deleted rows" and leaves the tenant filter exactly where it is, so an existing call
never quietly becomes a cross-tenant read the day an aggregate names PerTenant. Crossing tenants is
Tenant.Across(). This uses EF Core 10's named query filters.
An index is what you write
Rask adds nothing to an index. A key that is unique within a tenant names TenantId in the index; a key that
is unique across every tenant — a sign-in name, an external reference — does not:
public static void Configure(EntityTypeBuilder<User> builder)
{
builder.HasIndex(u => u.Username).IsUnique("Ez a felhasználónév foglalt."); // every tenant's rows
builder.HasIndex(u => new { u.TenantId, u.Badge }).IsUnique("This badge is handed out."); // one tenant's rows
}
Put TenantId first in an index that holds per tenant: the filtered query reads it from the front. A declared
TenantId is what the second line needs; for a shadow one, name it as a string —
builder.HasIndex(Columns.TenantId, "Badge"). An index is kept exactly as written, wherever it was declared —
Configure, an [Index] attribute, a context of your own — with its message,
its HasDatabaseName("…") and its filter, so a violation is recognised by the name the database reports.
Its unique rule is asked the same way — by a form bound to the generated model
and by the check before the save — over exactly the index's columns:
one that names TenantId about the row's tenant, one that does not about every tenant's rows, so a name another
tenant holds is refused at the field and at the save with the index's message, and never tells the caller whose
it is. Only the tenant filter is lifted for that question; a soft-deleted row counts the way it does for any
other rule.
Writes stamp it, and it never moves
A create records the tenant in flight — the signed-in user's, with no Tenant.Use needed. An update that
would move a row to another tenant is refused: copy the row into the other tenant instead. A row belongs to
the tenant it was loaded in: saving or deleting one while another tenant is in flight — or none — is refused
with an InvalidOperationException, and so is adding a row that already names another tenant, or a child to an
aggregate loaded in another — or under another tenant's parent, for a child
read through its parent; Tenant.Across() is how to do any of them on purpose. The column is never
on the generated form model, declared or not, so a post cannot set it. Inserting a tenant-scoped row inside Tenant.Across()
is refused too, unless the row's TenantId is already set, because "across" names no tenant to stamp.
The batteries
Rask's own tables carry a tenant without being partitioned by one. A partitioned table takes a query filter, and each of these has a background runner that must see every tenant's rows — so the tenant is data on the row, recorded when the work is created and honoured when it is used.
| Battery | What tenancy means there |
|---|---|
| Jobs, mail, outbox | Each row records the tenant in flight when it is enqueued, and the runner re-enters it before handling, sending or publishing — so a handler that reads a tenant-scoped table works as it did on the page. A job records the user too, so Current.UserId answers in its handler. |
| Cache | One cache, isolated by the key: with a tenant in flight the key is scoped to it, so each tenant gets its own value under the same name and cannot form another's key. With no tenant the key is untouched, which keeps anonymous requests — ASP.NET session and output caching — working. |
| File storage | A file belongs to the tenant that saved it. Looking one up, opening it, handing out a temporary URL and deleting it are scoped to the tenant in flight, so a tenant holding another's file id still cannot reach it. The orphan sweep sees every tenant's files. |
| Accounts | An address is unique within a tenant, so one person can hold an account at two companies; sign-in refuses an address that two tenants hold rather than guessing. |
A row created by the host itself — a recurring job scheduled at startup — or inside Tenant.Across() belongs
to no tenant, which is an ordinary answer rather than an error.
What it needs from the host
The context passes itself to the conventions, and says it can answer which tenant:
public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
: RaskDbContext(options)
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyRaskConventions(this); // `this`, so the filter can read the tenant
}
}
rask new writes that, and RaskDbContext implements ITenantScoped; a context over plain DbContext
declares : DbContext, ITenantScoped itself. The argument is not decoration: a query filter is compiled into
EF Core's cached model, so reading the tenant through a static is evaluated once and inlined into the SQL
as a literal — the first tenant to run a query would pin that value for every tenant after it. Reaching it
through the context makes EF lift it to a real parameter and re-bind it per query. The parameterless
ApplyRaskConventions() refuses a model with a tenant-scoped table rather than build a filter it cannot.
The auditing interceptors must be registered, as they already must be for CreatedAt. Without them nothing
stamps TenantId, every insert stores null, and the rows are invisible to every tenant — an empty result
rather than an error.
Outside a Rask app, the host also has to say who is signed in: register an IPrincipalSource (scoped) that
returns the session's or request's principal, and open Db.UseScope(scope) around each request's work so a
static read can reach it. See Wiring, when Rask is not hosting. The
same scope is where a resolver is found and called: with no Db.UseScope open
there is nothing to ask, and a tenant-scoped read throws as it does in an app without one.
What reads or writes without the filter
Worth knowing exactly, because each is a place a review should look:
| Path | What it does | Why |
|---|---|---|
Tenant.Across() |
reads every tenant's rows, and the rows that belong to none | the one deliberate way across; an insert inside it is refused unless the row already says its tenant |
IgnoreQueryFilters() on EF Core's own IQueryable |
lifts every filter, the tenant's included | EF Core's meaning of the call. Invoice.IgnoreQueryFilters() — Rask's — lifts soft delete only |
raw SQL — FromSql, ExecuteSql, SqlQuery |
whatever the SQL says | Rask does not rewrite SQL; a FromSql composed over an entity set still gets the filter |
BulkInsert with SkipChangeTracking |
writes rows with no tenant stamp | it replaces the change tracker, and the interceptors with it. Set TenantId on each row, or use the default path |
a row whose TenantId the entity set itself |
is saved into that tenant — inside Tenant.Across(), or when it is the tenant in flight; refused otherwise |
the stamp fills an unset tenant and leaves a set one alone; only the entity can set it, through its private setter |
a context of your own that does not call ApplyRaskConventions(this) |
no filter, no stamp | the conventions are what add them |
a second mapping of the same table — a legacy DbContext beside Rask's |
that context's own rules | Rask filters what goes through its contexts and nothing else |
Everything else — Where, Find, Count, Update, Delete, Save, AsQueryable(), a child's own read
face, an Include, ExecuteUpdate/ExecuteDelete over a set — goes through the filter.
On each provider
| SQLite | PostgreSQL | SQL Server | |
|---|---|---|---|
| tenant filter | ✅ | ✅ | ✅ |
| account uniqueness per tenant | ✅ | ✅ | ✅ |
The accounts index is over a column that folds a null tenant to Guid.Empty, not over the nullable tenant
itself, because NULL in a unique index is not portable: SQLite and PostgreSQL treat two NULLs as
distinct — so any number of administrators could share one address — while SQL Server treats them as equal,
so only one could. Same schema, three behaviours, and the kind that passes every test on the default
provider. Folding the null away makes one ordinary index that behaves identically everywhere.