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
orgidis a 404, not an empty result. - The caches are per-process and never invalidated. Changing an org's
databasefield requires a restart to take effect. - Collections are per-tenant.
customerin org A andcustomerin org B are unrelated collections with independent indexes.
Resolving the orgid
CurrentUserMiddleware looks in four places, in order:
orgidheaderorgidquery parameterorgidbody fieldorgid/orgIdcookie
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
| Path | Why |
|---|---|
/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.png | Browser 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.comThe 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:
/repository/org-by-hostname/:hostnameJWTPlatform-level orgs
Two env vars name orgs with special standing:
| Var | Role |
|---|---|
ROOT_ORG | The platform's own org. Returned in every sign-in response as rootOrg. |
SHARED_ORG | Backs 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
/repository/org/createJWT/repository/org/:orgidJWT/repository/org/update/:orgidJWT/repository/org/delete/:orgidJWT/repository/org/user/:emailJWT/repository/org/query/:datatypeJWTGET /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.