Skip to content

Commit

Permalink
Added SDN compliance api and data model
Browse files Browse the repository at this point in the history
  • Loading branch information
rhysyngsun committed May 20, 2019
1 parent 08d7574 commit 5a37736
Show file tree
Hide file tree
Showing 44 changed files with 6,417 additions and 353 deletions.
16 changes: 16 additions & 0 deletions app.json
Expand Up @@ -35,9 +35,17 @@
"CYBERSOURCE_ACCESS_KEY": {
"description": "CyberSource Access Key"
},
"CYBERSOURCE_INQUIRY_LOG_NACL_ENCRYPTION_KEY": {
"description": "The public key to encrypt export results with for our own security purposes. Should be a base64 encoded NaCl public key.",
"required": false
},
"CYBERSOURCE_PROFILE_ID": {
"description": "CyberSource Profile ID"
},
"CYBERSOURCE_MERCHANT_ID": {
"description": "The cybersource merchant id",
"required": false
},
"CYBERSOURCE_REFERENCE_PREFIX": {
"description": "a string prefix to identify the application in CyberSource transactions"
},
Expand All @@ -47,6 +55,14 @@
"CYBERSOURCE_SECURITY_KEY": {
"description": "CyberSource API key"
},
"CYBERSOURCE_TRANSACTION_KEY": {
"description": "The cybersource transaction key",
"required": false
},
"CYBERSOURCE_WSDL_URL": {
"description": "The URL to the cybersource WSDL",
"required": false
},
"GA_TRACKING_ID": {
"description": "Google analytics tracking ID",
"required": false
Expand Down
8 changes: 8 additions & 0 deletions authentication/exceptions.py
Expand Up @@ -76,5 +76,13 @@ class UnexpectedExistingUserException(PartialException):
"""Raised if a user already exists but shouldn't in the given pipeline step"""


class UserExportBlockedException(AuthException):
"""The user is blocked for export reasons from continuing to sign up"""


class UserTryAgainLaterException(AuthException):
"""The user should try to register again later"""


class UserMissingSocialAuthException(Exception):
"""Raised if the user doesn't have a social auth"""
70 changes: 70 additions & 0 deletions authentication/pipeline/compliance.py
@@ -0,0 +1,70 @@
"""Compliance pipeline actions"""
import logging

from django.conf import settings
from django.core import mail
from social_core.exceptions import AuthException

from authentication.exceptions import (
UserExportBlockedException,
UserTryAgainLaterException,
)
from compliance import api


log = logging.getLogger()


def verify_exports_compliance(
strategy, backend, user=None, **kwargs
): # pylint: disable=unused-argument
"""
Verify that the user is allowed by exports compliance
Args:
strategy (social_django.strategy.DjangoStrategy): the strategy used to authenticate
backend (social_core.backends.base.BaseAuth): the backend being used to authenticate
user (User): the current user
"""
if not api.is_exports_verification_enabled():
log.warning("Export compliance checks are disabled")
return {}

# skip this step if the user is active or they have an existing export inquiry logged
if user.is_active and user.exports_inquiries.exists():
return {}

try:
export_inquiry = api.verify_user_with_exports(user)
except Exception as exc: # pylint: disable=broad-except
# hard failure to request the exports API, log an error but don't let the user proceed
log.exception("Unable to verify exports compliance")
raise UserTryAgainLaterException(backend) from exc

if export_inquiry is None:
raise UserTryAgainLaterException(backend)
elif export_inquiry.is_denied:
log.info(
"User with email '%s' was denied due to exports violation, for reason_code=%s, info_code=%s",
user.email,
export_inquiry.reason_code,
export_inquiry.info_code,
)
try:
with mail.get_connection(settings.NOTIFICATION_EMAIL_BACKEND) as connection:
mail.send_mail(
f"Exports Compliance: denied {user.email}",
f"User with email '{user.email}' was denied due to exports violation, for reason_code={export_inquiry.reason_code}, info_code={export_inquiry.info_code}",
settings.MAILGUN_FROM_EMAIL,
[settings.EMAIL_SUPPORT],
connection=connection,
)
except Exception: # pylint: disable=broad-except
log.exception(
"Exception sending email to support regarding export compliance check failure"
)
raise UserExportBlockedException(backend)
elif export_inquiry.is_unknown:
raise AuthException("Unable to authenticate, please contact support")

return {}
110 changes: 110 additions & 0 deletions authentication/pipeline/compliance_test.py
@@ -0,0 +1,110 @@
"""Compliance pipeline tests"""
import pytest
from social_core.exceptions import AuthException

from authentication.exceptions import (
UserExportBlockedException,
UserTryAgainLaterException,
)
from authentication.pipeline import compliance
from compliance.factories import ExportsInquiryLogFactory


pytestmark = pytest.mark.django_db


def test_verify_exports_compliance_disabled(mocker):
"""Assert that nothing is done when the api is disabled"""
mock_api = mocker.patch("authentication.pipeline.compliance.api")
mock_api.is_exports_verification_enabled.return_value = False

assert compliance.verify_exports_compliance(None, None) == {}


@pytest.mark.parametrize(
"is_active, inquiry_exists, should_verify",
[
[True, True, False],
[True, False, True],
[False, True, True],
[False, False, True],
],
)
def test_verify_exports_compliance_user_active(
mailoutbox, mocker, user, is_active, inquiry_exists, should_verify
): # pylint: disable=too-many-arguments
"""Assert that the user is verified only if they already haven't been"""
user.is_active = is_active
if inquiry_exists:
ExportsInquiryLogFactory.create(user=user)

mock_api = mocker.patch("authentication.pipeline.compliance.api")
mock_api.verify_user_with_exports.return_value = mocker.Mock(
is_denied=False, is_unknown=False
)

assert compliance.verify_exports_compliance(None, None, user=user) == {}

if should_verify:
mock_api.verify_user_with_exports.assert_called_once_with(user)
assert len(mailoutbox) == 0


def test_verify_exports_compliance_no_record(mocker, user):
"""Assert that an error to try again later is raised if no ExportsInquiryLog is created"""

mock_api = mocker.patch("authentication.pipeline.compliance.api")
mock_api.verify_user_with_exports.return_value = None

with pytest.raises(UserTryAgainLaterException):
compliance.verify_exports_compliance(None, None, user=user)

mock_api.verify_user_with_exports.assert_called_once_with(user)


def test_verify_exports_compliance_api_raises_exception(mocker, user):
"""Assert that an error to try again later is raised if the export api raises an exception"""

mock_api = mocker.patch("authentication.pipeline.compliance.api")
mock_api.verify_user_with_exports.side_effect = Exception("error")

with pytest.raises(UserTryAgainLaterException):
compliance.verify_exports_compliance(None, None, user=user)

mock_api.verify_user_with_exports.assert_called_once_with(user)


@pytest.mark.parametrize("email_fails", [True, False])
def test_verify_exports_compliance_denied(mailoutbox, mocker, user, email_fails):
"""Assert that a UserExportBlockedException is raised if the inquiry result is denied"""
mock_api = mocker.patch("authentication.pipeline.compliance.api")
mock_api.verify_user_with_exports.return_value = mocker.Mock(
is_denied=True, is_unknown=False, reason_code=100, info_code="123"
)

if email_fails:
# a mail sending error should not obscurve the true error
mocker.patch(
"authentication.pipeline.compliance.mail.send_mail",
side_effect=Exception("mail error"),
)

with pytest.raises(UserExportBlockedException):
compliance.verify_exports_compliance(None, None, user=user)

mock_api.verify_user_with_exports.assert_called_once_with(user)
assert len(mailoutbox) == (0 if email_fails else 1)


def test_verify_exports_compliance_unknown(mailoutbox, mocker, user):
"""Assert that a UserExportBlockedException is raised if the inquiry result is unknown"""
mock_api = mocker.patch("authentication.pipeline.compliance.api")
mock_api.verify_user_with_exports.return_value = mocker.Mock(
is_denied=False, is_unknown=True
)

with pytest.raises(AuthException):
compliance.verify_exports_compliance(None, None, user=user)

mock_api.verify_user_with_exports.assert_called_once_with(user)
assert len(mailoutbox) == 0
30 changes: 28 additions & 2 deletions authentication/pipeline/user.py
Expand Up @@ -14,6 +14,8 @@
UnexpectedExistingUserException,
)
from authentication.utils import SocialAuthState

