docs
/
Platform

Multi-tenancy

How an orgid becomes a database, how domains resolve to tenants, and where the isolation boundary actually is.

Database per organization

AppEngine does not filter by tenant column. It switches databases.

async getDatabase(orgId): Promise<Db> {
  let dbName = this.siteToDB[orgId];
  if (!dbName) {
    const orgModel = await this.getOrg(orgId);
    if (!orgModel) throw new NotFoundException('Organization or Site Not Found: ' + orgId);
    dbName = orgModel.data.database;
    this.siteToDB[orgId] = dbName;
  }
  if (!this.dbconns[dbName]) {
    this.dbconns[dbName] = (await this.ensureClient()).db(dbName);
  }
  return this.dbconns[dbName];
}

The org record carries data.database. Two in-process caches — siteToDB (org → database name) and dbconns (database name → connection) — mean the lookup happens once per org per process.

Consequences worth stating plainly:

  • Isolation is structural. A query that forgets a tenant filter still cannot cross tenants, because it is running against a different database.
  • An unknown orgid is a 404, not an empty result.
  • The caches are per-process and never invalidated. Changing an org's database field requires a restart to take effect.
  • Collections are per-tenant. customer in org A and customer in org B are unrelated collections with independent indexes.

Resolving the orgid

CurrentUserMiddleware looks in four places, in order:

  1. orgid header
  2. orgid query parameter
  3. orgid body field
  4. orgid / orgId cookie

If an array arrives, the first element wins. With nothing found, the request fails immediately:

{ "code": "missing_orgid", "message": "Organization ID is required. Pass orgid as a header, query param, or body field." }

Routes exempt from the requirement

PathWhy
/health, /readiness (and ?… forms)A health check that needs a tenant header is not a health check — Kubernetes cannot send one.
/favicon.ico, /icons/manifest-icon-192.pngBrowser chrome.
/connect/oauth2callback/*The provider redirects here without headers; falls back to SHARED_ORG.
/connect/webhook/*The org is parsed out of the URL path itself.
/org-management/business-made-register, /org-management/check-org-name/*Pre-signup — the caller has no org yet. Falls back to ROOT_ORG, then SHARED_ORG, then appmint. The controller enforces an Origin allow-list instead.

Domain-based tenancy

One deployment can serve many customer domains. That is the shared org pattern.

Send three things:

domainAsOrg: true
shared-org-id: <the shared org>
x-client-host: customer-domain.com

The middleware then resolves the real tenant:

1. An explicit override wins If x-client-orgid is present, that value is used directly.

2. Otherwise resolve the host getOrgIdByDomainName(x-client-host) maps the domain to an org, and orgid is rewritten to it.

3. Identity still resolves against the shared org With domainAsOrg set, user and customer lookups run against shared-org-id, not the resolved tenant. The session lives in the shared org; the data lives in the tenant's. Failure here is logged and swallowed — the request continues with the original orgid rather than erroring. Silent fallthrough is worth knowing about when a domain mapping looks like it is being ignored.

The same mapping is available directly:

GET/repository/org-by-hostname/:hostnameJWT

Platform-level orgs

Two env vars name orgs with special standing:

VarRole
ROOT_ORGThe platform's own org. Returned in every sign-in response as rootOrg.
SHARED_ORGBacks multi-domain hosting and receives OAuth callbacks that arrive without a tenant. Returned as sharedOrg.

The RootSystem, RootAdmin, RootUser and RootPowerUser roles are the cross-org roles that operate at this level. RootSystem bypasses role checks in JwtAuthGuard outright.

Managing organizations

POST/repository/org/createJWT
GET/repository/org/:orgidJWT
POST/repository/org/update/:orgidJWT
DELETE/repository/org/delete/:orgidJWT
GET/repository/org/user/:emailJWT
POST/repository/org/query/:datatypeJWT

GET /repository/org/user/:email answers "which orgs does this person belong to" — the account switcher depends on it. OrgManagementModule (/org-management/*) handles signup, provisioning and org-level settings.

Scanner noise

Before any of the above, the middleware answers automated vulnerability probes with a bare 404 and logs nothing:

  • By path — extensions this app never serves (.php, .env, .sql, .bak…) and well-known prefixes (/wp-, /phpmyadmin, /.git, /.aws, /vendor/…).
  • By query string — PHP-CGI argument injection (allow_url_include, auto_prepend_file, php://input), ThinkPHP and pearcmd probes, where the path itself is innocent.

It matches on request.originalUrl, not request.path — with the middleware mounted on *, Express rewrites req.url to / on every request, so a req.path test would match nothing at all.