MDL Shield

Training credits

tool_mutrain

Print Report
Plugin Information

Training credits plugin (tool_mutrain) for Moodle LMS. Provides a framework system for tracking and aggregating training credits earned through course completions and program completions. Supports multiple credit frameworks with configurable requirements, context restrictions, date restrictions, and public/private access. Integrates with the MuTMS plugin suite (tool_mulib, customfield_mutrain, tool_muprog). Features include management UI for frameworks and fields, user-facing credit reports, event-driven completion syncing, scheduled cron task for cache maintenance, and report builder integration.

Version:2026032950
Release:v5.0.6.06
Reviewed for:5.1
Privacy API
Unit Tests
Behat Tests
Reviewed:2026-04-15
66 files·8,491 lines
Grade Justification

The plugin demonstrates strong security practices throughout: all pages require authentication via require_login(), authorization is enforced via require_capability() before any privileged operations, all forms use the Moodle form API (which handles sesskey validation automatically), database queries use parameterized placeholders via the $DB API, and output escaping uses format_string(), s(), and format_text() consistently.

Two medium-severity findings drive the grade:

  1. Privacy API compliance gap — the plugin implements both null_provider (declaring it stores no personal data) and metadata\provider (declaring metadata for two tables that store user IDs). This contradiction means Moodle's privacy subsystem will skip this plugin during GDPR export/deletion requests, leaving user completion and credit cache records unprocessed. While the data is derivative (cached from course/program completions), the privacy implementation should handle cleanup rather than relying solely on cron.

  2. Missing space in SQL concatenation in frameworks_user.php that would produce a SQL syntax error when multi-tenancy is active, breaking the user credits report for tenancy users.

Additional low-severity findings include a capability check that uses system context rather than the framework's actual context (UI bug only, not a security issue), and direct SQL value interpolation where the report builder API supports parameterized queries.

No security vulnerabilities were identified. The code is well-structured, uses transactions where appropriate, has comprehensive test coverage, and follows Moodle coding patterns consistently.

AI Summary

Plugin Overview

tool_mutrain is a training credits management plugin for Moodle, part of the MuTMS plugin suite. It provides:

  • Credit frameworks — configurable containers that aggregate training credits from custom fields on courses and programs
  • Completion tracking — event-driven and cron-based synchronization of course/program completions into a cached credit system
  • Management UI — admin pages for creating, updating, archiving, moving, and deleting frameworks; adding/removing custom fields
  • User reports — user-facing pages showing earned credits across frameworks, with drill-down to individual completions
  • Report builder integration — system reports using Moodle's report builder API with proper entity definitions

Security Assessment

The plugin follows Moodle security best practices:

  • All pages call require_login() and require_capability() with appropriate capability checks
  • Forms extend tool_mulib\local\ajax_form (Moodle form base class), providing automatic sesskey/CSRF protection
  • Database operations use the $DB API with parameterized queries throughout
  • Output is properly escaped with format_string(), s(), and format_text()
  • No raw HTTP requests, no filesystem bypasses, no shell execution, no eval usage
  • No hardcoded credentials or sensitive data in source

Key Findings

The review identified:

  • 2 medium findings — Privacy API compliance gap and a SQL concatenation bug
  • 2 low findings — incorrect capability context check and SQL interpolation patterns
  • No critical or high findings
  • No security vulnerabilities

Findings

complianceMedium
Contradictory Privacy API: null_provider with personal data storage

The plugin implements both core_privacy\local\metadata\null_provider and core_privacy\local\metadata\provider. These interfaces are contradictory:

  • null_provider declares that the plugin stores no personal data and requires no export/deletion handling
  • provider declares metadata for two tables (tool_mutrain_completion and tool_mutrain_credit) that both contain userid fields

Because null_provider is implemented, Moodle's privacy subsystem will skip this plugin entirely during GDPR data subject access requests and deletion requests. User completion and credit records will not be exported or deleted.

The plugin should:

  1. Remove the null_provider interface
  2. Implement core_privacy\local\request\plugin\provider to handle data export and deletion
  3. Or, if the data is truly considered transient cache, implement core_privacy\local\request\userlist_provider with appropriate cleanup
Risk Assessment

Medium risk. This is a GDPR/privacy compliance gap. When a data subject requests data export or deletion, this plugin's records will be skipped. The data is derivative (cached from course/program completions), and the cron task will eventually clean up orphaned records if the source data is deleted. However, relying on eventual cron cleanup is not compliant with GDPR requirements for timely data deletion, and the data would not appear in privacy exports at all.

Context

The plugin stores personal data in two tables:

  • tool_mutrain_completion — records which user completed which course/program, with timestamps
  • tool_mutrain_credit — records aggregated credit totals per user per framework, including when required credits were reached

Both tables have userid foreign keys to the user table. The plugin's own get_metadata() method correctly declares these tables and their fields, but the null_provider interface overrides this by telling the privacy subsystem to ignore the plugin.

