Skip to content

Commit

Permalink
feat: add provisioning API
Browse files Browse the repository at this point in the history
Adds skeleton of a provisioning API, without yet implementing the business logic.
ENT-10071
  • Loading branch information
iloveagent57 committed Feb 24, 2025
1 parent 3979865 commit a951ee9
Show file tree
Hide file tree
Showing 18 changed files with 1,306 additions and 963 deletions.
50 changes: 50 additions & 0 deletions docs/decisions/0024-provisioning-api.rst
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.
1 change: 1 addition & 0 deletions enterprise_access/apps/api/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
AssignmentConfigurationResponseSerializer,
AssignmentConfigurationUpdateRequestSerializer
)
from .provisioning import ProvisioningRequestSerializer, ProvisioningResponseSerializer
from .subsidy_access_policy import (
GroupMemberWithAggregatesRequestSerializer,
GroupMemberWithAggregatesResponseSerializer,
Expand Down
90 changes: 90 additions & 0 deletions enterprise_access/apps/api/serializers/provisioning.py
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 enterprise_access/apps/api/v1/tests/test_provisioning_views.py
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
5 changes: 5 additions & 0 deletions enterprise_access/apps/api/v1/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@
views.SubsidyAccessPolicyGroupViewset.as_view({'get': 'get_group_member_data_with_aggregates'}),
name='aggregated-subsidy-enrollments'
),
path(
'provisioning',
views.ProvisioningCreateView.as_view(),
name='provisioning-create',
),
]

urlpatterns += router.urls
1 change: 1 addition & 0 deletions enterprise_access/apps/api/v1/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from .content_assignments.assignment_configuration import AssignmentConfigurationViewSet
from .content_assignments.assignments import LearnerContentAssignmentViewSet
from .content_assignments.assignments_admin import LearnerContentAssignmentAdminViewSet
from .provisioning import ProvisioningCreateView
from .subsidy_access_policy import (
SubsidyAccessPolicyAllocateViewset,
SubsidyAccessPolicyGroupViewset,
Expand Down
40 changes: 40 additions & 0 deletions enterprise_access/apps/api/v1/views/provisioning.py
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)
4 changes: 4 additions & 0 deletions enterprise_access/apps/core/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
SYSTEM_ENTERPRISE_ADMIN_ROLE = 'enterprise_admin'
SYSTEM_ENTERPRISE_LEARNER_ROLE = 'enterprise_learner'
SYSTEM_ENTERPRISE_OPERATOR_ROLE = 'enterprise_openedx_operator'
SYSTEM_ENTERPRISE_PROVISIONING_ADMIN_ROLE = 'enterprise_provisioning_admin'

REQUESTS_ADMIN_ACCESS_PERMISSION = 'requests.has_admin_access'
REQUESTS_ADMIN_LEARNER_ACCESS_PERMISSION = 'requests.has_learner_or_admin_access'
Expand All @@ -33,6 +34,9 @@
BFF_OPERATOR_ROLE = 'enterprise_access_bff_operator'
BFF_READ_PERMISSION = 'bff.has_read_access'

PROVISIONING_ADMIN_ROLE = 'provisioning_admin'
PROVISIONING_CREATE_PERMISSION = 'provisioning.can_create'

ALL_ACCESS_CONTEXT = '*'


Expand Down
23 changes: 23 additions & 0 deletions enterprise_access/apps/core/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,10 +278,28 @@ def has_explicit_access_to_bff_operator(user, enterprise_customer_uuid):
return _has_explicit_access_to_role(user, enterprise_customer_uuid, constants.BFF_OPERATOR_ROLE)


@rules.predicate
def has_implicit_access_to_provisioning_admin(_, *args, **kwargs):
"""
Check if request user has implicit access to the provisioning admin role.
Note, there is no enterprise customer context against which access to this
role is checked.
Returns:
boolean: whether the request user has access.
"""
return request_user_has_implicit_access_via_jwt(
get_decoded_jwt(crum.get_current_request()),
constants.PROVISIONING_ADMIN_ROLE,
context=None,
)


######################################################
# Consolidate implicit and explicit rule predicates. #
######################################################


has_subsidy_request_admin_access = (
has_implicit_access_to_requests_admin | has_explicit_access_to_requests_admin
)
Expand Down Expand Up @@ -434,3 +452,8 @@ def has_explicit_access_to_bff_operator(user, enterprise_customer_uuid):
has_bff_operator_access
),
)

rules.add_perm(
constants.PROVISIONING_CREATE_PERMISSION,
has_implicit_access_to_provisioning_admin,
)
8 changes: 7 additions & 1 deletion enterprise_access/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@
CONTENT_ASSIGNMENTS_ADMIN_ROLE,
CONTENT_ASSIGNMENTS_LEARNER_ROLE,
CONTENT_ASSIGNMENTS_OPERATOR_ROLE,
PROVISIONING_ADMIN_ROLE,
REQUESTS_ADMIN_ROLE,
REQUESTS_LEARNER_ROLE,
SUBSIDY_ACCESS_POLICY_LEARNER_ROLE,
SUBSIDY_ACCESS_POLICY_OPERATOR_ROLE,
SYSTEM_ENTERPRISE_ADMIN_ROLE,
SYSTEM_ENTERPRISE_LEARNER_ROLE,
SYSTEM_ENTERPRISE_OPERATOR_ROLE
SYSTEM_ENTERPRISE_OPERATOR_ROLE,
SYSTEM_ENTERPRISE_PROVISIONING_ADMIN_ROLE
)
from enterprise_access.settings.utils import get_logger_config

Expand Down Expand Up @@ -58,6 +60,7 @@ def root(*path_fragments):
'csrf.apps.CsrfAppConfig', # Enables frontend apps to retrieve CSRF tokens,
'djangoql',
'django_celery_results',
'django_countries',
'django_filters',
'django_object_actions',
'rest_framework',
Expand Down Expand Up @@ -344,6 +347,9 @@ def root(*path_fragments):
REQUESTS_LEARNER_ROLE,
BFF_LEARNER_ROLE,
],
SYSTEM_ENTERPRISE_PROVISIONING_ADMIN_ROLE: [
PROVISIONING_ADMIN_ROLE,
],
}

# Request the user's permissions in the ID token
Expand Down
Loading

0 comments on commit a951ee9

Please sign in to comment.