diff --git a/db.js b/db.js index d1447c9eed..da5e1334dc 100644 --- a/db.js +++ b/db.js @@ -38,6 +38,34 @@ function deriveTenantDomain(domains) { } module.exports.deriveTenantDomain = deriveTenantDomain; +// Build the per-tenant document id for shared-MongoDB multi-tenant mode. Off OPENFRAME_MODE +// (or with no derived tenant) returns baseId unchanged — vanilla single-tenant behavior. +// Used for global-state docs in the main collection that would otherwise collide between +// pods sharing the same database (DatabaseIdentifier, SchemaVersion, LoginCookieEncryptionKey, +// InvitationLinkEncryptionKey, dbconfig). +function tenantScopedDocId(baseId, domains) { + if (process.env.OPENFRAME_MODE !== 'true') return baseId; + var d = deriveTenantDomain(domains); + return d ? (baseId + '_' + d) : baseId; +} +module.exports.tenantScopedDocId = tenantScopedDocId; + +// Filter an array of typed docs to only those that belong to the pod's derived tenant +// (or the default '' domain — same allow-list as the ChangeStream filter in +// SetupDatabase). Off OPENFRAME_MODE or without a derived tenant the array passes +// through unchanged, preserving vanilla single-tenant behavior. +// +// Used to neuter cross-tenant exposure in code paths that pull all docs of a type +// from the shared collection (GetAllType) without going through a domain-scoped DB +// helper — runtime caches in webserver.js, the SetupDatabase cleanup pass, etc. +function filterDocsToTenantDomain(docs, domains) { + if (process.env.OPENFRAME_MODE !== 'true' || !Array.isArray(docs)) return docs; + var d = deriveTenantDomain(domains); + if (!d) return docs; + return docs.filter(function (x) { return x && (x.domain === d || x.domain === '' || x.domain == null); }); +} +module.exports.filterDocsToTenantDomain = filterDocsToTenantDomain; + module.exports.CreateDB = function (parent, func) { var obj = {}; var Datastore = null; @@ -75,7 +103,12 @@ module.exports.CreateDB = function (parent, func) { obj.dbRecordsEncryptKey = null; obj.dbRecordsDecryptKey = null; obj.changeStream = false; - obj.pluginsActive = ((parent.config) && (parent.config.settings) && (parent.config.settings.plugins != null) && (parent.config.settings.plugins != false) && ((typeof parent.config.settings.plugins != 'object') || (parent.config.settings.plugins.enabled != false))); + // Plugins use the shared `plugins`/`pluginpermissions` collections without a domain field + // and the in-tree pluginpermissionlist UI dumps users/meshes/nodes from every tenant + // (meshuser.js:getpluginpermissionlist). Force-disable in OPENFRAME_MODE — the OpenFrame + // integration does not use MeshCentral plugins, so this trades nothing for a cross-tenant + // safety guarantee. Toggle via setting plugins.enabled if a tenant ever needs them. + obj.pluginsActive = (process.env.OPENFRAME_MODE !== 'true') && ((parent.config) && (parent.config.settings) && (parent.config.settings.plugins != null) && (parent.config.settings.plugins != false) && ((typeof parent.config.settings.plugins != 'object') || (parent.config.settings.plugins.enabled != false))); obj.dbCounters = { fileSet: 0, fileRemove: 0, @@ -374,9 +407,14 @@ module.exports.CreateDB = function (parent, func) { // List of valid identifiers var validIdentifiers = {} - // Load all user groups + // Load all user groups. + // In OPENFRAME_MODE, restrict the cleanup pass to this pod's tenant: every pod runs + // SetupDatabase at startup and writes back fixes (obj.Set on user/mesh below). Without + // domain filtering N pods race on each other's docs and cross-tenant data lands in + // this pod's logs and validIdentifiers set. obj.GetAllType('ugrp', function (err, docs) { if (err != null) { parent.debug('db', 'ERROR (GetAll user): ' + err); } + docs = filterDocsToTenantDomain(docs, parent.config.domains); if ((err == null) && (docs.length > 0)) { for (var i in docs) { // Add this as a valid user identifier @@ -387,6 +425,7 @@ module.exports.CreateDB = function (parent, func) { // Fix all of the creating & login to ticks by seconds, not milliseconds. obj.GetAllType('user', function (err, docs) { if (err != null) { parent.debug('db', 'ERROR (GetAll user): ' + err); } + docs = filterDocsToTenantDomain(docs, parent.config.domains); if ((err == null) && (docs.length > 0)) { for (var i in docs) { var fixed = false; @@ -425,9 +464,11 @@ module.exports.CreateDB = function (parent, func) { } // Remove all objects that have a "meshid" that no longer points to a valid mesh. - // Fix any incorrectly escaped user identifiers + // Fix any incorrectly escaped user identifiers. + // Tenant-scoped in OPENFRAME_MODE for the same reason as the user/ugrp passes above. obj.GetAllType('mesh', function (err, docs) { if (err != null) { parent.debug('db', 'ERROR (GetAll mesh): ' + err); } + docs = filterDocsToTenantDomain(docs, parent.config.domains); var meshlist = []; if ((err == null) && (docs.length > 0)) { for (var i in docs) { diff --git a/docs/multi-tenant-shared-mongodb.md b/docs/multi-tenant-shared-mongodb.md index d750a1f7d6..b21b804b17 100644 --- a/docs/multi-tenant-shared-mongodb.md +++ b/docs/multi-tenant-shared-mongodb.md @@ -48,8 +48,11 @@ This means user-facing API endpoints are already tenant-isolated by design. ## Problems with the vanilla codebase (before changes) -Running multiple MeshCentral instances against one database out-of-the-box had three -issues: +Running multiple MeshCentral instances against one database out-of-the-box had nine +issues — three structural (1-3), three cryptographic (4-6) where global secrets in +the main collection let one tenant forge tokens valid in another, and three runtime- +exposure (7-9) where unscoped GetAllType pulls and the plugin subsystem leaked +cross-tenant data into in-memory caches and admin UIs. ### 1. `DatabaseIdentifier` and `SchemaVersion` conflicts @@ -83,6 +86,56 @@ MeshCentral's `removeDomain(domainName)` helper calls `powerfile.deleteMany({ domain: domainName })`. Without the field, deleting a tenant never cleaned up its power events. +### 4. `LoginCookieEncryptionKey` shared across tenants + +`meshcentral.js:2175` and the on-demand login-token endpoints +(`getLoginToken`, `showLoginTokenKey`) read/write the doc +`{ _id: 'LoginCookieEncryptionKey', key: }`. With multiple pods on one DB the +first pod to start generates the key; every other pod reads and uses it. Login +cookies (`encodeCookie({ u: userid, ... }, key)`) are then signable by any tenant +admin against any tenant's userid — cross-tenant cookie forgery. + +### 5. `InvitationLinkEncryptionKey` shared across tenants + +Same shape as #4: `meshcentral.js:2185` writes a single global +`{ _id: 'InvitationLinkEncryptionKey' }`. Invitation tokens (delivered via URL — not +gated by browser SameSite/Domain rules like cookies are) become forgeable across +tenants. + +### 6. `dbconfig.amtWsEventSecret` shared across tenants + +`meshcentral.js:1753` reads/writes `{ _id: 'dbconfig' }`, which holds +`amtWsEventSecret` — a 256-bit HMAC key used to derive AMT WSMAN event credentials +(`webserver.js:5816`). Sharing it lets one tenant generate valid AMT event +credentials for any node in any other tenant. + +### 7. Runtime user/mesh/ugrp caches loaded across all tenants + +At startup `webserver.js:291` calls `obj.db.GetAllType('user', ...)` (and the same +for `mesh`, `ugrp`) and stores every returned document in `obj.users`, `obj.meshes`, +`obj.userGroups`. The ChangeStream filter from #2 keeps those caches from receiving +foreign-domain *updates*, but the initial load is unfiltered: each pod pre-populates +its in-memory state with every other tenant's identities. Memory grows O(N tenants) +per pod, and any code path that later does `obj.users[someId]` without re-checking +`u.domain` becomes a cross-tenant leak. + +### 8. SetupDatabase cleanup writes back fixes for foreign-domain docs + +The cleanup pass at `db.js:390-441` calls `GetAllType` for `ugrp`, `user`, `mesh` +during `SetupDatabase` to: build a global `validIdentifiers` set, fix mistyped +timestamps (`obj.Set(user)`), and prune stale links (`obj.Set(mesh)`). With shared +collections every pod processes every other tenant's docs at startup. Multiple pods +race on writes to the same documents, and one tenant's view of "valid identifiers" +ends up shaping cleanup decisions on another tenant's data. + +### 9. Plugin subsystem dumps cross-tenant data and uses unscoped collections + +`meshuser.js:4783-4813` (`getpluginpermissionlist`) sends every `user`, `ugrp`, +`mesh`, `node` from the database to the requesting site admin — used by the plugin +permission UI. The `plugins` and `pluginpermissions` collections themselves carry no +`domain` field. Any tenant admin opening the plugin UI sees other tenants' identities; +any tenant installing or configuring a plugin affects the whole shared cluster. + ## Changes made ### `db.js` — `deriveTenantDomain` helper + domain-scoped startup identifiers @@ -179,6 +232,153 @@ The domain is derived from `nodeid` which always has the format `node/{domain}/{ Server-level power events (`nodeid: '*'`) are left without a domain — they are infrastructure-level markers and not tenant-specific. +### `db.js` — `tenantScopedDocId` helper for shared global-state docs + +**File:** `db.js` + +Generalises the suffix-with-domain pattern from #1 so it can be reused by other call +sites in `meshcentral.js`. Returns `baseId` unchanged outside `OPENFRAME_MODE` or with +no derived tenant, preserving vanilla single-tenant behavior. + +```js +function tenantScopedDocId(baseId, domains) { + if (process.env.OPENFRAME_MODE !== 'true') return baseId; + var d = deriveTenantDomain(domains); + return d ? (baseId + '_' + d) : baseId; +} +module.exports.tenantScopedDocId = tenantScopedDocId; +``` + +### `meshcentral.js` — domain-scoped `LoginCookieEncryptionKey` + +**File:** `meshcentral.js` — three call sites: startup load (~line 2175), on-demand +generation in `getLoginToken` (~line 3855), and `showLoginTokenKey` (~line 3873). + +```js +// Before +obj.db.Get('LoginCookieEncryptionKey', function (err, docs) { ... }); +obj.db.Set({ _id: 'LoginCookieEncryptionKey', key: ..., time: Date.now() }); + +// After +const loginCookieKeyId = require('./db.js').tenantScopedDocId('LoginCookieEncryptionKey', obj.config && obj.config.domains); +obj.db.Get(loginCookieKeyId, function (err, docs) { ... }); +obj.db.Set({ _id: loginCookieKeyId, key: ..., time: Date.now() }); +``` + +Each pod ends up with its own `LoginCookieEncryptionKey_` doc. A login cookie +encoded by tenant-A's pod cannot be verified or forged using tenant-B's key. + +### `meshcentral.js` — domain-scoped `InvitationLinkEncryptionKey` + +Identical pattern at `meshcentral.js:~2185`. Each pod issues invitation tokens with +its own key; tokens from one tenant are unintelligible to another. + +### `meshcentral.js` — domain-scoped `dbconfig` + +**File:** `meshcentral.js:~1753` + +```js +// Before +obj.db.Get('dbconfig', function (err, dbconfig) { + if (...) { obj.dbconfig = dbconfig[0]; } else { obj.dbconfig = { _id: 'dbconfig', version: 1 }; } + if (obj.dbconfig.amtWsEventSecret == null) { ... obj.db.Set(obj.dbconfig); } +}); + +// After +const dbconfigId = require('./db.js').tenantScopedDocId('dbconfig', obj.config && obj.config.domains); +obj.db.Get(dbconfigId, function (err, dbconfig) { + if (...) { obj.dbconfig = dbconfig[0]; } else { obj.dbconfig = { _id: dbconfigId, version: 1 }; } + ... +}); +``` + +`amtWsEventSecret` is now per-tenant; AMT WSMAN event credentials derived from it +cannot be forged across tenants. + +### `db.js` — `filterDocsToTenantDomain` + scoped SetupDatabase cleanup + +**File:** `db.js` + +Sibling helper to `tenantScopedDocId` for the dual problem: dropping foreign-domain +documents from a result array. Off OPENFRAME_MODE or with no derived tenant, returns +the input unchanged. + +```js +function filterDocsToTenantDomain(docs, domains) { + if (process.env.OPENFRAME_MODE !== 'true' || !Array.isArray(docs)) return docs; + var d = deriveTenantDomain(domains); + if (!d) return docs; + return docs.filter(function (x) { return x && (x.domain === d || x.domain === '' || x.domain == null); }); +} +module.exports.filterDocsToTenantDomain = filterDocsToTenantDomain; +``` + +Applied to the three `GetAllType` calls in `SetupDatabase` (`db.js:390, 400, 441` — +`ugrp`, `user`, `mesh`). The cleanup writes (`obj.Set(user)`, `obj.Set(mesh)`) now +only touch this pod's tenant. The default `''` domain is included in the allow list +to keep vanilla rows visible — same predicate as the ChangeStream filter (#2). + +### `webserver.js` — tenant-scoped startup caches + +**File:** `webserver.js:291, 307, 312` + +```js +// Before (vanilla) +obj.db.GetAllType('user', function (err, docs) { + obj.common.unEscapeAllLinksFieldName(docs); + for (i in docs) { obj.users[docs[i]._id] = docs[i]; ... } + ... +}); + +// After +obj.db.GetAllType('user', function (err, docs) { + docs = require('./db.js').filterDocsToTenantDomain(docs, parent.config.domains); + obj.common.unEscapeAllLinksFieldName(docs); + for (i in docs) { obj.users[docs[i]._id] = docs[i]; ... } + ... +}); +``` + +`obj.users`, `obj.meshes`, `obj.userGroups` now contain only this pod's tenant +documents (plus default-domain rows). Verified on a two-pod spike: with `alice` in +tenant-a, `bob` in tenant-b, and a directly-injected foreign `user/tenant-c/...`, +each pod's load function returned only its own tenant's users. + +### `db.js` — `pluginsActive` forced off in OPENFRAME_MODE + +**File:** `db.js:106` + +```js +// Before +obj.pluginsActive = (parent.config && parent.config.settings && parent.config.settings.plugins != null && ...); + +// After +obj.pluginsActive = (process.env.OPENFRAME_MODE !== 'true') && + (parent.config && parent.config.settings && parent.config.settings.plugins != null && ...); +``` + +Plugins are not used in the OpenFrame integration. Disabling `pluginsActive` skips +creation of the unscoped `plugins`/`pluginpermissions` collections and disables the +`getpluginpermissionlist` code path that dumps cross-tenant identities. + +### `meshuser.js` — `serverUserCommandAmtStats` filtered by caller's domain + +**File:** `meshuser.js:7726` + +```js +// After +parent.parent.db.GetAllType('node', function (err, docs) { + if ((process.env.OPENFRAME_MODE === 'true') && Array.isArray(docs)) { + docs = docs.filter(function (n) { return n && n.domain === domain.id; }); + } + ... +}); +``` + +The `amtstats` server-console command (siteadmin-only) used to aggregate Intel AMT +device counts across every domain in the shared collection. The filter narrows the +aggregate to the caller's session domain. + ### `plugins/migrate.js` — config-derived tenant + optimized mesh lookup **File:** `plugins/migrate.js` @@ -290,7 +490,7 @@ chart uses `migrate.js` for non-interactive admin creation. |---|---| | `OPENFRAME_MODE` not set or `false` | Vanilla MeshCentral — derivation logic disabled | | `OPENFRAME_MODE=true`, `config.domains` has only `""` (and optionally `share` hosts) | OpenFrame mode, derives `''` → vanilla DB keys, ChangeStream over all keys | -| `OPENFRAME_MODE=true`, `config.domains` adds `tenant-1: { dns: ... }` | Full isolation: scoped identifiers (`*_tenant-1`), ChangeStream filtered to `["tenant-1", ""]`, migrate creates data under `tenant-1` | +| `OPENFRAME_MODE=true`, `config.domains` adds `tenant-1: { dns: ... }` | Full isolation: scoped identifiers and crypto material (`DatabaseIdentifier_tenant-1`, `SchemaVersion_tenant-1`, `LoginCookieEncryptionKey_tenant-1`, `InvitationLinkEncryptionKey_tenant-1`, `dbconfig_tenant-1`), ChangeStream filtered to `["tenant-1", ""]`, migrate creates data under `tenant-1` | ## MongoDB collection overview @@ -300,7 +500,21 @@ chart uses `migrate.js` for non-interactive admin creation. | `events` | Yes — on most events | All list queries filter by domain | | `power` | **Added by this patch** | Required for correct `removeDomain` cleanup | | `smbios` | Yes | One document per node (upsert) | -| `serverstats` | No | Server-wide infrastructure metric; not tenant-specific | +| `serverstats` | No | Server-wide infrastructure metric; not tenant-specific. Stats from all pods commingle in this collection — fine for capacity-planning aggregates, not suitable for per-tenant SLA reporting (use Prometheus per-pod for that). | + +### Global state docs in the main collection (per-tenant copies in OPENFRAME_MODE) + +These six `_id`s in the `meshcentral` collection have no `domain` field. In OPENFRAME_MODE +each is suffixed with the tenant domain (`_`) so every pod sharing one +database has its own copy and cannot read or overwrite another tenant's: + +| Doc | Holds | Why isolation matters | +|---|---|---| +| `DatabaseIdentifier` | UUID for this DB | Stable peering identity | +| `SchemaVersion` | Schema version int | Per-pod migration state | +| `LoginCookieEncryptionKey` | 80-byte cookie HMAC key | Forge any tenant's login cookie | +| `InvitationLinkEncryptionKey` | 80-byte invitation HMAC key | Forge any tenant's invitation token (URLs, no SameSite protection) | +| `dbconfig.amtWsEventSecret` | 32-byte HMAC key | Forge any tenant's AMT WSMAN event credentials | ## Scalability diff --git a/meshcentral.js b/meshcentral.js index 307fbc468b..b9eb792d6a 100644 --- a/meshcentral.js +++ b/meshcentral.js @@ -1750,8 +1750,12 @@ function CreateMeshCentralServer(config, args) { obj.db.storePowerEvent({ time: new Date(), nodeid: '*', power: 0, s: 1 }, obj.multiServer); // s:1 indicates that the server is starting up. // Read or setup database configuration values - obj.db.Get('dbconfig', function (err, dbconfig) { - if ((dbconfig != null) && (dbconfig.length == 1)) { obj.dbconfig = dbconfig[0]; } else { obj.dbconfig = { _id: 'dbconfig', version: 1 }; } + // Tenant-scoped in OPENFRAME_MODE: dbconfig holds amtWsEventSecret (HMAC key for AMT + // WSMAN event credential generation, see webserver.js:5816). Sharing it across pods + // lets one tenant forge another tenant's AMT event credentials. + const dbconfigId = require('./db.js').tenantScopedDocId('dbconfig', obj.config && obj.config.domains); + obj.db.Get(dbconfigId, function (err, dbconfig) { + if ((dbconfig != null) && (dbconfig.length == 1)) { obj.dbconfig = dbconfig[0]; } else { obj.dbconfig = { _id: dbconfigId, version: 1 }; } if (obj.dbconfig.amtWsEventSecret == null) { obj.crypto.randomBytes(32, function (err, buf) { obj.dbconfig.amtWsEventSecret = buf.toString('hex'); obj.db.Set(obj.dbconfig); }); } // This is used by the user to create a username/password for a Intel AMT WSMAN event subscription @@ -2171,22 +2175,29 @@ function CreateMeshCentralServer(config, args) { } // Login cookie encryption key not set, use one from the database + // In OPENFRAME_MODE the doc id is suffixed with the tenant domain so each + // pod sharing one MongoDB has its own key and cannot forge cookies for + // other tenants (see db.js:tenantScopedDocId). if (obj.loginCookieEncryptionKey == null) { - obj.db.Get('LoginCookieEncryptionKey', function (err, docs) { + const loginCookieKeyId = require('./db.js').tenantScopedDocId('LoginCookieEncryptionKey', obj.config && obj.config.domains); + obj.db.Get(loginCookieKeyId, function (err, docs) { if ((docs != null) && (docs.length > 0) && (docs[0].key != null) && (obj.args.logintokengen == null) && (docs[0].key.length >= 160)) { obj.loginCookieEncryptionKey = Buffer.from(docs[0].key, 'hex'); } else { - obj.loginCookieEncryptionKey = obj.generateCookieKey(); obj.db.Set({ _id: 'LoginCookieEncryptionKey', key: obj.loginCookieEncryptionKey.toString('hex'), time: Date.now() }); + obj.loginCookieEncryptionKey = obj.generateCookieKey(); obj.db.Set({ _id: loginCookieKeyId, key: obj.loginCookieEncryptionKey.toString('hex'), time: Date.now() }); } }); } // Load the invitation link encryption key from the database - obj.db.Get('InvitationLinkEncryptionKey', function (err, docs) { + // Tenant-scoped in OPENFRAME_MODE so invitation tokens issued by one tenant + // cannot be decoded or forged by another sharing the same MongoDB. + const invitationLinkKeyId = require('./db.js').tenantScopedDocId('InvitationLinkEncryptionKey', obj.config && obj.config.domains); + obj.db.Get(invitationLinkKeyId, function (err, docs) { if ((docs != null) && (docs.length > 0) && (docs[0].key != null) && (docs[0].key.length >= 160)) { obj.invitationLinkEncryptionKey = Buffer.from(docs[0].key, 'hex'); } else { - obj.invitationLinkEncryptionKey = obj.generateCookieKey(); obj.db.Set({ _id: 'InvitationLinkEncryptionKey', key: obj.invitationLinkEncryptionKey.toString('hex'), time: Date.now() }); + obj.invitationLinkEncryptionKey = obj.generateCookieKey(); obj.db.Set({ _id: invitationLinkKeyId, key: obj.invitationLinkEncryptionKey.toString('hex'), time: Date.now() }); } }); @@ -3851,8 +3862,9 @@ function CreateMeshCentralServer(config, args) { if (err != null || docs == null || docs.length == 0) { func('User ' + userid + ' not found.'); return; } else { - // Load the login cookie encryption key from the database - obj.db.Get('LoginCookieEncryptionKey', function (err, docs) { + // Load the login cookie encryption key from the database (tenant-scoped in OPENFRAME_MODE) + const loginCookieKeyId = require('./db.js').tenantScopedDocId('LoginCookieEncryptionKey', obj.config && obj.config.domains); + obj.db.Get(loginCookieKeyId, function (err, docs) { if ((docs.length > 0) && (docs[0].key != null) && (obj.args.logintokengen == null) && (docs[0].key.length >= 160)) { // Key is present, use it. obj.loginCookieEncryptionKey = Buffer.from(docs[0].key, 'hex'); @@ -3860,7 +3872,7 @@ function CreateMeshCentralServer(config, args) { } else { // Key is not present, generate one. obj.loginCookieEncryptionKey = obj.generateCookieKey(); - obj.db.Set({ _id: 'LoginCookieEncryptionKey', key: obj.loginCookieEncryptionKey.toString('hex'), time: Date.now() }, function () { func(obj.encodeCookie({ u: userid, a: 3 }, obj.loginCookieEncryptionKey)); }); + obj.db.Set({ _id: loginCookieKeyId, key: obj.loginCookieEncryptionKey.toString('hex'), time: Date.now() }, function () { func(obj.encodeCookie({ u: userid, a: 3 }, obj.loginCookieEncryptionKey)); }); } }); } @@ -3869,15 +3881,16 @@ function CreateMeshCentralServer(config, args) { // Show the user login token generation key obj.showLoginTokenKey = function (func) { - // Load the login cookie encryption key from the database - obj.db.Get('LoginCookieEncryptionKey', function (err, docs) { + // Load the login cookie encryption key from the database (tenant-scoped in OPENFRAME_MODE) + const loginCookieKeyId = require('./db.js').tenantScopedDocId('LoginCookieEncryptionKey', obj.config && obj.config.domains); + obj.db.Get(loginCookieKeyId, function (err, docs) { if ((docs.length > 0) && (docs[0].key != null) && (obj.args.logintokengen == null) && (docs[0].key.length >= 160)) { // Key is present, use it. func(docs[0].key); } else { // Key is not present, generate one. obj.loginCookieEncryptionKey = obj.generateCookieKey(); - obj.db.Set({ _id: 'LoginCookieEncryptionKey', key: obj.loginCookieEncryptionKey.toString('hex'), time: Date.now() }, function () { func(obj.loginCookieEncryptionKey.toString('hex')); }); + obj.db.Set({ _id: loginCookieKeyId, key: obj.loginCookieEncryptionKey.toString('hex'), time: Date.now() }, function () { func(obj.loginCookieEncryptionKey.toString('hex')); }); } }); }; diff --git a/meshuser.js b/meshuser.js index 34013e1a8b..4284f15d68 100644 --- a/meshuser.js +++ b/meshuser.js @@ -7725,6 +7725,12 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use function serverUserCommandAmtStats(cmdData) { parent.parent.db.GetAllType('node', function (err, docs) { var r = ''; + // In OPENFRAME_MODE the node collection is shared across tenants; restrict the + // aggregate to nodes in the calling session's domain so the stats don't include + // device counts from other tenants. + if ((process.env.OPENFRAME_MODE === 'true') && Array.isArray(docs)) { + docs = docs.filter(function (n) { return n && n.domain === domain.id; }); + } if (err != null) { r = "Error occured."; } else if ((docs == null) || (docs.length == 0)) { diff --git a/webserver.js b/webserver.js index d9cd146145..a7a0be17ee 100644 --- a/webserver.js +++ b/webserver.js @@ -287,8 +287,13 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF function EscapeHtml(x) { if (typeof x == 'string') return x.replace(/&/g, '&').replace(/>/g, '>').replace(//g, '>').replace(/').replace(/\n/g, '').replace(/\t/g, '  '); if (typeof x == "boolean") return x; if (typeof x == "number") return x; } - // Fetch all users from the database, keep this in memory + // Fetch all users from the database, keep this in memory. + // In OPENFRAME_MODE multiple pods share one collection — without filtering, every + // pod would cache every other tenant's users in memory and tally domainUserCount + // for foreign domains it does not own. filterDocsToTenantDomain narrows the result + // to this pod's tenant (plus the legacy '' domain) before it lands in obj.users. obj.db.GetAllType('user', function (err, docs) { + docs = require('./db.js').filterDocsToTenantDomain(docs, parent.config.domains); obj.common.unEscapeAllLinksFieldName(docs); var domainUserCount = {}, i = 0; for (i in parent.config.domains) { domainUserCount[i] = 0; } @@ -301,15 +306,19 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF } } - // Fetch all device groups (meshes) from the database, keep this in memory + // Fetch all device groups (meshes) from the database, keep this in memory. // As we load things in memory, we will also be doing some cleaning up. // We will not save any clean up in the database right now, instead it will be saved next time there is a change. + // Tenant-scoped in OPENFRAME_MODE for the same reason as the user load above. obj.db.GetAllType('mesh', function (err, docs) { + docs = require('./db.js').filterDocsToTenantDomain(docs, parent.config.domains); obj.common.unEscapeAllLinksFieldName(docs); for (var i in docs) { obj.meshes[docs[i]._id] = docs[i]; } // Get all meshes, including deleted ones. - // Fetch all user groups from the database, keep this in memory + // Fetch all user groups from the database, keep this in memory. + // Tenant-scoped in OPENFRAME_MODE. obj.db.GetAllType('ugrp', function (err, docs) { + docs = require('./db.js').filterDocsToTenantDomain(docs, parent.config.domains); obj.common.unEscapeAllLinksFieldName(docs); // Perform user group link cleanup