Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions lti_consumer/lti_1p3/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,19 @@
],
}

# Context membership roles

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@shimulch We already have a role map (right above, LTI_1P3_ROLE_MAP).

For the context membership service, we don't need the "simple" version (it's just used in the Resource Link Membership Service).

Can you remove this and simplify the current implementation?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@giovannicimolin Are institution roles can be used interchangeably with context roles? IMS seems to have separate vocabulary for both of them.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@shimulch I've missed that these are context roles as opposed of institution roles. You can keep this dict as is.

# https://www.imsglobal.org/spec/lti/v1p3/#lis-vocabulary-for-context-roles
LTI_1P3_CONTEXT_ROLE_MAP = {
'staff': [
'http://purl.imsglobal.org/vocab/lis/v2/membership#Administrator',
],
'instructor': [
'http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor',
],
'student': [
'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner',
],
}

LTI_1P3_ACCESS_TOKEN_REQUIRED_CLAIMS = {
"grant_type",
Expand All @@ -49,6 +62,9 @@
'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem',
'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly',
'https://purl.imsglobal.org/spec/lti-ags/scope/score',

# LTI-NRPS Scopes
'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly',
]


Expand Down
29 changes: 29 additions & 0 deletions lti_consumer/lti_1p3/consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from .key_handlers import ToolKeyHandler, PlatformKeyHandler
from .ags import LtiAgs
from .deep_linking import LtiDeepLinking
from .nprs import LtiNrps


class LtiConsumer1p3:
Expand Down Expand Up @@ -476,6 +477,9 @@ def __init__(self, *args, **kwargs):
self.ags = None
self.dl = None

# LTI NRPS Variables
self.nrps = None

@property
def lti_ags(self):
"""
Expand All @@ -488,6 +492,18 @@ def lti_ags(self):

return self.ags

@property
def lti_nrps(self):

@nedbat nedbat May 25, 2021

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is unusual to have a property just to raise an exception if the attribute is None. Does it simplify much code elsewhere? And the attribute is "nrps", so not private to the class?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self.nrps will be only set when LTI NRPS is enabled. Calling lti_nrps will result in an error if LTI NRPS is not enabled. Also,nrps is not supposed to be used directly but through lti_nrps.

"""
Returns LTI NRPS class or throw exception if not set up.
"""
if not self.nrps:
raise exceptions.LtiNrpsServiceNotSetUp(
"The LTI NRPS service was not set up for this consumer."
)

return self.nrps

def enable_ags(
self,
lineitems_url,
Expand Down Expand Up @@ -623,3 +639,16 @@ def set_dl_content_launch_parameters(

if custom:
self.set_custom_parameters(custom)

def enable_nrps(self, context_memberships_url):
"""
Enable LTI Names and Role Provisioning Service.

This will include the LTI NRPS Claim in the LTI message
and set up the required class.
"""

self.nrps = LtiNrps(context_memberships_url)

# Include LTI NRPS claim inside the LTI Launch message
self.set_extra_claim(self.nrps.get_lti_nrps_launch_claim())
4 changes: 4 additions & 0 deletions lti_consumer/lti_1p3/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,5 +57,9 @@ class LtiAdvantageServiceNotSetUp(Lti1p3Exception):
pass


class LtiNrpsServiceNotSetUp(Lti1p3Exception):
pass


class LtiDeepLinkingContentTypeNotSupported(Lti1p3Exception):
pass
67 changes: 57 additions & 10 deletions lti_consumer/lti_1p3/extensions/rest_framework/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,36 @@
from rest_framework import permissions


class LtiAgsPermissions(permissions.BasePermission):
class LTIBasePermissions(permissions.BasePermission):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neat 😄

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/LTI/Lti/ :)

"""
Base LTI Permissions.

This checks if the token included in the request
has the allowed scopes. Allowed scopes should be
returned by ``get_permission_scopes`` method, which
should be implemented by child classes.
"""
def has_permission(self, request, view):
# Retrieves token from request, which was already checked by
# the Authentication class, so we assume it's a sane value.
auth_token = request.headers['Authorization'].split()[1]

scopes = self.get_permission_scopes(request, view)

if scopes:
return request.lti_consumer.check_token(auth_token, scopes)

return False

def get_permission_scopes(self, request, view):
"""
This method should be overriden by child classes to return
a list of allowed scopes.
"""
raise NotImplementedError


class LtiAgsPermissions(LTIBasePermissions):
"""
LTI AGS Permissions.

