access.py 24.7 KB
Newer Older
1 2 3 4
"""This file contains (or should), all access control logic for the courseware.
Ideally, it will be the only place that needs to know about any special settings
like DISABLE_START_DATES"""
import logging
5
from datetime import datetime, timedelta
6
import pytz
7 8

from django.conf import settings
Ned Batchelder committed
9
from django.contrib.auth.models import AnonymousUser
10

11 12 13
from xmodule.course_module import (
    CourseDescriptor, CATALOG_VISIBILITY_CATALOG_AND_ABOUT,
    CATALOG_VISIBILITY_ABOUT)
14
from xmodule.error_module import ErrorDescriptor
15
from xmodule.x_module import XModule
16
from xmodule.split_test_module import get_split_user_partitions
17 18

from xblock.core import XBlock
19
from xmodule.partitions.partitions import NoSuchUserPartitionError, NoSuchUserPartitionGroupError
20

21
from external_auth.models import ExternalAuthMap
22
from courseware.masquerade import get_masquerade_role, is_masquerading_as_student
23
from django.utils.timezone import UTC
24
from student import auth
25
from student.roles import (
26 27 28
    GlobalStaff, CourseStaffRole, CourseInstructorRole,
    OrgStaffRole, OrgInstructorRole, CourseBetaTesterRole
)
Julia Hansbrough committed
29
from student.models import CourseEnrollment, CourseEnrollmentAllowed
30
from opaque_keys.edx.keys import CourseKey, UsageKey
31
from util.milestones_helpers import get_pre_requisite_courses_not_completed
32
DEBUG_ACCESS = False
33 34 35

log = logging.getLogger(__name__)

36

37 38 39 40 41
def debug(*args, **kwargs):
    # to avoid overly verbose output, this is off by default
    if DEBUG_ACCESS:
        log.debug(*args, **kwargs)

42

43
def has_access(user, action, obj, course_key=None):
44 45 46 47 48 49
    """
    Check whether a user has the access to do action on obj.  Handles any magic
    switching based on various settings.

    Things this module understands:
    - start dates for modules
50
    - visible_to_staff_only for modules
51
    - DISABLE_START_DATES
52
    - different access for instructor, staff, course staff, and students.
53
    - mobile_available flag for course modules
54

55 56
    user: a Django user object. May be anonymous. If none is passed,
                    anonymous is assumed
57

58 59
    obj: The object to check access for.  A module, descriptor, location, or
                    certain special strings (e.g. 'global')
60 61 62 63 64 65

    action: A string specifying the action that the client is trying to perform.

    actions depend on the obj type, but include e.g. 'enroll' for courses.  See the
    type-specific functions below for the known actions for that type.

66
    course_key: A course_key specifying which course run this access is for.
67 68 69
        Required when accessing anything other than a CourseDescriptor, 'global',
        or a location with category 'course'

70 71 72
    Returns a bool.  It is up to the caller to actually deny access in a way
    that makes sense in context.
    """
73 74 75 76
    # Just in case user is passed in as None, make them anonymous
    if not user:
        user = AnonymousUser()

77 78 79
    # delegate the work to type-specific functions.
    # (start with more specific types, then get more general)
    if isinstance(obj, CourseDescriptor):
80
        return _has_access_course_desc(user, action, obj)
81

82
    if isinstance(obj, ErrorDescriptor):
83
        return _has_access_error_desc(user, action, obj, course_key)
84

85
    if isinstance(obj, XModule):
86
        return _has_access_xmodule(user, action, obj, course_key)
87

88 89
    # NOTE: any descriptor access checkers need to go above this
    if isinstance(obj, XBlock):
90
        return _has_access_descriptor(user, action, obj, course_key)
91

92 93 94
    if isinstance(obj, CourseKey):
        return _has_access_course_key(user, action, obj)

95
    if isinstance(obj, UsageKey):
96
        return _has_access_location(user, action, obj, course_key)
97

98
    if isinstance(obj, basestring):
stv committed
99
        return _has_access_string(user, action, obj)
100

101 102
    # Passing an unknown object here is a coding error, so rather than
    # returning a default, complain.
