Merge pull request #2064 from edx/dhm/auth_consolidate

Replace authz with roles.py and reconcile their behaviors
This commit is contained in:
Don Mitchell
2014-01-15 07:21:03 -08:00
47 changed files with 605 additions and 792 deletions

View File

@@ -0,0 +1,86 @@
"""
The application interface to roles which checks whether any user trying to change
authorization has authorization to do so, which infers authorization via role hierarchy
(GlobalStaff is superset of auths of course instructor, ...), which consults the config
to decide whether to check course creator role, and other such functions.
"""
from django.core.exceptions import PermissionDenied
from django.conf import settings
from student.roles import GlobalStaff, CourseCreatorRole, CourseStaffRole, CourseInstructorRole, CourseRole, \
CourseBetaTesterRole
def has_access(user, role):
"""
Check whether this user has access to this role (either direct or implied)
:param user:
:param role: an AccessRole
"""
if not user.is_active:
return False
# do cheapest check first even tho it's not the direct one
if GlobalStaff().has_user(user):
return True
# CourseCreator is odd b/c it can be disabled via config
if isinstance(role, CourseCreatorRole):
# completely shut down course creation setting
if settings.FEATURES.get('DISABLE_COURSE_CREATION', False):
return False
# wide open course creation setting
if not settings.FEATURES.get('ENABLE_CREATOR_GROUP', False):
return True
if role.has_user(user):
return True
# if not, then check inferred permissions
if (isinstance(role, (CourseStaffRole, CourseBetaTesterRole)) and
CourseInstructorRole(role.location).has_user(user)):
return True
return False
def add_users(caller, role, *users):
"""
The caller requests adding the given users to the role. Checks that the caller
has sufficient authority.
:param caller: a user
:param role: an AccessRole
"""
_check_caller_authority(caller, role)
role.add_users(*users)
def remove_users(caller, role, *users):
"""
The caller requests removing the given users from the role. Checks that the caller
has sufficient authority.
:param caller: a user
:param role: an AccessRole
"""
# can always remove self (at this layer)
if not(len(users) == 1 and caller == users[0]):
_check_caller_authority(caller, role)
role.remove_users(*users)
def _check_caller_authority(caller, role):
"""
Internal function to check whether the caller has authority to manipulate this role
:param caller: a user
:param role: an AccessRole
"""
if not (caller.is_authenticated and caller.is_active):
raise PermissionDenied
# superuser
if GlobalStaff().has_user(caller):
return
if isinstance(role, (GlobalStaff, CourseCreatorRole)):
raise PermissionDenied
elif isinstance(role, CourseRole): # instructors can change the roles w/in their course
if not has_access(caller, CourseInstructorRole(role.location)):
raise PermissionDenied

View File

