Developer Platform (September 2019)

Investigating role permissions

«  Fetch final grade values for an instructor's classes   ·  [   home  ·   reference  ·   community   ·  search   ·  index   ·  routing table   ·  scopes table   ]   ·  Importing course content packages using APIs  »

Contents

D2L’s Integrated Learning Platform’s (ILP) role-based permission system can add complexity when you’re trying to use the Valence Learning Framework APIs , based as they are on an ID/Key-based authentication system that ties every API in a context that binds the action attempt with an LMS user and an organizational unit. Because the actions a user can take within the context of an organizational unit are defined by the user’s enrolled role within the org unit, simply asking “what permissions do I need to make this API call” is often not an easy question to answer. The answer depends upon what permissions the system gives your calling user (because of the user’s role) within the org unit context for the call.

This walk-through helps shed some light on this issue, and how you can investigate the implications for your projects.

Starting point. One of our clients reported that some of the calls their application was trying to make were not working. They identified these particular calls as problematic. In particular, they were getting error results with a 403 Forbidden return code that indicated a lack of authorization, and so wanted to know what permissions exactly they needed to make these calls:

Note

In these examples, we use our own sample org unit ID and user name data, not those of the client. One of the first things we checked with the client was that the org unit ID and user names that they specified in their calls actually did exist.

Investigate using a no-privileges role

While the list of permissions in the D2L ILP is expansive, the permissions are organized into groupings that correlate to groupings of API routes (and the tools or groups of functionality in the ILP’s Web UI).

Under most circumstances, the Valence Learning Framework API is built to rely upon giving appropriate functional access to real system users, and their roles, in context. In this case, because the clients were trying to make these calls with a utility account (a special user/role combination created in the service for the sole purpose of providing access to an administrative or maintenance client application), it became important to determine the exact set of permissions required to make these calls.

Accordingly, it seemed sensible to start the investigations by creating a special role with no privileges at all and a user to go with the role. (For example, our Getting Started sample employs a user built on a role with no privileges other than the right to manage the user’s own profile.)

Creating a no-privileges role and user

As a first step, then, we need to create a new noPrivileges user role in the ILP. Because we want as spare a testing footprint on the state of the back end service’s data, we’ll decide against making this new role a cascading role.

Once we’ve created the role, we’ll create a new user in the root org and assign the noPrivileges role to that user. It’s useful to manually set the password for that user so that we can immediately try a log in test to verify that the user can log in: we’ll need the password when we proceed to build a user context in any case.

Leaving this step, we should have this new state in our ILP environment:

  • A new, noPrivileges user role that has absolutely no privileges at all

  • A new testuser user, created in the root org, that has the noPrivileges role assigned in that org

Creating a user context

In order to test the API calls, we need a user context to authenticate with. Because the Python interpreter allows us to interactively work with a Python environment, we’ll work with the client library for that language. The only requirements to use the Python client library are a Python3 distribution, the D2LValence package (which you can download from our code repository, or PyPi), and Kenneth Reitz’s requests package (if you need to use some other package to do the HTTP calling, you can, but we built our Python client library to use the requests package by default so our examples will use it).

>>> import requests
>>> import d2lvalence.auth as d2lauth
>>>
>>> # Build an application context
>>> app_creds = { 'app_id': 'G9nUpvbZQyiPrk3um2YAkQ', 'app_key': 'ybZu7fm_JKJTFwKEHfoZ7Q' }
>>> ac = d2lauth.fashion_app_context(app_id=app_creds['app_id'], app_key=app_creds['app_key'])
>>>
>>> # Use the app context to build an url we can use for user authentication.
>>> auth_url = ac.create_url_for_authentication('yourLMS.edu', 'http://clientApp/landing/page')
>>> auth_url
'https://yourLMS.edu/d2l/auth/api/token?x_target=http%3A%2F%2FclientApp%2Flanding%2Fpage&x_b=MvLoaoeGN77jsFnJVvHT3lIe2PKpks6sh69NaqmLg3E&x_a=G9nUpvbZQyiPrk3um2YAkQ'
>>>
>>> # ... We now feed the 'auth_url' to the web browser/control that will
>>> # handle our user auth ... and we log in as the no-priveleges user.
>>>
>>> # It redirects back to the 'client landing page'
>>> # we provided, with the user ID/Key pair attached as quoted parms
>>> redirect_url = 'http://clientapp/landing/page?x_a=dC31ncmeHGvtullmp-6xSu&x_b=GPo8Rm7ou1fxZ7D8JHKOu1&x_c=093VuH_tHn1WGlla7pQ7MvGDJUX8lZ5gS5jwOgR8xNE'
>>>
>>> # Using the redirect_url, we get the app context to create a user context.
>>> uc_nopriv = ac.create_user_context(result_uri=redirect_url, host='lms.valence.desire2learn.com', encrypt_requests=True)

