Skip to content

Commit 75ae689

Browse files
author
Jim Abramson
committed
Merge pull request #94 from edx/jsa/ecom-3978
Fix model permissions for UserCredential.
2 parents 5fa592a + de9cd9c commit 75ae689

File tree

6 files changed

+264
-11
lines changed

6 files changed

+264
-11
lines changed

credentials/apps/api/permissions.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""
2+
Custom permissions classes for use with DRF.
3+
"""
4+
from django.http import Http404
5+
from rest_framework import permissions
6+
7+
8+
class UserCredentialViewSetPermissions(permissions.DjangoModelPermissions):
9+
"""
10+
Custom extension to DjangoModelPermissions for use with the
11+
UserCredentialViewSet.
12+
13+
This permissions class uses an explicit 'view' permission, enabling support
14+
for:
15+
- explicitly granting certain users read access to any UserCredential
16+
- implicitly granting any user read access to UserCredentials that were
17+
awarded specifically to them
18+
- denying read access (obscured by HTTP 404) in any other case
19+
20+
NOTE: users are allowed to read their own UserCredential records regardless
21+
of their 'status' (i.e. even if revoked).
22+
23+
WARNING: this permissions implementation does not cover the 'list' method
24+
of access. The access control required under DRF for that use case is
25+
presently implemented in the `list` method of the viewset itself.
26+
"""
27+
28+
# refer to the super() for more context on what this override is doing.
29+
perms_map = permissions.DjangoModelPermissions.perms_map
30+
perms_map.update({method: ['%(app_label)s.view_%(model_name)s'] for method in permissions.SAFE_METHODS})
31+
32+
def has_permission(self, request, view):
33+
"""
34+
Relax the base's view-level permissions in the case of 'safe'
35+
(read-only) methods, requiring only that the user be authenticated.
36+
37+
This lets us delay deciding whether or not read permission should be
38+
implicitly granted, until after DRF has fetched the requested object.
39+
"""
40+
return super(UserCredentialViewSetPermissions, self).has_permission(request, view) or (
41+
request.user.is_authenticated() and request.method in permissions.SAFE_METHODS
42+
)
43+
44+
def has_object_permission(self, request, view, obj):
45+
"""
46+
Allow access to specific objects when granted explicitly (via model
47+
permissions) or, if a read-only request, implicitly (via matching
48+
username).
49+
"""
50+
if super(UserCredentialViewSetPermissions, self).has_permission(request, view):
51+
return True
52+
elif request.user.username.lower() == obj.username.lower():
53+
return True
54+
else:
55+
raise Http404

credentials/apps/api/tests/test_views.py

Lines changed: 147 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@
55
import json
66

77
import ddt
8-
from django.contrib.auth.models import Permission
8+
from django.contrib.auth.models import Group, Permission
99
from django.core.urlresolvers import reverse
1010
from rest_framework.test import APITestCase, APIRequestFactory
1111
from testfixtures import LogCapture
1212

1313
from credentials.apps.api.serializers import UserCredentialSerializer
1414
from credentials.apps.api.tests import factories
15+
from credentials.apps.core.constants import Role
1516
from credentials.apps.credentials.models import UserCredential
1617

1718

@@ -40,6 +41,11 @@ def setUp(self):
4041
self.username = "test_user"
4142
self.request = APIRequestFactory().get('/')
4243

44+
def _add_permission(self, perm):
45+
""" DRY helper to add usercredential model permissions to self.user """
46+
# pylint: disable=no-member
47+
self.user.user_permissions.add(Permission.objects.get(codename='{}_usercredential'.format(perm)))
48+
4349
def _attempt_update_user_credential(self, data):
4450
""" Helper method that attempts to patch an existing credential object.
4551
@@ -50,13 +56,13 @@ def _attempt_update_user_credential(self, data):
5056
Response: HTTP response from the API.
5157
"""
5258
# pylint: disable=no-member
53-
self.user.user_permissions.add(Permission.objects.get(codename="change_usercredential"))
59+
self._add_permission('change')
5460
path = reverse("api:v1:usercredential-detail", args=[self.user_credential.id])
5561
return self.client.patch(path=path, data=json.dumps(data), content_type=JSON_CONTENT_TYPE)
5662

5763
def test_get(self):
5864
""" Verify a single user credential is returned. """
59-
65+
self._add_permission('view')
6066
path = reverse("api:v1:usercredential-detail", args=[self.user_credential.id])
6167
response = self.client.get(path)
6268
self.assertEqual(response.status_code, 200)
@@ -115,7 +121,7 @@ def _attempt_create_user_credentials(self, data):
115121
Response: HTTP response from the API.
116122
"""
117123
# pylint: disable=no-member
118-
self.user.user_permissions.add(Permission.objects.get(codename="add_usercredential"))
124+
self._add_permission('add')
119125
path = self.list_path
120126
return self.client.post(path=path, data=json.dumps(data), content_type=JSON_CONTENT_TYPE)
121127