103
    raise TypeError("Unknown object type in has_access(): '{0}'"
104 105
                    .format(type(obj)))

106 107

# ================ Implementation helpers ================================
108
def _has_access_course_desc(user, action, course):
109 110 111 112 113 114
    """
    Check if user has access to a course descriptor.

    Valid actions:

    'load' -- load the courseware, see inside the course
115
    'load_forum' -- can load and contribute to the forums (one access level for now)
116 117
    'load_mobile' -- can load from a mobile context
    'load_mobile_no_enrollment_check' -- can load from a mobile context without checking for enrollment
118 119 120 121
    'enroll' -- enroll.  Checks for enrollment window,
                  ACCESS_REQUIRE_STAFF_FOR_COURSE,
    'see_exists' -- can see that the course exists.
    'staff' -- staff access to course.
122 123
    'see_in_catalog' -- user is able to see the course listed in the course catalog.
    'see_about_page' -- user is able to see the course about page.
124 125
    """
    def can_load():
Calen Pennington committed
126 127
        """
        Can this user load this course?
128 129

        NOTE: this is not checking whether user is actually enrolled in the course.
Calen Pennington committed
130
        """
131
        # delegate to generic descriptor check to check start dates
132
        return _has_access_descriptor(user, 'load', course, course.id)
133

134 135 136 137
    def can_load_forum():
        """
        Can this user access the forums in this course?
        """
138 139 140 141 142 143 144
        return (
            can_load() and
            (
                CourseEnrollment.is_enrolled(user, course.id) or
                _has_staff_access_to_descriptor(user, course, course.id)
            )
        )
145

146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168
    def can_load_mobile():
        """
        Can this user access this course from a mobile device?
        """
        return (
            # check mobile requirements
            can_load_mobile_no_enroll_check() and
            # check enrollment
            (
                CourseEnrollment.is_enrolled(user, course.id) or
                _has_staff_access_to_descriptor(user, course, course.id)
            )
        )

    def can_load_mobile_no_enroll_check():
        """
        Can this enrolled user access this course from a mobile device?
        Note: does not check for enrollment since it is assumed the caller has done so.
        """
        return (
            # check start date
            can_load() and
            # check mobile_available flag
169
            is_mobile_available_for_user(user, course)
170 171
        )

172 173
    def can_enroll():
        """
174 175
        First check if restriction of enrollment by login method is enabled, both
            globally and by the course.
Calen Pennington committed
176
        If it is, then the user must pass the criterion set by the course, e.g. that ExternalAuthMap
177 178 179
            was set by 'shib:https://idp.stanford.edu/", in addition to requirements below.
        Rest of requirements:
        (CourseEnrollmentAllowed always overrides)
180
          or
181
        (staff can always enroll)
182 183 184
          or
        Enrollment can only happen in the course enrollment period, if one exists, and
        course is not invitation only.
185
        """
186

187
        # if using registration method to restrict (say shibboleth)
188
        if settings.FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD') and course.enrollment_domain:
189
            if user is not None and user.is_authenticated() and \
stv committed
190
                    ExternalAuthMap.objects.filter(user=user, external_domain=course.enrollment_domain):
191 192 193 194 195
                debug("Allow: external_auth of " + course.enrollment_domain)
                reg_method_ok = True
            else:
                reg_method_ok = False
        else:
196
            reg_method_ok = True  # if not using this access check, it's always OK.
197

198
        now = datetime.now(UTC())
199 200
        start = course.enrollment_start or datetime.min.replace(tzinfo=pytz.UTC)
        end = course.enrollment_end or datetime.max.replace(tzinfo=pytz.UTC)
201

202 203 204 205
        # if user is in CourseEnrollmentAllowed with right course key then can also enroll
        # (note that course.id actually points to a CourseKey)
        # (the filter call uses course_id= since that's the legacy database schema)
        # (sorry that it's confusing :( )
206
        if user is not None and user.is_authenticated() and CourseEnrollmentAllowed:
207 208
            if CourseEnrollmentAllowed.objects.filter(email=user.email, course_id=course.id):
                return True
209