Testing the user context

Once we have the user context, we can immediately test it with two API calls that any user (even one with no privileges) can make: retrieving the versions table for the back-end service, and retrieving the whoami data for the user context.

Version table data for the service. In actual fact, you can make this call with an anonymous user context, but having an actual user make the call is also just fine.

>>> # Create an authenticated URL to make the API call
>>> the_url = uc_nopriv.create_authenticated_url('/d2l/api/versions/')
>>> the_url
'http://yourLMS.edu/d2l/api/versions/?x_a=G9nUpvbZQyiPrk3um2YAkQ&x_c=wrbowWOeQnn199ZfDbbA4uFprLem4yJNXP5sO9VWDU4&x_b=dC31ncmeHGvtullmp-6xSu&x_d=DMD-SeRR4klMatSZc3uNV-Lc9EZzY1j1r34y4Jq_gwU&x_t=1354895553'
>>>
>>> # Make the (HTTP GET) call by using the requests library, the returned 'r'
>>> # is the response for the request.
>>> r = requests.get(the_url)
>>>
>>> # We can verify that the response status is a 200, and if so, for this
>>> # call we know we're supposed to get JSON back on success -- thus we can
>>> # ask for the JSON body of the request
>>> r.status_code
200
>>> r.json()
[{'LatestVersion': '2.1',
  'ProductCode': 'ep',
  'SupportedVersions': ['2.0', '2.1']},
 {'LatestVersion': '1.2',
  'ProductCode': 'le',
  'SupportedVersions': ['1.0', '1.1', '1.2']},
 {'LatestVersion': '1.2',
  'ProductCode': 'lp',
  'SupportedVersions': ['1.0', '1.1', '1.2']},
 {'LatestVersion': '1.0', 'ProductCode': 'LR', 'SupportedVersions': ['1.0']},
 {'LatestVersion': '1.1', 'ProductCode': 'lti', 'SupportedVersions': ['1.1']},
 {'LatestVersion': '1.2', 'ProductCode': 'rp', 'SupportedVersions': ['1.2']}]
>>>

Whoami information for the user. All users have the ability to make the whoami call, even if they have no role privileges at all. Notice that the first call tells us that this back-end service supports version 1.2 of the LP API; we’ll thus use that version for the next call:

>>> the_url = uc_nopriv.create_authenticated_url('/d2l/api/lp/1.2/users/whoami')
>>> the_url
'http://yourLMS.edu/d2l/api/lp/1.2/users/whoami?x_a=G9nUpvbZQyiPrk3um2YAkQ&x_c=8wSpTxNexR2R7kBc2OiFy7LJw9VVpEtDrqemhPAxHCc&x_b=dC31ncmeHGvtullmp-6xSu&x_d=1Y_cNvdhjn4t_GZc4RHkoYHqFItLZ7u3D-SnnsL4Sfw&x_t=1355169510'
>>>
>>> r = requests.get(the_url)
>>>
>>> # We use the same response-checking pattern here: if it's a 200, we can
>>> # look at the JSON body returned.
>>> r.status_code
200
>>> r.json()
{'FirstName': 'Util',
 'Identifier': '39911',
 'LastName': 'Extensibility',
 'ProfileIdentifier': 'dOKmEi8nvF',
 'UniqueName': 'extensibility_util'}
>>>

Now that we have a working user context, we can begin to investigate the actual routes that our client has reported problems with.

News permissions

