MDL Shield

Restriction by other restrictions met at least once

availability_oncemet

Print Report
Plugin Information

Availability condition that wraps other access restrictions and permanently remembers ("unlocks") them per user once they have been met. It also provides a staff report, with bulk reset, that lists the users holding an unlock for a given Once met restriction.

Version:2026100501
Release:v5.3-r2
Reviewed for:5.3
Privacy API
Unit Tests
Behat Tests
Reviewed:2026-11-05
34 files·7,427 lines
Grade Justification

The review found no security vulnerabilities. The one custom entry point (unlocks.php) does the following:

  • calls require_login() with the course and course module
  • checks a dedicated read capability before showing anything and a separate write capability before any reset
  • calls require_sesskey() on the state-changing request
  • validates the requested instance id against the restrictions actually stored on the item
  • escapes all user-derived output

All SQL is parameterised. The only dynamic fragments are the core flexible_table sort clause, which core whitelists against defined columns, and sql_like() initials filters with bound parameters.

The only finding is low severity: the unlock report does not respect separate groups mode. It matters only if a site gives the report capability to a role without moodle/site:accessallgroups, which the default archetypes do not. The plugin also ships a full Privacy API provider, event logging, event observers that clean up data, an uninstall notice, and extensive PHPUnit and Behat coverage.

AI Summary

availability_oncemet is a mid-sized availability condition for Moodle 5.3. It wraps other restrictions and stores a permanent per-user unlock in its own table once the nested restrictions are met. It adds one standalone page (unlocks.php), where staff can see who holds an unlock and reset it. Event observers remove unlock records when courses, modules, sections or users are deleted, when a restriction is removed, or when a course reset unenrols users.

The security posture is strong. The report page:

  • calls require_login() with the course and module
  • checks a dedicated read capability (viewunlocks, flagged RISK_PERSONAL) and a separate write capability (resetunlock, flagged RISK_DATALOSS)
  • calls require_sesskey() before resetting
  • rejects instance ids that are not stored on the requested item, so the page can't be used to probe unlock state across the course

SQL is parameterised throughout. Output is escaped with s() and format_string() or built by core renderers. Teacher-controlled instance ids are length-limited and only reach the DOM through setAttribute().

The only finding is that the unlock report ignores course group mode. It lists every unlock holder, whatever the viewer's group membership, which matters only if a site grants the report capability to a role without accessallgroups. Separately, there is an operational point for administrators (see the notes): if the plugin is disabled or uninstalled, core silently drops the wrapper and everything nested inside it, which can open restricted activities. The plugin's uninstall notice warns about this.

Findings

best practiceLow
Unlock report ignores separate groups mode

The unlock report lists every user who holds an unlock for the restriction, whatever groups the viewer belongs to. The query in unlocks::get_records() filters only by item, instance id, u.deleted and the initials bar, and unlocks.php never looks at groups_get_course_groupmode() or moodle/site:accessallgroups. Core participant-style reports usually limit a user without accessallgroups to their own groups in separate groups mode. The same applies to the reset action, which accepts any user id that holds an unlock for the instance.

Risk Assessment

Low risk. Out of the box nobody can exploit this, because every role holding the capability can already see all groups. It only becomes a minor disclosure of names and unlock times across groups if an administrator grants viewunlocks to a role without accessallgroups.

Context

unlocks.php is reached from a button in the availability form and is gated by availability/oncemet:viewunlocks. By default only editingteacher and manager hold that capability, and both also hold moodle/site:accessallgroups.

Identified Code
$sql = 'SELECT o.id, o.userid, o.timecreated AS timeunlocked ' . $namefields->selects . '
          FROM {availability_oncemet} o
          JOIN {user} u ON u.id = o.userid
               ' . $namefields->joins . '
         WHERE o.cmid = :cmid AND o.sectionid = :sectionid AND o.availabilityuuid = :instanceid
               AND u.deleted = 0';
Suggested Fix

In unlocks.php, work out the visible groups the way core reports do and pass an extra condition through the existing $where/$whereparams arguments:

$groupmode = $cm ? groups_get_activity_groupmode($cm, $course) : groups_get_course_groupmode($course);
if ($groupmode == SEPARATEGROUPS && !has_capability('moodle/site:accessallgroups', $context)) {
    $groupids = array_keys(groups_get_all_groups($course->id, $USER->id));
    // Restrict to members of $groupids (or to nobody if empty) via a groups_members subquery.
}

Apply the same restriction to the user ids accepted by unlocks::reset().

Identified Code
$removed = unlocks::reset($cmid, $sectionid, $instanceid, optional_param_array('unlockuserids', [], PARAM_INT));
Suggested Fix

Intersect the submitted user ids with the users the current user may see under the group rules above before calling unlocks::reset().

Additional AI Notes

Fail-open on disable or uninstall. Core decodes availability trees leniently (core_availability\tree::__construct() with $lax = true). A condition whose plugin is disabled or missing is silently dropped, and here that drops the whole Once met wrapper including every nested restriction, so affected items can become available to everyone. The plugin's db/uninstall.php counts the affected items and shows a clear warning (uninstaller_remainingrestrictions). Administrators should still know that simply disabling the plugin under Manage restrictions has the same effect, without that warning.

Code adapted from TinyMCE in the YUI module. yui/src/form/js/form.js contains a short UUID-v4 helper adapted from TinyMCE 8.2.2 (GPL-2.0-or-later), with an inline attribution comment. Since it is a small snippet inside a plugin-authored file rather than a bundled library, it has deliberately not been declared in thirdpartylibs.xml, and the comment explains why.

Unlocks are not part of backup/restore. Instance ids survive restore, but the unlock records do not, so learners in a restored or imported course have to meet the nested restrictions again. This looks intentional (get_objectid_mapping() returns NOT_MAPPED), but course maintainers may want to know about it.

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