Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,26 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}


test_isolation:
name: "Test: tenant isolation"
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# The image is only a runtime carrier here: it supplies node_modules (mongodb is not a
# package.json dependency), while the code under test is this checkout, mounted at /src.
- name: Run tenant isolation tests
run: test/run-tests.sh ${{ env.REGISTRY }}/${{ env.ORGANISATION }}/${{ env.REPOSITORY }}/meshcentral:latest


test_helm:
name: "Test Helm Chart"
needs: [changes]
Expand All @@ -135,7 +155,7 @@ jobs:

all-checks:
name: "All Checks"
needs: [changes, test, test_helm]
needs: [changes, test, test_isolation, test_helm]
runs-on: ubuntu-latest
if: always()
steps:
Expand Down
164 changes: 154 additions & 10 deletions db.js

Large diffs are not rendered by default.

69 changes: 55 additions & 14 deletions meshcentral.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,24 @@ function CreateMeshCentralServer(config, args) {
obj.msgserver = null; // Messaging server, used to sent used messages
obj.amtEventHandler = null;
obj.pluginHandler = null;
// This pod's tenant, or null outside OpenFrame mode. Used where a record or a database key
// would otherwise be shared by every tenant server writing into the one database.
// Resolved on first use and cached: a server's tenant cannot change while it runs, and every
// caller must get the same answer regardless of when it asks (db.js does the same).
var openframeDomainCache;
obj.openframeDomain = function () {
if (openframeDomainCache === undefined) {
openframeDomainCache = (process.env.OPENFRAME_MODE === 'true')
? (require('./db.js').deriveTenantDomain(obj.config.domains) || null)
: null;
}
return openframeDomainCache;
};
// Database keys that must not be one shared row across the fleet.
obj.tenantDbKey = function (baseId) {
const d = obj.openframeDomain();
return (d == null) ? baseId : (baseId + '_' + d);
};
obj.amtScanner = null;
obj.amtManager = null; // Intel AMT manager, used to oversee all Intel AMT devices, activate them and sync policies
obj.meshScanner = null;
Expand Down Expand Up @@ -1747,11 +1765,25 @@ function CreateMeshCentralServer(config, args) {
obj.fs.open(obj.path.join(obj.datapath, 'agenterrorlogs.txt'), 'a', function (err, fd) { obj.agentErrorLog = fd; })
}

// Perform other database cleanup
obj.db.cleanup();

// Set all nodes to power state of unknown (0)
obj.db.storePowerEvent({ time: new Date(), nodeid: '*', power: 0, s: 1 }, obj.multiServer); // s:1 indicates that the server is starting up.
// Perform other database cleanup.
// Skipped in OpenFrame mode: every tenant server shares one database, and cleanup()
// is written for "one server owns the whole database". It rewrites every tenant's
// user and mesh documents (db.js, obj.Set inside the GetAllType('user'/'mesh')
// callbacks) and deletes fleet-wide. What it repairs — pre-1.0 field formats and
// legacy type:'event'/'power'/'smbios' rows in the main collection — never existed
// in this database: those types are written to their own collections here.
// Routine housekeeping is covered elsewhere: RemoveMeshDocuments() on device group
// deletion, the explicit Remove() calls on device deletion, and the TTL indexes.
if (process.env.OPENFRAME_MODE !== 'true') { obj.db.cleanup(); }

// Set all nodes to power state of unknown (0).
// Skipped in OpenFrame mode: this marker is stored against the wildcard node id '*' and the
// power collection has no domain field, while getPowerTimeline() selects {nodeid: {$in:
// ['*', nodeid]}} — so on a shared database every pod's boot shows up in every tenant's
// device power timeline.
if (obj.openframeDomain() == null) {
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) {
Expand Down Expand Up @@ -2174,23 +2206,28 @@ function CreateMeshCentralServer(config, args) {
if ((obj.loginCookieEncryptionKey == null) || (obj.loginCookieEncryptionKey.length != 80)) { addServerWarning("Invalid \"LoginCookieEncryptionKey\" in config.json.", 20); obj.loginCookieEncryptionKey = null; }
}

// Login cookie encryption key not set, use one from the database
// Login cookie encryption key not set, use one from the database.
// Per tenant (tenantDbKey) rather than one row for the whole database: this key
// signs login tokens, relay cookies and auth cookies, so a single shared row
// means a cookie minted by one tenant's server verifies on every other one.
// Rotating it invalidates existing sessions for that tenant — expected, and the
// reason this needs a maintenance window.
if (obj.loginCookieEncryptionKey == null) {
obj.db.Get('LoginCookieEncryptionKey', function (err, docs) {
obj.db.Get(obj.tenantDbKey('LoginCookieEncryptionKey'), 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: obj.tenantDbKey('LoginCookieEncryptionKey'), key: obj.loginCookieEncryptionKey.toString('hex'), time: Date.now() });
}
});
}