For the news call, we’ll use the same methodology as we used with the whoami call:

  1. Fashion an authenticated URL

  2. Use the requests library to make the call

  3. Examine the returned response object

At this point, it will become plain that working with a logged-in admin user while we’re testing will be quite useful, so we can open a browser window and log into the our back-end service. The user we log in as should have as many privileges as possible, but will definitely need the ability to adjust user role permissions and (as we’ll see in a minute) user enrollments.

Starting point. To test this call, we need a test course offering, with some news content to request. In our test environment, we have a course offering called Extensibility 101, with a course code of EXT-101, and an orgUnitId value of 8083. We make sure that we have two news items for this course: a global news item (created by our Web UI administrator account at the root organization level), and a news item created specifically for this course offering (created by our Web UI administrator account in the course offering itself).

Testing the call

>>> # Notice that the org unit ID for our test course offering is 8083
>>> the_url=uc_nopriv.create_authenticated_url('/d2l/api/le/1.2/8083/news/')
>>> the_url
>>> 'http://yourLMS.edu/d2l/api/le/1.2/8083/news/?x_a=G9nUpvbZQyiPrk3um2YAkQ&x_c=sHtg3Nz9uF01GJLH2FoklNwAOrn6oWYLCzPNcLV3hIk&x_b=dC31ncmeHGvtullmp-6xSu&x_d=noysMNGXVKfv-_7Rkz9rWchF09ll8keHstHCYm72D6E&x_t=1355172740'
>>>
>>> r = requests.get(the_url)
>>> r.status_code
403
>>> # Because this is a 403, I know I'm not getting JSON back, so I ask for
>>> # the contents of the response object, not its JSON.
>>> r.contents
b'Forbidden'
>>>

Obviously, our no-privileges user doesn’t have sufficient permission to retrieve the news feed for the course. First, we’ll try adding some permissions for the user’s role.

Adjusting the user’s permissions

Because the LMS organizes permissions into groups according to a related platform tool, we’ll focus on just the News tool’s permissions. We suspect it would be sensible that, if we want to let a user see the current news feed for a particular course offering, that we provide that user’s role with the See News permission in Course Offering for org unit types.

However, after we try doing that, we still get the 403/Forbidden result. Something else is going on here.

Distinction between global routes, and org unit-specific routes

One thing that occurs to us: we’re making this API call in the context of a particular authenticated user, but we’re also making the call within the context of a single, specified course offering (org unit 8083). Why would any arbitrary user be allowed to view the news feed for a specific course?

Switching back to our logged in administrator user in the Web UI, we examine the User record for our no-privileges user, and we choose to manage the user’s enrollments. This confirms to us that the user only has one current enrollment: the user is enrolled, with the no-privilege role, in the organization itself.

When we also enroll the user in our course with the OrgUnitID 8083 (we need to know what the course’s name is here: we can presume that our client has this information), we make sure to use the no-privilege role for the user’s enrollment. Then we run our test again:

>>> the_url=uc_nopriv.create_authenticated_url('/d2l/api/le/1.2/8083/news/')
>>> the_url
'http://yourLMS.edu/d2l/api/le/1.2/8083/news/?x_a=G9nUpvbZQyiPrk3um2YAkQ&x_c=LQCAC5FFKdvup3aLrk7jlu0M_uqiHq11pG0lwTSo28o&x_b=dC31ncmeHGvtullmp-6xSu&x_d=IHD-c0Cz9LT7-uRPfDpsGP7mrkCzp3vJncErkkhBpCI&x_t=1355325448'
>>>
>>> r = requests.get(the_url)
>>> r.status_code
200
>>> r.json()
[{'Attachments': [],
  'Body': {'Html': '<p>This is a test news item.</p>',
   'Text': 'This is a test news item.'},
  'EndDate': None,
  'Id': 7345,
  'IsGlobal': False,
  'IsHidden': False,
  'IsPublished': True,
  'ShowOnlyInCourseOfferings': False,
  'StartDate': '2012-12-12T15:18:00.000Z',
  'Title': 'EXT 101 Local Test News Item'}]
>>>

Here, we notice that we’re not seeing the entire news feed for the course. We’re seeing the news item created specifically for the course offering, but we’re not seeing the global news item. We go back to the role permissions tool and give the no privileges role the See News permission for the Organization org unit type, and try our test again:

