diff --git a/lang/en/cache.php b/lang/en/cache.php index 327aba68f3e3d..771393b21e075 100644 --- a/lang/en/cache.php +++ b/lang/en/cache.php @@ -56,6 +56,7 @@ $string['cachedef_course_image'] = 'Course images'; $string['cachedef_course_user_dates'] = 'The user dates for courses set to relative dates mode'; $string['cachedef_completion'] = 'Activity completion status'; +$string['cachedef_deprecatedcapabilities'] = 'System deprecated capabilities list'; $string['cachedef_databasemeta'] = 'Database meta information'; $string['cachedef_eventinvalidation'] = 'Event invalidation'; $string['cachedef_externalbadges'] = 'External badges for particular user'; diff --git a/lib/accesslib.php b/lib/accesslib.php index bf7e1f7d6ab00..e21d94c7e9726 100644 --- a/lib/accesslib.php +++ b/lib/accesslib.php @@ -2525,6 +2525,20 @@ function is_inside_frontpage(context $context) { function get_capability_info($capabilityname) { $caps = get_all_capabilities(); + // Check for deprecated capability. + if ($deprecatedinfo = get_deprecated_capability_info($capabilityname)) { + if (!empty($deprecatedinfo['replacement'])) { + // Let's try again with this capability if it exists. + if (isset($caps[$deprecatedinfo['replacement']])) { + $capabilityname = $deprecatedinfo['replacement']; + } else { + debugging("Capability '{$capabilityname}' was supposed to be replaced with ". + "'{$deprecatedinfo['replacement']}', which does not exist !"); + } + } + $fullmessage = $deprecatedinfo['fullmessage']; + debugging($fullmessage, DEBUG_DEVELOPER); + } if (!isset($caps[$capabilityname])) { return null; } @@ -2532,6 +2546,57 @@ function get_capability_info($capabilityname) { return (object) $caps[$capabilityname]; } +/** + * Returns deprecation info for this particular capabilty (cached) + * + * Do not use this function except in the get_capability_info + * + * @param string $capabilityname + * @return stdClass|null with deprecation message and potential replacement if not null + */ +function get_deprecated_capability_info($capabilityname) { + // Here if we do like get_all_capabilities, we run into performance issues as the full array is unserialised each time. + // We could have used an adhoc task but this also had performance issue. Last solution was to create a cache using + // the official caches.php file. The performance issue shows in test_permission_evaluation. + $cache = cache::make('core', 'deprecatedcapabilities'); + // Cache has not be initialised. + if (!$cache->get('deprecated_capabilities_initialised')) { + // Look for deprecated capabilities in each components. + $allcaps = get_all_capabilities(); + $components = []; + $alldeprecatedcaps = []; + foreach ($allcaps as $cap) { + if (!in_array($cap['component'], $components)) { + $components[] = $cap['component']; + $defpath = core_component::get_component_directory($cap['component']).'/db/access.php'; + if (file_exists($defpath)) { + $deprecatedcapabilities = []; + require($defpath); + if (!empty($deprecatedcapabilities)) { + foreach ($deprecatedcapabilities as $cname => $cdef) { + $cache->set($cname, $cdef); + } + } + } + } + } + $cache->set('deprecated_capabilities_initialised', true); + } + if (!$cache->has($capabilityname)) { + return null; + } + $deprecatedinfo = $cache->get($capabilityname); + $deprecatedinfo['fullmessage'] = "The capability '{$capabilityname}' is deprecated."; + if (!empty($deprecatedinfo['message'])) { + $deprecatedinfo['fullmessage'] .= $deprecatedinfo['message']; + } + if (!empty($deprecatedinfo['replacement'])) { + $deprecatedinfo['fullmessage'] .= + "It will be replaced by '{$deprecatedinfo['replacement']}'."; + } + return $deprecatedinfo; +} + /** * Returns all capabilitiy records, preferably from MUC and not database. * @@ -4135,6 +4200,11 @@ function get_user_capability_contexts(string $capability, bool $getcategories, $ $userid = $USER->id; } + if (!$capinfo = get_capability_info($capability)) { + debugging('Capability "'.$capability.'" was not found! This has to be fixed in code.'); + return [false, false]; + } + if ($doanything && is_siteadmin($userid)) { // If the user is a site admin and $doanything is enabled then there is no need to restrict // the list of courses. @@ -4143,7 +4213,7 @@ function get_user_capability_contexts(string $capability, bool $getcategories, $ } else { // Gets SQL to limit contexts ('x' table) to those where the user has this capability. list ($contextlimitsql, $contextlimitparams) = \core\access\get_user_capability_course_helper::get_sql( - $userid, $capability); + $userid, $capinfo->name); if (!$contextlimitsql) { // If the does not have this capability in any context, return false without querying. return [false, false]; diff --git a/lib/db/caches.php b/lib/db/caches.php index 8e65c01e76b43..084350647fd3a 100644 --- a/lib/db/caches.php +++ b/lib/db/caches.php @@ -149,6 +149,16 @@ 'ttl' => 3600, // Just in case. ), + // Cache the deprecated capabilities list. See get_deprecated_capability_info in accesslib. + 'deprecatedcapabilities' => array( + 'mode' => cache_store::MODE_APPLICATION, + 'simplekeys' => false, // We need to hash the key. + 'simpledata' => true, + 'staticacceleration' => true, + 'staticaccelerationsize' => 1, + 'ttl' => 3600, // Just in case. + ), + // YUI Module cache. // This stores the YUI module metadata for Shifted YUI modules in Moodle. 'yuimodules' => array( diff --git a/lib/tests/accesslib_test.php b/lib/tests/accesslib_test.php index 1fac88af5d656..1a5448d4c0953 100644 --- a/lib/tests/accesslib_test.php +++ b/lib/tests/accesslib_test.php @@ -1970,6 +1970,157 @@ public function test_has_capability_and_friends() { $this->assertFalse(has_all_capabilities($sca, $coursecontext, 0)); } + /** + * Utility method to fake a plugin + * + * @param string $pluginname plugin name + * @return void + */ + protected function setup_fake_plugin($pluginname) { + global $CFG; + // Here we have to hack the component loader so we can insert our fake plugin and test that + // the access.php works. + $mockedcomponent = new ReflectionClass(core_component::class); + $mockedplugins = $mockedcomponent->getProperty('plugins'); + $mockedplugins->setAccessible(true); + $plugins = $mockedplugins->getValue(); + $plugins['fake'] = [$pluginname => "{$CFG->dirroot}/lib/tests/fixtures/fakeplugins/$pluginname"]; + $mockedplugins->setValue($plugins); + update_capabilities('fake_access'); + $this->resetDebugging(); // We have debugging messages here that we need to get rid of. + // End of the component loader mock. + } + + /** + * Test get_deprecated_capability_info() + * + * @covers ::get_deprecated_capability_info + */ + public function test_get_deprecated_capability_info() { + $this->resetAfterTest(); + $course = $this->getDataGenerator()->create_course(); + $coursecontext = context_course::instance($course->id); + $user = $this->getDataGenerator()->create_and_enrol($course); + $this->setup_fake_plugin('access'); + + // For now we have deprecated fake/access:fakecapability. + $capinfo = get_deprecated_capability_info('fake/access:fakecapability'); + $this->assertNotEmpty($capinfo); + $this->assertEquals("The capability 'fake/access:fakecapability' is" + . " deprecated.This capability should not be used anymore.", $capinfo['fullmessage']); + } + + /** + * Test get_deprecated_capability_info() through has_capability + * + * @covers ::get_deprecated_capability_info + */ + public function test_get_deprecated_capability_info_through_has_capability() { + $this->resetAfterTest(); + $course = $this->getDataGenerator()->create_course(); + $coursecontext = context_course::instance($course->id); + $user = $this->getDataGenerator()->create_and_enrol($course); + $this->setup_fake_plugin('access'); + + // For now we have deprecated fake/access:fakecapability. + $hascap = has_capability('fake/access:fakecapability', $coursecontext, $user); + $this->assertTrue($hascap); + $this->assertDebuggingCalled("The capability 'fake/access:fakecapability' is deprecated." + . "This capability should not be used anymore."); + } + + /** + * Test get_deprecated_capability_info() through get_user_capability_contexts() + * + * @covers ::get_deprecated_capability_info + */ + public function test_get_deprecated_capability_info_through_get_user_capability_contexts() { + $this->resetAfterTest(); + $category = $this->getDataGenerator()->create_category(); + $course = $this->getDataGenerator()->create_course(['categoryid' => $category->id]); + $user = $this->getDataGenerator()->create_and_enrol($course); + $this->setup_fake_plugin('access'); + + // For now we have deprecated fake/access:fakecapability. + list($categories, $courses) = get_user_capability_contexts('fake/access:fakecapability', false, $user->id); + $this->assertNotEmpty($courses); + $this->assertDebuggingCalled("The capability 'fake/access:fakecapability' is deprecated." + . "This capability should not be used anymore."); + } + + /** + * Test get_deprecated_capability_info with a capability that does not exist + * + * @param string $capability the capability name + * @param array $debugmessages the debug messsages we expect + * @param bool $expectedexisting does the capability exist + * @covers ::get_deprecated_capability_info + * @dataProvider deprecated_capabilities_use_cases + */ + public function test_get_deprecated_capability_specific_cases(string $capability, array $debugmessages, + bool $expectedexisting) { + $this->resetAfterTest(); + $course = $this->getDataGenerator()->create_course(); + $coursecontext = context_course::instance($course->id); + $user = $this->getDataGenerator()->create_and_enrol($course); + $this->setup_fake_plugin('access'); + + // For now we have deprecated fake/access:fakecapability. + $this->resetDebugging(); + $hascap = has_capability($capability, $coursecontext, $user); + $this->assertEquals($expectedexisting, $hascap); + $this->assertDebuggingCalledCount(count($debugmessages), $debugmessages); + } + + /** + * Specific use case for deprecated capabilities + * + * @return array + */ + public function deprecated_capabilities_use_cases() { + return [ + 'capability missing' => [ + 'fake/access:missingcapability', + [ + "Capability \"fake/access:missingcapability\" was not found! This has to be fixed in code." + ], + false + ], + 'replacement no info' => [ + 'fake/access:replacementnoinfo', + [ + "The capability 'fake/access:replacementnoinfo' is deprecated.", + ], + true + ], + 'replacement missing' => [ + 'fake/access:replacementmissing', + [ + "The capability 'fake/access:replacementmissing' is deprecated.This capability should not be used anymore.", + ], + true + ], + 'replacement with non existing cap' => [ + 'fake/access:replacementwithwrongcapability', + [ + "Capability 'fake/access:replacementwithwrongcapability' was supposed to be replaced with" + . " 'fake/access:nonexistingcapabilty', which does not exist !", + "The capability 'fake/access:replacementwithwrongcapability' is deprecated." + . "This capability should not be used anymore.It will be replaced by 'fake/access:nonexistingcapabilty'." + ], + true + ], + 'replacement with existing' => [ + 'fake/access:replacementwithexisting', // Existing capability buf for a different role. + [ + "The capability 'fake/access:replacementwithexisting' is deprecated.This capability should not be used anymore." + . "It will be replaced by 'fake/access:existingcapability'.", + ], + false // As the capability is applied to managers, we should not have this capability for this simple user. + ], + ]; + } + /** * Test that assigning a fake cap does not return. * diff --git a/lib/tests/fixtures/fakeplugins/access/db/access.php b/lib/tests/fixtures/fakeplugins/access/db/access.php new file mode 100644 index 0000000000000..f4e87204caf7e --- /dev/null +++ b/lib/tests/fixtures/fakeplugins/access/db/access.php @@ -0,0 +1,80 @@ +. + +/** + * Fake component for testing + * + * @package core + * @copyright 2022 Laurent David + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +// Fake capabilities. +$capabilities = [ + 'fake/access:fakecapability' => [ + 'riskbitmask' => RISK_SPAM | RISK_PERSONAL | RISK_XSS | RISK_CONFIG | RISK_DATALOSS, + 'captype' => 'write', + 'contextlevel' => CONTEXT_SYSTEM, + 'archetypes' => [ + 'user' => CAP_ALLOW + ] + ], + 'fake/access:existingcapability' => [ + 'riskbitmask' => RISK_SPAM | RISK_PERSONAL | RISK_XSS | RISK_CONFIG | RISK_DATALOSS, + 'captype' => 'write', + 'contextlevel' => CONTEXT_SYSTEM, + 'archetypes' => [ + 'manager' => CAP_ALLOW + ] + ], + 'fake/access:replacementnoinfo' => [ + 'riskbitmask' => RISK_SPAM | RISK_PERSONAL | RISK_XSS | RISK_CONFIG | RISK_DATALOSS, + 'captype' => 'write', + 'contextlevel' => CONTEXT_SYSTEM, + 'archetypes' => [ + 'user' => CAP_ALLOW + ] + ], + 'fake/access:replacementmissing' => [ + 'riskbitmask' => RISK_SPAM | RISK_PERSONAL | RISK_XSS | RISK_CONFIG | RISK_DATALOSS, + 'captype' => 'write', + 'contextlevel' => CONTEXT_SYSTEM, + 'archetypes' => [ + 'user' => CAP_ALLOW + ] + ], + 'fake/access:replacementwithwrongcapability' => [ + 'riskbitmask' => RISK_SPAM | RISK_PERSONAL | RISK_XSS | RISK_CONFIG | RISK_DATALOSS, + 'captype' => 'write', + 'contextlevel' => CONTEXT_SYSTEM, + 'archetypes' => [ + 'user' => CAP_ALLOW + ] + ], +]; + +// Deprecated capabilities - MDL-55580. +$deprecatedcapabilities = [ + 'fake/access:fakecapability' => ['replacement' => '', 'message' => 'This capability should not be used anymore.'], + 'fake/access:replacementmissing' => ['message' => 'This capability should not be used anymore.'], + 'fake/access:replacementnoinfo' => [], + 'fake/access:replacementwithwrongcapability' => ['replacement' => 'fake/access:nonexistingcapabilty', + 'message' => 'This capability should not be used anymore.'], + 'fake/access:replacementwithexisting' => ['replacement' => 'fake/access:existingcapability', + 'message' => 'This capability should not be used anymore.'], +]; diff --git a/lib/tests/fixtures/fakeplugins/access/version.php b/lib/tests/fixtures/fakeplugins/access/version.php new file mode 100644 index 0000000000000..3686dd57b1099 --- /dev/null +++ b/lib/tests/fixtures/fakeplugins/access/version.php @@ -0,0 +1,29 @@ +. + +/** + * Fake component for testing + * + * @package core + * @copyright 2022 Laurent David + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$plugin->version = 2022050200; +$plugin->requires = 2022041200; +$plugin->component = 'fake_access'; diff --git a/lib/upgrade.txt b/lib/upgrade.txt index 5d81c4c1851ed..be3bdb76008e4 100644 --- a/lib/upgrade.txt +++ b/lib/upgrade.txt @@ -1,5 +1,14 @@ This files describes API changes in core libraries and APIs, information provided here is intended especially for developers. +=== 4.1 === + +* A process to deprecate capabilities by flagging them in the access.php by adding a $deprecatedcapabilities variable (an array). +This array will list the deprecated capabilities and a possible replacement. Once we declare the capability as deprecated, a debugging +message will be displayed (in DEBUG_DEVELOPPER mode only) when using the deprecated capability. +Declaration is as follow: + $deprecatedcapabilities = [ + 'fake/access:fakecapability' => ['replacement' => '', 'message' => 'This capability should not be used anymore.'] + ]; === 4.1 === * Final deprecation of the following functions behat_field_manager::get_node_type() and behat_field_manager::get_field() diff --git a/version.php b/version.php index 05d2d48645e90..e0a44669e58ce 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2022100700.00; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2022100700.01; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes. $release = '4.1dev (Build: 20221007)'; // Human-friendly version name