diff --git a/admin/tool/dataprivacy/classes/api.php b/admin/tool/dataprivacy/classes/api.php index 9aaea2865c239..8ec0bb53c484b 100644 --- a/admin/tool/dataprivacy/classes/api.php +++ b/admin/tool/dataprivacy/classes/api.php @@ -608,6 +608,54 @@ public static function can_create_data_request_for_user($user, $requester = null return has_capability('tool/dataprivacy:makedatarequestsforchildren', $usercontext, $requester); } + /** + * Checks whether a user can download a data request. + * + * @param int $userid Target user id (subject of data request) + * @param int $requesterid Requester user id (person who requsted it) + * @param int|null $downloaderid Person who wants to download user id (default current) + * @return bool + * @throws coding_exception + */ + public static function can_download_data_request_for_user($userid, $requesterid, $downloaderid = null) { + global $USER; + + if (!$downloaderid) { + $downloaderid = $USER->id; + } + + $usercontext = \context_user::instance($userid); + // If it's your own and you have the right capability, you can download it. + if ($userid == $downloaderid && has_capability('tool/dataprivacy:downloadownrequest', $usercontext, $downloaderid)) { + return true; + } + // If you can download anyone's in that context, you can download it. + if (has_capability('tool/dataprivacy:downloadallrequests', $usercontext, $downloaderid)) { + return true; + } + // If you can have the 'child access' ability to request in that context, and you are the one + // who requested it, then you can download it. + if ($requesterid == $downloaderid && self::can_create_data_request_for_user($userid, $requesterid)) { + return true; + } + return false; + } + + /** + * Gets an action menu link to download a data request. + * + * @param \context_user $usercontext User context (of user who the data is for) + * @param int $requestid Request id + * @return \action_menu_link_secondary Action menu link + * @throws coding_exception + */ + public static function get_download_link(\context_user $usercontext, $requestid) { + $downloadurl = moodle_url::make_pluginfile_url($usercontext->id, + 'tool_dataprivacy', 'export', $requestid, '/', 'export.zip', true); + $downloadtext = get_string('download', 'tool_dataprivacy'); + return new \action_menu_link_secondary($downloadurl, null, $downloadtext); + } + /** * Creates a new data purpose. * diff --git a/admin/tool/dataprivacy/classes/output/data_requests_table.php b/admin/tool/dataprivacy/classes/output/data_requests_table.php index d8b0644a0db3d..51bb1330911fe 100644 --- a/admin/tool/dataprivacy/classes/output/data_requests_table.php +++ b/admin/tool/dataprivacy/classes/output/data_requests_table.php @@ -208,6 +208,14 @@ public function col_actions($data) { break; } + if ($status == api::DATAREQUEST_STATUS_COMPLETE) { + $userid = $data->foruser->id; + $usercontext = \context_user::instance($userid, IGNORE_MISSING); + if ($usercontext && api::can_download_data_request_for_user($userid, $data->requestedbyuser->id)) { + $actions[] = api::get_download_link($usercontext, $requestid); + } + } + $actionsmenu = new action_menu($actions); $actionsmenu->set_menu_trigger(get_string('actions')); $actionsmenu->set_owner_selector('request-actions-' . $requestid); diff --git a/admin/tool/dataprivacy/classes/output/my_data_requests_page.php b/admin/tool/dataprivacy/classes/output/my_data_requests_page.php index c5e18a135086d..d82968c31a03c 100644 --- a/admin/tool/dataprivacy/classes/output/my_data_requests_page.php +++ b/admin/tool/dataprivacy/classes/output/my_data_requests_page.php @@ -95,7 +95,8 @@ public function export_for_template(renderer_base $output) { $requestexporter = new data_request_exporter($request, ['context' => $outputcontext]); $item = $requestexporter->export($output); - if ($request->get('userid') != $USER->id) { + $self = $request->get('userid') == $USER->id; + if (!$self) { // Append user name if it differs from $USER. $a = (object)['typename' => $item->typename, 'user' => $item->foruser->fullname]; $item->typename = get_string('requesttypeuser', 'tool_dataprivacy', $a); @@ -110,6 +111,10 @@ public function export_for_template(renderer_base $output) { $cancancel = false; // Show download links only for export-type data requests. $candownload = $type == api::DATAREQUEST_TYPE_EXPORT; + if ($usercontext) { + $candownload = api::can_download_data_request_for_user( + $request->get('userid'), $request->get('requestedby')); + } break; case api::DATAREQUEST_STATUS_CANCELLED: case api::DATAREQUEST_STATUS_REJECTED: @@ -126,10 +131,7 @@ public function export_for_template(renderer_base $output) { $actions[] = new action_menu_link_secondary($cancelurl, null, $canceltext, $canceldata); } if ($candownload && $usercontext) { - $downloadurl = moodle_url::make_pluginfile_url($usercontext->id, 'tool_dataprivacy', 'export', $requestid, '/', - 'export.zip', true); - $downloadtext = get_string('download', 'tool_dataprivacy'); - $actions[] = new action_menu_link_secondary($downloadurl, null, $downloadtext); + $actions[] = api::get_download_link($usercontext, $requestid); } if (!empty($actions)) { $actionsmenu = new action_menu($actions); diff --git a/admin/tool/dataprivacy/classes/task/process_data_request_task.php b/admin/tool/dataprivacy/classes/task/process_data_request_task.php index c58f5749cebd0..6db9252df7f2a 100644 --- a/admin/tool/dataprivacy/classes/task/process_data_request_task.php +++ b/admin/tool/dataprivacy/classes/task/process_data_request_task.php @@ -139,8 +139,17 @@ public function execute() { $output = $PAGE->get_renderer('tool_dataprivacy'); $emailonly = false; + $notifyuser = true; switch ($request->type) { case api::DATAREQUEST_TYPE_EXPORT: + // Check if the user is allowed to download their own export. (This is for + // institutions which centrally co-ordinate subject access request across many + // systems, not just one Moodle instance, so we don't want every instance emailing + // the user.) + if (!api::can_download_data_request_for_user($request->userid, $request->requestedby, $request->userid)) { + $notifyuser = false; + } + $typetext = get_string('requesttypeexport', 'tool_dataprivacy'); // We want to notify the user in Moodle about the processing results. $message->notification = 1; @@ -179,18 +188,40 @@ public function execute() { $message->fullmessagehtml = $messagehtml; // Send message to the user involved. - if ($emailonly) { - email_to_user($foruser, $dpo, $subject, $message->fullmessage, $messagehtml); - } else { - message_send($message); + if ($notifyuser) { + if ($emailonly) { + email_to_user($foruser, $dpo, $subject, $message->fullmessage, $messagehtml); + } else { + message_send($message); + } + mtrace('Message sent to user: ' . $messagetextdata['username']); } - mtrace('Message sent to user: ' . $messagetextdata['username']); - // Send to requester as well if this request was made on behalf of another user who's not a DPO, - // and has the capability to make data requests for the user (e.g. Parent). - if (!api::is_site_dpo($request->requestedby) && $foruser->id != $request->requestedby) { + // Send to requester as well in some circumstances. + if ($foruser->id != $request->requestedby) { + $sendtorequester = false; + switch ($request->type) { + case api::DATAREQUEST_TYPE_EXPORT: + // Send to the requester as well if they can download it, unless they are the + // DPO. If we didn't notify the user themselves (because they can't download) + // then send to requester even if it is the DPO, as in that case the requester + // needs to take some action. + if (api::can_download_data_request_for_user($request->userid, $request->requestedby, $request->requestedby)) { + $sendtorequester = !$notifyuser || !api::is_site_dpo($request->requestedby); + } + break; + case api::DATAREQUEST_TYPE_DELETE: + // Send to the requester if they are not the DPO and if they are allowed to + // create data requests for the user (e.g. Parent). + $sendtorequester = !api::is_site_dpo($request->requestedby) && + api::can_create_data_request_for_user($request->userid, $request->requestedby); + break; + default: + throw new moodle_exception('errorinvalidrequesttype', 'tool_dataprivacy'); + } + // Ensure the requester has the capability to make data requests for this user. - if (api::can_create_data_request_for_user($request->userid, $request->requestedby)) { + if ($sendtorequester) { $requestedby = core_user::get_user($request->requestedby); $message->userto = $requestedby; $messagetextdata['username'] = fullname($requestedby); diff --git a/admin/tool/dataprivacy/db/access.php b/admin/tool/dataprivacy/db/access.php index ecc2ec3b8996f..ad3186702afaf 100644 --- a/admin/tool/dataprivacy/db/access.php +++ b/admin/tool/dataprivacy/db/access.php @@ -49,4 +49,22 @@ 'contextlevel' => CONTEXT_USER, 'archetypes' => [] ], + + // Capability for users to download the results of their own data request. + 'tool/dataprivacy:downloadownrequest' => [ + 'riskbitmask' => 0, + 'captype' => 'read', + 'contextlevel' => CONTEXT_USER, + 'archetypes' => [ + 'user' => CAP_ALLOW + ] + ], + + // Capability for administrators to download other people's data requests. + 'tool/dataprivacy:downloadallrequests' => [ + 'riskbitmask' => RISK_PERSONAL, + 'captype' => 'read', + 'contextlevel' => CONTEXT_USER, + 'archetypes' => [] + ], ]; diff --git a/admin/tool/dataprivacy/lang/en/tool_dataprivacy.php b/admin/tool/dataprivacy/lang/en/tool_dataprivacy.php index b01a0b6b0dc80..4af32a8733fc6 100755 --- a/admin/tool/dataprivacy/lang/en/tool_dataprivacy.php +++ b/admin/tool/dataprivacy/lang/en/tool_dataprivacy.php @@ -66,6 +66,8 @@ $string['dataprivacy:makedatarequestsforchildren'] = 'Make data requests for minors'; $string['dataprivacy:managedatarequests'] = 'Manage data requests'; $string['dataprivacy:managedataregistry'] = 'Manage data registry'; +$string['dataprivacy:downloadownrequest'] = 'Download your own exported data'; +$string['dataprivacy:downloadallrequests'] = 'Download exported data for everyone'; $string['dataregistry'] = 'Data registry'; $string['dataregistryinfo'] = 'The data registry enables categories (types of data) and purposes (the reasons for processing data) to be set for all content on the site - from users and courses down to activities and blocks. For each purpose, a retention period may be set. When a retention period has expired, the data is flagged and listed for deletion, awaiting admin confirmation.'; $string['datarequestcreatedforuser'] = 'Data request created for {$a}'; diff --git a/admin/tool/dataprivacy/lib.php b/admin/tool/dataprivacy/lib.php index 3c66485ef3c4d..73ffc1452b0d6 100755 --- a/admin/tool/dataprivacy/lib.php +++ b/admin/tool/dataprivacy/lib.php @@ -185,26 +185,18 @@ function tool_dataprivacy_output_fragment_contextlevel_form($args) { * @return bool Returns false if we don't find a file. */ function tool_dataprivacy_pluginfile($course, $cm, $context, $filearea, $args, $forcedownload, array $options = array()) { - global $USER; - if ($context->contextlevel == CONTEXT_USER) { // Make sure the user is logged in. require_login(null, false); - // Validate the user downloading this archive. - $usercontext = context_user::instance($USER->id); - // The user downloading this is not the user the archive has been prepared for. Check if it's the requester (e.g. parent). - if ($usercontext->instanceid !== $context->instanceid) { - // Get the data request ID. This should be the first element of the $args array. - $itemid = $args[0]; - // Fetch the data request object. An invalid ID will throw an exception. - $datarequest = new \tool_dataprivacy\data_request($itemid); - - // Check if the user is the requester and has the capability to make data requests for the target user. - $candownloadforuser = has_capability('tool/dataprivacy:makedatarequestsforchildren', $context); - if ($USER->id != $datarequest->get('requestedby') || !$candownloadforuser) { - return false; - } + // Get the data request ID. This should be the first element of the $args array. + $itemid = $args[0]; + // Fetch the data request object. An invalid ID will throw an exception. + $datarequest = new \tool_dataprivacy\data_request($itemid); + + // Check if user is allowed to download it. + if (!\tool_dataprivacy\api::can_download_data_request_for_user($context->instanceid, $datarequest->get('requestedby'))) { + return false; } // All good. Serve the exported data. diff --git a/admin/tool/dataprivacy/tests/api_test.php b/admin/tool/dataprivacy/tests/api_test.php index f4a7a66e2f77a..358ea7deeed59 100644 --- a/admin/tool/dataprivacy/tests/api_test.php +++ b/admin/tool/dataprivacy/tests/api_test.php @@ -276,6 +276,57 @@ public function test_can_manage_data_requests() { $this->assertFalse(api::can_manage_data_requests($nondpoincapable->id)); } + /** + * Test for api::can_download_data_request_for_user() + */ + public function test_can_download_data_request_for_user() { + $generator = $this->getDataGenerator(); + + // Three victims. + $victim1 = $generator->create_user(); + $victim2 = $generator->create_user(); + $victim3 = $generator->create_user(); + + // Assign a user as victim 1's parent. + $systemcontext = \context_system::instance(); + $parentrole = $generator->create_role(); + assign_capability('tool/dataprivacy:makedatarequestsforchildren', CAP_ALLOW, $parentrole, $systemcontext); + $parent = $generator->create_user(); + role_assign($parentrole, $parent->id, \context_user::instance($victim1->id)); + + // Assign another user as data access wonder woman. + $wonderrole = $generator->create_role(); + assign_capability('tool/dataprivacy:downloadallrequests', CAP_ALLOW, $wonderrole, $systemcontext); + $staff = $generator->create_user(); + role_assign($wonderrole, $staff->id, $systemcontext); + + // Finally, victim 3 has been naughty; stop them accessing their own data. + $naughtyrole = $generator->create_role(); + assign_capability('tool/dataprivacy:downloadownrequest', CAP_PROHIBIT, $naughtyrole, $systemcontext); + role_assign($naughtyrole, $victim3->id, $systemcontext); + + // Victims 1 and 2 can access their own data, regardless of who requested it. + $this->assertTrue(api::can_download_data_request_for_user($victim1->id, $victim1->id, $victim1->id)); + $this->assertTrue(api::can_download_data_request_for_user($victim2->id, $staff->id, $victim2->id)); + + // Victim 3 cannot access his own data. + $this->assertFalse(api::can_download_data_request_for_user($victim3->id, $victim3->id, $victim3->id)); + + // Victims 1 and 2 cannot access another victim's data. + $this->assertFalse(api::can_download_data_request_for_user($victim2->id, $victim1->id, $victim1->id)); + $this->assertFalse(api::can_download_data_request_for_user($victim1->id, $staff->id, $victim2->id)); + + // Staff can access everyone's data. + $this->assertTrue(api::can_download_data_request_for_user($victim1->id, $victim1->id, $staff->id)); + $this->assertTrue(api::can_download_data_request_for_user($victim2->id, $staff->id, $staff->id)); + $this->assertTrue(api::can_download_data_request_for_user($victim3->id, $staff->id, $staff->id)); + + // Parent can access victim 1's data only if they requested it. + $this->assertTrue(api::can_download_data_request_for_user($victim1->id, $parent->id, $parent->id)); + $this->assertFalse(api::can_download_data_request_for_user($victim1->id, $staff->id, $parent->id)); + $this->assertFalse(api::can_download_data_request_for_user($victim2->id, $parent->id, $parent->id)); + } + /** * Test for api::create_data_request() */ diff --git a/admin/tool/dataprivacy/tests/behat/dataexport.feature b/admin/tool/dataprivacy/tests/behat/dataexport.feature new file mode 100644 index 0000000000000..3ab0467f40958 --- /dev/null +++ b/admin/tool/dataprivacy/tests/behat/dataexport.feature @@ -0,0 +1,107 @@ +@tool @tool_dataprivacy +Feature: Data export from the privacy API + In order to export data for users and meet legal requirements + As an admin, user, or parent + I need to be able to export data for a user + + Background: + Given the following "users" exist: + | username | firstname | lastname | + | victim | Victim User | 1 | + | parent | Long-suffering | Parent | + And the following "roles" exist: + | shortname | name | archetype | + | tired | Tired | | + And the following "permission overrides" exist: + | capability | permission | role | contextlevel | reference | + | tool/dataprivacy:makedatarequestsforchildren | Allow | tired | System | | + And the following "role assigns" exist: + | user | role | contextlevel | reference | + | parent | tired | User | victim | + And the following config values are set as admin: + | contactdataprotectionofficer | 1 | tool_dataprivacy | + + @javascript + Scenario: As admin, export data for a user and download it + Given I log in as "admin" + And I navigate to "Users > Privacy and policies > Data requests" in site administration + And I follow "New request" + And I set the field "Requesting for" to "Victim User 1" + And I press "Save changes" + Then I should see "Victim User 1" + And I should see "Pending" in the "Victim User 1" "table_row" + And I run all adhoc tasks + And I reload the page + And I should see "Awaiting approval" in the "Victim User 1" "table_row" + And I follow "Actions" + And I follow "Approve request" + And I press "Approve request" + And I should see "Approved" in the "Victim User 1" "table_row" + And I run all adhoc tasks + And I reload the page + And I should see "Complete" in the "Victim User 1" "table_row" + And I follow "Actions" + And following "Download" should download between "1" and "100000" bytes + + @javascript + Scenario: As a student, request data export and then download it when approved + Given I log in as "victim" + And I follow "Profile" in the user menu + And I follow "Data requests" + And I follow "New request" + And I press "Save changes" + Then I should see "Export all of my personal data" + And I should see "Pending" in the "Export all of my personal data" "table_row" + And I run all adhoc tasks + And I reload the page + And I should see "Awaiting approval" in the "Export all of my personal data" "table_row" + + And I log out + And I log in as "admin" + And I navigate to "Users > Privacy and policies > Data requests" in site administration + And I follow "Actions" + And I follow "Approve request" + And I press "Approve request" + + And I log out + And I log in as "victim" + And I follow "Profile" in the user menu + And I follow "Data requests" + And I should see "Approved" in the "Export all of my personal data" "table_row" + And I run all adhoc tasks + And I reload the page + And I should see "Complete" in the "Export all of my personal data" "table_row" + And I follow "Actions" + And following "Download" should download between "1" and "100000" bytes + + @javascript + Scenario: As a parent, request data export for my child because I don't trust the little blighter + Given I log in as "parent" + And I follow "Profile" in the user menu + And I follow "Data requests" + And I follow "New request" + And I set the field "Requesting for" to "Victim User 1" + And I press "Save changes" + Then I should see "Victim User 1" + And I should see "Pending" in the "Victim User 1" "table_row" + And I run all adhoc tasks + And I reload the page + And I should see "Awaiting approval" in the "Victim User 1" "table_row" + + And I log out + And I log in as "admin" + And I navigate to "Users > Privacy and policies > Data requests" in site administration + And I follow "Actions" + And I follow "Approve request" + And I press "Approve request" + + And I log out + And I log in as "parent" + And I follow "Profile" in the user menu + And I follow "Data requests" + And I should see "Approved" in the "Victim User 1" "table_row" + And I run all adhoc tasks + And I reload the page + And I should see "Complete" in the "Victim User 1" "table_row" + And I follow "Actions" + And following "Download" should download between "1" and "100000" bytes