diff --git a/course/loginas.php b/course/loginas.php index fed3d131e82..04b56443dcc 100644 --- a/course/loginas.php +++ b/course/loginas.php @@ -36,47 +36,17 @@ require_sesskey(); $course = $DB->get_record('course', array('id'=>$id), '*', MUST_EXIST); // User must be logged in. - -$systemcontext = context_system::instance(); -$coursecontext = context_course::instance($course->id); - require_login(); -if (has_capability('moodle/user:loginas', $systemcontext)) { - if (is_siteadmin($userid)) { - throw new \moodle_exception('nologinas'); - } - $context = $systemcontext; - $PAGE->set_context($context); -} else { - require_login($course); - require_capability('moodle/user:loginas', $coursecontext); - if (is_siteadmin($userid)) { - throw new \moodle_exception('nologinas'); - } - if (!is_enrolled($coursecontext, $userid)) { - throw new \moodle_exception('usernotincourse'); - } - $context = $coursecontext; +$user = $DB->get_record('user', ['id' => $userid]); - // Check if course has SEPARATEGROUPS and user is part of that group. - if (groups_get_course_groupmode($course) == SEPARATEGROUPS && - !has_capability('moodle/site:accessallgroups', $context)) { - $samegroup = false; - if ($groups = groups_get_all_groups($course->id, $USER->id)) { - foreach ($groups as $group) { - if (groups_is_member($group->id, $userid)) { - $samegroup = true; - break; - } - } - } - if (!$samegroup) { - throw new \moodle_exception('nologinas'); - } - } +$context = \core\session\loginas_helper::get_context_user_can_login_as($USER, $user, $course); +if (empty($context)) { + throw new moodle_exception('nologinas'); } +$PAGE->set_context($context); + // Login as this user and return to course home page. \core\session\manager::loginas($userid, $context); // Add a notification to let the logged in as user know that all content will be force cleaned diff --git a/lib/classes/session/loginas_helper.php b/lib/classes/session/loginas_helper.php new file mode 100644 index 00000000000..0b6bf9e3bb5 --- /dev/null +++ b/lib/classes/session/loginas_helper.php @@ -0,0 +1,113 @@ +. + +namespace core\session; + +use context; +use stdClass; +use context_course; +use context_system; +use core\session\manager as sessionmanager; + +/** + * Helper functions for the 'login as' feature. + * + * @package core + * @author Jason den Dulk + * @copyright 2025 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class loginas_helper { + + /** + * Determine which context a user can login as another user for, given the requested target user and course. If the + * person cannot login at all, then null will be returned. + * + * @param stdClass $currentuser The current user. Defaults to $USER. + * @param stdClass $loginasuser The user to be logged in as. + * @param stdClass|null $course The course currently being looked at. Applies to those who can only login within a + * course context. If null, then only system context will be considered. + * @return context|null Returns the context that the user is able to login as, or null if no context can be found. + */ + public static function get_context_user_can_login_as( + stdClass $currentuser, + stdClass $loginasuser, + ?stdClass $course = null + ): ?context { + + $systemcontext = context_system::instance(); + + if ( + $loginasuser->deleted || // Can't login as a user that has been removed. + $currentuser->id == $loginasuser->id || // Pointless for a user to login as himself. + sessionmanager::is_loggedinas() // Already logged in as someone. + ) { + return null; + } + + // Site admins can always login as someone else. + if (is_siteadmin($currentuser)) { + return $systemcontext; + } + + // Non admins can never login as an admin. + if (is_siteadmin($loginasuser)) { + return null; + } + + // Now, we accept anyone who can loginas at the site level, and they can do so at the system level. + if (has_capability('moodle/user:loginas', $systemcontext, $currentuser)) { + return $systemcontext; + } + + if (!empty($course)) { + $coursecontext = context_course::instance($course->id); + + if ( + // Reject all that do not have loginas capability. + !has_capability('moodle/user:loginas', $coursecontext, $currentuser) || + // Reject if user is trying to login as someone with site level loginas powers. + has_capability('moodle/user:loginas', $systemcontext, $loginasuser) || + // Reject if other is not enrolled. + !is_enrolled($coursecontext, $loginasuser->id) + ) { + return null; + } + + // Check if the users are in the same group. + if (groups_get_course_groupmode($course) == SEPARATEGROUPS && + !has_capability('moodle/site:accessallgroups', $coursecontext, $currentuser)) { + $samegroup = false; + if ($groups = groups_get_all_groups($course->id, $currentuser->id)) { + foreach ($groups as $group) { + if (groups_is_member($group->id, $loginasuser->id)) { + $samegroup = true; + break; + } + } + } + if (!$samegroup) { + return null; + } + } + + // Passed all checks. + return $coursecontext; + } + + return null; + } +} diff --git a/lib/myprofilelib.php b/lib/myprofilelib.php index 7bcae3edf6f..8b197430cea 100644 --- a/lib/myprofilelib.php +++ b/lib/myprofilelib.php @@ -108,9 +108,7 @@ function core_myprofile_navigation(core_user\output\myprofile\tree $tree, $user, } // Login as ... - if (!$user->deleted && !$iscurrentuser && - !\core\session\manager::is_loggedinas() && has_capability('moodle/user:loginas', - $courseorsystemcontext) && !is_siteadmin($user->id)) { + if (!empty(\core\session\loginas_helper::get_context_user_can_login_as($USER, $user, $course))) { $url = new moodle_url('/course/loginas.php', array('id' => $courseid, 'user' => $user->id, 'sesskey' => sesskey())); $node = new core_user\output\myprofile\node('administration', 'loginas', get_string('loginas'), null, $url); diff --git a/lib/tests/session/loginas_helper_test.php b/lib/tests/session/loginas_helper_test.php new file mode 100644 index 00000000000..5fea0fd3a3e --- /dev/null +++ b/lib/tests/session/loginas_helper_test.php @@ -0,0 +1,319 @@ +. + +namespace core\session; + +use context_course; +use context_system; + +/** + * Unit tests for loginas_helper class. + * + * @package core + * @author Jason den Dulk + * @covers \core\session\loginas_helper + * @copyright 2025 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +final class loginas_helper_test extends \advanced_testcase { + + /** + * Tests various users wanting to login as other users of the same role. + */ + public function test_loginas_same_role(): void { + global $CFG, $DB; + + $this->resetAfterTest(); + + $managerrole = $DB->get_field('role', 'id', ['shortname' => 'manager']); + + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + + // By default, users cannot login as other users. + $this->assertNull(loginas_helper::get_context_user_can_login_as($user1, $user2)); + $this->assertNull(loginas_helper::get_context_user_can_login_as($user2, $user1)); + + // Admins can login as other admins. + $originalsiteadmins = $CFG->siteadmins; + $CFG->siteadmins .= ',' . $user1->id . ',' . $user2->id; + $systemcontext = context_system::instance(); + $this->assertEquals($systemcontext, loginas_helper::get_context_user_can_login_as($user1, $user2)); + $this->assertEquals($systemcontext, loginas_helper::get_context_user_can_login_as($user2, $user1)); + + // Managers can login as other managers. + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + role_assign($managerrole, $user1->id, $systemcontext->id); + role_assign($managerrole, $user2->id, $systemcontext->id); + + $this->assertEquals($systemcontext, loginas_helper::get_context_user_can_login_as($user1, $user2)); + $this->assertEquals($systemcontext, loginas_helper::get_context_user_can_login_as($user2, $user1)); + + // Course managers can login as other course managers, but only in the course context. + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + + $course = $this->getDataGenerator()->create_course(); + $coursecontext = context_course::instance($course->id); + $this->getDataGenerator()->enrol_user($user1->id, $course->id, 'manager'); + $this->getDataGenerator()->enrol_user($user2->id, $course->id, 'manager'); + + $this->assertNull(loginas_helper::get_context_user_can_login_as($user1, $user2)); + $this->assertNull(loginas_helper::get_context_user_can_login_as($user2, $user1)); + + $this->assertEquals($coursecontext, loginas_helper::get_context_user_can_login_as($user1, $user2, $course)); + $this->assertEquals($coursecontext, loginas_helper::get_context_user_can_login_as($user2, $user1, $course)); + + // Students cannot login as another student. + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + + $course = $this->getDataGenerator()->create_course(); + $this->getDataGenerator()->enrol_user($user1->id, $course->id, 'student'); + $this->getDataGenerator()->enrol_user($user2->id, $course->id, 'student'); + + $this->assertNull(loginas_helper::get_context_user_can_login_as($user1, $user2)); + $this->assertNull(loginas_helper::get_context_user_can_login_as($user2, $user1)); + + $this->assertNull(loginas_helper::get_context_user_can_login_as($user1, $user2, $course)); + $this->assertNull(loginas_helper::get_context_user_can_login_as($user2, $user1, $course)); + } + + /** + * Tests trying to login as a deleted user. + */ + public function test_loginas_deleted_user(): void { + global $USER; + + $this->resetAfterTest(); + $this->setAdminUser(); + + // Sanity check that ordinary login as works. + $user = $this->getDataGenerator()->create_user(); + $this->assertEquals(context_system::instance(), loginas_helper::get_context_user_can_login_as($USER, $user)); + + // Cannot login as a user that has been deleted. + $user = $this->getDataGenerator()->create_user(['deleted' => true]); + $this->assertNull(loginas_helper::get_context_user_can_login_as($USER, $user)); + } + + /** + * Tests various users wanting to login as other users of differing roles. + */ + public function test_loginas_different_roles(): void { + global $CFG, $DB; + + $this->resetAfterTest(); + + $systemcontext = context_system::instance(); + $managerrole = $DB->get_field('role', 'id', ['shortname' => 'manager']); + $originalsiteadmins = $CFG->siteadmins; + + $users = []; + for ($i = 0; $i < 11; ++$i) { + $users[] = $this->getDataGenerator()->create_user(); + } + + $courses = [ + $this->getDataGenerator()->create_course(), + $this->getDataGenerator()->create_course(), + ]; + $coursecontexts = [ + context_course::instance($courses[0]->id), + context_course::instance($courses[1]->id), + ]; + + // User 0 is an admin. + $CFG->siteadmins .= ',' . $users[0]->id; + + // User 1 is a manager. + role_assign($managerrole, $users[1]->id, $systemcontext->id); + + // User 2 is a manager and an admin. + $CFG->siteadmins .= ',' . $users[2]->id; + role_assign($managerrole, $users[2]->id, $systemcontext->id); + + // User 3 is a course manager for course 0. + $this->getDataGenerator()->enrol_user($users[3]->id, $courses[0]->id, 'manager'); + + // User 4 is a course manager for course 0 and a site manager. + $this->getDataGenerator()->enrol_user($users[4]->id, $courses[0]->id, 'manager'); + role_assign($managerrole, $users[4]->id, $systemcontext->id); + + // User 5 is a course manager for course 0 and an admin. + $this->getDataGenerator()->enrol_user($users[5]->id, $courses[0]->id, 'manager'); + $CFG->siteadmins .= ',' . $users[5]->id; + + // User 6 is a student for course 0. + $this->getDataGenerator()->enrol_user($users[6]->id, $courses[0]->id, 'student'); + + // User 7 is a student for course 0 and an admin. + $this->getDataGenerator()->enrol_user($users[7]->id, $courses[0]->id, 'student'); + $CFG->siteadmins .= ',' . $users[7]->id; + + // User 8 is a course manager for course 1. + $this->getDataGenerator()->enrol_user($users[8]->id, $courses[1]->id, 'manager'); + + // User 9 is a student for course 1. + $this->getDataGenerator()->enrol_user($users[9]->id, $courses[1]->id, 'student'); + + // User 10 is a user without courses or roles. + + // This matrix defines loginas expectations. 'S' = system context. # = course context. 'X' = nothing. + $matrix = [ + // ... 0 1 2 3 4 5 6 7 8 9 10. + 0 => ['X', 'S', 'S', 'S', 'S', 'S', 'S', 'S', 'S', 'S', 'S'], + 1 => ['X', 'X', 'X', 'S', 'S', 'X', 'S', 'X', 'S', 'S', 'S'], + 2 => ['S', 'S', 'X', 'S', 'S', 'S', 'S', 'S', 'S', 'S', 'S'], + 3 => ['X', 'X', 'X', 'X', 'X', 'X', '0', 'X', 'X', 'X', 'X'], + 4 => ['X', 'S', 'X', 'S', 'X', 'X', 'S', 'X', 'S', 'S', 'S'], + 5 => ['S', 'S', 'S', 'S', 'S', 'X', 'S', 'S', 'S', 'S', 'S'], + 6 => ['X', 'X', 'X', 'X', 'X', 'X', 'X', 'X', 'X', 'X', 'X'], + 7 => ['S', 'S', 'S', 'S', 'S', 'S', 'S', 'X', 'S', 'S', 'S'], + 8 => ['X', 'X', 'X', 'X', 'X', 'X', 'X', 'X', 'X', '1', 'X'], + 9 => ['X', 'X', 'X', 'X', 'X', 'X', 'X', 'X', 'X', 'X', 'X'], + 10 => ['X', 'X', 'X', 'X', 'X', 'X', 'X', 'X', 'X', 'X', 'X'], + ]; + + // Now test each user against each other user, and compare the results to the expectation matrix. + foreach ($users as $uid => $user) { + foreach ($users as $oid => $other) { + $resultsnocourse = loginas_helper::get_context_user_can_login_as($user, $other); + $resultscourse0 = loginas_helper::get_context_user_can_login_as($user, $other, $courses[0]); + $resultscourse1 = loginas_helper::get_context_user_can_login_as($user, $other, $courses[1]); + + switch ($matrix[$uid][$oid]) { + case 'X': + // No loginas is possible. + $this->assertNull($resultsnocourse); + $this->assertNull($resultscourse0); + $this->assertNull($resultscourse1); + break; + case 'S': + // Loginas can happen at the site level. + $this->assertEquals($systemcontext, $resultsnocourse); + $this->assertEquals($systemcontext, $resultscourse0); + $this->assertEquals($systemcontext, $resultscourse1); + break; + case '0': + // Loginas can only happen within the context of course 0. + $this->assertNull($resultsnocourse); + $this->assertEquals($coursecontexts[0], $resultscourse0); + $this->assertNull($resultscourse1); + break; + case '1': + // Loginas can only happen within the context of course 1. + $this->assertNull($resultsnocourse); + $this->assertNull($resultscourse0); + $this->assertEquals($coursecontexts[1], $resultscourse1); + break; + } + } + } + } + + /** + * Providor function for test_loginas_groups(). + * + * @return array[] + */ + public static function loginas_groups_providor(): array { + return [ + 'Separate groups' => [ + 'groupmode' => SEPARATEGROUPS, + 'accessallgroups' => true, + 'canloginassamegroup' => true, + 'canloginasdifferentgroup' => true, + ], + 'Separate groups, no access' => [ + 'groupmode' => SEPARATEGROUPS, + 'accessallgroups' => false, + 'canloginassamegroup' => true, + 'canloginasdifferentgroup' => false, + ], + 'Visible groups' => [ + 'groupmode' => VISIBLEGROUPS, + 'accessallgroups' => true, + 'canloginassamegroup' => true, + 'canloginasdifferentgroup' => true, + ], + 'Visible groups, no access' => [ + 'groupmode' => VISIBLEGROUPS, + 'accessallgroups' => false, + 'canloginassamegroup' => true, + 'canloginasdifferentgroup' => true, + ], + ]; + } + + /** + * Tests users wanting to login as other users of different groups. + * + * @param int $groupmode + * @param bool $accessallgroups + * @param bool $canloginassamegroup + * @param bool $canloginasdifferentgroup + * + * @dataProvider loginas_groups_providor + */ + public function test_loginas_groups( + int $groupmode, + bool $accessallgroups, + bool $canloginassamegroup, + bool $canloginasdifferentgroup + ): void { + global $DB; + + $this->resetAfterTest(); + + // Set up manager and students. + $manager = $this->getDataGenerator()->create_user(); + $student1 = $this->getDataGenerator()->create_user(); + $student2 = $this->getDataGenerator()->create_user(); + + $course = $this->getDataGenerator()->create_course(['groupmode' => $groupmode]); + $coursecontext = context_course::instance($course->id); + + // Add or remove accessallgroups permission. + $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager'], MUST_EXIST); + $permission = $accessallgroups ? CAP_ALLOW : CAP_PREVENT; + assign_capability('moodle/site:accessallgroups', $permission, $managerroleid, $coursecontext, true); + + $this->getDataGenerator()->enrol_user($manager->id, $course->id, 'manager'); + $this->getDataGenerator()->enrol_user($student1->id, $course->id, 'student'); + $this->getDataGenerator()->enrol_user($student2->id, $course->id, 'student'); + + // Manager and student 1 are in the same group. Student 2 is in a different group. + $group1 = $this->getDataGenerator()->create_group(['courseid' => $course->id]); + $group2 = $this->getDataGenerator()->create_group(['courseid' => $course->id]); + + $this->getDataGenerator()->create_group_member(['groupid' => $group1->id, 'userid' => $manager->id]); + $this->getDataGenerator()->create_group_member(['groupid' => $group1->id, 'userid' => $student1->id]); + $this->getDataGenerator()->create_group_member(['groupid' => $group2->id, 'userid' => $student2->id]); + + // Manager wants to login as a student in the same group. + $this->assertEquals( + $canloginassamegroup, + (bool) loginas_helper::get_context_user_can_login_as($manager, $student1, $course) + ); + // Manager wants to login as a student in a different group. + $this->assertEquals( + $canloginasdifferentgroup, + (bool) loginas_helper::get_context_user_can_login_as($manager, $student2, $course) + ); + } +}