Skip to content

Commit

Permalink
feat: adding skeleton for provisioning create api
Browse files Browse the repository at this point in the history
ENT-9970. Adds a functional (in terms of auth N/Z, routing, tests) provisioning
API skeleton that does no actual provisioning.

* Now has drf-spec stuff configured.
  • Loading branch information
iloveagent57 committed Feb 19, 2025
1 parent 8b16678 commit b26a256
Show file tree
Hide file tree
Showing 19 changed files with 1,358 additions and 1,069 deletions.
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=False, allow_blank=True)


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
1 change: 1 addition & 0 deletions requirements/base.in
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Django>=2.2 # Web application framework
djangoql
django-cors-headers
django-celery-results
django-countries
django-crum
django-extensions
django-filter
Expand Down
Loading

0 comments on commit b26a256

Please sign in to comment.