@@ -268,6 +274,7 @@ def test_create_with_empty_attributes(self):
268274

269275
def test_list_with_username_filter(self):
270276
""" Verify the list endpoint supports filter data by username."""
277+
self._add_permission('view')
271278
factories.UserCredentialFactory(username="dummy-user")
272279
response = self.client.get(self.list_path, data={'username': self.user_credential.username})
273280
self.assertEqual(response.status_code, 200)
@@ -281,6 +288,7 @@ def test_list_with_username_filter(self):
281288

282289
def test_list_with_status_filter(self):
283290
""" Verify the list endpoint supports filtering by status."""
291+
self._add_permission('view')
284292
factories.UserCredentialFactory.create_batch(2, status="revoked", username=self.user_credential.username)
285293
response = self.client.get(self.list_path, data={'status': self.user_credential.status})
286294
self.assertEqual(response.status_code, 400)
@@ -441,6 +449,122 @@ def test_users_lists_access_by_authenticated_users(self):
441449
self.assertEqual(response.status_code, 401)
442450

443451

452+
@ddt.ddt
453+
class UserCredentialViewSetPermissionsTests(APITestCase):
454+
"""
455+
Thoroughly exercise the custom view- and object-level permissions for this viewset.
456+
"""
457+
458+
def make_user(self, group=None, perm=None, **kwargs):
459+
""" DRY helper to create users with specific groups and/or permissions. """
460+
# pylint: disable=no-member
461+
user = factories.UserFactory(**kwargs)
462+
if group:
463+
user.groups.add(Group.objects.get(name=group))
464+
if perm:
465+
user.user_permissions.add(Permission.objects.get(codename='{}_usercredential'.format(perm)))
466+
return user
467+
468+
@ddt.data(
469+
({'group': Role.ADMINS}, 200),
470+
({'perm': 'view'}, 200),
471+
({'perm': 'add'}, 404),
472+
({'perm': 'change'}, 404),
473+
({'username': 'test-user'}, 200),
474+
({'username': 'TeSt-uSeR'}, 200),
475+
({'username': 'other'}, 404),
476+
)
477+
@ddt.unpack
478+
def test_list(self, user_kwargs, expected_status):
479+
"""
480+
The list method (GET) requires either 'view' permission, or for the
481+
'username' query parameter to match that of the requesting user.
482+
"""
483+
list_path = reverse("api:v1:usercredential-list")
484+
485+
self.client.force_authenticate(self.make_user(**user_kwargs)) # pylint: disable=no-member
486+
response = self.client.get(list_path, {'username': 'test-user'})
487+
self.assertEqual(response.status_code, expected_status)
488+
489+
@ddt.data(
490+
({'group': Role.ADMINS}, 201),
491+
({'perm': 'add'}, 201),
492+
({'perm': 'view'}, 403),
493+
({'perm': 'change'}, 403),
494+
({}, 403),
495+
({'username': 'test-user'}, 403),
496+
)
497+
@ddt.unpack
498+
def test_create(self, user_kwargs, expected_status):
499+
"""
500+
The creation (POST) method requires the 'add' permission.
501+
"""
502+
list_path = reverse('api:v1:usercredential-list')
503+
program_certificate = factories.ProgramCertificateFactory()
504+
post_data = {
505+
'username': 'test-user',
506+
'credential': {
507+
'program_id': program_certificate.program_id
508+
},
509+
'attributes': [],
510+
}
511+
512+
self.client.force_authenticate(self.make_user(**user_kwargs)) # pylint: disable=no-member
513+
response = self.client.post(list_path, data=json.dumps(post_data), content_type=JSON_CONTENT_TYPE)
514+
self.assertEqual(response.status_code, expected_status)
515+
516+
@ddt.data(
517+
({'group': Role.ADMINS}, 200),
518+
({'perm': 'view'}, 200),
519+
({'perm': 'add'}, 404),
520+
({'perm': 'change'}, 404),
521+
({'username': 'test-user'}, 200),
522+
({'username': 'TeSt-uSeR'}, 200),
523+
({'username': 'other-user'}, 404),
524+
)
525+
@ddt.unpack
526+
def test_retrieve(self, user_kwargs, expected_status):
527+
"""
528+
The retrieve (GET) method requires the 'view' permission, or for the
529+
requested object to be associated with the username of the requesting
530+
user.
531+
"""
532+
program_cert = factories.ProgramCertificateFactory()
533+
user_credential = factories.UserCredentialFactory.create(credential=program_cert, username='test-user')
534+
detail_path = reverse("api:v1:usercredential-detail", args=[user_credential.id])
535+
536+
self.client.force_authenticate(self.make_user(**user_kwargs)) # pylint: disable=no-member
537+
response = self.client.get(detail_path)
538+
self.assertEqual(response.status_code, expected_status)
539+
540+
@ddt.data(
541+
({'group': Role.ADMINS}, 200),
542+
({'perm': 'view'}, 403),
543+
({'perm': 'add'}, 403),
544+
({'perm': 'change'}, 200),
545+
({'username': 'test-user'}, 403),
546+
({}, 403),
547+
)
548+
@ddt.unpack
549+
def test_partial_update(self, user_kwargs, expected_status):
550+
"""
551+
The partial update (PATCH) method requires the 'change' permission.
552+
"""
553+
program_cert = factories.ProgramCertificateFactory()
554+
user_credential = factories.UserCredentialFactory.create(credential=program_cert, username='test-user')
555+
detail_path = reverse("api:v1:usercredential-detail", args=[user_credential.id])
556+
post_data = {
557+
'username': 'test-user',
558+
'credential': {
559+
'program_id': program_cert.program_id
560+
},
561+
'attributes': [{'name': 'dummy-attr-name', 'value': 'dummy-attr-value'}],
562+
}
563+
self.client.force_authenticate(self.make_user(**user_kwargs)) # pylint: disable=no-member
564+
response = self.client.patch(path=detail_path, data=json.dumps(post_data), content_type=JSON_CONTENT_TYPE)
565+
self.assertEqual(response.status_code, expected_status)
566+
567+
444568
class CredentialViewSetTests(APITestCase):
445569
""" Base Class for ProgramCredentialViewSetTests and CourseCredentialViewSetTests. """
446570