@@ -0,0 +1,242 @@
"""
Classes used to model the roles used in the courseware. Each role is responsible for checking membership,
adding users, removing users, and listing members
"""
from abc import ABCMeta, abstractmethod
from django.contrib.auth.models import User, Group
from xmodule.modulestore import Location
from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundError
from xmodule.modulestore.django import loc_mapper
from xmodule.modulestore.locator import CourseLocator, Locator
class CourseContextRequired(Exception):
"""
Raised when a course_context is required to determine permissions
"""
pass
class AccessRole(object):
"""
Object representing a role with particular access to a resource
"""
__metaclass__ = ABCMeta
@abstractmethod
def has_user(self, user): # pylint: disable=unused-argument
"""
Return whether the supplied django user has access to this role.
"""
return False
@abstractmethod
def add_users(self, *users):
"""
Add the role to the supplied django users.
"""
pass
@abstractmethod
def remove_users(self, *users):
"""
Remove the role from the supplied django users.
"""
pass
@abstractmethod
def users_with_role(self):
"""
Return a django QuerySet for all of the users with this role
"""
return User.objects.none()
class GlobalStaff(AccessRole):
"""
The global staff role
"""
def has_user(self, user):
return user.is_staff
def add_users(self, *users):
for user in users:
if (user.is_authenticated and user.is_active):
user.is_staff = True
user.save()
def remove_users(self, *users):
for user in users:
# don't check is_authenticated nor is_active on purpose
user.is_staff = False
user.save()
def users_with_role(self):
raise Exception("This operation is un-indexed, and shouldn't be used")
class GroupBasedRole(AccessRole):
"""
A role based on membership to any of a set of groups.
"""
def __init__(self, group_names):
"""
Create a GroupBasedRole from a list of group names
The first element of `group_names` will be the preferred group
to use when adding a user to this Role.
If a user is a member of any of the groups in the list, then
they will be consider a member of the Role
"""
self._group_names = [name.lower() for name in group_names]
def has_user(self, user):
"""
Return whether the supplied django user has access to this role.
"""
if not (user.is_authenticated and user.is_active):
return False
# pylint: disable=protected-access
if not hasattr(user, '_groups'):
user._groups = set(name.lower() for name in user.groups.values_list('name', flat=True))
return len(user._groups.intersection(self._group_names)) > 0
def add_users(self, *users):
"""
Add the supplied django users to this role.
"""
# silently ignores anonymous and inactive users so that any that are
# legit get updated.
users = [user for user in users if user.is_authenticated and user.is_active]
group, _ = Group.objects.get_or_create(name=self._group_names[0])
group.user_set.add(*users)
# remove cache
for user in users:
if hasattr(user, '_groups'):
del user._groups
def remove_users(self, *users):
"""
Remove the supplied django users from this role.
"""
groups = Group.objects.filter(name__in=self._group_names)
for group in groups:
group.user_set.remove(*users)
# remove cache
for user in users:
if hasattr(user, '_groups'):
del user._groups
def users_with_role(self):
"""
Return a django QuerySet for all of the users with this role
"""
return User.objects.filter(groups__name__in=self._group_names)
class CourseRole(GroupBasedRole):
"""
A named role in a particular course
"""
def __init__(self, role, location, course_context=None):
"""
Location may be either a Location, a string, dict, or tuple which Location will accept
in its constructor, or a CourseLocator. Handle all these giving some preference to
the preferred naming.
"""
# TODO: figure out how to make the group name generation lazy so it doesn't force the
# loc mapping?
self.location = Locator.to_locator_or_location(location)
self.role = role
# direct copy from auth.authz.get_all_course_role_groupnames will refactor to one impl asap
groupnames = []
# pylint: disable=no-member
if isinstance(self.location, Location):
try:
groupnames.append('{0}_{1}'.format(role, self.location.course_id))
course_context = self.location.course_id # course_id is valid for translation
except InvalidLocationError: # will occur on old locations where location is not of category course
if course_context is None:
raise CourseContextRequired()
else:
groupnames.append('{0}_{1}'.format(role, course_context))
try:
locator = loc_mapper().translate_location(course_context, self.location, False, False)
groupnames.append('{0}_{1}'.format(role, locator.package_id))
except (InvalidLocationError, ItemNotFoundError):
# if it's never been mapped, the auth won't be via the Locator syntax
pass
# least preferred legacy role_course format
groupnames.append('{0}_{1}'.format(role, self.location.course))
elif isinstance(self.location, CourseLocator):
groupnames.append('{0}_{1}'.format(role, self.location.package_id))
# handle old Location syntax
old_location = loc_mapper().translate_locator_to_location(self.location, get_course=True)
if old_location:
# the slashified version of the course_id (myu/mycourse/myrun)
groupnames.append('{0}_{1}'.format(role, old_location.course_id))
# add the least desirable but sometimes occurring format.
groupnames.append('{0}_{1}'.format(role, old_location.course))
super(CourseRole, self).__init__(groupnames)
class OrgRole(GroupBasedRole):
"""
A named role in a particular org
"""
def __init__(self, role, location):
# pylint: disable=no-member
location = Location(location)
super(OrgRole, self).__init__(['{}_{}'.format(role, location.org)])
class CourseStaffRole(CourseRole):
"""A Staff member of a course"""
ROLE = 'staff'
def __init__(self, *args, **kwargs):
super(CourseStaffRole, self).__init__(self.ROLE, *args, **kwargs)
class CourseInstructorRole(CourseRole):
"""A course Instructor"""
ROLE = 'instructor'
def __init__(self, *args, **kwargs):
super(CourseInstructorRole, self).__init__(self.ROLE, *args, **kwargs)
class CourseBetaTesterRole(CourseRole):
"""A course Beta Tester"""
ROLE = 'beta_testers'
def __init__(self, *args, **kwargs):
super(CourseBetaTesterRole, self).__init__(self.ROLE, *args, **kwargs)
class OrgStaffRole(OrgRole):
"""An organization staff member"""
def __init__(self, *args, **kwargs):
super(OrgStaffRole, self).__init__('staff', *args, **kwargs)
class OrgInstructorRole(OrgRole):
"""An organization instructor"""
def __init__(self, *args, **kwargs):
super(OrgInstructorRole, self).__init__('instructor', *args, **kwargs)
class CourseCreatorRole(GroupBasedRole):
"""
This is the group of people who have permission to create new courses (we may want to eventually
make this an org based role).
"""
ROLE = "course_creator_group"
def __init__(self, *args, **kwargs):
super(CourseCreatorRole, self).__init__(self.ROLE, *args, **kwargs)

View File