>>> the_url=uc_nopriv.create_authenticated_url('/d2l/api/le/1.2/8083/news/')
>>> the_url
'http://yourLMS.edu/d2l/api/le/1.2/8083/news/?x_a=G9nUpvbZQyiPrk3um2YAkQ&x_c=qJigqjjxA1EGquK5jDfJnWE7S9doXl-14gvADqajrPI&x_b=dC31ncmeHGvtullmp-6xSu&x_d=amifxmeeDVQipJ-UWRsS2AV-1LYp2vxPp2yFGY2zPJU&x_t=1355326337'
>>>
>>> r = requests.get(the_url)
>>> r.status_code
200
>>>
>>> r.json()
[{'Attachments': [],
  'Body': {'Html': '<p>This is a test news item.</p>',
   'Text': 'This is a test news item.'},
  'EndDate': None,
  'Id': 7345,
  'IsGlobal': False,
  'IsHidden': False,
  'IsPublished': True,
  'ShowOnlyInCourseOfferings': False,
  'StartDate': '2012-12-12T15:18:00.000Z',
  'Title': 'EXT 101 Local Test News Item'},
 {'Attachments': [],
  'Body': {'Html': '<p>This is a test global item.</p>',
   'Text': 'This is a test global item.'},
  'EndDate': None,
  'Id': 7343,
  'IsGlobal': True,
  'IsHidden': False,
  'IsPublished': True,
  'ShowOnlyInCourseOfferings': False,
  'StartDate': '2012-11-30T20:54:00.000Z',
  'Title': 'Global Test News Item One'}]
>>>

Conclusion

From these tests, we can make several conclusions:

  • The See News permission defines the ability for an assigned user role to see news, depending on where the news item gets created. If you want a user role to see news items created in a course offering, the role needs See News checked for the course offering org unit type; if you want a user role to see news items created at the org level, the role needs See News checked for the organization.

  • The user’s enrollment in an org unit gives them general access to the news feed for that org unit.

    That is, if the user is enrolled in an org unit, they have access to the news feed, and the role they’re enrolled with will affect the items visible to them through that feed.

Discussions permissions

There is much to the discussions call that looks similar to the news call: it’s scoped by org unit (course offering), and it’s a distinct tool for use within courses (the discussions functionality); accordingly, we’ll again use the same general approach we used to investigate the news permissions.

Starting point. To test this call, we’ll first return our role/user pairing to a known, empty state. We’ll remove the news permissions, and we’ll remove the enrollment for the user from the course offering.

We also need to make sure that our course has discussion forums; our EXT-101 test course has several forums: a group forum for use with testing of group functionality, and a general purpose forum.

Testing the call

Once we have a clean slate, the call becomes quite familiar:

>>> the_url=uc_nopriv.create_authenticated_url('/d2l/api/le/1.2/8083/discussions/forums/')
>>> the_url
'http://yourLMS.edu/d2l/api/le/1.2/8083/discussions/forums/?x_a=G9nUpvbZQyiPrk3um2YAkQ&x_c=pNDz3PViH64o1RKwIPmI_RHq-z5ny5Gfu8SlYatxLhQ&x_b=dC31ncmeHGvtullmp-6xSu&x_d=fHnnU-u7PA7WDauIrYajMvgEiYpG7SuWshLo2XeYceE&x_t=1355349409'
>>>
>>> r = requests.get(the_url)
>>> r.status_code
403
>>>
>>> r.content
b'{"Errors":[{"Message":"Not Authorized"}]}'
>>>

Again, our no-privileges user doesn’t have sufficient permissions to retrieve the discussion forum list for the course. Because this is an org unit-scoped call, we now have a suspicion that we’ll have to not only ensure that the user role has the appropriate permissions, but probably also the user needs enrolling in the course. We might as well test by setting both those things, and then taking things away to find the minimum requirements.

Adjusting the user state

We add the user back to the enrollment list for the course, and examine the role permissions grouped together for the discussions tool. We’ll start by adding a single role permission: Access to Discussions for course offering org unit types.