Expand All @@ -19,14 +48,11 @@ class LtiAgsPermissions(permissions.BasePermission):
Results: Not implemented yet.
Score: Not implemented yet.
"""
def has_permission(self, request, view):

def get_permission_scopes(self, request, view):
"""
Check if LTI AGS permissions are set in auth token.
Return LTI AGS allowed scopes.
"""
# Retrieves token from request, which was already checked by
# the Authentication class, so we assume it's a sane value.
auth_token = request.headers['Authorization'].split()[1]

scopes = []
if view.action in ['list', 'retrieve']:
# We don't need to wrap this around a try-catch because
Expand All @@ -48,7 +74,28 @@ def has_permission(self, request, view):
'https://purl.imsglobal.org/spec/lti-ags/scope/score',
]

if scopes:
return request.lti_consumer.check_token(auth_token, scopes)
return scopes

return False

class LtiNrpsContextMembershipsPermissions(LTIBasePermissions):
"""
LTI NRPS Context Memberships Permissions.

This checks if the token included in the request has the allowed scopes to read/write
the LTI NRPS Context Memberships Service.

Context Membership scopes: https://www.imsglobal.org/spec/lti-nrps/v2p0#scope-and-service-security
"""

def get_permission_scopes(self, request, view):
"""
Return LTI NRPS Context Memberships allowed scopes.
"""
scopes = []

if view.action == 'list':
scopes = [
'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly'
]

return scopes
11 changes: 11 additions & 0 deletions lti_consumer/lti_1p3/extensions/rest_framework/renderers.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,14 @@ class LineItemResultsRenderer(renderers.JSONRenderer):
"""
media_type = 'application/vnd.ims.lis.v2.resultcontainer+json'
format = 'json'


class MembershipResultRenderer(renderers.JSONRenderer):
"""
NRPS Membership Service Renderer.

It's a JSON renderer, but uses a custom media_type.
Reference: https://www.imsglobal.org/spec/lti-nrps/v2p0#membership-container-media-type
"""
media_type = 'application/vnd.ims.lti-nrps.v2.membershipcontainer+json'
format = 'json'
53 changes: 53 additions & 0 deletions lti_consumer/lti_1p3/extensions/rest_framework/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from opaque_keys.edx.keys import UsageKey

from lti_consumer.models import LtiAgsLineItem, LtiAgsScore
from lti_consumer.lti_1p3.constants import LTI_1P3_CONTEXT_ROLE_MAP


class UsageKeyField(serializers.Field):
Expand Down Expand Up @@ -374,3 +375,55 @@ class LtiDlImageSerializer(serializers.Serializer):
thumbnail = LtiDLIconPropertySerializer(required=False)
width = serializers.IntegerField(min_value=1, required=False)
height = serializers.IntegerField(min_value=1, required=False)


class LtiContextSerializer(serializers.Serializer):
"""
Serializer for a LTI Context
"""
id = UsageKeyField()


class LtiNrpsContextMemberBasicSerializer(serializers.Serializer):
"""
Non PII fields serializer for Context Member.
"""
status = serializers.CharField(default='Active')
user_id = serializers.CharField(source='external_id')
roles = serializers.SerializerMethodField()

def get_roles(self, user_info):
"""
Prepare and return Context Roles for user.
"""
roles = []
for role in user_info['roles']:
if LTI_1P3_CONTEXT_ROLE_MAP.get(role):
roles += LTI_1P3_CONTEXT_ROLE_MAP[role]
return set(roles)


class LtiNrpsContextMemberPIISerializer(LtiNrpsContextMemberBasicSerializer):
"""
Personally identifiable information serializer for Context Member.
"""
name = serializers.CharField(required=False)
email = serializers.EmailField(required=False)


# pylint: disable=abstract-method
class LtiNrpsContextMembershipBasicSerializer(serializers.Serializer):
"""
Serializer for LTI NRPS Context Memberships Endpoint Response
"""
id = serializers.CharField()
context = LtiContextSerializer()
members = LtiNrpsContextMemberBasicSerializer(many=True)


