Serves every tenant with the same APIs, schemas, hooks and permissions.
One database per tenant
Of the type of the instance ; the connection string lives in a row of a tenants table.
Name the tenant
crm::acme in the path, or the header x-am-tenant-username: acme.
Tokens
A person of a tenant gets a token which works for that tenant only.
A multi-tenant instance serves many customers (tenants) with one set of APIs. Every tenant has its own database, of the same type as the instance.
The APIs, schemas, hooks, settings and permissions of the instance are defined once and serve every tenant. Customers share the servers and the APIs, not the databases.
The instance keeps its own connection string. That database is the structure database: a request without a tenant works on it.
Supported for MongoDB, MySQL, MariaDB, SQL Server, PostgreSQL and Oracle instances.
A CRM is sold to two companies, Acme and Globex. Each one gets its own PostgreSQL database: crm_acme and crm_globex.
One instance named crm serves both. Its own connection string points to crm, the structure database, which holds the tables every tenant has: customers, orders, users.
The tenants live in a table tenants of another instance, catalog, one row per customer.
Use a table of any instance, with one row per tenant: a username column and a connection string column.
The table needs an active schema in API Maker.
To keep connection strings encrypted, add conversions.encryption to that column in its schema, and save the rows through the APIs of the table. API Maker decrypts it with the default secret when it connects.
For Oracle tenants, add columns for the username and the password of each tenant, and if a tenant connects with a privilege, a column for it: SYSDBA, SYSOPER, SYSASM, SYSBACKUP, SYSDG, SYSKM or SYSRAC. A tenant without a privilege connects as a plain user of its database.
import*asTfrom'types';letSecret:T.ISecretType|any={// ... common and your other keysmultiTenant:{crm:<T.IMultiTenantSecretObj>{instanceName:'catalog',// instance of the tenants tabledatabaseName:'public',collectionName:'tenants',usernameColumn:'username',connectionStringColumn:'connection_string',find:{active:true},// optional, added to the tenant lookup// Oracle only// oracleDBUsernameColumn: 'db_username',// oracleDBPasswordColumn: 'db_password',// oracleDBPrivilegeColumn: 'db_privilege', // optional : SYSDBA, SYSOPER... empty for a plain user},},};module.exports=Secret;
instanceName, databaseName, collectionName, usernameColumn and connectionStringColumn are required. A request of the instance fails with an error naming the missing keys otherwise.
A tenant must match its username and the find. With find: { active: true }, setting active to false suspends a tenant. One table can also keep the tenants of several instances apart, for example with find: { db_kind: 'pg' }.
Each multi-tenant instance chooses its own entry, so two instances can use two tenants tables.
A request never names the entry: the instance knows it. The tenant is all a request names.
GET /api/gen/admin/crm::acme/crm/customers # the customers of Acme, from crm_acme
GET /api/gen/admin/crm::globex/crm/customers # the customers of Globex, from crm_globex
GET /api/gen/admin/crm/crm/customers # no tenant: the structure database
GET /api/gen/admin/crm/crm/customers
x-am-tenant-username: acme
The rules:
The instance looks the tenant up in its own tenants table, the entry chosen as its Connection String Multi Tenant. One header therefore serves every multi-tenant instance, even instances with different tenants tables.
An empty header names no tenant: the structure database.
When the path and the header both name a tenant, the tenant of the path is used.
A tenant the table does not have, or which the find leaves out: 400, Unable to find tenant with username 'acme'.
crm:: or ::acme: 400, Please provide valid multi tenant instance name.
A tenant named for an instance which is not multi-tenant changes nothing: that instance has one database for every tenant.
Call a custom API with the tenant header. It reads the tenant in g.req.params, and the APIs it calls with g.sys.db and g.sys.system work on the database of that tenant: the header is passed on to them. Each instance they call finds the tenant in its own tenants table.
import*asTfrom'types';asyncfunctionmain(g:T.IAMGlobal){consttenant=g.req.params.tenantUsername;// 'acme', undefined without the header// The database of acme : no need to name the tenant.constcustomers=awaitg.sys.db.getAll({instance:'crm',database:'crm',collection:'customers'});constorders=awaitg.sys.db.gen.queryGen({instance:'crm',database:'crm',collection:'orders',find:{status:'paid'},});return{tenant,customers,orders};}module.exports=main;
fromamimportGasyncdefmain(g:G):tenant=g.req.params.tenantUsername# 'acme', None without the header# The database of acme : no need to name the tenant.customers=awaitg.sys.db.get_all('crm','crm','customers')orders=awaitg.sys.db.gen.query_gen('crm','crm','orders',find={'status':'paid'})return{'tenant':tenant,'customers':customers,'orders':orders}
<?phpfunctionmain(AmG$g){$tenant=$g->req->params->tenantUsername;// 'acme', null without the header// The database of acme : no need to name the tenant.$customers=$g->sys->db->getAll('crm','crm','customers');$orders=$g->sys->db->gen->queryGen('crm','crm','orders',find:['status'=>'paid']);return['tenant'=>$tenant,'customers'=>$customers,'orders'=>$orders];}
filestaticclassApi{publicstaticasyncTask<object?>Main(Gg){vartenant=g.Req.Params.GetString("tenantUsername");// "acme", null without the header// The database of acme : no need to name the tenant.varcustomers=awaitg.Sys.Db.GetAll(newGetAll("crm","crm","customers"));varorders=awaitg.Sys.Db.Gen.QueryGen(newQuery("crm","crm","orders"){Find=newJsonObject{["status"]="paid"}});returnnew{tenant,customers,orders};}}
packagecodeimport"apimaker/am"funcMain(g*am.G)(any,error){tenant:=g.Req.Params().String("tenantUsername")// "acme", "" without the header// The database of acme : no need to name the tenant.customers,err:=g.Sys.Db.GetAll(am.GetAll{Instance:"crm",Database:"crm",Collection:"customers"})iferr!=nil{returnnil,err}orders,err:=g.Sys.Db.Gen.QueryGen(am.Query{Instance:"crm",Database:"crm",Collection:"orders",Find:am.M{"status":"paid"}})iferr!=nil{returnnil,err}returnam.M{"tenant":tenant,"customers":customers,"orders":orders},nil}
useam::{Errors,G,GetAll,Query,Value,json};pubfnmain(g:&mutG)->Result<Value,Errors>{lettenant=g.req.params()["tenantUsername"].clone();// "acme", null without the header// The database of acme : no need to name the tenant.letcustomers=g.sys.db.get_all(GetAll::new("crm","crm","customers"))?;letorders=g.sys.db.query_gen(Query::new("crm","crm","orders").find(json!({"status":"paid"})))?;Ok(json!({"tenant":tenant,"customers":customers,"orders":orders}))}
importam.G;importam.GetAll;importam.JsonObject;importam.Query;importjava.util.List;importjava.util.Map;classApi{staticObjectmain(Gg){Stringtenant=g.req.getParams().getString("tenantUsername");// "acme", null without the header// The database of acme : no need to name the tenant.List<JsonObject>customers=g.sys.db.getAll(newGetAll("crm","crm","customers"));List<JsonObject>orders=g.sys.db.gen.queryGen(newQuery("crm","crm","orders").find(Map.of("status","paid")));returnJsonObject.of("tenant",tenant,"customers",customers,"orders",orders);}}
importam.Gimportam.GetAllimportam.Queryfunmain(g:G):Any?{valtenant=g.req.params.getString("tenantUsername")// "acme", null without the header// The database of acme : no need to name the tenant.valcustomers=g.sys.db.getAll(GetAll("crm","crm","customers"))valorders=g.sys.db.gen.queryGen(Query("crm","crm","orders").find(mapOf("status"to"paid")))returnmapOf("tenant"totenant,"customers"tocustomers,"orders"toorders)}
Another tenant, or the structure database, on purpose:
// In the instance name : the tenant of the name wins over the tenant of the request.constcustomersOfGlobex=awaitg.sys.db.getAll({instance:'crm::globex',database:'crm',collection:'customers'});// With the header of the call.constordersOfGlobex=awaitg.sys.db.count({instance:'crm',database:'crm',collection:'orders',headers:{'x-am-tenant-username':'globex'},});// An empty header : the structure database.consttemplates=awaitg.sys.db.getAll({instance:'crm',database:'crm',collection:'templates',headers:{'x-am-tenant-username':''},});
# In the instance name : the tenant of the name wins over the tenant of the request.customers_of_globex=awaitg.sys.db.get_all('crm::globex','crm','customers')# With the header of the call.orders_of_globex=awaitg.sys.db.count('crm','crm','orders',headers={'x-am-tenant-username':'globex'},)# An empty header : the structure database.templates=awaitg.sys.db.get_all('crm','crm','templates',headers={'x-am-tenant-username':''},)
// In the instance name : the tenant of the name wins over the tenant of the request.$customersOfGlobex = $g->sys->db->getAll('crm::globex', 'crm', 'customers');// With the header of the call.$ordersOfGlobex = $g->sys->db->count( 'crm', 'crm', 'orders', headers: ['x-am-tenant-username' => 'globex'],);// An empty header : the structure database.$templates = $g->sys->db->getAll( 'crm', 'crm', 'templates', headers: ['x-am-tenant-username' => ''],);
// In the instance name : the tenant of the name wins over the tenant of the request.varcustomersOfGlobex=awaitg.Sys.Db.GetAll(newGetAll("crm::globex","crm","customers"));// With the header of the call.varordersOfGlobex=awaitg.Sys.Db.Count(newCount("crm","crm","orders"){Headers=newJsonObject{["x-am-tenant-username"]="globex"},});// An empty header : the structure database.vartemplates=awaitg.Sys.Db.GetAll(newGetAll("crm","crm","templates"){Headers=newJsonObject{["x-am-tenant-username"]=""},});
// In the instance name : the tenant of the name wins over the tenant of the request.customersOfGlobex,err:=g.Sys.Db.GetAll(am.GetAll{Instance:"crm::globex",Database:"crm",Collection:"customers"})iferr!=nil{returnnil,err}// With the header of the call.ordersOfGlobex,err:=g.Sys.Db.Count(am.Count{Instance:"crm",Database:"crm",Collection:"orders",Headers:am.M{"x-am-tenant-username":"globex"},})iferr!=nil{returnnil,err}// An empty header : the structure database.templates,err:=g.Sys.Db.GetAll(am.GetAll{Instance:"crm",Database:"crm",Collection:"templates",Headers:am.M{"x-am-tenant-username":""},})iferr!=nil{returnnil,err}
// In the instance name : the tenant of the name wins over the tenant of the request.letcustomers_of_globex=g.sys.db.get_all(GetAll::new("crm::globex","crm","customers"))?;// With the header of the call.letorders_of_globex=g.sys.db.count(Count::new("crm","crm","orders").headers(json!({"x-am-tenant-username":"globex"})),)?;// An empty header : the structure database.lettemplates=g.sys.db.get_all(GetAll::new("crm","crm","templates").headers(json!({"x-am-tenant-username":""})),)?;
// In the instance name : the tenant of the name wins over the tenant of the request.varcustomersOfGlobex=g.sys.db.getAll(newGetAll("crm::globex","crm","customers"));// With the header of the call.varordersOfGlobex=g.sys.db.count(newCount("crm","crm","orders").headers(Map.of("x-am-tenant-username","globex")));// An empty header : the structure database.vartemplates=g.sys.db.getAll(newGetAll("crm","crm","templates").headers(Map.of("x-am-tenant-username","")));
// In the instance name : the tenant of the name wins over the tenant of the request.valcustomersOfGlobex=g.sys.db.getAll(GetAll("crm::globex","crm","customers"))// With the header of the call.valordersOfGlobex=g.sys.db.count(Count("crm","crm","orders").headers(mapOf("x-am-tenant-username"to"globex")),)// An empty header : the structure database.valtemplates=g.sys.db.getAll(GetAll("crm","crm","templates").headers(mapOf("x-am-tenant-username"to"")),)
Queries and table meta run in the database of the tenant too:
constrows=awaitg.sys.system.executeQuery({instance:'crm',database:'crm',query:'select status, count(*) as orders from orders group by status',});constcolumns=awaitg.sys.system.getTableMeta({instance:'crm',database:'crm',table:'orders'});// Another tenant : name it in the instance.constrowsOfGlobex=awaitg.sys.system.executeQuery({instance:'crm::globex',database:'crm',query:'select count(*) from orders'});
rows=awaitg.sys.system.execute_query('crm','select status, count(*) as orders from orders group by status',database='crm',)columns=awaitg.sys.system.get_table_meta('crm','crm',table='orders')# Another tenant : name it in the instance.rows_of_globex=awaitg.sys.system.execute_query('crm::globex','select count(*) from orders',database='crm')
$rows = $g->sys->system->executeQuery( 'crm', 'select status, count(*) as orders from orders group by status', database: 'crm',);$columns = $g->sys->system->getTableMeta('crm', 'crm', table: 'orders');// Another tenant : name it in the instance.$rowsOfGlobex = $g->sys->system->executeQuery('crm::globex', 'select count(*) from orders', database: 'crm');
varrows=awaitg.Sys.System.ExecuteQuery(newExecuteQuery("crm","select status, count(*) as orders from orders group by status"){Database="crm",});varcolumns=awaitg.Sys.System.GetTableMeta(newGetTableMeta{Instance="crm",Database="crm",Table="orders"});// Another tenant : name it in the instance.varrowsOfGlobex=awaitg.Sys.System.ExecuteQuery(newExecuteQuery("crm::globex","select count(*) from orders"){Database="crm"});
rows,err:=g.Sys.System.ExecuteQuery(am.ExecuteQuery{Instance:"crm",Database:"crm",Query:"select status, count(*) as orders from orders group by status",})iferr!=nil{returnnil,err}columns,err:=g.Sys.System.GetTableMeta(am.GetTableMeta{Instance:"crm",Database:"crm",Table:"orders"})iferr!=nil{returnnil,err}// Another tenant : name it in the instance.rowsOfGlobex,err:=g.Sys.System.ExecuteQuery(am.ExecuteQuery{Instance:"crm::globex",Database:"crm",Query:"select count(*) from orders"})iferr!=nil{returnnil,err}
letrows=g.sys.system.execute_query(ExecuteQuery::new("crm","select status, count(*) as orders from orders group by status").database("crm"),)?;letcolumns=g.sys.system.get_table_meta(GetTableMeta::default().instance("crm").database("crm").table("orders"))?;// Another tenant : name it in the instance.letrows_of_globex=g.sys.system.execute_query(ExecuteQuery::new("crm::globex","select count(*) from orders").database("crm"))?;
varrows=g.sys.system.executeQuery(newExecuteQuery("crm","select status, count(*) as orders from orders group by status").database("crm"));varcolumns=g.sys.system.getTableMeta(newGetTableMeta().instance("crm").database("crm").table("orders"));// Another tenant : name it in the instance.varrowsOfGlobex=g.sys.system.executeQuery(newExecuteQuery("crm::globex","select count(*) from orders").database("crm"));
valrows=g.sys.system.executeQuery(ExecuteQuery("crm","select status, count(*) as orders from orders group by status").database("crm"),)valcolumns=g.sys.system.getTableMeta(GetTableMeta().instance("crm").database("crm").table("orders"))// Another tenant : name it in the instance.valrowsOfGlobex=g.sys.system.executeQuery(ExecuteQuery("crm::globex","select count(*) from orders").database("crm"))
A custom API with enableCaching keeps its answers per tenant. Reset them for one tenant:
g.sys.cache.resetCacheCustomApisOfTenant("customers-report","acme")// without tenantUsername : every tenant
A custom API whose authProviders name a token generator on a users table of the instance needs a token of the tenant of the request, see Users of a tenant.
Reads the row of the tenant from the tenants table, decrypts its connection string if needed, and opens a connection pool for that tenant (for PostgreSQL, one per tenant and database). The next requests of the tenant reuse the pool.
A name the tenants table does not know opens no pool.
A new tenant needs no restart and no deployment: its first request reads its row. Onboarding a customer is creating its database and adding one row to the tenants table.
Logs record the tenant of each call.
The databases panel and the API testing page let you pick a tenant to work with its data.
The cached answers of a multi-tenant instance are kept per tenant: a tenant never gets the cached answer of another tenant, nor of the structure database.
A request naming its tenant in the path and one naming it with the header share their cached answers.
A write for one tenant drops the cached answers of that tenant. A write to the structure database drops those of every tenant.
The tables of an instance which is not multi-tenant are read by every tenant: a write made for any tenant drops the cached answers of all of them.
Custom APIs with caching keep their answers per tenant as well. A write to the database of a tenant drops the cached answers of the custom APIs reading that table, for that tenant.
awaitg.sys.cache.resetCacheDB({instance:'crm::acme',database:'crm',collection:'customers'});// acme onlyawaitg.sys.cache.resetCacheDB({instance:'crm',database:'crm',collection:'customers'});// the structure database and every tenant
awaitg.sys.cache.reset_cache_db('crm::acme','crm','customers')# acme onlyawaitg.sys.cache.reset_cache_db('crm','crm','customers')# the structure database and every tenant
$g->sys->cache->resetCacheDB('crm::acme', 'crm', 'customers'); // acme only$g->sys->cache->resetCacheDB('crm', 'crm', 'customers'); // the structure database and every tenant
awaitg.Sys.Cache.ResetCacheDB(newResetCacheDB("crm::acme","crm","customers"));// acme onlyawaitg.Sys.Cache.ResetCacheDB(newResetCacheDB("crm","crm","customers"));// the structure database and every tenant
if_,err:=g.Sys.Cache.ResetCacheDB(am.ResetCacheDB{Instance:"crm::acme",Database:"crm",Collection:"customers"});err!=nil{// acme onlyreturnnil,err}if_,err:=g.Sys.Cache.ResetCacheDB(am.ResetCacheDB{Instance:"crm",Database:"crm",Collection:"customers"});err!=nil{// the structure database and every tenantreturnnil,err}
g.sys.cache.reset_cache_db(ResetCacheDB::new("crm::acme","crm","customers"))?;// acme onlyg.sys.cache.reset_cache_db(ResetCacheDB::new("crm","crm","customers"))?;// the structure database and every tenant
g.sys.cache.resetCacheDB(newResetCacheDB("crm::acme","crm","customers"));// acme onlyg.sys.cache.resetCacheDB(newResetCacheDB("crm","crm","customers"));// the structure database and every tenant
g.sys.cache.resetCacheDB(ResetCacheDB("crm::acme","crm","customers"))// acme onlyg.sys.cache.resetCacheDB(ResetCacheDB("crm","crm","customers"))// the structure database and every tenant
API Maker reads Alice from the users table of crm_acme, and the token it returns is made for acme:
Requests of acme accept it, as long as Alice is in the users table of crm_acme.
A request for globex is refused with 401: Tenant from x-am-user-authorization and x-am-tenant-username should be same. Even when globex has a user with the same username and password.
A request without a tenant, on the structure database, is refused with 401.
A refreshed token keeps the tenant, whatever tenant the refresh call names.
A token made without a tenant, or from a users table in another instance, is not tied to a tenant: it is checked against the users table of the tenant each request names. To keep such a caller to its own tenant, compare g.req.params.tenantUsername with the tenant of the caller in a pre hook.
When the connection string of a tenant changes, call the Multi tenant instance updated system API. Every server drops the pools of that tenant, for PostgreSQL the pools of all its databases. The cached answers of the instance and of the tenants table are cleared, so the next request of the tenant reads its row again and connects to the new database.
Until then, the servers keep using the pools they have.
It also accepts an array of { instanceName, username }, and answers each item: { success: true, data: true }, or success: false with the error: an unknown instance (404) or an instance which is not multi-tenant (400).