Affected Code
class provider implements
    \core_privacy\local\metadata\null_provider,
    \core_privacy\local\metadata\provider {
Suggested Fix

Remove null_provider and implement the request handler interface:

class provider implements
    \core_privacy\local\metadata\provider,
    \core_privacy\local\request\plugin\provider {

Then implement the required methods: get_contexts_for_userid(), export_user_data(), delete_data_for_all_users_in_context(), and delete_data_for_user().

code qualityMedium
Missing space in SQL concatenation breaks tenancy user reports

In the frameworks_user system report, when multi-tenancy is active, a SQL condition is appended without a leading space. This produces invalid SQL like ...archived = 0AND (...), causing a database error that prevents the user credits report from loading for tenancy users.

Risk Assessment

Medium risk. This is a functional bug, not a security vulnerability. It only manifests when multi-tenancy (tool_mutenancy) is active and the user belongs to a tenant. The user credits report page will throw a database exception, preventing users from viewing their training credits. Sites not using multi-tenancy are unaffected.

Context

The $basewhere string is built in two steps: line 62 sets the base condition ending with archived = 0, and line 67 conditionally appends a tenancy filter. The concatenation operator .= joins the strings directly without any whitespace separator.

Affected Code
if (\tool_mulib\local\mulib::is_mutenancy_active()) {
    if ($usercontext->tenantid) {
        $basewhere .= "AND ({$contextalias}.tenantid IS NULL OR {$contextalias}.tenantid = $usercontext->tenantid)";
    }
}
Suggested Fix

Add a leading space before AND:

$basewhere .= " AND ({$contextalias}.tenantid IS NULL OR {$contextalias}.tenantid = $usercontext->tenantid)";
code qualityLow
Field removal button checks system context instead of framework context

In the fields table, the "remove field" action button checks tool/mutrain:manageframeworks capability against context_system rather than the framework's actual context. This means:

  • Users with manageframeworks in a category context (but not system) will not see the remove button, even though they can access the removal page directly
  • The actual field_remove.php page correctly checks the capability against the framework's context

This is a UI inconsistency, not a security issue, since the page-level check is correct.

Risk Assessment

Low risk. This is a UI/UX bug only. Users with category-level management capability will not see the remove button in the table, but they can still navigate to the removal page directly (which performs the correct authorization check). No security bypass is possible.

Context

The fields table class is used on the management/framework.php page, which already requires tool/mutrain:viewframeworks in the framework's context. The table displays custom fields associated with a framework and optionally shows a remove button. The framework record is stored as $this->framework in the constructor.

Identified Code
if (!$this->framework->archived && has_capability('tool/mutrain:manageframeworks', \context_system::instance())) {
Suggested Fix

Check capability against the framework's context instead of system context:

$context = \context::instance_by_id($this->framework->contextid);
if (!$this->framework->archived && has_capability('tool/mutrain:manageframeworks', $context)) {
code qualityLow
Direct SQL value interpolation where parameterized queries are available

Several system reports interpolate PHP variables directly into SQL strings passed to add_base_condition_sql(), which accepts an optional $params array. While the interpolated values are all integers from trusted sources (context instance IDs, database record IDs, timestamps), using parameterized queries is the recommended Moodle practice for defense in depth.

Risk Assessment

Low risk. No actual SQL injection vulnerability exists because all interpolated values are integers from the Moodle database or context system. However, using parameterized queries is a best practice that protects against future code changes that might introduce non-integer values, and is the pattern recommended by the Moodle report builder API.

Context

The add_base_condition_sql() method in Moodle's report builder API accepts a second $params array argument for parameterized queries. The interpolated values in this plugin all come from $usercontext->instanceid (integer user ID), $this->framework->id (integer DB primary key), and $this->framework->restrictafter (integer timestamp) — all from trusted database sources.

Identified Code
$basewhere = "{$this->creditalias}.userid = $userid AND {$this->frameworkalias}.publicaccess = 1 AND {$this->frameworkalias}.archived = 0";
Suggested Fix

Use parameterized queries with database::generate_param_name():

$useridparam = \core_reportbuilder\local\helpers\database::generate_param_name();
$basewhere = "{$this->creditalias}.userid = :{$useridparam} AND {$this->frameworkalias}.publicaccess = 1 AND {$this->frameworkalias}.archived = 0";
$params[$useridparam] = $userid;

Then pass $params to add_base_condition_sql().

Identified Code
$basewhere[] = "{$this->completionalias}.userid = {$usercontext->instanceid}";
$basewhere[] = "{$this->frameworkalias}.id = {$this->framework->id}";
Suggested Fix

Use parameterized queries similarly to the suggested fix above.

Identified Code
$basewhere[] = "{$this->completionalias}.timecompleted >= {$this->framework->restrictafter}";
Suggested Fix

Use a parameterized query for the restrictafter value.

Additional AI Notes

The plugin has good test coverage with PHPUnit tests for core business logic (framework operations, area completions, utilities, events) and Behat generator support. This is a positive indicator of code quality and maintainability.

The database schema upgrade path in db/upgrade.php is well-structured, using proper XMLDB operations with $DB->get_manager() (the correct location for DDL in upgrade files), savepoints, and conditional field existence checks.

The plugin's SQL handling in framework::sync_credits() correctly provides separate MySQL and PostgreSQL UPDATE syntax for the credit aggregation query. This is a valid approach to handling cross-database UPDATE...FROM differences that Moodle's DB API does not abstract away.

All management pages use the tool_mulib\local\ajax_form base class (extending Moodle's moodleform), which provides automatic sesskey/CSRF validation. No manual sesskey handling is needed or missing.

The plugin depends on tool_mulib and customfield_mutrain as declared in version.php. Several classes extend base classes from tool_mulib (e.g., ajax_form, form_autocomplete\base, sql). The review evaluated the plugin's own code in isolation; the security of the base classes in tool_mulib is outside scope.

This review was generated by an AI system and may contain inaccuracies. Findings should be verified by a human reviewer before acting on them.