210 211 212 213 214 215 216 217 218 219 220
        if _has_staff_access_to_descriptor(user, course, course.id):
            return True

        # Invitation_only doesn't apply to CourseEnrollmentAllowed or has_staff_access_access
        if course.invitation_only:
            debug("Deny: invitation only")
            return False

        if reg_method_ok and start < now < end:
            debug("Allow: in enrollment period")
            return True
221 222 223 224 225 226 227 228 229 230 231 232 233

    def see_exists():
        """
        Can see if can enroll, but also if can load it: if user enrolled in a course and now
        it's past the enrollment period, they should still see it.

        TODO (vshnayder): This means that courses with limited enrollment periods will not appear
        to non-staff visitors after the enrollment period is over.  If this is not what we want, will
        need to change this logic.
        """
        # VS[compat] -- this setting should go away once all courses have
        # properly configured enrollment_start times (if course should be
        # staff-only, set enrollment_start far in the future.)
234
        if settings.FEATURES.get('ACCESS_REQUIRE_STAFF_FOR_COURSE'):
235 236
            # if this feature is on, only allow courses that have ispublic set to be
            # seen by non-staff
Calen Pennington committed
237
            if course.ispublic:
238
                debug("Allow: ACCESS_REQUIRE_STAFF_FOR_COURSE and ispublic")
239
                return True
240
            return _has_staff_access_to_descriptor(user, course, course.id)
241 242 243

        return can_enroll() or can_load()

244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266
    def can_see_in_catalog():
        """
        Implements the "can see course in catalog" logic if a course should be visible in the main course catalog
        In this case we use the catalog_visibility property on the course descriptor
        but also allow course staff to see this.
        """
        return (
            course.catalog_visibility == CATALOG_VISIBILITY_CATALOG_AND_ABOUT or
            _has_staff_access_to_descriptor(user, course, course.id)
        )

    def can_see_about_page():
        """
        Implements the "can see course about page" logic if a course about page should be visible
        In this case we use the catalog_visibility property on the course descriptor
        but also allow course staff to see this.
        """
        return (
            course.catalog_visibility == CATALOG_VISIBILITY_CATALOG_AND_ABOUT or
            course.catalog_visibility == CATALOG_VISIBILITY_ABOUT or
            _has_staff_access_to_descriptor(user, course, course.id)
        )

267 268 269 270 271 272 273 274 275 276 277 278 279 280 281
    def can_view_courseware_with_prerequisites():  # pylint: disable=invalid-name
        """
        Checks if prerequisite courses feature is enabled and course has prerequisites
        and user is neither staff nor anonymous then it returns False if user has not
        passed prerequisite courses otherwise return True.
        """
        if settings.FEATURES['ENABLE_PREREQUISITE_COURSES'] \
                and not _has_staff_access_to_descriptor(user, course, course.id) \
                and course.pre_requisite_courses \
                and not user.is_anonymous() \
                and get_pre_requisite_courses_not_completed(user, [course.id]):
            return False
        else:
            return True

282 283
    checkers = {
        'load': can_load,
284
        'view_courseware_with_prerequisites': can_view_courseware_with_prerequisites,
285
        'load_forum': can_load_forum,
286 287
        'load_mobile': can_load_mobile,
        'load_mobile_no_enrollment_check': can_load_mobile_no_enroll_check,
288 289
        'enroll': can_enroll,
        'see_exists': see_exists,
290 291
        'staff': lambda: _has_staff_access_to_descriptor(user, course, course.id),
        'instructor': lambda: _has_instructor_access_to_descriptor(user, course, course.id),
292 293
        'see_in_catalog': can_see_in_catalog,
        'see_about_page': can_see_about_page,
294
    }
295 296 297

    return _dispatch(checkers, action, user, course)

298

299
def _has_access_error_desc(user, action, descriptor, course_key):
300 301 302 303 304 305 306 307
    """
    Only staff should see error descriptors.

    Valid actions:
    'load' -- load this descriptor, showing it to the user.
    'staff' -- staff access to descriptor.
    """
    def check_for_staff():
308
        return _has_staff_access_to_descriptor(user, descriptor, course_key)
