Multi-Tenant¶
The multi-tenant module (Trysil.Http.MultiTenant/) provides tenant isolation for HTTP applications. Each tenant gets its own configuration and database connection, managed through a thread-safe singleton.
Architecture¶
The module consists of three classes:
| Class | Unit | Description |
|---|---|---|
TTTenantConfig |
Trysil.Http.MultiTenant.Config |
Abstract base for tenant configuration |
TTTenantConnection |
Trysil.Http.MultiTenant.Connection |
Creates connections for a tenant |
TTMultiTenant<T> |
Trysil.Http.MultiTenant |
Thread-safe singleton tenant registry |
TTTenant<T> |
Trysil.Http.MultiTenant |
Holds a tenant's name, config, and connection |
Defining a Tenant Configuration¶
Extend TTTenantConfig to provide tenant-specific connection parameters:
type
TMyTenantConfig = class(TTTenantConfig)
strict protected
function GetConnectionName: String; override;
function GetParameters: TTFireDACConnectionParameters; override;
public
constructor Create(const AName: String); override;
end;
function TMyTenantConfig.GetConnectionName: String;
begin
Result := 'tenant_' + FName;
end;
function TMyTenantConfig.GetParameters: TTFireDACConnectionParameters;
begin
// Load connection parameters for this tenant
// e.g., from a configuration file, database, or environment
Result.Server := 'db-server';
Result.DatabaseName := 'db_' + FName;
Result.Username := 'app_user';
Result.Password := 'secret';
end;
The Create constructor receives the tenant name. GetConnectionName must return a unique FireDAC connection name. GetParameters provides the database connection details. The constructor and GetParameters run outside any lock, and two requests for a new tenant can run them at once, twice for the same name: they have to be thread-safe, and GetParameters has to return the same parameters every time.
Using TTMultiTenant¶
TTMultiTenant<T> is a class-level singleton — it is created automatically on unit initialization and destroyed on finalization.
Get or Create a Tenant¶
var LTenant := TTMultiTenant<TMyTenantConfig>.Instance.GetOrAdd('acme');
// Access tenant properties
LTenant.Name; // 'acme'
LTenant.Config; // TMyTenantConfig instance
LTenant.Connection; // TTTenantConnection instance
GetOrAdd is thread-safe, and nothing of yours runs while it holds the write lock. Your TTTenantConfig is built and its connection registered before the lock is taken; the lock is then held only long enough to publish the tenant in the dictionary. That matters because GetParameters is where a host reads a file, queries a directory or asks a configuration service: while the write lock is held every other tenant's first access waits, readers included, so a slow GetParameters for one tenant used to be a stall for all of them.
The price is that two threads racing on the same new tenant both build a config and both register the connection, and only the winner's tenant is published. Registering the same name twice with the same parameters is a no-op - TTFireDACConnectionPool.RegisterConnection compares the signature and refuses only a different one - so the loser's work is discarded, not conflicting. If the parameters differ, the loser's registration is refused: that request raises ETTenantUnavailable, and the failure it records refuses the requests for that tenant that arrive before the winner has published it.
Resolving a tenant is on the anonymous path
The per-request context is built before the route is resolved and before authentication - see The context is built before the route is known - so a GetOrAdd called from its constructor is reached by an anonymous caller asking for a URI that does not exist, with a tenant header.
Three things keep that bounded, and the first two are yours. Resolve the name against the tenants you have before calling GetOrAdd. Build the connection on first use rather than in the constructor, if your authentication does not need it. The third is here: a name that cannot be a name is refused before anything is opened, and a tenant that failed to open is remembered for FailureCooldown so the second attempt costs nothing.
Validate the tenant name against a closed list
A tenant name is almost always request data - a header, a subdomain, a claim - and it ends up as a FireDAC connection definition name and, in most applications, as a database name or a path. Resolve it against the tenants you actually have before calling GetOrAdd; do not hand it whatever arrived.
GetOrAdd refuses a name that cannot be one at all, with ETTenantNameNotValid: it must start with a letter or a digit and hold at most 63 of those plus _, - and .. That stops ../, a separator smuggled into a connection string, and an empty name. It is a floor, not the check you owe: acme and wilecoyote both pass it, and only you know which of them exists. TTTenantName.IsValid is the same test, callable before you get there.
ETTenantNameNotValid descends from ETTenantUnavailable, so a host that already catches the latter keeps working; catch it first to answer 400 rather than 404.
A failed creation always raises ETTenantUnavailable, which carries TenantName and OriginalClassName and keeps the original message. GetOrAdd never lets the underlying exception through, so the class a host catches does not depend on timing:
try
LTenant := TTMultiTenant<TMyTenantConfig>.Instance.GetOrAdd(LName);
except
on E: ETTenantUnavailable do
raise ETHttpNotFound.CreateFmt('Unknown tenant %s', [E.TenantName]);
end;
That try..except is not decoration. ETTenantUnavailable descends from ETException, not from ETHttpException, so without it an unresolvable tenant answers 500 with the fixed body and the reason reaches the log only. If your TTTenantConfig used to raise an ETHttp* to turn an unknown host into a 404, that status no longer reaches the client on its own: catch and re-raise where you resolve the tenant. E.OriginalClassName says what failed underneath.
The reason it is a class of its own rather than the original exception re-raised: an exception cannot be faithfully reconstructed from a class reference in Delphi, because constructors are not virtual - rebuilding an ETHttpNotFound that way would call Exception.Create and leave its status code at zero.
Failures are rate limited. GetOrAdd records the name with a cooldown (FailureCooldown, 5000 ms by default, settable, 0 disables it), and calls inside that window fail without touching the disk. Failures are still not memoized permanently -- a tenant repaired by dropping its folder in place must not stay broken until restart -- but the cost of an anonymous caller rotating the Host header stops being a function of traffic. The failure table is capped at 128 names: expired entries are swept on every insert, and when it is full the entry closest to expiry is evicted, so a full table never means an unprotected one.
A tenant whose schema is behind¶
Column metadata are read from the database lazily, the first time an entity is used on a connection, and cached per connection and per type. In a per-database multi-tenant application that means a tenant that missed a migration starts normally and fails later in the day, on the first request that touches the entity whose column is not there.
The framework cannot check it for you: it does not know which entities your application has. You do, so ask for them where you resolve the tenant:
procedure TMyTenantConfig.WarmUp(const AContext: TTContext);
begin
AContext.GetMetadata<TOrder>();
AContext.GetMetadata<TCustomer>();
end;
Each call probes the table once and fills the cache, and a tenant whose schema is behind fails on that call, with the name of the entity in the message.
Create a Connection¶
var LConnection := LTenant.Connection.CreateConnection;
try
var LContext := TTContext.Create(LConnection);
try
// Per-tenant ORM operations...
finally
LContext.Free;
end;
finally
LConnection.Free;
end;
TTTenantConnection.CreateConnection uses TTFireDACConnectionFactory internally. The connection is registered automatically when the tenant is first created (AfterConstruction).
List All Tenants¶
var LNames := TTMultiTenant<TMyTenantConfig>.Instance.GetAll;
for var LName in LNames do
WriteLn(LName);
The order is not guaranteed and is not the order of registration: sort the result if you display it.
Remove a Tenant¶
Remove detaches the tenant from the registry -- TryGet and GetAll stop seeing it -- but does not destroy the instance. Tenants handed out by TryGet and GetOrAdd are borrowed references, and a thread that resolved one an instant earlier may be about to call Connection.CreateConnection on it; the write lock protects the structure, not the references already given away. The instance is released with the registry, on finalization.
Each Remove therefore costs one instance for the life of the process, because
a later GetOrAdd on the same name builds a new one rather than reviving the
old: reviving it would hand back the configuration read the first time, and
that is the one thing you are replacing when you go through the deregistration
sequence below. Remove is for a tenant that has gone away, or whose
definition you are about to replace -- not for a periodic sweep of the cache.
Remove means "forget the cache", not "revoke the customer": the FireDAC connection definition stays registered, and a GetOrAdd on the same name afterwards rebuilds the tenant rather than failing. Registering a definition that already exists unchanged is a no-op - it is not replaced - so the name never burns and nothing is torn down under a request thread. Registering a different definition under a name already in use still raises, as it always did: the name is the identity, and silently swapping the database behind it would turn a configuration mistake into a runtime one.
"Unchanged" is compared on the exact text of the parameter lines Trysil passed - DriverID first, then the ones your TTTenantConfig produced - sorted and matched byte for byte, values and parameter names alike. Two lists that FireDAC would consider the same definition but that differ in the case of a key, or in the spacing around the =, count as a conflict. Build the list the same way on every call and this never comes up; build it from user-facing text and it will.
Revoking a customer for real would mean closing the pool and deregistering the connection, which needs a use count on the borrowed references: that is design, and it is not what Remove does today.
Deregistering a connection¶
TTFireDACConnection.UnregisterConnection(AName) is the other half, and it is a hard teardown, not the inverse of a cache miss. It closes every open connection on that definition, destroys the physical connection host and its pool, and only then deletes the definition.
That is what makes it the way to replace a definition - registration refuses to overwrite one, so UnregisterConnection followed by RegisterConnection is the sequence - but it is also why it must not run with requests in flight on that name. A TTConnection another thread resolved an instant earlier keeps its object, and loses the FireDAC connection underneath it: the next statement on it raises. Quiesce the tenant first, the same way you would before any other destructive maintenance.
Integration with HTTP Server¶
In a typical multi-tenant REST API, resolve the tenant from the request -- a header, a subdomain, the first segment of the URI -- in your per-request context:
type
TAPIContext = class
strict private
FTenant: TTTenant<TMyTenantConfig>;
FConnection: TTConnection;
FContext: TTHttpContext;
public
constructor Create;
destructor Destroy; override;
end;
constructor TAPIContext.Create;
begin
inherited Create;
// Tenant name would come from the request
// (set during authentication, for example)
FTenant := TTMultiTenant<TMyTenantConfig>.Instance.GetOrAdd(FTenantName);
FConnection := FTenant.Connection.CreateConnection;
FContext := TTHttpContext.Create(FConnection);
end;
Putting the tenant in the log¶
TTHttpUser carries a writable Tenant, and TTHttpLogUser copies it into the
response log line as "Tenant". Nothing fills it for you: write it where you
already write Username and the areas, which is the Check of your
authentication class, and in the constructor of your base controller for the
anonymous routes.
It matters more than it looks. After an incident on an installation with one
database per tenant, the log says who, what and when, and without this it
does not say on which database. The only correlation left is Host, which
works for whoever separates tenants by subdomain and for nobody else - and the
two ways this page recommends, a header and a JWT claim, leave no trace at all.
A thread variable cannot stand in for it either: LogRequest and LogResponse
enqueue, and the writer runs on the log thread pool.
One Secret Per Tenant¶
Whatever carries the tenant name -- header, subdomain, URI -- it arrives from the
client, and the context is built before the request is authenticated: the
listener calls C.Create and only then the authentication class. So the tenant
that will serve the request is chosen from something nobody has verified yet.
With one signing secret shared by every tenant, that is a hole. A user of tenant
acme sends their own valid token with the header changed to globex: the token
verifies, because the secret is the same, and the request runs against another
customer's database with that user's privileges.
Give each tenant its own secret and the hole closes by itself, with no comparison to write and no ordering to fix. The unverified tenant name no longer decides who you are: it only decides which key you are verified against, and a token signed for another tenant fails that check.
The secret does not have to be stored per tenant -- derive it:
type
TJWTPayload = class(TTHttpJWTHS256Payload)
strict private
FTenant: String;
strict protected
function GetSecret: String; override;
public
property Tenant: String read FTenant write FTenant;
end;
function TJWTPayload.GetSecret: String;
begin
result := THashSHA2.GetHMAC(FTenant, TConfig.Instance.Authentication.Secret);
end;
Set Tenant from the request before verifying the token, in your Check, and
set it the same way when you issue one. Derive with an HMAC rather than
concatenating the two strings, so that no pair of tenant names can produce the
same key.
With RS256, choose the key from the tenant, not from the kid
GetVerificationKey(AKeyID) receives the kid read out of the token,
which is chosen by whoever sent the request. Returning the key that the
kid names reopens the hole this section closes: a user of acme sends
their own valid token, kid and all, with X-Tenant: globex, the
signature verifies against acme's public key, and the request runs on
globex.
Pick the key from the resolved tenant, exactly as GetSecret does above,
and let the kid select only among that tenant's key versions. A kid
that belongs to no key of that tenant is not an error to raise: return
nil, which Verify already turns into False.
The same applies to HS256 if you override GetSecretFor(AKeyID): it
receives that same untrusted kid. The example above is safe because the
default override delegates to GetSecret, which derives from the tenant -
an override that keys on the kid alone reopens the hole.
Thread Safety¶
TTMultiTenant<T>operations (GetOrAdd,GetAll,Remove) are protected by a multi-read exclusive-write lockTTIdentityMapis scoped toTTContext, not global — no cross-tenant cache collision- The listener builds a new context
Cfor each request and frees it afterwards, so whatCcreates - aTTContext, a connection - lives one request. This is isolation of state, not of authorization: nothing leaks between two requests, and which tenant a request ends up serving is decided by the name you resolve and by the key that name selects
Key Points¶
- Tenant names are case-insensitive (stored as lowercase internally)
TTTenantConfigis abstract — you must provideGetConnectionNameandGetParameters- The singleton is per-generic-type:
TTMultiTenant<TConfigA>andTTMultiTenant<TConfigB>are separate instances - Connection registration runs on the first
GetOrAddof a tenant, on every thread racing on it