// Load the invitation link encryption key from the database
obj.db.Get('InvitationLinkEncryptionKey', function (err, docs) {
obj.db.Get(obj.tenantDbKey('InvitationLinkEncryptionKey'), 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: obj.tenantDbKey('InvitationLinkEncryptionKey'), key: obj.invitationLinkEncryptionKey.toString('hex'), time: Date.now() });
}
});

Expand Down Expand Up @@ -2238,6 +2275,10 @@ function CreateMeshCentralServer(config, args) {
const node = obj.connectivityByNode[i];
if (node && typeof node.connectivity !== 'undefined' && node.connectivity === 4) { data.conn.am++; }
}
// Stamp the tenant: the collection is shared and upstream writes no domain,
// so without this every tenant's "My Server" timeline is the fleet's sum.
const statsDomain = obj.openframeDomain();
if (statsDomain != null) { data.domain = statsDomain; }
if (obj.firstStats === true) { delete obj.firstStats; data.first = true; }
if (obj.multiServer != null) { data.s = obj.multiServer.serverid; }
obj.db.SetServerStats(data); // Save the stats to the database
Expand Down Expand Up @@ -3856,15 +3897,15 @@ function CreateMeshCentralServer(config, args) {
func('User ' + userid + ' not found.'); return;
} else {
// Load the login cookie encryption key from the database
obj.db.Get('LoginCookieEncryptionKey', function (err, docs) {
obj.db.Get(obj.tenantDbKey('LoginCookieEncryptionKey'), 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');
func(obj.encodeCookie({ u: userid, a: 3 }, obj.loginCookieEncryptionKey));
} 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: obj.tenantDbKey('LoginCookieEncryptionKey'), key: obj.loginCookieEncryptionKey.toString('hex'), time: Date.now() }, function () { func(obj.encodeCookie({ u: userid, a: 3 }, obj.loginCookieEncryptionKey)); });
}
});
}
Expand All @@ -3874,14 +3915,14 @@ 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) {
obj.db.Get(obj.tenantDbKey('LoginCookieEncryptionKey'), 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: obj.tenantDbKey('LoginCookieEncryptionKey'), key: obj.loginCookieEncryptionKey.toString('hex'), time: Date.now() }, function () { func(obj.loginCookieEncryptionKey.toString('hex')); });
}
});
};
Expand Down
33 changes: 22 additions & 11 deletions meshuser.js
Original file line number Diff line number Diff line change
Expand Up @@ -1967,6 +1967,9 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use
if (typeof command.removeMultiFactor != 'boolean') break;
if ((command.pass != '') && (common.checkPasswordRequirements(command.pass, domain.passwordrequirements) == false)) break; // Password does not meet requirements