309 310 311

    checkers = {
        'load': check_for_staff,
312 313
        'staff': check_for_staff,
        'instructor': lambda: _has_instructor_access_to_descriptor(user, descriptor, course_key)
314
    }
315 316 317 318

    return _dispatch(checkers, action, user, descriptor)


319 320 321 322 323
def _has_group_access(descriptor, user, course_key):
    """
    This function returns a boolean indicating whether or not `user` has
    sufficient group memberships to "load" a block (the `descriptor`)
    """
324 325 326 327
    if len(descriptor.user_partitions) == len(get_split_user_partitions(descriptor.user_partitions)):
        # Short-circuit the process, since there are no defined user partitions that are not
        # user_partitions used by the split_test module. The split_test module handles its own access
        # via updating the children of the split_test module.
328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376
        return True

    # use merged_group_access which takes group access on the block's
    # parents / ancestors into account
    merged_access = descriptor.merged_group_access
    # check for False in merged_access, which indicates that at least one
    # partition's group list excludes all students.
    if False in merged_access.values():
        log.warning("Group access check excludes all students, access will be denied.", exc_info=True)
        return False

    # resolve the partition IDs in group_access to actual
    # partition objects, skipping those which contain empty group directives.
    # if a referenced partition could not be found, access will be denied.
    try:
        partitions = [
            descriptor._get_user_partition(partition_id)  # pylint:disable=protected-access
            for partition_id, group_ids in merged_access.items()
            if group_ids is not None
        ]
    except NoSuchUserPartitionError:
        log.warning("Error looking up user partition, access will be denied.", exc_info=True)
        return False

    # next resolve the group IDs specified within each partition
    partition_groups = []
    try:
        for partition in partitions:
            groups = [
                partition.get_group(group_id)
                for group_id in merged_access[partition.id]
            ]
            if groups:
                partition_groups.append((partition, groups))
    except NoSuchUserPartitionGroupError:
        log.warning("Error looking up referenced user partition group, access will be denied.", exc_info=True)
        return False

    # look up the user's group for each partition
    user_groups = {}
    for partition, groups in partition_groups:
        user_groups[partition.id] = partition.scheme.get_group_for_user(
            course_key,
            user,
            partition,
        )

    # finally: check that the user has a satisfactory group assignment
    # for each partition.
Andy Armstrong committed
377
    if not all(user_groups.get(partition.id) in groups for partition, groups in partition_groups):
378 379 380 381 382 383
        return False

    # all checks passed.
    return True


384
def _has_access_descriptor(user, action, descriptor, course_key=None):
385 386 387 388 389 390 391 392 393 394 395 396
    """
    Check if user has access to this descriptor.

    Valid actions:
    'load' -- load this descriptor, showing it to the user.
    'staff' -- staff access to descriptor.

    NOTE: This is the fallback logic for descriptors that don't have custom policy
    (e.g. courses).  If you call this method directly instead of going through
    has_access(), it will not do the right thing.
    """
    def can_load():
397 398 399 400 401 402
        """
        NOTE: This does not check that the student is enrolled in the course
        that contains this module.  We may or may not want to allow non-enrolled
        students to see modules.  If not, views should check the course, so we
        don't have to hit the enrollments table on every module load.
        """
403 404 405
        if descriptor.visible_to_staff_only and not _has_staff_access_to_descriptor(user, descriptor, course_key):
            return False

406 407 408 409 410 411
        # enforce group access
        if not _has_group_access(descriptor, user, course_key):
            # if group_access check failed, deny access unless the requestor is staff,
            # in which case immediately grant access.
            return _has_staff_access_to_descriptor(user, descriptor, course_key)

412
        # If start dates are off, can always load
413
        if settings.FEATURES['DISABLE_START_DATES'] and not is_masquerading_as_student(user, course_key):
414
            debug("Allow: DISABLE_START_DATES")
415 416 417
            return True

        # Check start date
418
        if 'detached' not in descriptor._class_tags and descriptor.start is not None:
419
            now = datetime.now(UTC())
420 421 422
            effective_start = _adjust_start_date_for_beta_testers(
                user,
                descriptor,
423
                course_key=course_key
424
            )
