-
Notifications
You must be signed in to change notification settings - Fork 11
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: provisioning api skeleton #638
Merged
+1,306
−963
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The entire concept of adding a new system-wide role is new to me. Don't we have to create this on the edxapp side now? Why not just assign PROVISIONING_ADMIN_ROLE to SYSTEM_ENTERPRISE_OPERATOR_ROLE so that only backend services and operational staff can call this endpoint?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good question - this role already exists (which surprised me). I'm not quite sure of the motivation for it, but it's there so I used it. But maybe we should additionally assign the provisioning feature role to the system operator role.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed! ➕ 1️⃣