Rask
Rask
v0.23.1-alpha.0.738
You're reading the Rask v0.23.1-alpha.0.738 guides. Updated .View source
All guides

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 Guid from 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 an int column, which would otherwise truncate into another tenant's.
  • Tenant 0 is Guid.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, an Include from the root — asks the parent with an EXISTS that seeks the parent's key, and Tenant.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 null then; 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.Tenant is 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.

Confirm