# pylint: disable=abstract-method
class LtiNrpsContextMembershipPIISerializer(LtiNrpsContextMembershipBasicSerializer):
"""
Serializer for a LTI NRPS Context Memberships Endpoint Response with PII fields
"""
members = LtiNrpsContextMemberPIISerializer(many=True)
43 changes: 43 additions & 0 deletions lti_consumer/lti_1p3/nprs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""
LTI Names and Role Provisioning Service implementation
"""


class LtiNrps:
"""
LTI NRPS Consumer

Implements Names and Role Provisioning Services and ties
them in with the LTI Consumer.

Available services:
* Context Membership Service

Reference: https://www.imsglobal.org/spec/lti-nrps/v2p0#overview
"""
def __init__(
self,
context_memberships_url,
):
self.context_memberships_url = context_memberships_url

def get_available_scopes(self):
"""
Retrieves list of available token scopes in this instance.
"""

return [
'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly'
]

def get_lti_nrps_launch_claim(self):
"""
Returns LTI NRPS Claim to be injected in the LTI launch message.
"""

return {
"https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice": {
"context_memberships_url": self.context_memberships_url,
"service_versions": ["2.0"]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@
from django.test.testcases import TestCase

from lti_consumer.lti_1p3.consumer import LtiConsumer1p3
from lti_consumer.lti_1p3.extensions.rest_framework.permissions import LtiAgsPermissions
from lti_consumer.models import LtiConfiguration
from lti_consumer.lti_1p3.extensions.rest_framework.permissions import (
LtiAgsPermissions,
LtiNrpsContextMembershipsPermissions,
)


# Variables required for testing and verification
ISS = "http://test-platform.example/"
Expand Down Expand Up @@ -244,3 +248,29 @@ def test_scores_action_permissions(self, token_scopes, is_allowed):
perm_class.has_permission(self.mock_request, mock_view),
is_allowed,
)

@ddt.data(
(["https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly"], True),
([], False),
)
@ddt.unpack
def test_nrps_membership_permissions(self, token_scopes, is_allowed):
"""
Test if LTI NRPS Context membership endpoint is availabe for correct token.
"""
perm_class = LtiNrpsContextMembershipsPermissions()

mock_view = MagicMock()

# Make token and include it in the mock request
token = self._make_token(token_scopes)
self.mock_request.headers = {
"Authorization": "Bearer {}".format(token)
}

# Test scores view
mock_view.action = 'list'
self.assertEqual(
perm_class.has_permission(self.mock_request, mock_view),
is_allowed,
)
35 changes: 35 additions & 0 deletions lti_consumer/lti_1p3/tests/test_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from lti_consumer.lti_1p3 import exceptions
from lti_consumer.lti_1p3.ags import LtiAgs
from lti_consumer.lti_1p3.nprs import LtiNrps
from lti_consumer.lti_1p3.constants import LTI_1P3_CONTEXT_TYPE
from lti_consumer.lti_1p3.consumer import LtiAdvantageConsumer, LtiConsumer1p3

Expand Down Expand Up @@ -726,3 +727,37 @@ def test_set_dl_content_launch_parameters(self):
{"test": "test"}
)
self.assertEqual(self.lti_consumer.launch_url, "example.com")

def test_no_nrps_returns_failure(self):
"""
Test that when LTI NRPS isn't configured, the class yields an error.
"""
with self.assertRaises(exceptions.LtiNrpsServiceNotSetUp):
self.lti_consumer.lti_nrps # pylint: disable=pointless-statement

def test_enable_nrps(self):
"""
Test enabling LTI NRPS and checking that required parameters are set.
"""
self.lti_consumer.enable_nrps("http://example.com/20/membership")

# Check that the NRPS class was properly instanced and set
self.assertIsInstance(self.lti_consumer.nrps, LtiNrps)

# Check retrieving class works
lti_nrps_class = self.lti_consumer.lti_nrps
self.assertEqual(self.lti_consumer.nrps, lti_nrps_class)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this testing an internal detail of the class? Is "nrps" private or not?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nrps is not supposed to be used directly but through lti_nrps. This tests if lti_nrps returns the same object as nrps attribute.


# Check that enabling the NRPS adds the LTI NRPS claim
# in the launch message
self.assertEqual(
self.lti_consumer.extra_claims,
{
"https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice": {
"context_memberships_url": "http://example.com/20/membership",
"service_versions": [
"2.0"
]
}
}
)
Loading