// Every tenant shares one database and parent.users holds all of them, so a
// bare id lookup resolves another tenant's user. Same check as 'deleteuser'.
if ((command.userid.split('/').length != 3) || (command.userid.split('/')[1] != domain.id)) break;
var chguser = parent.users[command.userid];
if (chguser) {
// If we are not full administrator, we can't change anything on a different full administrator
Expand Down Expand Up @@ -2020,6 +2023,9 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use
if (common.validateString(command.userid, 1, 2048) == false) break;
if (common.validateString(command.msg, 1, 4096) == false) break;

// Every tenant shares one database and parent.users holds all of them, so a
// bare id lookup resolves another tenant's user. Same check as 'deleteuser'.
if ((command.userid.split('/').length != 3) || (command.userid.split('/')[1] != domain.id)) break;
// Can only perform this operation on other users of our group.
var chguser = parent.users[command.userid];
if (chguser == null) break; // This user does not exists
Expand Down Expand Up @@ -2058,6 +2064,9 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use
// Send a notification message to a user
if ((user.siteadmin & 2) == 0) break;

// Every tenant shares one database and parent.users holds all of them, so a
// bare id lookup resolves another tenant's user. Same check as 'deleteuser'.
if ((command.userid.split('/').length != 3) || (command.userid.split('/')[1] != domain.id)) break;
// Can only perform this operation on other users of our group.
var chguser = parent.users[command.userid];
if (chguser == null) break; // This user does not exists
Expand Down Expand Up @@ -4779,8 +4788,10 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use

var result = { users: [], userGroups: [], meshes: [], nodes: [] };

// Get all users
parent.db.GetAllType('user', function(err, docs) {
// Get all users in this domain. Domain-scoped because every tenant shares one
// database: an unscoped read here returns every tenant's users, and their ids
// (user/<domain>/<name>) disclose the other tenants' domain keys and admin names.
parent.db.GetAllTypeForDomain('user', domain.id, function(err, docs) {
if (docs) {
docs.forEach(function(u) {
if (u.name && u._id) {
Expand All @@ -4789,8 +4800,8 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use
});
}

// Get all user groups
parent.db.GetAllType('ugrp', function(err, ugrps) {
// Get all user groups in this domain
parent.db.GetAllTypeForDomain('ugrp', domain.id, function(err, ugrps) {
if (ugrps) {
ugrps.forEach(function(ug) {
if (ug.name && ug._id) {
Expand All @@ -4799,8 +4810,8 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use
});
}

// Get all meshes (device groups)
parent.db.GetAllType('mesh', function(err, meshes) {
// Get all meshes (device groups) in this domain
parent.db.GetAllTypeForDomain('mesh', domain.id, function(err, meshes) {
if (meshes) {
meshes.forEach(function(m) {
if (m.name && m._id && !m.deleted) {
Expand All @@ -4809,8 +4820,8 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use
});
}

// Get all nodes (devices)
parent.db.GetAllType('node', function(err, nodes) {
// Get all nodes (devices) in this domain
parent.db.GetAllTypeForDomain('node', domain.id, function(err, nodes) {
if (nodes) {
// Create a map of meshid to meshname for grouping
var meshMap = {};
Expand Down Expand Up @@ -6531,7 +6542,7 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use
else if (common.validateString(command.subject, 1, 1000) == false) { errMsg = 'Invalid subject message'; }
else if (common.validateString(command.msg, 1, 10000) == false) { errMsg = 'Invalid message'; }
else {
emailuser = parent.users[command.userid];
emailuser = ((command.userid.split('/').length == 3) && (command.userid.split('/')[1] == domain.id)) ? parent.users[command.userid] : null; // Domain-scoped: parent.users holds every tenant's users
if (emailuser == null) { errMsg = 'Invalid userid'; }
else if (emailuser.email == null) { errMsg = 'No validated email address for this user'; }
else if (emailuser.emailVerified !== true) { errMsg = 'No validated email address for this user'; }
Expand Down Expand Up @@ -7032,7 +7043,7 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use
else if (common.validateString(command.userid, 1, 2048) == false) { errMsg = "Invalid username"; }
else if (common.validateString(command.msg, 1, 160) == false) { errMsg = "Invalid SMS message"; }
else {
smsuser = parent.users[command.userid];
smsuser = ((command.userid.split('/').length == 3) && (command.userid.split('/')[1] == domain.id)) ? parent.users[command.userid] : null; // Domain-scoped: parent.users holds every tenant's users
if (smsuser == null) { errMsg = "Invalid username"; }
else if (smsuser.phone == null) { errMsg = "No phone number for this user"; }
}
Expand All @@ -7055,7 +7066,7 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use
else if (common.validateString(command.userid, 1, 2048) == false) { errMsg = "Invalid username"; }
else if (common.validateString(command.msg, 1, 160) == false) { errMsg = "Invalid message"; }
else {
msguser = parent.users[command.userid];
msguser = ((command.userid.split('/').length == 3) && (command.userid.split('/')[1] == domain.id)) ? parent.users[command.userid] : null; // Domain-scoped: parent.users holds every tenant's users
if (msguser == null) { errMsg = "Invalid username"; }
else if (msguser.msghandle == null) { errMsg = "No messaging service configured for this user"; }
}
Expand Down
Loading
Loading