from compliance import api as compliance_api
from users.serializers import UserSerializer, ProfileSerializer

# pylint: disable=keyword-arg-before-vararg
Expand Down Expand Up @@ -176,10 +178,34 @@ def forbid_hijack(strategy, backend, **kwargs): # pylint: disable=unused-argume
Args:
strategy (social_django.strategy.DjangoStrategy): the strategy used to authenticate
backend (social_core.backends.base.BaseAuth): the backend being used to authenticate
user (User): the current user
flow (str): the type of flow (login or register)
"""
# As first step in pipeline, stop a hijacking admin from going any further
if strategy.session_get("is_hijacked_user"):
raise AuthException("You are hijacking another user, don't try to login again")
return {}


def activate_user(
strategy, backend, user=None, is_new=False, **kwargs
): # pylint: disable=unused-argument
"""
Activate the user's account if they passed export controls
Args:
strategy (social_django.strategy.DjangoStrategy): the strategy used to authenticate
backend (social_core.backends.base.BaseAuth): the backend being used to authenticate
user (User): the current user
"""
if user.is_active or not is_new:
return {}

export_inquiry = user.exports_inquiries.order_by("-created_on").first()

# if the user has an export inquiry that is considered successful, activate them
if not compliance_api.is_exports_verification_enabled() or (
export_inquiry is not None and export_inquiry.is_success
):
user.is_active = True
user.save()

return {}
34 changes: 34 additions & 0 deletions authentication/pipeline/user_test.py
Expand Up @@ -18,6 +18,8 @@
RequireUserException,
)
from authentication.utils import SocialAuthState
from compliance.constants import RESULT_SUCCESS, RESULT_DENIED, RESULT_UNKNOWN
from compliance.factories import ExportsInquiryLogFactory


@pytest.fixture
Expand Down Expand Up @@ -371,3 +373,35 @@ def test_forbid_hijack(mocker, hijacked):
user_actions.forbid_hijack(*args, **kwargs)
else:
assert user_actions.forbid_hijack(*args, **kwargs) == {}


@pytest.mark.parametrize("is_active", [True, False])
@pytest.mark.parametrize("is_new", [True, False])
@pytest.mark.parametrize(
"is_enabled, has_inquiry, computed_result, expected",
[
[True, True, RESULT_SUCCESS, True], # feature enabled, result is success
[True, True, RESULT_DENIED, False], # feature enabled, result is denied
[True, True, RESULT_UNKNOWN, False], # feature enabled, result is unknown
[False, False, None, True], # feature disabled
[True, False, None, False], # feature enabled, no result
],
)
def test_activate_user(
mocker, user, is_active, is_new, is_enabled, has_inquiry, computed_result, expected
): # pylint: disable=too-many-arguments
"""Test that activate_user takes the correct action"""
user.is_active = is_active
if has_inquiry:
ExportsInquiryLogFactory.create(user=user, computed_result=computed_result)

mocker.patch(
"authentication.pipeline.user.compliance_api.is_exports_verification_enabled",
return_value=is_enabled,
)

assert user_actions.activate_user(None, None, user=user, is_new=is_new) == {}

if not user.is_active and is_new:
# only if the user is inactive and just registered
assert user.is_active is expected
13 changes: 13 additions & 0 deletions authentication/serializers.py
Expand Up @@ -21,6 +21,8 @@
RequireProviderException,
RequireRegistrationException,
RequireProfileException,
UserExportBlockedException,
UserTryAgainLaterException,
)
from authentication.utils import SocialAuthState

Expand Down Expand Up @@ -284,6 +286,17 @@ def create(self, validated_data):
result = SocialAuthState(
SocialAuthState.STATE_REGISTER_EXTRA_DETAILS, partial=exc.partial
)
except UserExportBlockedException:
result = SocialAuthState(
SocialAuthState.STATE_USER_BLOCKED,
errors=["Unable to complete registration, please contact support"],
)
except UserTryAgainLaterException:
result = SocialAuthState(
SocialAuthState.STATE_ERROR_TEMPORARY,
errors=["Unable to register at this time, please try again later"],
)

return result


Expand Down
2 changes: 2 additions & 0 deletions authentication/utils.py
Expand Up @@ -24,8 +24,10 @@ class SocialAuthState:
# end states
STATE_SUCCESS = "success"
STATE_ERROR = "error"
STATE_ERROR_TEMPORARY = "error-temporary"
STATE_INACTIVE = "inactive"
STATE_INVALID_EMAIL = "invalid-email"
STATE_USER_BLOCKED = "user-blocked"

def __init__(
self,
Expand Down

0 comments on commit 5a37736

Please sign in to comment.