@@ -0,0 +1,189 @@
"""
Tests authz.py
"""
import mock
from django.test import TestCase
from django.contrib.auth.models import User
from xmodule.modulestore import Location
from django.core.exceptions import PermissionDenied
from student.roles import CourseInstructorRole, CourseStaffRole, CourseCreatorRole
from student.tests.factories import AdminFactory
from student.auth import has_access, add_users, remove_users
class CreatorGroupTest(TestCase):
"""
Tests for the course creator group.
"""
def setUp(self):
""" Test case setup """
self.user = User.objects.create_user('testuser', 'test+courses@edx.org', 'foo')
self.admin = User.objects.create_user('Mark', 'admin+courses@edx.org', 'foo')
self.admin.is_staff = True
def test_creator_group_not_enabled(self):
"""
Tests that CourseCreatorRole().has_user always returns True if ENABLE_CREATOR_GROUP
and DISABLE_COURSE_CREATION are both not turned on.
"""
self.assertTrue(has_access(self.user, CourseCreatorRole()))
def test_creator_group_enabled_but_empty(self):
""" Tests creator group feature on, but group empty. """
with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}):
self.assertFalse(has_access(self.user, CourseCreatorRole()))
# Make user staff. This will cause CourseCreatorRole().has_user to return True.
self.user.is_staff = True
self.assertTrue(has_access(self.user, CourseCreatorRole()))
def test_creator_group_enabled_nonempty(self):
""" Tests creator group feature on, user added. """
with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}):
add_users(self.admin, CourseCreatorRole(), self.user)
self.assertTrue(has_access(self.user, CourseCreatorRole()))
# check that a user who has not been added to the group still returns false
user_not_added = User.objects.create_user('testuser2', 'test+courses2@edx.org', 'foo2')
self.assertFalse(has_access(user_not_added, CourseCreatorRole()))
# remove first user from the group and verify that CourseCreatorRole().has_user now returns false
remove_users(self.admin, CourseCreatorRole(), self.user)
self.assertFalse(has_access(self.user, CourseCreatorRole()))
def test_course_creation_disabled(self):
""" Tests that the COURSE_CREATION_DISABLED flag overrides course creator group settings. """
with mock.patch.dict('django.conf.settings.FEATURES',
{'DISABLE_COURSE_CREATION': True, "ENABLE_CREATOR_GROUP": True}):
# Add user to creator group.
add_users(self.admin, CourseCreatorRole(), self.user)
# DISABLE_COURSE_CREATION overrides (user is not marked as staff).
self.assertFalse(has_access(self.user, CourseCreatorRole()))
# Mark as staff. Now CourseCreatorRole().has_user returns true.
self.user.is_staff = True
self.assertTrue(has_access(self.user, CourseCreatorRole()))
# Remove user from creator group. CourseCreatorRole().has_user still returns true because is_staff=True
remove_users(self.admin, CourseCreatorRole(), self.user)
self.assertTrue(has_access(self.user, CourseCreatorRole()))
def test_add_user_not_authenticated(self):
"""
Tests that adding to creator group fails if user is not authenticated
"""
with mock.patch.dict('django.conf.settings.FEATURES',
{'DISABLE_COURSE_CREATION': False, "ENABLE_CREATOR_GROUP": True}):
self.user.is_authenticated = False
add_users(self.admin, CourseCreatorRole(), self.user)
self.assertFalse(has_access(self.user, CourseCreatorRole()))
def test_add_user_not_active(self):
"""
Tests that adding to creator group fails if user is not active
"""
with mock.patch.dict('django.conf.settings.FEATURES',
{'DISABLE_COURSE_CREATION': False, "ENABLE_CREATOR_GROUP": True}):
self.user.is_active = False
add_users(self.admin, CourseCreatorRole(), self.user)
self.assertFalse(has_access(self.user, CourseCreatorRole()))
def test_add_user_to_group_requires_staff_access(self):
with self.assertRaises(PermissionDenied):
self.admin.is_staff = False
add_users(self.admin, CourseCreatorRole(), self.user)
with self.assertRaises(PermissionDenied):
add_users(self.user, CourseCreatorRole(), self.user)
def test_add_user_to_group_requires_active(self):
with self.assertRaises(PermissionDenied):
self.admin.is_active = False
add_users(self.admin, CourseCreatorRole(), self.user)
def test_add_user_to_group_requires_authenticated(self):
with self.assertRaises(PermissionDenied):
self.admin.is_authenticated = False
add_users(self.admin, CourseCreatorRole(), self.user)
def test_remove_user_from_group_requires_staff_access(self):
with self.assertRaises(PermissionDenied):
self.admin.is_staff = False
remove_users(self.admin, CourseCreatorRole(), self.user)
def test_remove_user_from_group_requires_active(self):
with self.assertRaises(PermissionDenied):
self.admin.is_active = False
remove_users(self.admin, CourseCreatorRole(), self.user)
def test_remove_user_from_group_requires_authenticated(self):
with self.assertRaises(PermissionDenied):
self.admin.is_authenticated = False
remove_users(self.admin, CourseCreatorRole(), self.user)
class CourseGroupTest(TestCase):
"""
Tests for instructor and staff groups for a particular course.
"""
def setUp(self):
""" Test case setup """
self.global_admin = AdminFactory()
self.creator = User.objects.create_user('testcreator', 'testcreator+courses@edx.org', 'foo')
self.staff = User.objects.create_user('teststaff', 'teststaff+courses@edx.org', 'foo')
self.location = Location('i4x', 'mitX', '101', 'course', 'test')
def test_add_user_to_course_group(self):
"""
Tests adding user to course group (happy path).
"""
# Create groups for a new course (and assign instructor role to the creator).
self.assertFalse(has_access(self.creator, CourseInstructorRole(self.location)))
add_users(self.global_admin, CourseInstructorRole(self.location), self.creator)
add_users(self.global_admin, CourseStaffRole(self.location), self.creator)
self.assertTrue(has_access(self.creator, CourseInstructorRole(self.location)))
# Add another user to the staff role.
self.assertFalse(has_access(self.staff, CourseStaffRole(self.location)))
add_users(self.creator, CourseStaffRole(self.location), self.staff)
self.assertTrue(has_access(self.staff, CourseStaffRole(self.location)))
def test_add_user_to_course_group_permission_denied(self):
"""
Verifies PermissionDenied if caller of add_user_to_course_group is not instructor role.
"""
add_users(self.global_admin, CourseInstructorRole(self.location), self.creator)
add_users(self.global_admin, CourseStaffRole(self.location), self.creator)
with self.assertRaises(PermissionDenied):
add_users(self.staff, CourseStaffRole(self.location), self.staff)
def test_remove_user_from_course_group(self):
"""
Tests removing user from course group (happy path).
"""
add_users(self.global_admin, CourseInstructorRole(self.location), self.creator)
add_users(self.global_admin, CourseStaffRole(self.location), self.creator)
add_users(self.creator, CourseStaffRole(self.location), self.staff)
self.assertTrue(has_access(self.staff, CourseStaffRole(self.location)))
remove_users(self.creator, CourseStaffRole(self.location), self.staff)
self.assertFalse(has_access(self.staff, CourseStaffRole(self.location)))
remove_users(self.creator, CourseInstructorRole(self.location), self.creator)
self.assertFalse(has_access(self.creator, CourseInstructorRole(self.location)))
def test_remove_user_from_course_group_permission_denied(self):
"""
Verifies PermissionDenied if caller of remove_user_from_course_group is not instructor role.
"""
add_users(self.global_admin, CourseInstructorRole(self.location), self.creator)
another_staff = User.objects.create_user('another', 'teststaff+anothercourses@edx.org', 'foo')
add_users(self.global_admin, CourseStaffRole(self.location), self.creator, self.staff, another_staff)
with self.assertRaises(PermissionDenied):
remove_users(self.staff, CourseStaffRole(self.location), another_staff)

