-
Notifications
You must be signed in to change notification settings - Fork 11
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Adds skeleton of a provisioning API, without yet implementing the business logic. ENT-10071
- Loading branch information
1 parent
3979865
commit a951ee9
Showing
18 changed files
with
1,306 additions
and
963 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,50 @@ | ||
0024 A Customer/Subsidy Provisioning API | ||
**************************************** | ||
|
||
Status | ||
====== | ||
**In progress** (February 2025) | ||
|
||
Context | ||
======= | ||
We want to be able to automatically provision new customer records and subscription plans | ||
(and eventually learner credit budgets) with a single API call from a client. | ||
For example, the following business records will need to be created | ||
(via service-to-service API calls) in the provisioning of a net-new subscription customer: | ||
|
||
1. Creation of an EnterpriseCustomer record. | ||
2. Creation of PendingEnterpriseCustomerAdmin record(s). | ||
3. Creation of an EnterpriseCustomerCatalog record. | ||
4. Creation of a CustomerAgreement record. | ||
5. Creation of a SubscriptionPlan record. | ||
|
||
Such an endpoint would be helpful not just for external clients (customers), but also | ||
from internal tools, such as the enterprise provisioning page in the support-tools MFE, | ||
and back-office tools and systems that fulfill enterprise subsidy contracts. | ||
|
||
Decision | ||
======== | ||
1. We'll start by introducing a provisioning API with a single endpoint that supports | ||
the creation of new subscription-based customers. | ||
2. In the future, we'll add explicit endpoints to modify existing business records or to add | ||
new subsidy records for existing customers. | ||
3. We'll model the provisioning business logic as a "workflow" or "pipeline", in which | ||
each step of the pipeline is assumed to be idempotent. For the sake of the ``create`` endpoint, | ||
this means that each step can be thought of as a get-or-create action. | ||
|
||
Alternatives Considered | ||
======================= | ||
We've previously considered requiring clients to call each required business domain API, | ||
in the correct sequence, with a valid set of inputs to each. This was rejected because | ||
it requires that any client with a need to provision enterprise business records acquire | ||
the requisite domain expertise to understand what each business record represents, maintain | ||
the correct sequential order, gracefully handle exceptions, and so on. That (rejected) | ||
solution may also hamper our ability to introduce new business domain models or flows in the future | ||
(because it would risk breaking clients' existing provisioning flows). | ||
|
||
Consequences | ||
============ | ||
The serializers we create will define the interface and boundary behavior between | ||
the provisioning API and clients thereof. We'll have to balance how flexiblility | ||
of this boundary with other attributes, such as how prescriptive the input side of the | ||
boundary is, and how extensible a provisioning flow can be. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,90 @@ | ||
""" | ||
Serializers for the provisioning app. | ||
""" | ||
import logging | ||
|
||
from django_countries.serializer_fields import CountryField | ||
from rest_framework import serializers | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
|
||
class BaseSerializer(serializers.Serializer): | ||
""" | ||
Base implementation for request and response serializers. | ||
""" | ||
def create(self, *args, **kwargs): | ||
return None | ||
|
||
def update(self, *args, **kwargs): | ||
return None | ||
|
||
|
||
## All the REQUEST serializers go under here ## | ||
|
||
|
||
class EnterpriseCustomerRequestSerializer(BaseSerializer): | ||
""" | ||
Customer object serializer for provisioning requests. | ||
""" | ||
name = serializers.CharField( | ||
help_text='The unique name of the Enterprise Customer.', | ||
) | ||
country = CountryField( | ||
help_text='The two letter ISO 3166-2 ISO code representing the customer country.', | ||
) | ||
slug = serializers.SlugField( | ||
help_text='An optional customer slug. One will be generated if not provided.', | ||
required=False, | ||
allow_blank=True, | ||
) | ||
|
||
|
||
class PendingCustomerAdminRequestSerializer(BaseSerializer): | ||
""" | ||
Pending admin serializer for provisioning requests. | ||
""" | ||
user_email = serializers.EmailField( | ||
help_text='The email address of the requested admin.', | ||
) | ||
|
||
|
||
class ProvisioningRequestSerializer(BaseSerializer): | ||
""" | ||
Request serializer for provisioning create view. | ||
""" | ||
enterprise_customer = EnterpriseCustomerRequestSerializer( | ||
help_text='Object describing the requested Enterprise Customer.' | ||
) | ||
pending_admins = PendingCustomerAdminRequestSerializer( | ||
help_text='List of objects containing requested customer admin email addresses.', | ||
many=True, | ||
) | ||
|
||
|
||
## All the RESPONSE serializers go under here ## | ||
|
||
|
||
class EnterpriseCustomerResponseSerializer(BaseSerializer): | ||
""" | ||
Customer object serializer for provisioning responses. | ||
""" | ||
uuid = serializers.UUIDField() | ||
name = serializers.CharField() | ||
country = CountryField() | ||
slug = serializers.SlugField(required=True, allow_blank=False) | ||
|
||
|
||
class PendingCustomerAdminResponseSerializer(BaseSerializer): | ||
""" | ||
Pending admin serializer for provisioning responses. | ||
""" | ||
user_email = serializers.EmailField() | ||
|
||
|
||
class ProvisioningResponseSerializer(BaseSerializer): | ||
""" | ||
Response serializer for provisioning create view. | ||
""" | ||
enterprise_customer = EnterpriseCustomerResponseSerializer() | ||
pending_admins = PendingCustomerAdminResponseSerializer(many=True) |
92 changes: 92 additions & 0 deletions
92
enterprise_access/apps/api/v1/tests/test_provisioning_views.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,92 @@ | ||
""" | ||
Tests for the provisioning views. | ||
""" | ||
import uuid | ||
|
||
import ddt | ||
from edx_rbac.constants import ALL_ACCESS_CONTEXT | ||
from rest_framework import status | ||
from rest_framework.reverse import reverse | ||
|
||
from enterprise_access.apps.core.constants import ( | ||
SYSTEM_ENTERPRISE_ADMIN_ROLE, | ||
SYSTEM_ENTERPRISE_LEARNER_ROLE, | ||
SYSTEM_ENTERPRISE_OPERATOR_ROLE, | ||
SYSTEM_ENTERPRISE_PROVISIONING_ADMIN_ROLE | ||
) | ||
from test_utils import APITest | ||
|
||
PROVISIONING_CREATE_ENDPOINT = reverse('api:v1:provisioning-create') | ||
|
||
TEST_ENTERPRISE_UUID = uuid.uuid4() | ||
|
||
|
||
@ddt.ddt | ||
class TestProvisioningAuth(APITest): | ||
""" | ||
Tests Authentication and Permission checking for provisioning. | ||
""" | ||
@ddt.data( | ||
# A role that's not mapped to any feature perms will get you a 403. | ||
( | ||
{'system_wide_role': 'some-other-role', 'context': str(TEST_ENTERPRISE_UUID)}, | ||
status.HTTP_403_FORBIDDEN, | ||
), | ||
# A good learner role, AND in the correct context/customer STILL gets you a 403. | ||
# Provisioning APIs are inaccessible to all learners. | ||
( | ||
{'system_wide_role': SYSTEM_ENTERPRISE_LEARNER_ROLE, 'context': ALL_ACCESS_CONTEXT}, | ||
status.HTTP_403_FORBIDDEN, | ||
), | ||
# An admin role is not authorized to provision. | ||
( | ||
{'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, | ||
status.HTTP_401_UNAUTHORIZED, | ||
), | ||
) | ||
@ddt.unpack | ||
def test_provisioning_create_view_forbidden(self, role_context_dict, expected_response_code): | ||
""" | ||
Tests that we get expected 40x responses for the provisioning create view.. | ||
""" | ||
# Set the JWT-based auth that we'll use for every request | ||
if role_context_dict: | ||
self.set_jwt_cookie([role_context_dict]) | ||
|
||
response = self.client.post(PROVISIONING_CREATE_ENDPOINT) | ||
assert response.status_code == expected_response_code | ||
|
||
def test_provisioning_create_allowed_for_provisioning_admins(self): | ||
""" | ||
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, | ||
}]) | ||
|
||
request_payload = { | ||
"enterprise_customer": { | ||
"name": "Test customer", | ||
"country": "US", | ||
"slug": "test-customer", | ||
}, | ||
"pending_admins": [ | ||
{ | ||
"user_email": "[email protected]", | ||
}, | ||
], | ||
} | ||
response = self.client.post(PROVISIONING_CREATE_ENDPOINT, data=request_payload) | ||
assert response.status_code == status.HTTP_201_CREATED |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
""" | ||
Rest API views for the browse and request app. | ||
""" | ||
import logging | ||
|
||
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.response import Response | ||
|
||
from enterprise_access.apps.api import serializers | ||
from enterprise_access.apps.core import constants | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
PROVISIONING_API_TAG = 'Provisioning' | ||
|
||
|
||
@extend_schema( | ||
tags=[PROVISIONING_API_TAG], | ||
summary='Create a new provisioning request.', | ||
request=serializers.ProvisioningRequestSerializer, | ||
responses={ | ||
status.HTTP_200_OK: serializers.ProvisioningResponseSerializer, | ||
status.HTTP_201_CREATED: serializers.ProvisioningResponseSerializer, | ||
}, | ||
) | ||
class ProvisioningCreateView(PermissionRequiredMixin, generics.CreateAPIView): | ||
""" | ||
Create view for provisioning. | ||
""" | ||
authentication_classes = (JwtAuthentication,) | ||
permission_classes = (permissions.IsAuthenticated,) | ||
permission_required = constants.PROVISIONING_CREATE_PERMISSION | ||
|
||
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.