Restriction by relative date
availability_relativedate
An availability condition plugin that restricts access to course modules or sections based on dates relative to course start/end, user enrolment start/end, or completion of another activity. Provides an extra restriction option in the standard Moodle availability/restrict access UI.
The plugin is small, well-structured and follows Moodle conventions throughout. All database queries use named placeholders via $DB, JSON-decoded condition values are coerced through intval() in the constructor, the privacy implementation correctly declares null_provider, and the settings page is gated by the admin tree. The frontend uses format_string() on user-controlled section/module names before they reach the YUI script (matching the pattern used by availability_completion in core), so there is no XSS path through the option labels. No raw filesystem, schema, or HTTP access exists outside Moodle's APIs. The only observations are minor code-quality items: a request-scoped cache key ("0_{courseid}") is shared between frontend.php (used as a presence flag with the course id as the value) and condition.php::calc() case 4 (which interprets the cached value as an enrolment timestamp), and update_dependency_id matches both course_modules and course_sections for what is logically a course-module reference. Both are theoretical correctness concerns rather than security issues.
Plugin overview
availability_relativedate is a Moodle availability condition plugin that adds a "Relative date" restriction to the standard Restrict access UI. Authors can require an item to become available a configurable number of minutes/hours/days/weeks/months after or before the course start, course end, user enrolment, enrolment-method end, or completion of another activity.
Architecture
classes/condition.php— extends\core_availability\condition. Encapsulates the condition state (relativenumber,relativedwm,relativestart,relativecoursemodule) and exposesis_available(),get_description(),save(),update_after_restore(), and thecalc()helper.classes/frontend.php— extends\core_availability\frontend. Supplies the data array consumed by the YUI form (allowed start options, completion-enabled activity selector, default values).classes/autoupdate.php— observer forcore\event\course_module_deletedthat rewrites references in dependent availability trees.classes/privacy/provider.php— implementsnull_provider, declaring that the plugin stores no personal data.db/caches.php— defines twoMODE_REQUESTcaches (enrolstart,enrolend) used to memoise per-(user, course) enrolment lookups within a single request.db/events.php— registers the deletion observer.settings.php— admin settings (maximum number and default values).yui/src/form/— YUI editor module that renders the in-page form widget.
Security posture
- All SQL uses named placeholders through
$DB. No string concatenation of user-controlled values into queries. - Constructor uses
intval()on every JSON field, so malformed or hostile JSON values cannot reach DB queries as non-integers. format_string()is applied to section and module names infrontend.phpbefore they are passed to the YUI initialiser (matches the pattern inavailability_completion).settings.phpis wrapped inif ($ADMIN->fulltree); there are no other web-accessible entry points.- No filesystem, curl, eval, schema-changing, or
$_*superglobal usage anywhere in the plugin. - Privacy API is implemented via
null_providerand aprivacy:metadatalanguage string.
Notable observations
- Cache key reuse —
frontend.phpstores the course id under the key"0_{$course->id}"in theenrolendcache as a presence flag indicating that at least one enrol method has an end date.condition.php::calc()case 4 stores a numeric timestamp under the same key shape ("{$userid}_{$course->id}") and reads the cached value as a date. If$useridis0(unauthenticated/visitor) and both code paths run in the same request, the calc would consume the course id as if it were a timestamp. This is aMODE_REQUESTcache, so the blast radius is one request, and the scenarios that would line both paths up together are unusual. update_dependency_id— the method updatesrelativecoursemodulefor bothcourse_modulesandcourse_sectionstable updates. Since the field stores acourse_modules.id, acceptingcourse_sectionsupdates can rewrite the reference incorrectly when a section id happens to match the stored cmid. Core'savailability_completiononly matchescourse_modules.- YUI editor — the in-page form is implemented as a YUI module (
yui/src/form/js/form.js). YUI is broadly deprecated in Moodle, but core availability conditions (e.g.availability_completion) still use YUI for this same form layer, so this remains an acceptable pattern at present. - The strings appended via
+ this.activitySelector[i].name +are produced byformat_string()in PHP — the same trust boundary that core comments document with "String has already been escaped using format_string." There is no XSS path through these labels at the moment.
Tests
The plugin ships comprehensive PHPUnit and Behat coverage (condition/backup/frontend/privacy/simple tests, plus several Behat features). A tests/coverage.php is included.
Conclusion
No security findings. The plugin is a small, careful implementation that reuses core APIs correctly and matches the style of the in-tree availability conditions. The few observations recorded are low/info code-quality items.
Findings
Both frontend.php and condition.php::calc() use the availability_relativedate / enrolend cache with keys of the form "<userid>_<courseid>", but they store different value types under those keys.
frontend.phpwrites$course->id(an integer course id) under key"0_{$course->id}"as a presence flag that means "this course has at least one enrol method with an end date". The frontend code only ever consumes this entry via$cache->has($key).condition.php::calc()case 4 writes a Unix timestamp (the user's most recentenrolenddate) under key"{$userid}_{$course->id}"and reads it back as a timestamp that is fed to$this->fixdate("+{$x}", $lowest).
If $userid === 0 (unauthenticated visitor or a code path not setting a logged-in user) and both paths run in the same request, calc() would read the course id out of the cache and treat it as if it were an enrolment end timestamp — producing a relative date anchored to ~1970.
The cache is MODE_REQUEST, so a stale value cannot survive a single request, and the editing UI in frontend.php is only reachable by users with editing capability — so the collision is unlikely in practice. It is still a correctness smell because the two callers use incompatible semantics for the same key.
Low risk. The cache is request-scoped, the frontend writer requires editing capability, and a $userid of 0 reaching calc() in the same request is an unusual code path. The worst-case symptom is an incorrect date computation for the duration of one request — no data leakage or privilege escalation.
The enrolend cache is declared in db/caches.php as MODE_REQUEST, so values only live for a single PHP request. frontend.php::get_javascript_init_params() runs while a teacher is editing a course module's availability conditions; condition.php::calc() case 4 runs whenever the availability framework evaluates a relativedate condition. Both can write entries keyed "<userid>_<courseid>" but they store fundamentally different values (course id vs. epoch timestamp), and the frontend writer reuses 0 as the userid placeholder.
$cache = \cache::make('availability_relativedate', 'enrolend');
if (
!$cache->has($key) &&
$DB->count_records_select('enrol', 'courseid = :courseid AND enrolenddate > 0', ['courseid' => $course->id]) > 0
) {
// Just set a value.
$cache->set($key, $course->id);
}
Use a separate cache definition (or a distinct key prefix) for the presence-flag use case so the two code paths can never collide. For example, add an enrolendexists definition in db/caches.php and use it from frontend.php, leaving enrolend strictly for cached timestamps in condition.php.
Alternatively, namespace the key explicitly in the frontend, e.g. "hasenrolend_{$course->id}", so the cmid/userid pattern used by calc() cannot overlap.
case 4:
// After latest enrolment end date.
$cache = \cache::make('availability_relativedate', 'enrolend');
if ($cache->has($key)) {
$lowest = $cache->get($key);
} else {
$sql = 'SELECT e.enrolenddate
FROM {user_enrolments} ue
JOIN {enrol} e on ue.enrolid = e.id
WHERE e.courseid = :courseid AND ue.userid = :userid
ORDER by e.enrolenddate DESC';
$lowest = $this->getlowest($sql, $course->id, $userid);
$cache->set($key, $lowest);
}
return $this->fixdate("+{$x}", $lowest);
After splitting the caches as described above, this block does not need to change. If you prefer to keep a single cache, switch to a dedicated key per use (e.g. "ts_{$userid}_{$course->id}" here and "exists_{$course->id}" in frontend.php).
The relativecoursemodule property stores a course_modules.id. The update_dependency_id() method, however, returns true and rewrites the value whenever the table is either course_modules or course_sections.
course_modules.id and course_sections.id live in independent sequences and can therefore share numeric values. If the availability framework calls update_dependency_id('course_sections', $oldid, $newid) and $oldid coincidentally equals the stored cmid, the stored cmid is rewritten to a section id — silently corrupting the condition.
Core's availability_completion::update_dependency_id() (at public/availability/condition/completion/classes/condition.php:551) only matches course_modules for the same reason.
Low risk. Triggering the bad rewrite requires the framework to call update_dependency_id for course_sections with a section $oldid that exactly matches the stored cmid — uncommon but not impossible in large or long-lived courses. The failure mode is data corruption within a single availability condition (it will subsequently behave as if the activity is missing), not a security boundary violation.
update_dependency_id is invoked by core during restore/duplication operations to rewrite references whose underlying records have been renumbered. The plugin's own update_after_restore() already handles the course-module remapping using restore_dbops::get_backup_ids_record(), so the section-table branch is not load-bearing.
public function update_dependency_id($table, $oldid, $newid) {
if (
$this->relativestart === 7 &&
in_array($table, ['course_modules', 'course_sections']) &&
$this->relativecoursemodule === $oldid
) {
$this->relativecoursemodule = $newid;
return true;
}
return false;
}
Restrict the table check to course_modules only, matching core:
public function update_dependency_id($table, $oldid, $newid) {
if (
$this->relativestart === 7 &&
$table === 'course_modules' &&
$this->relativecoursemodule === $oldid
) {
$this->relativecoursemodule = $newid;
return true;
}
return false;
}
The corresponding assertions in tests/condition_test.php::test_get_description() that currently expect update_dependency_id('course_sections', ...) to return true should be updated to expect false.
condition::options_dwm() is declared as public static function options_dwm($plural = true): array. The settings file calls it with the integer 2. PHP coerces the integer to true, so the result is correct, but the call site is misleading and would break under stricter typing.
Negligible risk. Cosmetic only — no functional impact under current PHP semantics, and the values displayed in the admin setting are correct.
options_dwm() returns either the plural or singular forms of minute(s), hour(s), etc. depending on the boolean argument. The integer 2 is being read as a stand-in for "more than one" rather than as a true boolean.
choices: availability_relativedate\condition::options_dwm(2)
Pass an explicit boolean to match the parameter contract:
choices: availability_relativedate\condition::options_dwm(true)
Or simply omit the argument and rely on the default value (true).
The in-page availability editor for this condition is delivered as a YUI module at yui/src/form/js/form.js (and its built artefacts under yui/build/...). Moodle has been actively retiring YUI; new code should prefer AMD/ES modules in amd/src/.
That said, core availability conditions — including availability_completion — still ship the same kind of YUI form. The core_availability framework's frontend::include_all_javascript() loads moodle-<plugin>-form YUI modules for every enabled plugin, so until core migrates, third-party availability plugins are effectively obliged to use YUI for the editor too. No action is required today.
No risk. Forward-looking maintenance note.
The YUI module is loaded via core_availability\frontend::include_all_javascript(), which collects the moodle-availability_<plugin>-form module name from every enabled availability plugin and loads them in a single YUI batch. There is no AMD entry point for these editors yet.
Track the core migration (see core_availability upgrade notes in future versions) and migrate the editor module to AMD when core opens that path for availability plugins.
The helper getlowest() is named as if it returned the lowest enrolment date, but the SQL queries supplied to it use ORDER BY ... DESC and IGNORE_MULTIPLE, so the first record returned is the highest value (most recent timestart/enrolenddate). The actual behaviour is what the surrounding logic wants, but the name misleads anyone reading the code.
No risk. Naming-only observation.
getlowest() is called from calc() cases 3 and 4, both of which select the relevant column with ORDER BY ... DESC and want the most recent timestamp.
private function getlowest(string $sql, int $courseid, int $userid): int {
global $DB;
$parameters = ['courseid' => $courseid, 'userid' => $userid];
if ($lowestrec = $DB->get_record_sql($sql, $parameters, IGNORE_MULTIPLE)) {
$recs = get_object_vars($lowestrec);
foreach ($recs as $value) {
return $value;
}
}
return 0;
}
Rename to something accurate, e.g. get_latest_enrolment_value() / get_first_value(), and update the docblock to match. Purely cosmetic.
SQL safety. All DB queries use named placeholders through $DB->get_record_sql() and $DB->count_records_select(). JSON inputs to the condition constructor are coerced with intval() before they reach any query, so even hostile JSON cannot inject non-integer values into the parameter set.
Privacy. The plugin correctly implements \core_privacy\local\metadata\null_provider and exposes a privacy:metadata language string explaining that no personal data is stored. The condition itself only persists numeric configuration (number/dwm/start/cmid) inside the standard course_modules.availability / course_sections.availability columns.
XSS surface in the YUI editor. Section and module names that the editor renders are produced by format_string() in classes/frontend.php before they are handed off to the YUI script. This matches the trust pattern used by core's availability_completion (// String has already been escaped using format_string.). The other string injected into the editor HTML — the section heading fallback — is "{$str} {$sectionnum}", where $str comes from get_string('section') and $sectionnum is an integer.
Tests. The plugin ships PHPUnit suites for condition, backup, frontend, simple, behat, and privacy, plus eight Behat feature files. tests/coverage.php declares classes/ as the in-scope folder for coverage, with version.php/lib.php excluded.
Settings page. settings.php is wrapped in if ($ADMIN->fulltree) and uses admin_setting_configtext / admin_setting_configselect with PARAM_INT validation on the user-editable text field. No state-changing actions are exposed outside the admin tree.