Testing the call now shows that it works.

>>> the_url=uc_nopriv.create_authenticated_url('/d2l/api/le/1.2/8083/discussions/forums/')
>>> the_url
>>> 'http://yourLMS.edu/d2l/api/le/1.2/8083/discussions/forums/?x_a=G9nUpvbZQyiPrk3um2YAkQ&x_c=rouuURDDjmxJEkGkeyzZDa80A0Rh6NE9JV2_aJidc1Q&x_b=dC31ncmeHGvtullmp-6xSu&x_d=GtPPgTsQBqnEQWX5fm-9K1c5bVNjn_XaZlmU-n8idSM&x_t=1355350217'
>>>
>>> r = requests.get(the_url)
>>> r.status_code
200
>>> r.json()
[{'AllowAnonymous': False,
  'Description': {'Html': ' ', 'Text': ' '},
  'EndDate': None,
  'ForumId': 4174,
  'IsHidden': False,
  'IsLocked': False,
  'Name': 'Test Groups Forum',
  'PostEndDate': None,
  'PostStartDate': None,
  'RequiresApproval': False,
  'StartDate': None},
 {'AllowAnonymous': False,
  'Description': {'Html': '<p>This is a course forum; put topics into it as you wish.</p>',
   'Text': 'This is a course forum; put topics into it as you wish.'},
  'EndDate': None,
  'ForumId': 4305,
  'IsHidden': False,
  'IsLocked': False,
  'Name': 'Course Forum',
  'PostEndDate': None,
  'PostStartDate': None,
  'RequiresApproval': False,
  'StartDate': None}]
>>>

If we take away the enrollment in the course from the user, we can see that (just as with the news tool) the calling user context is no longer allowed to see the discussion forum list for this org unit (despite the role permission setting).

Conclusion

From these tests, again, we can make several conclusions:

  • To get the list of discussion forums for an org unit, the calling user’s role needs to have the Access to Discussions permission for the org unit type in question.

  • Again, the user’s enrollment in an org unit gives them general access to the discussions for that org unit: what they can do with that access gets defined by the role permissions for the role they hold for that enrollment.

User entry permissions

The user entry call in question is different to the news and discussion calls in several interesting ways. Firstly, it’s not a call bound by an org unit ID; this means that instead it operates on the root org. Secondly, because the call could presumably involve users’ personal information, it’s likely that the UIP permissions will come into play.

Testing the call

As with the previous tests, we first revert our user and role back to a clean slate (no enrollments, other than the root org, and no privileges), and then re-fashion our call. To no surprise, we discover our no privileges user is not allowed to make the call:

>>> # We do not provide the query parameters for this call here;
>>> # authentication token generation depends only on the API route.
>>> the_url = uc_nopriv.create_authenticated_url('/d2l/api/lp/1.2/users/')
>>>
>>> # The API call takes a query parameters, so we create a dictionary
>>> # to contain our query parameters
>>> qp = {'userName':'extensibility_student'}
>>> r = requests.get(the_url, params=qp)
>>>
>>> # Show the full URL for the request
>>> r.request.full_url
'http://yourLMS.edu/d2l/api/lp/1.2/users/?x_a=G9nUpvbZQyiPrk3um2YAkQ&x_c=ovSR0zUXxHfvv_5yKzR-IjuJFTnCFuXrsJicXF-4DWs&x_b=dC31ncmeHGvtullmp-6xSu&x_d=FXzgMoL4rUJKRLQ-0J6FXoUAKJUg7-_Tk27NHWSIuG4&x_t=1355763798&userName=extensibility_student'
>>> r.status_code
403
>>> r.content
b'{"Errors":[{"Message":"Not Authorized"}]}'
>>>

But, why is the user not authorized to make this call. Is it that they cannot use the route itself? Or is there a problem with the userName query parameter?

Adjusting the user’s permissions

To try and isolate the problem, let’s first attempt to make the call without the query parameter at all:

>>> the_url = uc_nopriv.create_authenticated_url('/d2l/api/lp/1.2/users/')
>>> r = requests.get(the_url)
>>> r.status_code
403
>>> r.content
b'{"Errors":[{"Message":"Not Authorized"}]}'
>>>