@@ -450,10 +574,21 @@ class CredentialViewSetTests(APITestCase):
450574
def setUp(self):
451575
super(CredentialViewSetTests, self).setUp()
452576

577+
# pylint: disable=no-member
453578
self.user = factories.UserFactory()
579+
self.user.groups.add(Group.objects.get(name=Role.ADMINS))
454580
self.client.force_authenticate(self.user) # pylint: disable=no-member
455581
self.request = APIRequestFactory().get('/')
456582

583+
def assert_permission_required(self, data):
584+
"""
585+
Ensure access to these APIs is restricted to those with explicit model
586+
permissions.
587+
"""
588+
self.client.force_authenticate(user=factories.UserFactory()) # pylint: disable=no-member
589+
response = self.client.get(self.list_path, data)
590+
self.assertEqual(response.status_code, 403)
591+
457592
def assert_list_without_id_filter(self, path, expected):
458593
"""Helper method used for making request and assertions. """
459594
response = self.client.get(path)
@@ -508,6 +643,10 @@ def test_list_with_status_filter(self):
508643
factories.UserCredentialFactory.create_batch(2, status="revoked", username=self.user_credential.username)
509644
self.assert_list_with_status_filter(data={'program_id': self.program_id, 'status': UserCredential.AWARDED}, )
510645

646+
def test_permission_required(self):
647+
""" Verify that requests require explicit model permissions. """
648+
self.assert_permission_required({'program_id': self.program_id, 'status': UserCredential.AWARDED})
649+
511650

512651
class CourseCredentialViewSetTests(CredentialViewSetTests):
513652
""" Tests for CourseCredentialViewSetTests. """
@@ -555,3 +694,7 @@ def test_list_with_certificate_type(self):
555694
json.loads(response.content),
556695
{'count': 1, 'next': None, 'previous': None, 'results': [expected]}
557696
)
697+
698+
def test_permission_required(self):
699+
""" Verify that requests require explicit model permissions. """
700+
self.assert_permission_required({'course_id': self.course_id, 'status': UserCredential.AWARDED})

