Restriction by other restrictions met at least once
availability_oncemet
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.
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.
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, flaggedRISK_PERSONAL) and a separate write capability (resetunlock, flaggedRISK_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
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.
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.
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.
$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';
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().
$removed = unlocks::reset($cmid, $sectionid, $instanceid, optional_param_array('unlockuserids', [], PARAM_INT));
Intersect the submitted user ids with the users the current user may see under the group rules above before calling unlocks::reset().
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.