Merge branch '83399-admin-loginas' of https://github.com/jaypha/moodle

This commit is contained in:
Andrew Nicols
2025-03-19 11:54:08 +08:00
4 changed files with 439 additions and 39 deletions
+6 -36
View File
@@ -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
+113
View File
@@ -0,0 +1,113 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
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 <[email protected]>
* @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;
}
}
+1 -3
View File
@@ -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);
+319
View File
@@ -0,0 +1,319 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core\session;
use context_course;
use context_system;
/**
* Unit tests for loginas_helper class.
*
* @package core
* @author Jason den Dulk <[email protected]>
* @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)
);
}
}