It’s typical of these kinds of org-level API calls (i.e. calls that are not scoped by a particular org unit) that they require organization level permissions, sometimes centered around a tool. If we provide our user’s role with See the User Management Tool at the organization level, and make the call again we end up with permission to retrieve user records:

>>> the_url = uc_nopriv.create_authenticated_url('/d2l/api/lp/1.2/users/')
>>> r = requests.get(the_url)
>>> r.json()
{'Items': [{'Activation': {'IsActive': False},
   'DisplayName': 'Anonymous User',
   'ExternalEmail': None,
   'FirstName': None,
   'LastName': None,
   'MiddleName': None,
   'OrgDefinedId': None,
   'OrgId': 6606,
   'UniqueIdentifier': None,
   'UserId': 372,
   'UserName': None},
  {'Activation': {'IsActive': True},
   'DisplayName': 'Anonymous User',
   'ExternalEmail': None,
   'FirstName': None,
   'LastName': None,
   'MiddleName': None,
   'OrgDefinedId': None,
   'OrgId': 6606,
   'UniqueIdentifier': None,
   'UserId': 373,
   'UserName': None},
... # rest of the output truncated

We can immediately notice something about these returned records that can give us a hint about the call with the query parameter failing: none of these user records contain a UserName property value; in fact, most of the properties in these user records are missing. Checking the User Information Privacy block of role permissions, we see that See Usernames is one of the permissions. Let’s try to activate that for our role, at the organization level, and make the call again:

>>> the_url = uc_nopriv.create_authenticated_url('/d2l/api/lp/1.2/users/')
>>> r = requests.get(the_url)
>>> r.json()
{'Items': [{'Activation': {'IsActive': False},
   'DisplayName': 'Anonymous User',
   'ExternalEmail': None,
   'FirstName': None,
   'LastName': None,
   'MiddleName': None,
   'OrgDefinedId': None,
   'OrgId': 6606,
   'UniqueIdentifier': 'TestUserOne',
   'UserId': 372,
   'UserName': 'TestUserOne'},
  {'Activation': {'IsActive': True},
   'DisplayName': 'Anonymous User',
   'ExternalEmail': None,
   'FirstName': None,
   'LastName': None,
   'MiddleName': None,
   'OrgDefinedId': None,
   'OrgId': 6606,
   'UniqueIdentifier': 'TestUserTwo',
   'UserId': 373,
   'UserName': 'TestUserTwo'},
... # rest of the output truncated

Now we can see the UserName and UniqueIdentifier properties for the user records; if we add permissions to see all the other User Information Privacy properties at the organization level, the rest of the returned records will start to fill out. With just the UserName UIP permissions active, let’s try making our original call again:

>>> the_url = uc_nopriv.create_authenticated_url('/d2l/api/lp/1.2/users/')
>>> qp = {'userName':'extensibility_student'}
>>> r = requests.get(the_url,params=qp)
>>> r.json()
Out[104]:
{'Activation': {'IsActive': True},
 'DisplayName': 'Anonymous User',
 'ExternalEmail': None,
 'FirstName': None,
 'LastName': None,
 'MiddleName': None,
 'OrgDefinedId': None,
 'OrgId': 6606,
 'UniqueIdentifier': 'extensibility_student',
 'UserId': 1582,
 'UserName': 'extensibility_student'}
>>>

Conclusion

From these tests, finally, we can make several conclusions:

  • To see any user records with this call, the calling user must have permission to See the User Management Tool at the organization level to use this call at all.

  • The data that the caller can see inside any returned user records is controlled by the calling user’s permissions around User Information Privacy.

  • To use any of the query parameter “filters” on this API call, the calling user must have the UIP permission to see that information – in fact, this is the real reason our client was getting a 403 “Not Authorized” error on the call: not because they were not permitted to search for particular user roles (they in fact had those permissions), but because they were attempting to use a query parameter as a filter that involved a property governed by UIP that they had no permission to see.

«  Fetch final grade values for an instructor's classes   ·  [   home  ·   reference  ·   community   ·  search   ·  index   ·  routing table   ·  scopes table   ]   ·  Importing course content packages using APIs  »