diff --git a/admin/tool/log/store/database/classes/privacy/provider.php b/admin/tool/log/store/database/classes/privacy/provider.php new file mode 100644 index 00000000000..b5e8eacf1b0 --- /dev/null +++ b/admin/tool/log/store/database/classes/privacy/provider.php @@ -0,0 +1,114 @@ +. + +/** + * Data provider. + * + * @package logstore_database + * @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 logstore_database\privacy; +defined('MOODLE_INTERNAL') || die(); + +use context; +use core_privacy\local\metadata\collection; +use core_privacy\local\request\contextlist; + +/** + * Data provider class. + * + * @package logstore_database + * @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, + \tool_log\local\privacy\logstore_provider { + + use \tool_log\local\privacy\moodle_database_export_and_delete; + + /** + * 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 { + $collection->add_external_location_link('log', [ + 'eventname' => 'privacy:metadata:log:eventname', + 'userid' => 'privacy:metadata:log:userid', + 'relateduserid' => 'privacy:metadata:log:relateduserid', + 'anonymous' => 'privacy:metadata:log:anonymous', + 'other' => 'privacy:metadata:log:other', + 'timecreated' => 'privacy:metadata:log:timecreated', + 'origin' => 'privacy:metadata:log:origin', + 'ip' => 'privacy:metadata:log:ip', + 'realuserid' => 'privacy:metadata:log:realuserid', + ], 'privacy:metadata:log'); + return $collection; + } + + /** + * 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) { + list($db, $table) = static::get_database_and_table(); + if (!$db || !$table) { + return; + } + + $sql = 'userid = :userid1 OR relateduserid = :userid2 OR realuserid = :userid3'; + $params = ['userid1' => $userid, 'userid2' => $userid, 'userid3' => $userid]; + $contextids = $db->get_fieldset_select($table, 'DISTINCT contextid', $sql, $params); + if (empty($contextids)) { + return; + } + + $sql = implode(' UNION ', array_map(function($id) use ($db) { + return 'SELECT ' . $id . $db->sql_null_from_clause(); + }, $contextids)); + $contextlist->add_from_sql($sql, []); + } + + /** + * Get the database object. + * + * @return array Containing moodle_database, string, or null values. + */ + protected static function get_database_and_table() { + $manager = get_log_manager(); + $store = new \logstore_database\log\store($manager); + $db = $store->get_extdb(); + return $db ? [$db, $store->get_config_value('dbtable')] : [null, null]; + } + + /** + * Get the path to export the logs to. + * + * @return array + */ + protected static function get_export_subcontext() { + return [get_string('privacy:path:logs', 'tool_log'), get_string('pluginname', 'logstore_database')]; + } +} diff --git a/admin/tool/log/store/database/lang/en/logstore_database.php b/admin/tool/log/store/database/lang/en/logstore_database.php index 0873229bf63..8f49696063d 100644 --- a/admin/tool/log/store/database/lang/en/logstore_database.php +++ b/admin/tool/log/store/database/lang/en/logstore_database.php @@ -44,6 +44,16 @@ $string['other'] = 'Other'; $string['participating'] = 'Participating'; $string['pluginname'] = 'External database log'; $string['pluginname_desc'] = 'A log plugin that stores log entries in an external database table.'; +$string['privacy:metadata:log'] = 'A collection of past events'; +$string['privacy:metadata:log:anonymous'] = 'Whether the event was flagged as anonymous'; +$string['privacy:metadata:log:eventname'] = 'The event name'; +$string['privacy:metadata:log:ip'] = 'The IP address used at the time of the event'; +$string['privacy:metadata:log:origin'] = 'The origin of the event'; +$string['privacy:metadata:log:other'] = 'Additional information about the event'; +$string['privacy:metadata:log:realuserid'] = 'The ID of the real user behind the event, when masquerading a user.'; +$string['privacy:metadata:log:relateduserid'] = 'The ID of a user related to this event'; +$string['privacy:metadata:log:timecreated'] = 'The time at which the event occurred'; +$string['privacy:metadata:log:userid'] = 'The ID of the user who triggered this event'; $string['read'] = 'Read'; $string['tablenotfound'] = 'Specified table was not found'; $string['teaching'] = 'Teaching'; diff --git a/admin/tool/log/store/database/tests/privacy_test.php b/admin/tool/log/store/database/tests/privacy_test.php new file mode 100644 index 00000000000..4a1e37ec52d --- /dev/null +++ b/admin/tool/log/store/database/tests/privacy_test.php @@ -0,0 +1,416 @@ +. + +/** + * Data provider tests. + * + * @package logstore_database + * @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 logstore_database\privacy\provider; + +require_once(__DIR__ . '/fixtures/event.php'); + +/** + * Data provider testcase class. + * + * This testcase is almost identical to the logstore_standard testcase, aside from the + * initialisation of the relevant logstore obviously. + * + * @package logstore_database + * @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 logstore_database_privacy_testcase extends provider_testcase { + + public function setUp() { + global $CFG; + $this->resetAfterTest(); + $this->preventResetByRollback(); // Logging waits till the transaction gets committed. + + // Fake the settings, we will abuse the standard plugin table here... + set_config('dbdriver', $CFG->dblibrary . '/' . $CFG->dbtype, 'logstore_database'); + set_config('dbhost', $CFG->dbhost, 'logstore_database'); + set_config('dbuser', $CFG->dbuser, 'logstore_database'); + set_config('dbpass', $CFG->dbpass, 'logstore_database'); + set_config('dbname', $CFG->dbname, 'logstore_database'); + set_config('dbtable', $CFG->prefix . 'logstore_standard_log', 'logstore_database'); + if (!empty($CFG->dboptions['dbpersist'])) { + set_config('dbpersist', 1, 'logstore_database'); + } else { + set_config('dbpersist', 0, 'logstore_database'); + } + if (!empty($CFG->dboptions['dbsocket'])) { + set_config('dbsocket', $CFG->dboptions['dbsocket'], 'logstore_database'); + } else { + set_config('dbsocket', '', 'logstore_database'); + } + if (!empty($CFG->dboptions['dbport'])) { + set_config('dbport', $CFG->dboptions['dbport'], 'logstore_database'); + } else { + set_config('dbport', '', 'logstore_database'); + } + if (!empty($CFG->dboptions['dbschema'])) { + set_config('dbschema', $CFG->dboptions['dbschema'], 'logstore_database'); + } else { + set_config('dbschema', '', 'logstore_database'); + } + if (!empty($CFG->dboptions['dbcollation'])) { + set_config('dbcollation', $CFG->dboptions['dbcollation'], 'logstore_database'); + } else { + set_config('dbcollation', '', 'logstore_database'); + } + if (!empty($CFG->dboptions['dbhandlesoptions'])) { + set_config('dbhandlesoptions', $CFG->dboptions['dbhandlesoptions'], 'logstore_database'); + } else { + set_config('dbhandlesoptions', false, 'logstore_database'); + } + } + + public function test_get_contexts_for_userid() { + $admin = \core_user::get_user(2); + $u1 = $this->getDataGenerator()->create_user(); + $u2 = $this->getDataGenerator()->create_user(); + $u3 = $this->getDataGenerator()->create_user(); + + $c1 = $this->getDataGenerator()->create_course(); + $cm1 = $this->getDataGenerator()->create_module('url', ['course' => $c1]); + $c2 = $this->getDataGenerator()->create_course(); + $cm2 = $this->getDataGenerator()->create_module('url', ['course' => $c2]); + + $sysctx = context_system::instance(); + $c1ctx = context_course::instance($c1->id); + $c2ctx = context_course::instance($c2->id); + $cm1ctx = context_module::instance($cm1->cmid); + $cm2ctx = context_module::instance($cm2->cmid); + + $this->enable_logging(); + $manager = get_log_manager(true); + + // User 1 is the author. + $this->setUser($u1); + $this->assert_contextlist_equals($this->get_contextlist_for_user($u1), []); + $e = \logstore_database\event\unittest_executed::create(['context' => $cm1ctx]); + $e->trigger(); + $this->assert_contextlist_equals($this->get_contextlist_for_user($u1), [$cm1ctx]); + + // User 2 is the related user. + $this->setUser(0); + $this->assert_contextlist_equals($this->get_contextlist_for_user($u2), []); + $e = \logstore_database\event\unittest_executed::create(['context' => $cm2ctx, 'relateduserid' => $u2->id]); + $e->trigger(); + $this->assert_contextlist_equals($this->get_contextlist_for_user($u2), [$cm2ctx]); + + // Admin user is the real user. + $this->assert_contextlist_equals($this->get_contextlist_for_user($admin), []); + $this->assert_contextlist_equals($this->get_contextlist_for_user($u3), []); + $this->setAdminUser(); + \core\session\manager::loginas($u3->id, $sysctx); + $e = \logstore_database\event\unittest_executed::create(['context' => $c1ctx]); + $e->trigger(); + $this->assert_contextlist_equals($this->get_contextlist_for_user($admin), [$sysctx, $c1ctx]); + $this->assert_contextlist_equals($this->get_contextlist_for_user($u3), [$sysctx, $c1ctx]); + + // By admin user masquerading u1 related to u3. + $this->assert_contextlist_equals($this->get_contextlist_for_user($u1), [$cm1ctx]); + $this->assert_contextlist_equals($this->get_contextlist_for_user($u3), [$sysctx, $c1ctx]); + $this->assert_contextlist_equals($this->get_contextlist_for_user($admin), [$sysctx, $c1ctx]); + $this->setAdminUser(); + \core\session\manager::loginas($u1->id, context_system::instance()); + $e = \logstore_database\event\unittest_executed::create(['context' => $c2ctx, 'relateduserid' => $u3->id]); + $e->trigger(); + $this->assert_contextlist_equals($this->get_contextlist_for_user($u1), [$sysctx, $cm1ctx, $c2ctx]); + $this->assert_contextlist_equals($this->get_contextlist_for_user($u3), [$sysctx, $c1ctx, $c2ctx]); + $this->assert_contextlist_equals($this->get_contextlist_for_user($admin), [$sysctx, $c1ctx, $c2ctx]); + } + + public function test_delete_data_for_user() { + global $DB; + $u1 = $this->getDataGenerator()->create_user(); + $u2 = $this->getDataGenerator()->create_user(); + $c1 = $this->getDataGenerator()->create_course(); + $c2 = $this->getDataGenerator()->create_course(); + $sysctx = context_system::instance(); + $c1ctx = context_course::instance($c1->id); + $c2ctx = context_course::instance($c2->id); + + $this->enable_logging(); + $manager = get_log_manager(true); + + // User 1 is the author. + $this->setUser($u1); + $e = \logstore_database\event\unittest_executed::create(['context' => $c1ctx]); + $e->trigger(); + $e = \logstore_database\event\unittest_executed::create(['context' => $c1ctx]); + $e->trigger(); + $e = \logstore_database\event\unittest_executed::create(['context' => $c2ctx]); + $e->trigger(); + + // User 2 is the author. + $this->setUser($u2); + $e = \logstore_database\event\unittest_executed::create(['context' => $c1ctx]); + $e->trigger(); + $e = \logstore_database\event\unittest_executed::create(['context' => $c2ctx]); + $e->trigger(); + + // Confirm data present. + $this->assertTrue($DB->record_exists('logstore_standard_log', ['userid' => $u1->id, 'contextid' => $c1ctx->id])); + $this->assertEquals(3, $DB->count_records('logstore_standard_log', ['userid' => $u1->id])); + $this->assertEquals(2, $DB->count_records('logstore_standard_log', ['userid' => $u2->id])); + + // Delete all the things! + provider::delete_data_for_user(new approved_contextlist($u1, 'logstore_database', [$c1ctx->id])); + $this->assertFalse($DB->record_exists('logstore_standard_log', ['userid' => $u1->id, 'contextid' => $c1ctx->id])); + $this->assertEquals(1, $DB->count_records('logstore_standard_log', ['userid' => $u1->id])); + $this->assertEquals(2, $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(); + $c2 = $this->getDataGenerator()->create_course(); + $sysctx = context_system::instance(); + $c1ctx = context_course::instance($c1->id); + $c2ctx = context_course::instance($c2->id); + + $this->enable_logging(); + $manager = get_log_manager(true); + + // User 1 is the author. + $this->setUser($u1); + $e = \logstore_database\event\unittest_executed::create(['context' => $c1ctx]); + $e->trigger(); + $e = \logstore_database\event\unittest_executed::create(['context' => $c1ctx]); + $e->trigger(); + $e = \logstore_database\event\unittest_executed::create(['context' => $c2ctx]); + $e->trigger(); + + // User 2 is the author. + $this->setUser($u2); + $e = \logstore_database\event\unittest_executed::create(['context' => $c1ctx]); + $e->trigger(); + $e = \logstore_database\event\unittest_executed::create(['context' => $c2ctx]); + $e->trigger(); + + // Confirm data present. + $this->assertTrue($DB->record_exists('logstore_standard_log', ['contextid' => $c1ctx->id])); + $this->assertEquals(3, $DB->count_records('logstore_standard_log', ['userid' => $u1->id])); + $this->assertEquals(2, $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(1, $DB->count_records('logstore_standard_log', ['userid' => $u1->id])); + $this->assertEquals(1, $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(); + $u2 = $this->getDataGenerator()->create_user(); + $u3 = $this->getDataGenerator()->create_user(); + $u4 = $this->getDataGenerator()->create_user(); + $c1 = $this->getDataGenerator()->create_course(); + $cm1 = $this->getDataGenerator()->create_module('url', ['course' => $c1]); + $c2 = $this->getDataGenerator()->create_course(); + $cm2 = $this->getDataGenerator()->create_module('url', ['course' => $c2]); + $sysctx = context_system::instance(); + $c1ctx = context_course::instance($c1->id); + $c2ctx = context_course::instance($c2->id); + $cm1ctx = context_module::instance($cm1->cmid); + $cm2ctx = context_module::instance($cm2->cmid); + + $path = [get_string('privacy:path:logs', 'tool_log'), get_string('pluginname', 'logstore_database')]; + $this->enable_logging(); + $manager = get_log_manager(true); + + // User 1 is the author. + $this->setUser($u1); + $e = \logstore_database\event\unittest_executed::create(['context' => $c1ctx, 'other' => ['i' => 0]]); + $e->trigger(); + + // User 2 is related. + $this->setUser(0); + $e = \logstore_database\event\unittest_executed::create(['context' => $c1ctx, 'relateduserid' => $u2->id, + 'other' => ['i' => 1]]); + $e->trigger(); + + // Admin user masquerades u3, which is related to u4. + $this->setAdminUser(); + \core\session\manager::loginas($u3->id, $sysctx); + $e = \logstore_database\event\unittest_executed::create(['context' => $c1ctx, 'relateduserid' => $u4->id, + 'other' => ['i' => 2]]); + $e->trigger(); + + // Confirm data present for u1. + provider::export_user_data(new approved_contextlist($u1, 'logstore_database', [$c2ctx->id, $c1ctx->id])); + $data = writer::with_context($c2ctx)->get_data($path); + $this->assertEmpty($data); + $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(0, $data->logs[0]['other']['i']); + + // Confirm data present for u2. + writer::reset(); + provider::export_user_data(new approved_contextlist($u2, 'logstore_database', [$c2ctx->id, $c1ctx->id])); + $data = writer::with_context($c2ctx)->get_data($path); + $this->assertEmpty($data); + $data = writer::with_context($c1ctx)->get_data($path); + $this->assertCount(1, $data->logs); + $this->assertEquals(transform::yesno(false), $data->logs[0]['author_of_the_action_was_you']); + $this->assertEquals(transform::yesno(true), $data->logs[0]['related_user_was_you']); + $this->assertSame(1, $data->logs[0]['other']['i']); + + // Confirm data present for u3. + writer::reset(); + provider::export_user_data(new approved_contextlist($u3, 'logstore_database', [$c2ctx->id, $c1ctx->id])); + $data = writer::with_context($c2ctx)->get_data($path); + $this->assertEmpty($data); + $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->assertEquals(transform::yesno(false), $data->logs[0]['related_user_was_you']); + $this->assertEquals(transform::yesno(true), $data->logs[0]['author_of_the_action_was_masqueraded']); + $this->assertEquals(transform::yesno(false), $data->logs[0]['masquerading_user_was_you']); + $this->assertSame(2, $data->logs[0]['other']['i']); + + // Confirm data present for u4. + writer::reset(); + provider::export_user_data(new approved_contextlist($u4, 'logstore_database', [$c2ctx->id, $c1ctx->id])); + $data = writer::with_context($c2ctx)->get_data($path); + $this->assertEmpty($data); + $data = writer::with_context($c1ctx)->get_data($path); + $this->assertCount(1, $data->logs); + $this->assertEquals(transform::yesno(false), $data->logs[0]['author_of_the_action_was_you']); + $this->assertEquals(transform::yesno(true), $data->logs[0]['related_user_was_you']); + $this->assertEquals(transform::yesno(true), $data->logs[0]['author_of_the_action_was_masqueraded']); + $this->assertEquals(transform::yesno(false), $data->logs[0]['masquerading_user_was_you']); + $this->assertSame(2, $data->logs[0]['other']['i']); + + // Add anonymous events. + $this->setUser($u1); + $e = \logstore_database\event\unittest_executed::create(['context' => $c2ctx, 'relateduserid' => $u2->id, + 'anonymous' => true]); + $e->trigger(); + $this->setAdminUser(); + \core\session\manager::loginas($u3->id, $sysctx); + $e = \logstore_database\event\unittest_executed::create(['context' => $c2ctx, 'relateduserid' => $u4->id, + 'anonymous' => true]); + $e->trigger(); + + // Confirm data present for u1. + provider::export_user_data(new approved_contextlist($u1, 'logstore_database', [$c2ctx->id])); + $data = writer::with_context($c2ctx)->get_data($path); + $this->assertCount(1, $data->logs); + $this->assertEquals(transform::yesno(true), $data->logs[0]['action_was_done_anonymously']); + $this->assertEquals(transform::yesno(true), $data->logs[0]['author_of_the_action_was_you']); + + // Confirm data present for u2. + writer::reset(); + provider::export_user_data(new approved_contextlist($u2, 'logstore_database', [$c2ctx->id])); + $data = writer::with_context($c2ctx)->get_data($path); + $this->assertCount(1, $data->logs); + $this->assertEquals(transform::yesno(true), $data->logs[0]['action_was_done_anonymously']); + $this->assertArrayNotHasKey('author_of_the_action_was_you', $data->logs[0]); + $this->assertArrayNotHasKey('authorid', $data->logs[0]); + $this->assertEquals(transform::yesno(true), $data->logs[0]['related_user_was_you']); + + // Confirm data present for u3. + writer::reset(); + provider::export_user_data(new approved_contextlist($u3, 'logstore_database', [$c2ctx->id])); + $data = writer::with_context($c2ctx)->get_data($path); + $this->assertCount(1, $data->logs); + $this->assertEquals(transform::yesno(true), $data->logs[0]['action_was_done_anonymously']); + $this->assertEquals(transform::yesno(true), $data->logs[0]['author_of_the_action_was_you']); + $this->assertEquals(transform::yesno(true), $data->logs[0]['author_of_the_action_was_masqueraded']); + $this->assertArrayNotHasKey('masquerading_user_was_you', $data->logs[0]); + $this->assertArrayNotHasKey('masqueradinguserid', $data->logs[0]); + + // Confirm data present for u4. + writer::reset(); + provider::export_user_data(new approved_contextlist($u4, 'logstore_database', [$c2ctx->id])); + $data = writer::with_context($c2ctx)->get_data($path); + $this->assertCount(1, $data->logs); + $this->assertEquals(transform::yesno(true), $data->logs[0]['action_was_done_anonymously']); + $this->assertArrayNotHasKey('author_of_the_action_was_you', $data->logs[0]); + $this->assertArrayNotHasKey('authorid', $data->logs[0]); + $this->assertEquals(transform::yesno(true), $data->logs[0]['related_user_was_you']); + $this->assertEquals(transform::yesno(true), $data->logs[0]['author_of_the_action_was_masqueraded']); + $this->assertArrayNotHasKey('masquerading_user_was_you', $data->logs[0]); + $this->assertArrayNotHasKey('masqueradinguserid', $data->logs[0]); + } + + /** + * Assert the content of a context list. + * + * @param contextlist $contextlist The collection. + * @param array $expected List of expected contexts or IDs. + * @return void + */ + protected function assert_contextlist_equals($contextlist, array $expected) { + $expectedids = array_map(function($context) { + if (is_object($context)) { + return $context->id; + } + return $context; + }, $expected); + $contextids = array_map('intval', $contextlist->get_contextids()); + sort($contextids); + sort($expectedids); + $this->assertEquals($expectedids, $contextids); + } + + /** + * Enable logging. + * + * @return void + */ + protected function enable_logging() { + set_config('enabled_stores', 'logstore_database', 'tool_log'); + set_config('buffersize', 0, 'logstore_database'); + set_config('logguests', 1, 'logstore_database'); + get_log_manager(true); + } + + /** + * Get the contextlist for a user. + * + * @param object $user The user. + * @return contextlist + */ + protected function get_contextlist_for_user($user) { + $contextlist = new contextlist(); + provider::add_contexts_for_userid($contextlist, $user->id); + return $contextlist; + } +}