425
            if now > effective_start:
426
                # after start date, everyone can see it
427
                debug("Allow: now > effective start date")
428 429
                return True
            # otherwise, need staff access
430
            return _has_staff_access_to_descriptor(user, descriptor, course_key)
431 432

        # No start date, so can always load.
433
        debug("Allow: no start date")
434 435 436 437
        return True

    checkers = {
        'load': can_load,
438 439 440
        'staff': lambda: _has_staff_access_to_descriptor(user, descriptor, course_key),
        'instructor': lambda: _has_instructor_access_to_descriptor(user, descriptor, course_key)
    }
441 442 443 444

    return _dispatch(checkers, action, user, descriptor)


445
def _has_access_xmodule(user, action, xmodule, course_key):
446 447 448 449 450 451 452
    """
    Check if user has access to this xmodule.

    Valid actions:
      - same as the valid actions for xmodule.descriptor
    """
    # Delegate to the descriptor
453
    return has_access(user, action, xmodule.descriptor, course_key)
454 455


456
def _has_access_location(user, action, location, course_key):
457 458 459 460 461 462 463 464 465 466 467
    """
    Check if user has access to this location.

    Valid actions:
    'staff' : True if the user has staff access to this location

    NOTE: if you add other actions, make sure that

     has_access(user, location, action) == has_access(user, get_item(location), action)
    """
    checkers = {
468 469
        'staff': lambda: _has_staff_access_to_location(user, location, course_key)
    }
470 471 472 473

    return _dispatch(checkers, action, user, location)


474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489
def _has_access_course_key(user, action, course_key):
    """
    Check if user has access to the course with this course_key

    Valid actions:
    'staff' : True if the user has staff access to this location
    'instructor' : True if the user has staff access to this location
    """
    checkers = {
        'staff': lambda: _has_staff_access_to_location(user, None, course_key),
        'instructor': lambda: _has_instructor_access_to_location(user, None, course_key),
    }

    return _dispatch(checkers, action, user, course_key)


stv committed
490
def _has_access_string(user, action, perm):
491 492 493 494 495 496 497 498 499 500 501 502 503 504
    """
    Check if user has certain special access, specified as string.  Valid strings:

    'global'

    Valid actions:

    'staff' -- global staff access.
    """

    def check_staff():
        if perm != 'global':
            debug("Deny: invalid permission '%s'", perm)
            return False
505
        return GlobalStaff().has_user(user)
506 507 508

    checkers = {
        'staff': check_staff
509
    }
510 511 512 513

    return _dispatch(checkers, action, user, perm)


514 515 516 517 518 519 520 521 522 523 524 525
#####  Internal helper methods below

def _dispatch(table, action, user, obj):
    """
    Helper: call table[action], raising a nice pretty error if there is no such key.

    user and object passed in only for error messages and debugging
    """
    if action in table:
        result = table[action]()
        debug("%s user %s, object %s, action %s",
              'ALLOWED' if result else 'DENIED',
526
              user,
527
              obj.location.to_deprecated_string() if isinstance(obj, XBlock) else str(obj),
528
              action)
529 530
        return result

531
    raise ValueError(u"Unknown action for object type '{0}': '{1}'".format(
532 533
        type(obj), action))

534

535
def _adjust_start_date_for_beta_testers(user, descriptor, course_key=None):  # pylint: disable=invalid-name
536 537 538 539 540 541 542 543 544 545
    """
    If user is in a beta test group, adjust the start date by the appropriate number of
    days.

    Arguments:
       user: A django user.  May be anonymous.
       descriptor: the XModuleDescriptor the user is trying to get access to, with a
       non-None start date.

    Returns:
546
        A datetime.  Either the same as start, or earlier for beta testers.
547 548 549 550 551 552 553

    NOTE: number of days to adjust should be cached to avoid looking it up thousands of
    times per query.

    NOTE: For now, this function assumes that the descriptor's location is in the course
    the user is looking at.  Once we have proper usages and definitions per the XBlock
    design, this should use the course the usage is in.
554

555
    NOTE: If testing manually, make sure FEATURES['DISABLE_START_DATES'] = False
556
    in envs/dev.py!
557
    """
