From 8ff7363219441f953bd5e8b915b7fb0eaa64aca6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Massart?= Date: Mon, 23 Apr 2018 16:41:44 +0800 Subject: [PATCH] MDL-62135 tool_log: Implement privacy API --- .../tool/log/classes/local/privacy/helper.php | 148 +++++++++++++++ .../local/privacy/logstore_provider.php | 78 ++++++++ .../moodle_database_export_and_delete.php | 123 ++++++++++++ admin/tool/log/classes/privacy/provider.php | 113 +++++++++++ admin/tool/log/lang/en/tool_log.php | 8 +- admin/tool/log/tests/privacy_test.php | 176 ++++++++++++++++++ 6 files changed, 645 insertions(+), 1 deletion(-) create mode 100644 admin/tool/log/classes/local/privacy/helper.php create mode 100644 admin/tool/log/classes/local/privacy/logstore_provider.php create mode 100644 admin/tool/log/classes/local/privacy/moodle_database_export_and_delete.php create mode 100644 admin/tool/log/classes/privacy/provider.php create mode 100644 admin/tool/log/tests/privacy_test.php diff --git a/admin/tool/log/classes/local/privacy/helper.php b/admin/tool/log/classes/local/privacy/helper.php new file mode 100644 index 0000000000000..4aa17d15ef23a --- /dev/null +++ b/admin/tool/log/classes/local/privacy/helper.php @@ -0,0 +1,148 @@ +. + +/** + * Privacy helper. + * + * @package tool_log + * @copyright 2018 Frédéric Massart + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace tool_log\local\privacy; +defined('MOODLE_INTERNAL') || die(); + +use core_privacy\local\request\transform; + +/** + * Privacy helper class. + * + * @package tool_log + * @copyright 2018 Frédéric Massart + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class helper { + + /** + * Returns an event from a standard record. + * + * @see \logstore_standard\log\store::get_log_event() + * @param object $data Log data. + * @return \core\event\base + */ + protected static function restore_event_from_standard_record($data) { + $extra = ['origin' => $data->origin, 'ip' => $data->ip, 'realuserid' => $data->realuserid]; + $data = (array) $data; + $id = $data['id']; + $data['other'] = unserialize($data['other']); + if ($data['other'] === false) { + $data['other'] = []; + } + unset($data['origin']); + unset($data['ip']); + unset($data['realuserid']); + unset($data['id']); + + if (!$event = \core\event\base::restore($data, $extra)) { + return null; + } + + return $event; + } + + /** + * Transform a standard log record for a user. + * + * @param object $record The record. + * @param int $userid The user ID. + * @return array + */ + public static function transform_standard_log_record_for_userid($record, $userid) { + + // Restore the event to try to get the name, description and other field. + $restoredevent = static::restore_event_from_standard_record($record); + if ($restoredevent) { + $name = $restoredevent->get_name(); + $description = $restoredevent->get_description(); + $other = $restoredevent->other; + + } else { + $name = $record->eventname; + $description = "Unknown event ({$name})"; + $other = unserialize($record->other); + } + + $realuserid = $record->realuserid; + $isauthor = $record->userid == $userid; + $isrelated = $record->relateduserid == $userid; + $isrealuser = $realuserid == $userid; + $ismasqueraded = $realuserid !== null && $record->userid != $realuserid; + $ismasquerading = $isrealuser && !$isauthor; + $isanonymous = $record->anonymous; + + $data = [ + 'name' => $name, + 'description' => $description, + 'timecreated' => transform::datetime($record->timecreated), + 'ip' => $record->ip, + 'origin' => static::transform_origin($record->origin), + 'other' => $other ? $other : [] + ]; + + if ($isanonymous) { + $data['action_was_done_anonymously'] = transform::yesno($isanonymous); + } + if ($isauthor || !$isanonymous) { + $data['authorid'] = transform::user($record->userid); + $data['author_of_the_action_was_you'] = transform::yesno($isauthor); + } + + if ($record->relateduserid) { + $data['relateduserid'] = transform::user($record->relateduserid); + $data['related_user_was_you'] = transform::yesno($isrelated); + } + + if ($ismasqueraded) { + $data['author_of_the_action_was_masqueraded'] = transform::yesno(true); + if ($ismasquerading || !$isanonymous) { + $data['masqueradinguserid'] = transform::user($realuserid); + $data['masquerading_user_was_you'] = transform::yesno($ismasquerading); + } + } + + return $data; + } + + /** + * Transform origin. + * + * @param string $origin The page request origin. + * @return string + */ + public static function transform_origin($origin) { + switch ($origin) { + case 'cli': + case 'restore': + case 'web': + case 'ws': + return get_string('privacy:request:origin:' . $origin, 'tool_log'); + break; + } + return $origin; + } +} diff --git a/admin/tool/log/classes/local/privacy/logstore_provider.php b/admin/tool/log/classes/local/privacy/logstore_provider.php new file mode 100644 index 0000000000000..cecc13006a2bd --- /dev/null +++ b/admin/tool/log/classes/local/privacy/logstore_provider.php @@ -0,0 +1,78 @@ +. + +/** + * Logstore provider interface. + * + * @package tool_log + * @copyright 2018 Frédéric Massart + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace tool_log\local\privacy; +defined('MOODLE_INTERNAL') || die(); + +use context; +use core_privacy\local\request\contextlist; +use core_privacy\local\request\approved_contextlist; + +/** + * Logstore provider interface. + * + * Logstore subplugins providers must implement this interface. + * + * @package tool_log + * @copyright 2018 Frédéric Massart + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +interface logstore_provider extends \core_privacy\local\request\plugin\subplugin_provider { + + /** + * Add contexts that contain user information for the specified user. + * + * @param contextlist $contextlist The contextlist to add the contexts to. + * @param int $userid The user to find the contexts for. + * @return void + */ + public static function add_contexts_for_userid(contextlist $contextlist, $userid); + + /** + * Export all user data for the specified user, in the specified contexts. + * + * @param approved_contextlist $contextlist The approved contexts to export information for. + * @return void + */ + public static function export_user_data(approved_contextlist $contextlist); + + /** + * Delete all data for all users in the specified context. + * + * @param context $context The specific context to delete data for. + * @return void + */ + public static function delete_data_for_all_users_in_context(context $context); + + /** + * Delete all user data for the specified user, in the specified contexts. + * + * @param approved_contextlist $contextlist The approved contexts and user information to delete information for. + * @return void + */ + public static function delete_data_for_user(approved_contextlist $contextlist); + +} diff --git a/admin/tool/log/classes/local/privacy/moodle_database_export_and_delete.php b/admin/tool/log/classes/local/privacy/moodle_database_export_and_delete.php new file mode 100644 index 0000000000000..da973eadf922d --- /dev/null +++ b/admin/tool/log/classes/local/privacy/moodle_database_export_and_delete.php @@ -0,0 +1,123 @@ +. + +/** + * Moodle database: export and delete. + * + * @package tool_log + * @copyright 2018 Frédéric Massart + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace tool_log\local\privacy; +defined('MOODLE_INTERNAL') || die(); + +use context; +use core_privacy\local\request\approved_contextlist; +use core_privacy\local\request\writer; + +/** + * Moodle database: export and delete trait. + * + * This is to be used with logstores which use a database and table with the same columns + * as the core plugin 'logstore_standard'. + * + * This trait expects the following methods to be present in the object: + * + * - public static function get_database_and_table(): [moodle_database|null, string|null] + * - public static function get_export_subcontext(): [] + * + * @package tool_log + * @copyright 2018 Frédéric Massart + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +trait moodle_database_export_and_delete { + + /** + * Export all user data for the specified user, in the specified contexts. + * + * @param approved_contextlist $contextlist The approved contexts to export information for. + */ + public static function export_user_data(approved_contextlist $contextlist) { + list($db, $table) = static::get_database_and_table(); + if (!$db || !$table) { + return; + } + + $userid = $contextlist->get_user()->id; + list($insql, $inparams) = $db->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED); + + $sql = "(userid = :userid1 OR relateduserid = :userid2 OR realuserid = :userid3) AND contextid $insql"; + $params = array_merge($inparams, [ + 'userid1' => $userid, + 'userid2' => $userid, + 'userid3' => $userid, + ]); + + $path = static::get_export_subcontext(); + $flush = function($lastcontextid, $data) use ($path) { + $context = context::instance_by_id($lastcontextid); + writer::with_context($context)->export_data($path, (object) ['logs' => $data]); + }; + + $lastcontextid = null; + $data = []; + $recordset = $db->get_recordset_select($table, $sql, $params, 'contextid, timecreated, id'); + foreach ($recordset as $record) { + if ($lastcontextid && $lastcontextid != $record->contextid) { + $flush($lastcontextid, $data); + $data = []; + } + $data[] = helper::transform_standard_log_record_for_userid($record, $userid); + $lastcontextid = $record->contextid; + } + if ($lastcontextid) { + $flush($lastcontextid, $data); + } + $recordset->close(); + } + + /** + * Delete all data for all users in the specified context. + * + * @param context $context The specific context to delete data for. + */ + public static function delete_data_for_all_users_in_context(context $context) { + list($db, $table) = static::get_database_and_table(); + if (!$db || !$table) { + return; + } + $db->delete_records($table, ['contextid' => $context->id]); + } + + /** + * Delete all user data for the specified user, in the specified contexts. + * + * @param approved_contextlist $contextlist The approved contexts and user information to delete information for. + */ + public static function delete_data_for_user(approved_contextlist $contextlist) { + list($db, $table) = static::get_database_and_table(); + if (!$db || !$table) { + return; + } + list($insql, $inparams) = $db->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED); + $params = array_merge($inparams, ['userid' => $contextlist->get_user()->id]); + $db->delete_records_select($table, "userid = :userid AND contextid $insql", $params); + } + +} diff --git a/admin/tool/log/classes/privacy/provider.php b/admin/tool/log/classes/privacy/provider.php new file mode 100644 index 0000000000000..775eb9a77071e --- /dev/null +++ b/admin/tool/log/classes/privacy/provider.php @@ -0,0 +1,113 @@ +. + +/** + * Data provider. + * + * @package tool_log + * @copyright 2018 Frédéric Massart + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace tool_log\privacy; +defined('MOODLE_INTERNAL') || die(); + +use context; +use core_privacy\local\metadata\collection; +use core_privacy\local\request\approved_contextlist; +use core_privacy\local\request\transform; +use core_privacy\local\request\writer; +use tool_log\log\manager; + +/** + * Data provider class. + * + * @package tool_log + * @copyright 2018 Frédéric Massart + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class provider implements + \core_privacy\local\metadata\provider, + \core_privacy\local\request\subsystem\provider { + + /** + * Returns metadata. + * + * @param collection $collection The initialised collection to add items to. + * @return collection A listing of user data stored through this system. + */ + public static function get_metadata(collection $collection) { + $collection->add_plugintype_link('logstore', [], 'privacy:metadata:logstore'); + return $collection; + } + + /** + * Get the list of contexts that contain user information for the specified user. + * + * @param int $userid The user to search. + * @return contextlist $contextlist The contextlist containing the list of contexts used in this plugin. + */ + public static function get_contexts_for_userid($userid) { + $contextlist = new \core_privacy\local\request\contextlist(); + static::call_subplugins_method_with_args('add_contexts_for_userid', [$contextlist, $userid]); + return $contextlist; + } + + /** + * Export all user data for the specified user, in the specified contexts. + * + * @param approved_contextlist $contextlist The approved contexts to export information for. + */ + public static function export_user_data(approved_contextlist $contextlist) { + static::call_subplugins_method_with_args('export_user_data', [$contextlist]); + } + + /** + * Delete all data for all users in the specified context. + * + * @param context $context The specific context to delete data for. + */ + public static function delete_data_for_all_users_in_context(context $context) { + static::call_subplugins_method_with_args('delete_data_for_all_users_in_context', [$context]); + } + + /** + * Delete all user data for the specified user, in the specified contexts. + * + * @param approved_contextlist $contextlist The approved contexts and user information to delete information for. + */ + public static function delete_data_for_user(approved_contextlist $contextlist) { + static::call_subplugins_method_with_args('delete_data_for_user', [$contextlist]); + } + + /** + * Invoke the subplugins method with arguments. + * + * @param string $method The method name. + * @param array $args The arguments. + * @return void + */ + protected static function call_subplugins_method_with_args($method, array $args = []) { + $interface = 'tool_log\local\privacy\logstore_provider'; + $subplugins = manager::get_store_plugins(); + foreach ($subplugins as $subplugin => $unused) { + \core_privacy\manager::component_class_callback($subplugin, $interface, $method, $args); + } + } + +} diff --git a/admin/tool/log/lang/en/tool_log.php b/admin/tool/log/lang/en/tool_log.php index c57c424330c14..bfda3002e44a8 100644 --- a/admin/tool/log/lang/en/tool_log.php +++ b/admin/tool/log/lang/en/tool_log.php @@ -26,7 +26,13 @@ $string['configlogplugins'] = 'Please enable all required plugins and arrange them in appropriate order.'; $string['logging'] = 'Logging'; $string['managelogging'] = 'Manage log stores'; -$string['reportssupported'] = 'Reports supported'; $string['pluginname'] = 'Log store manager'; +$string['privacy:metadata:logstore'] = 'The log stores'; +$string['privacy:path:logs'] = 'Logs'; +$string['privacy:request:origin:cli'] = 'Command line tool'; +$string['privacy:request:origin:restore'] = 'Backup being restored'; +$string['privacy:request:origin:web'] = 'Standard web request'; +$string['privacy:request:origin:ws'] = 'Mobile app or web service'; +$string['reportssupported'] = 'Reports supported'; $string['subplugintype_logstore'] = 'Log store'; $string['subplugintype_logstore_plural'] = 'Log stores'; diff --git a/admin/tool/log/tests/privacy_test.php b/admin/tool/log/tests/privacy_test.php new file mode 100644 index 0000000000000..9e5be71462191 --- /dev/null +++ b/admin/tool/log/tests/privacy_test.php @@ -0,0 +1,176 @@ +. + +/** + * Data provider tests. + * + * @package tool_log + * @category test + * @copyright 2018 Frédéric Massart + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); +global $CFG; + +use core_privacy\tests\provider_testcase; +use core_privacy\local\request\contextlist; +use core_privacy\local\request\approved_contextlist; +use core_privacy\local\request\transform; +use core_privacy\local\request\writer; +use tool_log\privacy\provider; + +require_once($CFG->dirroot . '/admin/tool/log/store/standard/tests/fixtures/event.php'); + +/** + * Data provider testcase class. + * + * We're not testing the full functionality, just that the provider passes the requests + * down to at least one of its subplugin. Each subplugin should have tests to cover the + * different provider methods in depth. + * + * @package tool_log + * @category test + * @copyright 2018 Frédéric Massart + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class tool_log_privacy_testcase extends provider_testcase { + + public function setUp() { + $this->resetAfterTest(); + $this->preventResetByRollback(); // Logging waits till the transaction gets committed. + } + + public function test_get_contexts_for_userid() { + $admin = \core_user::get_user(2); + $u1 = $this->getDataGenerator()->create_user(); + $c1 = $this->getDataGenerator()->create_course(); + $c1ctx = context_course::instance($c1->id); + + $this->enable_logging(); + $manager = get_log_manager(true); + + $this->setUser($u1); + $this->assertEmpty(provider::get_contexts_for_userid($u1->id)->get_contextids(), []); + $e = \logstore_standard\event\unittest_executed::create(['context' => $c1ctx]); + $e->trigger(); + $this->assertEquals($c1ctx->id, provider::get_contexts_for_userid($u1->id)->get_contextids()[0]); + } + + public function test_delete_data_for_user() { + global $DB; + $u1 = $this->getDataGenerator()->create_user(); + $u2 = $this->getDataGenerator()->create_user(); + $c1 = $this->getDataGenerator()->create_course(); + $c1ctx = context_course::instance($c1->id); + + $this->enable_logging(); + $manager = get_log_manager(true); + + // User 1 is the author. + $this->setUser($u1); + $e = \logstore_standard\event\unittest_executed::create(['context' => $c1ctx]); + $e->trigger(); + $e = \logstore_standard\event\unittest_executed::create(['context' => $c1ctx]); + $e->trigger(); + + // User 2 is the author. + $this->setUser($u2); + $e = \logstore_standard\event\unittest_executed::create(['context' => $c1ctx]); + $e->trigger(); + + // Confirm data present. + $this->assertTrue($DB->record_exists('logstore_standard_log', ['userid' => $u1->id, 'contextid' => $c1ctx->id])); + $this->assertEquals(2, $DB->count_records('logstore_standard_log', ['userid' => $u1->id])); + $this->assertEquals(1, $DB->count_records('logstore_standard_log', ['userid' => $u2->id])); + + // Delete all the things! + provider::delete_data_for_user(new approved_contextlist($u1, 'logstore_standard', [$c1ctx->id])); + $this->assertFalse($DB->record_exists('logstore_standard_log', ['userid' => $u1->id, 'contextid' => $c1ctx->id])); + $this->assertEquals(0, $DB->count_records('logstore_standard_log', ['userid' => $u1->id])); + $this->assertEquals(1, $DB->count_records('logstore_standard_log', ['userid' => $u2->id])); + } + + public function test_delete_data_for_all_users_in_context() { + global $DB; + $u1 = $this->getDataGenerator()->create_user(); + $u2 = $this->getDataGenerator()->create_user(); + $c1 = $this->getDataGenerator()->create_course(); + $c1ctx = context_course::instance($c1->id); + + $this->enable_logging(); + $manager = get_log_manager(true); + + // User 1 is the author. + $this->setUser($u1); + $e = \logstore_standard\event\unittest_executed::create(['context' => $c1ctx]); + $e->trigger(); + $e = \logstore_standard\event\unittest_executed::create(['context' => $c1ctx]); + $e->trigger(); + + // User 2 is the author. + $this->setUser($u2); + $e = \logstore_standard\event\unittest_executed::create(['context' => $c1ctx]); + $e->trigger(); + + // Confirm data present. + $this->assertTrue($DB->record_exists('logstore_standard_log', ['contextid' => $c1ctx->id])); + $this->assertEquals(2, $DB->count_records('logstore_standard_log', ['userid' => $u1->id])); + $this->assertEquals(1, $DB->count_records('logstore_standard_log', ['userid' => $u2->id])); + + // Delete all the things! + provider::delete_data_for_all_users_in_context($c1ctx); + $this->assertFalse($DB->record_exists('logstore_standard_log', ['contextid' => $c1ctx->id])); + $this->assertEquals(0, $DB->count_records('logstore_standard_log', ['userid' => $u1->id])); + $this->assertEquals(0, $DB->count_records('logstore_standard_log', ['userid' => $u2->id])); + } + + public function test_export_data_for_user() { + $admin = \core_user::get_user(2); + $u1 = $this->getDataGenerator()->create_user(); + $c1 = $this->getDataGenerator()->create_course(); + $c1ctx = context_course::instance($c1->id); + + $path = [get_string('privacy:path:logs', 'tool_log'), get_string('pluginname', 'logstore_standard')]; + $this->enable_logging(); + $manager = get_log_manager(true); + + // User 1 is the author. + $this->setUser($u1); + $e = \logstore_standard\event\unittest_executed::create(['context' => $c1ctx, 'other' => ['i' => 123]]); + $e->trigger(); + + // Confirm data present for u1. + provider::export_user_data(new approved_contextlist($u1, 'tool_log', [$c1ctx->id])); + $data = writer::with_context($c1ctx)->get_data($path); + $this->assertCount(1, $data->logs); + $this->assertEquals(transform::yesno(true), $data->logs[0]['author_of_the_action_was_you']); + $this->assertSame(123, $data->logs[0]['other']['i']); + } + + /** + * Enable logging. + * + * @return void + */ + protected function enable_logging() { + set_config('enabled_stores', 'logstore_standard', 'tool_log'); + set_config('buffersize', 0, 'logstore_standard'); + set_config('logguests', 1, 'logstore_standard'); + } +}