View File

@@ -0,0 +1,47 @@
"""
Tests of student.roles
"""
from django.test import TestCase
from xmodule.modulestore import Location
from courseware.tests.factories import UserFactory, StaffFactory, InstructorFactory
from student.tests.factories import AnonymousUserFactory
from student.roles import GlobalStaff, CourseRole
class RolesTestCase(TestCase):
"""
Tests of student.roles
"""
def setUp(self):
self.course = Location('i4x://edX/toy/course/2012_Fall')
self.anonymous_user = AnonymousUserFactory()
self.student = UserFactory()
self.global_staff = UserFactory(is_staff=True)
self.course_staff = StaffFactory(course=self.course)
self.course_instructor = InstructorFactory(course=self.course)
def test_global_staff(self):
self.assertFalse(GlobalStaff().has_user(self.student))
self.assertFalse(GlobalStaff().has_user(self.course_staff))
self.assertFalse(GlobalStaff().has_user(self.course_instructor))
self.assertTrue(GlobalStaff().has_user(self.global_staff))
def test_group_name_case_insensitive(self):
uppercase_loc = "i4x://ORG/COURSE/course/NAME"
lowercase_loc = uppercase_loc.lower()
lowercase_group = "role_org/course/name"
uppercase_group = lowercase_group.upper()
lowercase_user = UserFactory(groups=lowercase_group)
uppercase_user = UserFactory(groups=uppercase_group)
self.assertTrue(CourseRole("role", lowercase_loc).has_user(lowercase_user))
self.assertTrue(CourseRole("role", uppercase_loc).has_user(lowercase_user))
self.assertTrue(CourseRole("role", lowercase_loc).has_user(uppercase_user))
self.assertTrue(CourseRole("role", uppercase_loc).has_user(uppercase_user))