Fix User leakage in Batch enrollment - #435
Conversation
2596240 to
e25c8ad
Compare
add missing comment
e25c8ad to
a3c9489
Compare
There was a problem hiding this comment.
Thanks @melvinsoft.
Before I go in full details, I'd like to ask about two things:
- Make this change gated by a feature flag e.g.
FEATURES['TAHOE_MULTITENANT_BULK_ENROLLMENT'] - Add few tests to ensure this is working as expected
I'll give it another detailed round of review soon.
| # handled at a higher level (i.e. in enroll_email). Besides, this | ||
| # class creates readonly objects. | ||
| exists_user = User.objects.filter(email=email).exists() | ||
| organization = get_current_site().organizations.first() |
There was a problem hiding this comment.
This can raise an exception if the RequestCache class does not cache the current request object. If we are not in a view class that has a request object, we should instead pass the site instance to classes and methods that need it. This both makes it explicit and helps makes tests more robust. For the specific case of EmailEnrollmentState's constructor should take a site object
Another improvement is to then move the 'user exists' check into a sites module so that site handling (setters and getters for site specific filtering) is handled in the single module.
For an example, see the sites module in the appsembler api: https://github.com/appsembler/edx-platform/blob/appsembler/tahoe/master/openedx/core/djangoapps/appsembler/api/sites.py
Making a central point for setting/getting site specific data lets us both reuse code and do focused unit testing for site isolation.
So we could have a sites.user_exists_in_site(user=user, site=site) call or just try to get the user as in
user = sites.get_user_for_site(user_email=email, site=site)
There was a problem hiding this comment.
I like the refactoring idea you're suggesting @johnbaldwin, .organizations.first() is being used a lot and it should be placed in a function instead of copying the pattern all over tahoe.
| # class creates readonly objects. | ||
| exists_user = User.objects.filter(email=email).exists() | ||
| organization = get_current_site().organizations.first() | ||
| exists_user = organization.userorganizationmapping_set.filter(user__email=email).exists() |
There was a problem hiding this comment.
so this call could be exists_user = sites.site_has_user(site=site, user_email=email)
But what would be simpler is to just try to get the user for the site with a function that returns None if the user is not in the site. So we'd define a function in a common sites module and we could make the call work like this:
user = sites.get_user_for_site(site=site, user_email=email)
if user:
mode, is_active = CourseEnrollment...
...
else:
...
exists_allowed = ...
state_auto_enroll = ...
self.user = bool(user)
...
There was a problem hiding this comment.
And it looks like EmailEnrollmentState is only used in this module (instructor.enrollments), so when we add site as a constructor param, we only have to change it in this module and the test_enrollment module
./lms/djangoapps/instructor/tests/test_enrollment.py:26: EmailEnrollmentState,
./lms/djangoapps/instructor/tests/test_enrollment.py:62: ees = EmailEnrollmentState(self.course_key, eobjs.email)
./lms/djangoapps/instructor/tests/test_enrollment.py:94: before = EmailEnrollmentState(self.course_key, eobjs.email)
./lms/djangoapps/instructor/tests/test_enrollment.py:103: after = EmailEnrollmentState(self.course_key, eobjs.email)
./lms/djangoapps/instructor/tests/test_enrollment.py:597:class SettableEnrollmentState(EmailEnrollmentState):
./lms/djangoapps/instructor/enrollment.py:49:class EmailEnrollmentState(object):
./lms/djangoapps/instructor/enrollment.py:126: returns two EmailEnrollmentState's
./lms/djangoapps/instructor/enrollment.py:129: previous_state = EmailEnrollmentState(course_id, student_email)
./lms/djangoapps/instructor/enrollment.py:162: after_state = EmailEnrollmentState(course_id, student_email)
./lms/djangoapps/instructor/enrollment.py:176: returns two EmailEnrollmentState's
./lms/djangoapps/instructor/enrollment.py:179: previous_state = EmailEnrollmentState(course_id, student_email)
./lms/djangoapps/instructor/enrollment.py:196: after_state = EmailEnrollmentState(course_id, student_email)
There was a problem hiding this comment.
@johnbaldwin I'd be really careful not to change so many files. It's very easy that we'd forget to migrate future uses when upgrading to a newer release.
Git conflicts can be really unproductive to resolve.
If we really want to be careful, we can still use something like this function:
edx-platform/openedx/core/djangoapps/request_cache/__init__.py
Lines 63 to 72 in 64555c6
Which addresses the issues of having the site returned as None.
There was a problem hiding this comment.
Reading that function, a dummy site is in the fake request is returned. In this case, the user should be rejected. We should test that this behaves how we want
|
@melvinsoft I made a bunch of comments suggesting an implementation approach that explicitly passes the current After thinking about it after I typed up my comments I decided to leave my comments in place just so you see what I'm thinking based on my efforts to try to make site isolation safer and easier to test Probably the most expedient things to do instead are A) Be clear in how the code works for when C) Add test to run the code for a user that doesn't belong to the site/organization For examples, please check out my tests for the Tahoe API: https://github.com/appsembler/edx-platform/tree/appsembler/tahoe/master/openedx/core/djangoapps/appsembler/api/tests |
|
@johnbaldwin @OmarIthawi Thank you so much for your reviews. I've implemented the first round of changes, basically refactoring some code into functions and adding a feature flag to control which function we call as @OmarIthawi requested. After chatting with @abeals, since this bug is pressuring us, test may come in a second iteration next sprint. |
| @@ -0,0 +1,27 @@ | |||
| """ | |||
There was a problem hiding this comment.
@melvinsoft We may want to move this module out of instructor to a common place. Since our site checking depends on Appsembler's fork of organizations, how about moving it to openedx.core.djangoapps.appsembler.sites.api? The api module seems reasonable to me.
Another option is to just use the existing openedx.core.djangoapps.appsembler.api.sites module, for which I have already created unit tests
|
|
||
| def user_exists_in_organization(user_email, organization): | ||
| """ | ||
| TODO |
There was a problem hiding this comment.
Just to write comments for the functions.
| return get_user_by_username_or_email(unique_student_identifier) | ||
| # Appsembler Specific: We call our custom method insted the default one, | ||
| # to make sure the user is get inside the org. | ||
| return get_user_by_username_or_email_inside_organization(unique_student_identifier) |
There was a problem hiding this comment.
@melvinsoft Do we need to add a feature flag here too?
johnbaldwin
left a comment
There was a problem hiding this comment.
@melvinsoft Thanks for addressing our comments. I have more, sorry:
One of the get_user... calls doesn't look like it is wrapped by a feature flag:
Also, the new sites module appears to be failing pep8. Please see my inline comments.
For the new functionality in instructor.sites, I think we need to make a team decision as to how we handle site isolation: Via the site context or via the organization context. openedx.core.djangoapps.appsembler.api.sites uses the site object to support isolation. Your new code uses the organization object to support isolation.
The thing I'm concerned with the most is that we fail with site isolation because we've got multiple contexts. Since we've got just one org per site, I'd like to restrict it at the site level just because it seems clearer to me as a story: Customer has a subdomain on Tahoe or they use their own domain. This domain provides the top level isolation.
At some point if we do multiple orgs isolation within sites, we're going to have to rework a lot anyway and that would be a time to do a "step back and think about this architecturally"
Your thoughts?
|
@johnbaldwin I fixed some PEP8 issues, but I think the checks are failing because the files we changed, have PEP8 errors from before, from example the file Please give it another round of review and approve it if it's meeting the criteria, according our chat yesterday. |
|
@thraxil Thanks for addressing the PEP8 issues! @johnbaldwin Another ping here for a final review and approval. Thanks! |
| exists_user = User.objects.filter(email=email).exists() | ||
| site = get_current_site() | ||
| organization = get_organization_for_site(site) | ||
| exists_user = user_exists_in_organization(email, organization) |
There was a problem hiding this comment.
@melvinsoft why do a user check instead of just a single call to try to get the user and use if user instead of if exists_user? Is seems to make an extra call that is not needed.
There was a problem hiding this comment.
@johnbaldwin Thanks for asking this, this is edX code, no ours, we trying to fix the issue with the less code changes as possible, since makes future merges way more difficult. I don't like the design, but I choose to stick with the code as much as I can.
johnbaldwin
left a comment
There was a problem hiding this comment.
@melvinsoft Thanks for addressing my comments. Just one small question, otherwise, let's ship it!
We're experiencing problems with the Batch Enrollment section, that is causing small leaks between sites. But before, is important to understand how it works.
The Batch enrollment section is basically a TextArea in where you can enter a comma / line separated list of usernames or emails (yeah, all mixed in the same list), the batch enrollment will iterate over all of those users and do the following:
In Tahoe we're having several issues, because we're emulating a multi tenant system:
1 - If a user is registered in any Tahoe site with a certain email, it can be automatically enrolled in other Tahoe site by email, this causes data leakage in reports, and also the user receives an invitation for a course in a site that cannot access.
2 - If you paste random username like
carlosand by any chance a user with that username exists in another Tahoe site, it will be enrolled in the course, causing the same problem described above.The tricky part of this issue, is that we cannot remove the functionality of invite non registered users by email, because is the charm of the feature, invite user to register and enroll in a course.
The PR solves most of those issues, but simply check if the user exists inside the organization, if is searched by username and not exists in the site, even if exists in another site, it simply will return a not exists. If the user is searched by email, despite if exists in another site or not, but don't exists in the current one, it'll be invited by email.
The edge case is, if the user exists in another site, it won't be able to sign up, but that's a different problem that will be tackled here: https://github.com/appsembler/tech-design-proposals/pull/8