credentials/apps/api/v1/views.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@
33
"""
44
import logging
55

6-
from rest_framework import filters, mixins, viewsets
6+
from django.http import Http404
7+
from rest_framework import mixins, viewsets
78
from rest_framework.exceptions import ValidationError
8-
from rest_framework.permissions import DjangoModelPermissions
99
from credentials.apps.api.filters import ProgramFilter, CourseFilter
1010

11+
from credentials.apps.api.permissions import UserCredentialViewSetPermissions
1112
from credentials.apps.api.serializers import UserCredentialCreationSerializer, UserCredentialSerializer
1213
from credentials.apps.credentials.models import UserCredential
1314

@@ -19,16 +20,22 @@ class UserCredentialViewSet(viewsets.ModelViewSet):
1920
""" UserCredentials endpoints. """
2021

2122
queryset = UserCredential.objects.all()
22-
filter_backends = (filters.DjangoFilterBackend,)
2323
filter_fields = ('username', 'status')
2424
serializer_class = UserCredentialSerializer
25-
permission_classes = (DjangoModelPermissions,)
25+
permission_classes = (UserCredentialViewSetPermissions,)
2626

2727
def list(self, request, *args, **kwargs):
28-
if not self.request.query_params.get('username'):
28+
if not request.query_params.get('username'):
2929
raise ValidationError(
3030
{'error': 'A username query string parameter is required for filtering user credentials.'})
3131

32+
# provide an additional permission check related to the username
33+
# query string parameter. See also `UserCredentialViewSetPermissions`
34+
if not request.user.has_perm('credentials.view_usercredential') and (
35+
request.user.username.lower() != request.query_params['username'].lower()
36+
):
37+
raise Http404
38+
3239
return super(UserCredentialViewSet, self).list(request, *args, **kwargs) # pylint: disable=maybe-no-member
3340

3441
def create(self, request, *args, **kwargs):
@@ -39,7 +46,6 @@ def create(self, request, *args, **kwargs):
3946
class ProgramsCredentialsViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
4047
"""It will return the all credentials for programs."""
4148
queryset = UserCredential.objects.all()
42-
filter_backends = (filters.DjangoFilterBackend,)
4349
filter_class = ProgramFilter
4450
serializer_class = UserCredentialSerializer
4551

@@ -55,7 +61,6 @@ def list(self, request, *args, **kwargs):
5561
class CourseCredentialsViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
5662
"""It will return the all credentials for courses."""
5763
queryset = UserCredential.objects.all()
58-
filter_backends = (filters.DjangoFilterBackend,)
5964
filter_class = CourseFilter
6065
serializer_class = UserCredentialSerializer
6166

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# -*- coding: utf-8 -*-
2+
from __future__ import unicode_literals
3+
4+
from django.contrib.auth.models import Group, Permission
5+
from django.contrib.contenttypes.models import ContentType
6+
from django.core.exceptions import ObjectDoesNotExist
7+
from django.db import migrations
8+
9+
from credentials.apps.core.constants import Role
10+
11+
12+
def create_view_permission(apps, schema_editor):
13+
"""
14+
Add an explicit view permission for UserCredential, and associate it with
15+
the ADMIN role.
16+
"""
17+
content_type = ContentType.objects.get(app_label="credentials", model="usercredential")
18+
permission, created = Permission.objects.get_or_create(
19+
content_type=content_type,
20+
codename='view_usercredential',
21+
name='Can view any user credential',
22+
)
23+
if created:
24+
Group.objects.get(name=Role.ADMINS).permissions.add(permission)
25+
26+
27+
def destroy_view_permission(apps, schema_editor):
28+
"""
29+
Remove the view permission, if it exists. Note that the permission will
30+
automatically be removed from any roles to which it had been linked.
31+
"""
32+
try:
33+
content_type = ContentType.objects.get(app_label='credentials', model='usercredential')
34+
permission = Permission.objects.get(content_type=content_type, codename='view_usercredential')
35+
permission.delete()
36+
except ObjectDoesNotExist:
37+
pass
38+
39+
40+
class Migration(migrations.Migration):
41+
42+
dependencies = [
43+
('core', '0002_auto_20160111_1251'),
44+
]
45+
46+
operations = [
47+
migrations.RunPython(code=create_view_permission, reverse_code=destroy_view_permission),
48+
]

0 commit comments

Comments
 (0)