Multi-tenancy
tool_mutenancy
Multi-tenancy plugin (MuTMS suite) for Moodle that enables partitioning a single Moodle instance into isolated tenants, each with their own users, categories, cohorts, branding, and authentication settings. Developed by Petr Skoda. Requires a companion `tool_mulib` plugin and a core patch for full functionality.
The plugin demonstrates strong security practices overall: all pages use require_login() and appropriate require_capability() checks, all forms leverage moodleform (which handles sesskey validation automatically), database queries predominantly use parameterized $DB methods, and output is properly escaped using format_string(), s(), and html_writer. The Privacy API is fully implemented.
The most severe finding is the wrong capability check in member_delete.php (medium), which checks tool/mutenancy:memberupdate instead of tool/mutenancy:memberdelete. This allows a user with only the update capability to delete tenant member accounts. However, the impact is constrained: both capabilities are assigned to the manager archetype by default, so the issue only manifests when an administrator deliberately configures separate capability permissions — and even then, it requires an elevated role (tenant manager). The report builder UI correctly restricts the delete action visibility using the memberdelete capability, meaning the mismatch only affects direct URL access.
The remaining findings are low-severity code quality issues: a bitwise OR operator bug in db/access.php that incorrectly computes the riskbitmask, an unescaped color value in the appearance renderer output, a dead-code bug in the update_tenant external API, and integer values interpolated into SQL (safe in practice but not using parameterized queries).
The plugin modifies core tables (context and user) via DDL in db/install.php, which is expected and necessary for a multi-tenancy solution. It has comprehensive test coverage (phpunit + behat) and clean architecture.
Plugin Overview
tool_mutenancy is a multi-tenancy plugin for Moodle 5.0 that enables a single Moodle instance to serve multiple isolated tenants. Each tenant gets its own:
- Course category (top-level)
- System cohort for automatic member enrolment
- Optional associated cohort for global users with tenant access
- Branding overrides (logos, favicon, Boost theme colors/SCSS)
- Authentication overrides (self-registration, login form, email restrictions)
- Dedicated managers with a custom
tenantmanagerrole
The plugin introduces a custom CONTEXT_TENANT context level (requiring a core patch) and adds tenantid fields to the context and user core tables.
Key Architecture
- Core classes:
tenancy,tenant,manager,member,user,config,appearanceinclasses/local/ - Management UI: AJAX-based forms via
tool_mulibfor all CRUD operations - Web services: 7 external functions for tenant and manager CRUD + 4 form autocomplete helpers
- Report builder: System reports for tenant listing and tenant user browsing
- Hooks: Primary navigation extension and bulk user action hooks
- Events: Full event coverage for tenant lifecycle operations
- Cron: Scheduled task to sync cohort membership and manager roles
Security Summary
The plugin follows Moodle security best practices consistently. One medium-severity capability bypass was found in member_delete.php where the wrong capability is checked. All other findings are low-severity code quality issues. No critical or high-severity vulnerabilities were identified.
Findings
The member_delete.php page checks tool/mutenancy:memberupdate instead of tool/mutenancy:memberdelete. The plugin defines separate capabilities for updating and deleting tenant members in db/access.php, but the delete page only enforces the weaker update capability. This means a user granted memberupdate but not memberdelete can still delete tenant member accounts by navigating directly to the delete URL.
Notably, the report builder UI in users.php system report correctly checks tool/mutenancy:memberdelete for the delete action visibility (line 307), creating an inconsistency where the UI hides the delete button but the backend still allows it.
Medium risk. The vulnerability requires:
- An administrator to have deliberately configured separate capability permissions (removing
memberdeletewhile keepingmemberupdate) - The attacker to have an elevated role (tenant manager or equivalent)
- Direct URL knowledge since the UI correctly hides the action
In the default configuration, both capabilities are granted to the same archetype, so the issue has no practical impact. The risk materialises only in non-default security configurations where the admin explicitly intended to restrict deletion.
The plugin defines two separate capabilities for member management: tool/mutenancy:memberupdate (at CONTEXT_USER level) and tool/mutenancy:memberdelete (at CONTEXT_USER level). Both are granted to the manager archetype by default. The intent is to allow granular control — an admin could permit managers to edit users but not delete them. The member_delete.php page handler undermines this granularity by checking the wrong capability.
- As a site admin, remove the
tool/mutenancy:memberdeletecapability from thetenantmanagerrole while keepingtool/mutenancy:memberupdate. - Log in as a tenant manager.
- The delete button will not appear in the tenant users report (correct).
- Navigate directly to
/admin/tool/mutenancy/management/member_delete.php?id=<userid>— the page loads and allows deletion (incorrect).
$personalcontext = context_user::instance($userid);
require_capability('tool/mutenancy:memberupdate', $personalcontext);
Change the capability check to use the correct memberdelete capability:
$personalcontext = context_user::instance($userid);
require_capability('tool/mutenancy:memberdelete', $personalcontext);
return has_capability('tool/mutenancy:memberdelete', \context_user::instance($row->id));
The riskbitmask for the tool/mutenancy:admin capability uses the logical OR operator (||) instead of the bitwise OR operator (|). This causes the expression to evaluate to true (integer 1) instead of the intended combined bitmask value.
In Moodle, RISK_PERSONAL = 8, RISK_DATALOSS = 16, RISK_SPAM = 1. The intended bitmask is 8 | 16 | 1 = 25, but the actual value is 1 (which equals RISK_SPAM only).
Low risk. This is a code quality bug with no direct security impact. The riskbitmask is used for informational purposes only — it helps administrators understand what risks a capability entails when assigning roles. The capability itself still functions correctly; the only consequence is that the admin UI does not display the full set of risk warnings for this capability.
Moodle uses riskbitmask values to warn administrators about dangerous capabilities. The RISK_PERSONAL, RISK_DATALOSS, and RISK_SPAM constants are bitfield values that should be combined with bitwise OR (|). Using logical OR (||) returns a boolean true (cast to 1), which means only RISK_SPAM is effectively flagged.
'riskbitmask' => RISK_PERSONAL || RISK_DATALOSS || RISK_SPAM,
Use the bitwise OR operator:
'riskbitmask' => RISK_PERSONAL | RISK_DATALOSS | RISK_SPAM,
The tenant appearance renderer outputs the brandcolor value directly into HTML without using s() or other escaping. The value is placed both inside a style attribute and as text content of a <span> element.
While the theme_boost_edit form validates the color using appearance::is_valid_color() (which restricts values to hex codes, named CSS colors, and rgb()/hsl() patterns), the value stored in the database via config::override() is not independently validated. If the form validation is bypassed (e.g. by direct database modification), the unescaped output could lead to XSS.
Low risk. The form-level validation is strong enough to prevent malicious values through the normal UI flow. Exploitation would require direct database modification (admin-level access) or a bypassed code path. The defensive fix (adding s() escaping) is trivial and follows defense-in-depth principles.
The brand color value flows from config::get() → database → renderer output. The normal path through theme_boost_edit.php validates the color with appearance::is_valid_color() which uses restrictive regex patterns (/^#([[:xdigit:]]{3}){1,2}$/, named color whitelist, rgb() and hsl() patterns). These patterns do not allow <, >, ', or " characters, making XSS through the normal UI path extremely unlikely.
if ($color) {
$color = "<span style='color: $color'>$color</span>";
} else {
$color = get_string('none');
}
Escape the color value before embedding it in HTML:
if ($color) {
$escaped = s($color);
$color = "<span style='color: $escaped'>$escaped</span>";
} else {
$color = get_string('none');
}
In the update_tenant external API, the code that attempts to remove null values from the $tenant array contains a bug: unset($tenant->v) should be unset($tenant[$k]). The variable $tenant is an array at this point (before it's cast to object on line 85), and the code attempts to access it as an object. Additionally, $tenant->v tries to unset a literal property named v rather than the variable $k.
Low risk. This is a code logic bug rather than a security vulnerability. The tenant::update() method validates each field it processes and throws exceptions for invalid values (empty names, invalid idnumber format, etc.). The practical impact is limited — null values that should be ignored may trigger validation errors or no-op updates. There is no data corruption or security implication.
The update_tenant external API receives tenant data as an array from validate_parameters(). The intent is to strip null values before passing the data to tenant::update(), which uses property_exists() to determine which fields to update. Because the unset never actually removes null values, they are passed through to tenant::update() where property_exists() returns true and the null values are processed — potentially causing unintended field updates.
foreach ($tenant as $k => $v) {
if ($v === null) {
unset($tenant->v);
}
}
Fix the variable reference to properly unset array elements:
foreach ($tenant as $k => $v) {
if ($v === null) {
unset($tenant[$k]);
}
}
Several locations in the plugin interpolate integer values directly into SQL strings instead of using $DB parameterized query placeholders. While the values are safe (they come from (int) casts, database records, or Moodle constants), this pattern is inconsistent with Moodle coding standards and makes security auditing more difficult.
Low risk. There is no SQL injection vulnerability here — the values are guaranteed to be integers from trusted sources. This is a coding standards issue: Moodle best practice mandates parameterized queries for all dynamic values in SQL, regardless of their source. Using parameterized queries makes the code easier to audit and more resilient to future refactoring.
The interpolated values are: role IDs from get_roles_with_cap_in_context() (always integers), and tenant/cohort IDs from $DB->get_record() (database integer fields). Additionally, tenancy::get_related_users_exists() interpolates $tenantid which is always cast to (int). These are safe in practice because the source values are trusted integers.
$needed = implode(',', $needed);
$sql = "SELECT 'x'
FROM {role_assignments} ra
JOIN {context} c ON c.id = ra.contextid AND c.contextlevel = :tenantlevel
JOIN {tool_mutenancy_tenant} t ON t.id = c.instanceid AND t.archived = 0
WHERE ra.userid = :me AND ra.roleid IN ($needed)";
Use $DB->get_in_or_equal() to generate parameterized IN clauses:
[$insql, $inparams] = $DB->get_in_or_equal($needed, SQL_PARAMS_NAMED);
$sql = "SELECT 'x' ... WHERE ra.userid = :me AND ra.roleid $insql";
$params = array_merge(['tenantlevel' => \context_tenant::LEVEL, 'me' => $USER->id], $inparams);
$this->add_base_condition_sql("{$entityuseralias}.id IN (
SELECT tuser.id
FROM {user} tuser
LEFT JOIN {cohort_members} cm ON cm.cohortid = {$this->tenant->assoccohortid} AND cm.userid = tuser.id
WHERE (tuser.tenantid IS NULL AND cm.id IS NOT NULL)
OR tuser.tenantid = {$this->tenant->id}
)");
Use parameterized placeholders with database::generate_param_name() as done elsewhere in the same class.
The tenancy::switch() method references $CFG->tool_mutenancy_forced_tenantid on line 286 without declaring global $CFG at the top of the method. The method only declares global $SESSION, $USER. In PHP, isset() on an undefined local variable returns false without error, so the check silently fails and the debugging notice is never triggered.
Low risk. This is a minor code quality bug affecting only developer-mode debugging output. The unset() on the undefined variable would also have no effect, but since the condition is never true, the code path is dead. No functional or security impact in production.
The switch() method handles switching between tenants. The affected code is a developer debugging notice that should warn when switching while a tenant is forced — a scenario primarily relevant during development. The missing global declaration means this safeguard is silently bypassed.
if (isset($CFG->tool_mutenancy_forced_tenantid)) {
debugging('Tenant is forced, un-enforcing before switch', DEBUG_DEVELOPER);
unset($CFG->tool_mutenancy_forced_tenantid);
}
Add $CFG to the global declaration:
public static function switch(?int $tenantid): void {
global $CFG, $SESSION, $USER;
The plugin adds a tenantid column and index to both the context and user core database tables during installation, and removes them during uninstallation. This modifies core Moodle database schema using $DB->get_manager() (XMLDB DDL operations).
While this is flagged because it modifies tables the plugin does not own, this is a known and accepted pattern for multi-tenancy plugins that must extend core data structures. The operations are performed in db/install.php and db/uninstall.php — the appropriate locations for such modifications.
Low risk. Modifying core tables is inherently risky (potential conflicts with core upgrades, other plugins), but it is done correctly here:
- Uses XMLDB API (
$DB->get_manager()) rather than raw DDL - Checks for field existence before adding
- Properly cleans up in uninstall
- Located in the standard
db/install.phpanddb/uninstall.phpfiles
This is noted for transparency rather than as a defect.
The plugin requires a tenantid field on context and user tables to associate these core records with tenants. This is fundamental to its architecture — tenant isolation cannot be implemented without extending core tables. The plugin also requires a core patch file (mutenancy.php) verified in environmentlib.php, indicating that core modifications are an expected part of the deployment.
$dbman = $DB->get_manager();
$table = new xmldb_table('context');
$field = new xmldb_field('tenantid', XMLDB_TYPE_INTEGER, '10', null, null, null, null, 'locked');
if (!$dbman->field_exists($table, $field)) {
$dbman->add_field($table, $field);
}
$dbman = $DB->get_manager();
| Library | Version | License | Declared |
|---|---|---|---|
Font-Awesome Sitemap icon Provides the sitemap icon (pix/switch.svg) used for the tenant switching UI widget | 6.7.2 | CC BY 4.0 | ✓ |
The plugin depends on tool_mulib (version 2026032950+) and a core Moodle patch (mutenancy-5.0.6-02). The core patch is verified at install time via environmentlib.php. This means the plugin cannot be installed on a vanilla Moodle instance — the core patch must be applied first. This is an important deployment consideration.
The plugin has comprehensive test coverage: over 30 behat feature files and numerous phpunit test classes covering local classes, external APIs, events, hooks, patches, privacy provider, and report builder. This is well above average for a Moodle plugin.
The Privacy API implementation is complete: the plugin implements metadata\provider, core_userlist_provider, and plugin\provider interfaces, handling export and deletion of tenant manager data. The tool_mutenancy_manager table is the only table containing personal data (the user and context table extensions use tenantid as organizational data, not personal data).
All management pages use AJAX forms (via tool_mulib\local\ajax_form) which inherit Moodle's form framework including automatic sesskey validation. No manual sesskey checks are needed or missing.
The web service functions properly validate parameters, check capabilities, and enforce tenancy-is-active preconditions. The get_tenants function uses an allowlist (['id', 'name', 'idnumber', 'archived']) for field filtering, preventing arbitrary database field queries.