Calen Pennington committed
558
    if descriptor.days_early_for_beta is None:
559
        # bail early if no beta testing is set up
Calen Pennington committed
560
        return descriptor.start
561

562
    if CourseBetaTesterRole(course_key).has_user(user):
563
        debug("Adjust start time: user in beta role for %s", descriptor)
Calen Pennington committed
564 565
        delta = timedelta(descriptor.days_early_for_beta)
        effective = descriptor.start - delta
566
        return effective
567

Calen Pennington committed
568
    return descriptor.start
569

Calen Pennington committed
570

571 572 573 574
def _has_instructor_access_to_location(user, location, course_key=None):
    if course_key is None:
        course_key = location.course_key
    return _has_access_to_course(user, 'instructor', course_key)
575 576


577
def _has_staff_access_to_location(user, location, course_key=None):
578 579 580
    if course_key is None:
        course_key = location.course_key
    return _has_access_to_course(user, 'staff', course_key)
581

582

583
def _has_access_to_course(user, access_level, course_key):
584 585
    '''
    Returns True if the given user has access_level (= staff or
586 587 588
    instructor) access to the course with the given course_key.
    This ensures the user is authenticated and checks if global staff or has
    staff / instructor access.
589 590

    access_level = string, either "staff" or "instructor"
591 592
    '''
    if user is None or (not user.is_authenticated()):
593
        debug("Deny: no user or anon user")
594
        return False
595

596
    if is_masquerading_as_student(user, course_key):
597 598
        return False

599
    if GlobalStaff().has_user(user):
600
        debug("Allow: user.is_staff")
601 602
        return True

603
    if access_level not in ('staff', 'instructor'):
604
        log.debug("Error in access._has_access_to_course access_level=%s unknown", access_level)
605 606
        debug("Deny: unknown access level")
        return False
607

608
    staff_access = (
609 610
        CourseStaffRole(course_key).has_user(user) or
        OrgStaffRole(course_key.org).has_user(user)
611 612 613 614 615 616 617
    )

    if staff_access and access_level == 'staff':
        debug("Allow: user has course staff access")
        return True

    instructor_access = (
618 619
        CourseInstructorRole(course_key).has_user(user) or
        OrgInstructorRole(course_key.org).has_user(user)
620 621 622 623 624 625 626
    )

    if instructor_access and access_level in ('staff', 'instructor'):
        debug("Allow: user has course instructor access")
        return True

    debug("Deny: user did not have correct access")
627 628
    return False

629

630
def _has_instructor_access_to_descriptor(user, descriptor, course_key):  # pylint: disable=invalid-name
631 632 633 634 635
    """Helper method that checks whether the user has staff access to
    the course of the location.

    descriptor: something that has a location attribute
    """
636
    return _has_instructor_access_to_location(user, descriptor.location, course_key)
637

638

639
def _has_staff_access_to_descriptor(user, descriptor, course_key):
640 641 642
    """Helper method that checks whether the user has staff access to
    the course of the location.

643
    descriptor: something that has a location attribute
644
    """
645
    return _has_staff_access_to_location(user, descriptor.location, course_key)
646 647


648 649 650 651 652 653 654 655 656 657 658 659 660 661
def is_mobile_available_for_user(user, course):
    """
    Returns whether the given course is mobile_available for the given user.
    Checks:
        mobile_available flag on the course
        Beta User and staff access overrides the mobile_available flag
    """
    return (
        course.mobile_available or
        auth.has_access(user, CourseBetaTesterRole(course.id)) or
        _has_staff_access_to_descriptor(user, course, course.id)
    )


662
def get_user_role(user, course_key):
663 664 665 666
    """
    Return corresponding string if user has staff, instructor or student
    course role in LMS.
    """
667 668 669
    role = get_masquerade_role(user, course_key)
    if role:
        return role
670
    elif has_access(user, 'instructor', course_key):
671
        return 'instructor'
672
    elif has_access(user, 'staff', course_key):
673 674 675
        return 'staff'
    else:
        return 'student'