diff --git a/enterprise_access/apps/api/v1/tests/test_provisioning_views.py b/enterprise_access/apps/api/v1/tests/test_provisioning_views.py index 21d6689a..3b81904b 100644 --- a/enterprise_access/apps/api/v1/tests/test_provisioning_views.py +++ b/enterprise_access/apps/api/v1/tests/test_provisioning_views.py @@ -2,6 +2,7 @@ Tests for the provisioning views. """ import uuid +from unittest import mock import ddt from edx_rbac.constants import ALL_ACCESS_CONTEXT @@ -43,11 +44,6 @@ class TestProvisioningAuth(APITest): {'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE, 'context': ALL_ACCESS_CONTEXT}, status.HTTP_403_FORBIDDEN, ), - # Even operators can't provision - ( - {'system_wide_role': SYSTEM_ENTERPRISE_OPERATOR_ROLE, 'context': ALL_ACCESS_CONTEXT}, - status.HTTP_403_FORBIDDEN, - ), # No JWT based auth, no soup for you. ( None, @@ -66,15 +62,26 @@ def test_provisioning_create_view_forbidden(self, role_context_dict, expected_re response = self.client.post(PROVISIONING_CREATE_ENDPOINT) assert response.status_code == expected_response_code - def test_provisioning_create_allowed_for_provisioning_admins(self): + @ddt.data( + ( + {'system_wide_role': SYSTEM_ENTERPRISE_OPERATOR_ROLE, 'context': ALL_ACCESS_CONTEXT}, + status.HTTP_201_CREATED, + ), + ( + {'system_wide_role': SYSTEM_ENTERPRISE_PROVISIONING_ADMIN_ROLE, 'context': ALL_ACCESS_CONTEXT}, + status.HTTP_201_CREATED, + ), + ) + @ddt.unpack + @mock.patch('enterprise_access.apps.api.v1.views.provisioning.provisioning_api') + def test_provisioning_create_allowed_for_provisioning_admins( + self, role_context_dict, expected_response_code, mock_provisioning_api, + ): """ Tests that we get expected 200 response for the provisioning create view when the requesting user has the correct system role and provides a valid request payload. """ - self.set_jwt_cookie([{ - 'system_wide_role': SYSTEM_ENTERPRISE_PROVISIONING_ADMIN_ROLE, - 'context': ALL_ACCESS_CONTEXT, - }]) + self.set_jwt_cookie([role_context_dict]) request_payload = { "enterprise_customer": { @@ -89,4 +96,239 @@ def test_provisioning_create_allowed_for_provisioning_admins(self): ], } response = self.client.post(PROVISIONING_CREATE_ENDPOINT, data=request_payload) + assert response.status_code == expected_response_code + + mock_provisioning_api.get_or_create_enterprise_customer.assert_called_once_with( + **request_payload['enterprise_customer'], + ) + + created_customer = mock_provisioning_api.get_or_create_enterprise_customer.return_value + mock_provisioning_api.get_or_create_enterprise_admin_users.assert_called_once_with( + enterprise_customer_uuid=created_customer['uuid'], + user_emails=['test-admin@example.com'], + ) + + +@ddt.ddt +class TestProvisioningEndToEnd(APITest): + """ + Tests end-to-end calls to provisioning endpoints through mocked-out calls + to downstream services. + """ + def setUp(self): + super().setUp() + self.set_jwt_cookie([ + { + 'system_wide_role': SYSTEM_ENTERPRISE_PROVISIONING_ADMIN_ROLE, + 'context': ALL_ACCESS_CONTEXT, + }, + ]) + + @ddt.data( + # Data representing the state where a net-new customer is created. + { + 'existing_customer_data': None, + 'created_customer_data': { + 'name': 'Test Customer', + 'slug': 'test-customer', + 'country': 'US', + 'uuid': TEST_ENTERPRISE_UUID, + }, + 'expected_get_customer_kwargs': { + 'enterprise_customer_slug': 'test-customer', + }, + 'create_customer_called': True, + 'expected_create_customer_kwargs': { + 'name': 'Test Customer', + 'slug': 'test-customer', + 'country': 'US', + }, + }, + # Data representing the state where a customer with the given slug exists. + { + 'existing_customer_data': { + 'name': 'Test Customer', + 'slug': 'test-customer', + 'country': 'US', + 'uuid': TEST_ENTERPRISE_UUID, + }, + 'created_customer_data': None, + 'expected_get_customer_kwargs': { + 'enterprise_customer_slug': 'test-customer', + }, + 'create_customer_called': False, + 'expected_create_customer_kwargs': None + }, + ) + @mock.patch('enterprise_access.apps.provisioning.api.LmsApiClient') + def test_get_or_create_customer_and_admins_created(self, test_data, mock_lms_api_client): + """ + Tests cases where admins don't exist and customer is fetched or created. + """ + mock_client = mock_lms_api_client.return_value + mock_client.get_enterprise_customer_data.return_value = test_data['existing_customer_data'] + mock_client.get_enterprise_admin_users.return_value = [] + mock_client.get_enterprise_pending_admin_users.return_value = [] + + if test_data['created_customer_data']: + mock_client.create_enterprise_customer.return_value = test_data['created_customer_data'] + + mock_client.create_enterprise_admin_user.side_effect = [ + {'user_email': 'alice@foo.com', 'enterprise_customer_uuid': TEST_ENTERPRISE_UUID}, + {'user_email': 'bob@foo.com', 'enterprise_customer_uuid': TEST_ENTERPRISE_UUID}, + ] + + request_payload = { + "enterprise_customer": { + 'name': 'Test Customer', + 'slug': 'test-customer', + 'country': 'US', + }, + "pending_admins": [ + {"user_email": "alice@foo.com"}, + {"user_email": "bob@foo.com"}, + ], + } + response = self.client.post(PROVISIONING_CREATE_ENDPOINT, data=request_payload) assert response.status_code == status.HTTP_201_CREATED + + mock_client.get_enterprise_customer_data.assert_called_once_with( + **test_data['expected_get_customer_kwargs'], + ) + if test_data['create_customer_called']: + mock_client.create_enterprise_customer.assert_called_once_with( + **test_data['expected_create_customer_kwargs'], + ) + else: + self.assertFalse(mock_client.create_enterprise_customer.called) + + mock_client.get_enterprise_admin_users.assert_called_once_with(TEST_ENTERPRISE_UUID) + mock_client.get_enterprise_pending_admin_users.assert_called_once_with(TEST_ENTERPRISE_UUID) + mock_client.create_enterprise_admin_user.assert_has_calls([ + mock.call(TEST_ENTERPRISE_UUID, 'alice@foo.com'), + mock.call(TEST_ENTERPRISE_UUID, 'bob@foo.com'), + ], any_order=True) + + @ddt.data( + # No admin users exist, two pending admins created. + { + 'existing_admin_users': [], + 'existing_pending_admin_users': [], + 'create_pending_admins_called': True, + 'create_admin_user_side_effect': [ + {'user_email': 'alice@foo.com', 'enterprise_customer_uuid': TEST_ENTERPRISE_UUID}, + {'user_email': 'bob@foo.com', 'enterprise_customer_uuid': TEST_ENTERPRISE_UUID}, + ], + 'expected_create_pending_admin_calls': [ + mock.call(TEST_ENTERPRISE_UUID, 'alice@foo.com'), + mock.call(TEST_ENTERPRISE_UUID, 'bob@foo.com'), + ], + }, + # One pending admin exists, one new one created. + { + 'existing_admin_users': [], + 'existing_pending_admin_users': [ + {'user_email': 'alice@foo.com', 'enterprise_customer_uuid': TEST_ENTERPRISE_UUID}, + ], + 'create_pending_admins_called': True, + 'create_admin_user_side_effect': [ + {'user_email': 'bob@foo.com', 'enterprise_customer_uuid': TEST_ENTERPRISE_UUID}, + ], + 'expected_create_pending_admin_calls': [ + mock.call(TEST_ENTERPRISE_UUID, 'bob@foo.com'), + ], + }, + # One full admin exists, one new pending admin created. + { + 'existing_admin_users': [ + {'email': 'alice@foo.com', 'enterprise_customer_uuid': TEST_ENTERPRISE_UUID}, + ], + 'existing_pending_admin_users': [], + 'create_pending_admins_called': True, + 'create_admin_user_side_effect': [ + {'user_email': 'bob@foo.com', 'enterprise_customer_uuid': TEST_ENTERPRISE_UUID}, + ], + 'expected_create_pending_admin_calls': [ + mock.call(TEST_ENTERPRISE_UUID, 'bob@foo.com'), + ], + }, + # One full admin exists, one pending exists, none created. + { + 'existing_admin_users': [ + {'email': 'alice@foo.com', 'enterprise_customer_uuid': TEST_ENTERPRISE_UUID}, + ], + 'existing_pending_admin_users': [ + {'user_email': 'bob@foo.com', 'enterprise_customer_uuid': TEST_ENTERPRISE_UUID}, + ], + 'create_pending_admins_called': False, + 'create_admin_user_side_effect': None, + 'expected_create_pending_admin_calls': [], + }, + ) + @mock.patch('enterprise_access.apps.provisioning.api.LmsApiClient') + def test_customer_fetched_admins_fetched_or_created(self, test_data, mock_lms_api_client): + """ + Tests cases where [pending]admins are fetched or created, but the customer + already exists + """ + mock_client = mock_lms_api_client.return_value + mock_client.get_enterprise_customer_data.return_value = { + 'name': 'Test Customer', + 'slug': 'test-customer', + 'country': 'US', + 'uuid': TEST_ENTERPRISE_UUID, + } + mock_client.get_enterprise_admin_users.return_value = test_data['existing_admin_users'] + mock_client.get_enterprise_pending_admin_users.return_value = test_data['existing_pending_admin_users'] + mock_client.create_enterprise_admin_user.side_effect = test_data['create_admin_user_side_effect'] + + request_payload = { + "enterprise_customer": { + 'name': 'Test Customer', + 'slug': 'test-customer', + 'country': 'US', + }, + "pending_admins": [ + {"user_email": "alice@foo.com"}, + {"user_email": "bob@foo.com"}, + ], + } + response = self.client.post(PROVISIONING_CREATE_ENDPOINT, data=request_payload) + assert response.status_code == status.HTTP_201_CREATED + expected_response_payload = { + "enterprise_customer": { + 'name': 'Test Customer', + 'slug': 'test-customer', + 'country': 'US', + 'uuid': str(TEST_ENTERPRISE_UUID), + }, + "pending_admins": [ + {"user_email": "alice@foo.com"}, + {"user_email": "bob@foo.com"}, + ], + } + actual_response_payload = response.json() + self.assertEqual( + actual_response_payload['enterprise_customer'], + expected_response_payload['enterprise_customer'], + ) + self.assertCountEqual( + actual_response_payload['pending_admins'], + expected_response_payload['pending_admins'], + ) + self.assertEqual(actual_response_payload.keys(), expected_response_payload.keys()) + + mock_client.get_enterprise_customer_data.assert_called_once_with( + enterprise_customer_slug='test-customer', + ) + self.assertFalse(mock_client.create_enterprise_customer.called) + + mock_client.get_enterprise_admin_users.assert_called_once_with(TEST_ENTERPRISE_UUID) + mock_client.get_enterprise_pending_admin_users.assert_called_once_with(TEST_ENTERPRISE_UUID) + if test_data['create_pending_admins_called']: + mock_client.create_enterprise_admin_user.assert_has_calls( + test_data['expected_create_pending_admin_calls'], + any_order=True, + ) + else: + self.assertFalse(mock_client.create_enterprise_admin_user.called) diff --git a/enterprise_access/apps/api/v1/views/provisioning.py b/enterprise_access/apps/api/v1/views/provisioning.py index df0f182e..f63ba06a 100644 --- a/enterprise_access/apps/api/v1/views/provisioning.py +++ b/enterprise_access/apps/api/v1/views/provisioning.py @@ -3,20 +3,31 @@ """ import logging +import requests from drf_spectacular.utils import extend_schema from edx_rbac.mixins import PermissionRequiredMixin from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication -from rest_framework import generics, permissions, status +from rest_framework import exceptions, generics, permissions, status from rest_framework.response import Response from enterprise_access.apps.api import serializers from enterprise_access.apps.core import constants +from enterprise_access.apps.provisioning import api as provisioning_api logger = logging.getLogger(__name__) PROVISIONING_API_TAG = 'Provisioning' +class ProvisioningException(exceptions.APIException): + """ + General provisioning-related API exception. + """ + status_code = 422 + default_detail = 'Could not execute this provisioning request' + default_code = 'provisioning_error' + + @extend_schema( tags=[PROVISIONING_API_TAG], summary='Create a new provisioning request.', @@ -37,4 +48,41 @@ class ProvisioningCreateView(PermissionRequiredMixin, generics.CreateAPIView): def create(self, request, *args, **kwargs): request_serializer = serializers.ProvisioningRequestSerializer(data=request.data) request_serializer.is_valid(raise_exception=True) - return Response('ack', status=status.HTTP_201_CREATED) + + customer_request_data = request_serializer.validated_data['enterprise_customer'] + try: + created_customer = provisioning_api.get_or_create_enterprise_customer( + name=customer_request_data['name'], + country=customer_request_data['country'], + slug=customer_request_data['slug'], + ) + except requests.exceptions.HTTPError as exc: + raise ProvisioningException( + detail=f'Error get/creating customer record: {exc}', + code='customer_provisioning_error', + ) from exc + + admin_emails = [ + record.get('user_email') + for record in request_serializer.validated_data['pending_admins'] + ] + + try: + customer_admin_emails = provisioning_api.get_or_create_enterprise_admin_users( + enterprise_customer_uuid=created_customer['uuid'], + user_emails=admin_emails, + ) + except requests.exceptions.HTTPError as exc: + raise ProvisioningException( + detail=f'Error get/creating admin records: {exc}', + code='admin_provisioning_error', + ) from exc + + response_serializer = serializers.ProvisioningResponseSerializer({ + 'enterprise_customer': created_customer, + 'pending_admins': [{'user_email': email} for email in customer_admin_emails], + }) + return Response( + response_serializer.data, + status=status.HTTP_201_CREATED, + ) diff --git a/enterprise_access/apps/api_client/lms_client.py b/enterprise_access/apps/api_client/lms_client.py index cfbf5e22..aa8943c9 100755 --- a/enterprise_access/apps/api_client/lms_client.py +++ b/enterprise_access/apps/api_client/lms_client.py @@ -196,6 +196,33 @@ def get_enterprise_admin_users(self, enterprise_customer_uuid): return results + def get_enterprise_pending_admin_users(self, enterprise_customer_uuid): + """ + Gets all pending enterprise admin records for the given customer uuid. + + Arguments: + enterprise_customer_uuid (UUID): UUID of the enterprise customer. + Returns: + List of dictionaries of pending admin users. + """ + response = self.client.get( + self.pending_enterprise_admin_endpoint + f'?enterprise_customer={enterprise_customer_uuid}', + timeout=settings.LMS_CLIENT_TIMEOUT, + ) + try: + response.raise_for_status() + logger.info( + 'Fetched pending admin records for customer %s', enterprise_customer_uuid, + ) + payload = response.json() + return payload.get('results', []) + except requests.exceptions.HTTPError: + logger.exception( + 'Failed to fetch pending admin record for customer %s: %s', + enterprise_customer_uuid, response.content.decode() + ) + raise + def create_enterprise_admin_user(self, enterprise_customer_uuid, user_email): """ Creates a new enterprise pending admin record. @@ -210,12 +237,12 @@ def create_enterprise_admin_user(self, enterprise_customer_uuid, user_email): 'enterprise_customer': enterprise_customer_uuid, 'user_email': user_email, } + response = self.client.post( + self.pending_enterprise_admin_endpoint, + json=payload, + timeout=settings.LMS_CLIENT_TIMEOUT, + ) try: - response = self.client.post( - self.pending_enterprise_admin_endpoint, - json=payload, - timeout=settings.LMS_CLIENT_TIMEOUT, - ) response.raise_for_status() logger.info( 'Successfully created pending admin record for customer %s, email %s', @@ -225,8 +252,8 @@ def create_enterprise_admin_user(self, enterprise_customer_uuid, user_email): return payload except requests.exceptions.HTTPError: logger.exception( - 'Failed to create pending admin record for customer %s, email %s', - enterprise_customer_uuid, user_email, + 'Failed to create pending admin record for customer %s, email %s: %s', + enterprise_customer_uuid, user_email, response.content.decode() ) raise diff --git a/enterprise_access/apps/api_client/tests/test_lms_client.py b/enterprise_access/apps/api_client/tests/test_lms_client.py index d5584d63..7f990a0e 100644 --- a/enterprise_access/apps/api_client/tests/test_lms_client.py +++ b/enterprise_access/apps/api_client/tests/test_lms_client.py @@ -216,6 +216,43 @@ def test_create_enterprise_customer_data(self, mock_oauth_client, mock_json): timeout=settings.LMS_CLIENT_TIMEOUT, ) + @mock.patch('requests.Response.json') + @mock.patch('enterprise_access.apps.api_client.base_oauth.OAuthAPIClient') + def test_get_enterprise_pending_admin_users(self, mock_oauth_client, mock_json): + """ + Test that we can use the LmsApiClient to fetch existing pending admin records. + """ + customer_uuid = str(uuid4()) + + mock_response_payload_results = [{ + 'id': 1, + 'enterprise_customer': customer_uuid, + 'user_email': 'test-existing-admin@example.com', + }] + mock_response_payload = { + 'count': 1, + 'results': mock_response_payload_results, + } + mock_json.return_value = mock_response_payload + + mock_get = mock_oauth_client.return_value.get + + mock_get.return_value = requests.Response() + mock_get.return_value.status_code = 200 + + client = LmsApiClient() + response_payload = client.get_enterprise_pending_admin_users(customer_uuid) + + self.assertEqual(response_payload, mock_response_payload_results) + expected_url = ( + 'http://edx-platform.example.com/enterprise/api/v1/pending-enterprise-admin/' + f'?enterprise_customer={customer_uuid}' + ) + mock_get.assert_called_once_with( + expected_url, + timeout=settings.LMS_CLIENT_TIMEOUT, + ) + @mock.patch('requests.Response.json') @mock.patch('enterprise_access.apps.api_client.base_oauth.OAuthAPIClient') def test_create_enterprise_admin_user(self, mock_oauth_client, mock_json): @@ -316,6 +353,31 @@ def test_create_enterprise_admin_error(self, mock_oauth_client): timeout=settings.LMS_CLIENT_TIMEOUT, ) + @mock.patch('enterprise_access.apps.api_client.base_oauth.OAuthAPIClient') + def test_get_enterprise_pending_admin_error(self, mock_oauth_client): + """ + Tests that we raise an exception appropriately when listing pending + admin records with the LmsApiClient(). + """ + customer_uuid = str(uuid4()) + mock_get = mock_oauth_client.return_value.get + + mock_get.side_effect = requests.exceptions.HTTPError('whoopsie') + mock_get.return_value.status_code = 400 + + client = LmsApiClient() + with self.assertRaises(requests.exceptions.HTTPError): + client.get_enterprise_pending_admin_users(customer_uuid) + + expected_url = ( + 'http://edx-platform.example.com/enterprise/api/v1/pending-enterprise-admin/' + f'?enterprise_customer={customer_uuid}' + ) + mock_get.assert_called_once_with( + expected_url, + timeout=settings.LMS_CLIENT_TIMEOUT, + ) + @mock.patch('enterprise_access.apps.api_client.base_oauth.OAuthAPIClient') def test_unlink_users_from_enterprise(self, mock_oauth_client): """ diff --git a/enterprise_access/apps/provisioning/__init__.py b/enterprise_access/apps/provisioning/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/enterprise_access/apps/provisioning/api.py b/enterprise_access/apps/provisioning/api.py new file mode 100644 index 00000000..c8ec3b70 --- /dev/null +++ b/enterprise_access/apps/provisioning/api.py @@ -0,0 +1,64 @@ +""" +Python API for provisioning operations. +""" +import logging + +from ..api_client.lms_client import LmsApiClient + +logger = logging.getLogger(__name__) + + +def get_or_create_enterprise_customer(*, name, slug, country, **kwargs): + """ + Get or creates an enterprise customer with the provided arguments. + """ + client = LmsApiClient() + existing_customer = client.get_enterprise_customer_data(enterprise_customer_slug=slug) + if existing_customer: + logger.info('Provisioning: enterprise_customer slug %s already exists', slug) + return existing_customer + + created_customer = client.create_enterprise_customer( + name=name, slug=slug, country=country, **kwargs, + ) + logger.info('Provisioning: created enterprise customer with slug %s', slug) + return created_customer + + +def get_or_create_enterprise_admin_users(enterprise_customer_uuid, user_emails): + """ + Creates pending admin records from the given ``user_email`` for the customer + identified by ``enterprise_customer_uuid``. + """ + client = LmsApiClient() + existing_admins = client.get_enterprise_admin_users(enterprise_customer_uuid) + existing_admin_emails = {record['email'] for record in existing_admins} + logger.info( + 'Provisioning: customer %s has existing admin emails %s', + enterprise_customer_uuid, + existing_admin_emails, + ) + + existing_pending_admins = client.get_enterprise_pending_admin_users(enterprise_customer_uuid) + existing_pending_admin_emails = {record['user_email'] for record in existing_pending_admins} + logger.info( + 'Provisioning: customer %s has existing pending admin emails %s', + enterprise_customer_uuid, + existing_pending_admin_emails, + ) + + user_emails_to_create = list( + (set(user_emails) - existing_admin_emails) - existing_pending_admin_emails + ) + + created_admins = [] + for user_email in user_emails_to_create: + result = client.create_enterprise_admin_user(enterprise_customer_uuid, user_email) + created_admins.append(result['user_email']) + logger.info( + 'Provisioning: created admin %s for customer %s', + user_email, + enterprise_customer_uuid, + ) + + return created_admins + list(existing_pending_admin_emails) + list(existing_admin_emails) diff --git a/enterprise_access/settings/base.py b/enterprise_access/settings/base.py index 533f8dfd..2c7c7159 100644 --- a/enterprise_access/settings/base.py +++ b/enterprise_access/settings/base.py @@ -332,6 +332,7 @@ def root(*path_fragments): CONTENT_ASSIGNMENTS_OPERATOR_ROLE, REQUESTS_ADMIN_ROLE, BFF_OPERATOR_ROLE, + PROVISIONING_ADMIN_ROLE, ], SYSTEM_ENTERPRISE_ADMIN_ROLE: [ # enterprise admins only need learner-level access to Subsidy Access Policy APIs since they aren't responsible