Skip to content

Commit

Permalink
feat: enterprise sso orchestrator api client implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
alex-sheehan-edx committed Sep 15, 2023
1 parent 023d294 commit 712dccd
Show file tree
Hide file tree
Showing 10 changed files with 435 additions and 15 deletions.
21 changes: 15 additions & 6 deletions enterprise/api/v1/views/enterprise_customer_sso_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,19 +93,22 @@ def oauth_orchestration_complete(self, request, configuration_uuid, *args, **kwa
if not sso_configuration_record:
return Response(status=HTTP_404_NOT_FOUND)

# Small validation logging to ensure data integrity
if not sso_configuration_record.submitted_at:
LOGGER.warning(
f'SSO configuration record {sso_configuration_record.pk} has received a completion callback but has'
' not been marked as submitted.'
)

# Send a notification email to the enterprise associated with the configuration record
send_sso_configured_email.delay(sso_configuration_record.enterprise_customer.uuid)

# Completing the orchestration process means the configuration record is now configured and can be considered
# active
sso_configuration_record.configured_at = localized_utcnow()
sso_configuration_record.active = True
# Completing the orchestration process for the first time means the configuration record is now configured and
# can be considered active. However, subsequent configurations to update the record should not be reactivated,
# nor should the admins be renotified via email.
if not request.query_params.get('updating_existing_record'):
# Send a notification email to the enterprise associated with the configuration record
send_sso_configured_email.delay(sso_configuration_record.enterprise_customer.uuid)
sso_configuration_record.active = True

sso_configuration_record.save()
return Response(status=HTTP_200_OK)

Expand Down Expand Up @@ -179,6 +182,11 @@ def create(self, request, *args, **kwargs):
except TypeError as e:
LOGGER.error(f'{CONFIG_CREATE_ERROR}{e}')
return Response({'error': f'{CONFIG_CREATE_ERROR}{e}'}, status=HTTP_400_BAD_REQUEST)

# Wondering what to do here with error handling
# If we fail to submit for configuration (ie get a network error) should we rollback the created record?
new_record.submit_for_configuration()

return Response({'data': new_record.pk}, status=HTTP_201_CREATED)

@permission_required(
Expand Down Expand Up @@ -212,6 +220,7 @@ def update(self, request, *args, **kwargs):
try:
with transaction.atomic():
sso_configuration_record.update(**request.data.dict())
sso_configuration_record.first().submit_for_configuration(updating_existing_record=True)
except (TypeError, FieldDoesNotExist, ValidationError) as e:
LOGGER.error(f'{CONFIG_UPDATE_ERROR}{e}')
return Response({'error': f'{CONFIG_UPDATE_ERROR}{e}'}, status=HTTP_400_BAD_REQUEST)
Expand Down
6 changes: 3 additions & 3 deletions enterprise/api_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def get_api_url(self, path):

class BackendServiceAPIClient(APIClientMixin):
"""
API client based on OAuthAPIClient to cummunicate with edx services.
API client based on OAuthAPIClient to communicate with edx services.
Uses the backend service user to make requests.
"""
Expand All @@ -58,7 +58,7 @@ def __init__(self):

class UserAPIClient(APIClientMixin):
"""
API client based on requests.Session to cummunicate with edx services.
API client based on requests.Session to communicate with edx services.
Requires user object to instantiate the client with the jwt token authentication.
"""
Expand Down Expand Up @@ -105,7 +105,7 @@ def inner(self, *args, **kwargs):

class NoAuthAPIClient(APIClientMixin):
"""
API client based on requests.Session to cummunicate with edx services.
API client based on requests.Session to communicate with edx services.
Used to call APIs which don't require authentication.
"""
Expand Down
134 changes: 134 additions & 0 deletions enterprise/api_client/sso_orchestrator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
"""
Api client for the SSO Orchestrator API.
"""
from urllib.parse import urljoin

import requests
from edx_rest_api_client.client import get_request_id, user_agent
from requests.auth import HTTPBasicAuth
from requests.exceptions import RequestException
from rest_framework.reverse import reverse

from django.conf import settings

from enterprise.utils import (
get_configuration_value,
get_sso_orchestrator_api_base_url,
get_sso_orchestrator_basic_auth_password,
get_sso_orchestrator_basic_auth_username,
get_sso_orchestrator_configure_path,
)

USER_AGENT = user_agent()


class SsoOrchestratorClientError(RequestException):
"""
Indicate a problem when interacting with an enterprise api client.
"""


class EnterpriseSSOOrchestratorApiClient:
"""
The enterprise API client to communicate with the SSO Orchestrator API. Reads conf settings values to determine
orchestration paths and credentials.
Required settings values:
- LMS_ROOT_URL
- SSO_ORCHESTRATOR_API_BASE_URL
- SSO_ORCHESTRATOR_CONFIGURE_PATH
- SSO_ORCHESTRATOR_BASIC_AUTH_USERNAME
- SSO_ORCHESTRATOR_BASIC_AUTH_PASSWORD
"""

def __init__(self):
self.base_url = get_sso_orchestrator_api_base_url()
self.session = None

def _get_orchestrator_callback_url(self, config_pk):
"""
get the callback url for the SSO Orchestrator API
"""
lms_base_url = get_configuration_value('LMS_ROOT_URL', settings.LMS_ROOT_URL)
if not lms_base_url:
raise SsoOrchestratorClientError(
"Failed to create SSO Orchestrator callback url: LMS_ROOT_URL required.",
)
path = reverse(
'enterprise-customer-sso-configuration-orchestration-complete',
kwargs={'configuration_uuid': config_pk},
)
return urljoin(lms_base_url, path)

def _get_orchestrator_configure_url(self):
"""
get the configure url for the SSO Orchestrator API
"""
# probably want config value validated for this
return urljoin(self.base_url, get_sso_orchestrator_configure_path())

def _create_auth_header(self):
"""
create the basic auth header for requests to the SSO Orchestrator API
"""
if orchestrator_username := get_sso_orchestrator_basic_auth_username():
if orchestrator_password := get_sso_orchestrator_basic_auth_password():
return HTTPBasicAuth(orchestrator_password, orchestrator_username)
else:
raise SsoOrchestratorClientError(
"Failed to create SSO Orchestrator auth headers: username required.",
)
raise SsoOrchestratorClientError(
"Failed to create SSO Orchestrator auth headers: username required.",
)

def _create_session(self):
"""
create a requests session object
"""
if not self.session:
self.session = requests.Session()
self.session.headers['User-Agent'] = USER_AGENT
self.session.headers['X-Request-ID'] = get_request_id()

def _post(self, url, data=None):
"""
make a GET request to the SSO Orchestrator API
"""
self._create_session()
response = self.session.post(url, json=data, auth=self._create_auth_header())
if response.status_code >= 300:
raise SsoOrchestratorClientError(
f"Failed to make SSO Orchestrator API request: {response.status_code}",
response=response,
)
return response.status_code

def configure_sso_orchestration_record(
self,
config_data,
config_pk,
enterprise_data,
is_sap=False,
updating_existing_record=False,
sap_config_data=None,
):
"""
configure an SSO orchestration record
"""
config_data['uuid'] = str(config_data['uuid'])
request_data = {
'samlConfiguration': config_data,
'requestIdentifier': str(config_pk),
'enterprise': enterprise_data,
}

callback_url = self._get_orchestrator_callback_url(str(config_pk))
if updating_existing_record:
callback_url = f"{callback_url}?updating_existing_record=true"
request_data['callbackUrl'] = callback_url

if is_sap or sap_config_data:
request_data['sapsfConfiguration'] = sap_config_data

return self._post(self._get_orchestrator_configure_url(), data=request_data)
23 changes: 23 additions & 0 deletions enterprise/migrations/0184_auto_20230914_2057.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 3.2.20 on 2023-09-14 20:57

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('enterprise', '0183_auto_20230906_1435'),
]

operations = [
migrations.AddField(
model_name='enterprisecustomerssoconfiguration',
name='first_name_attribute',
field=models.CharField(blank=True, max_length=128, null=True),
),
migrations.AddField(
model_name='historicalenterprisecustomerssoconfiguration',
name='first_name_attribute',
field=models.CharField(blank=True, max_length=128, null=True),
),
]
76 changes: 75 additions & 1 deletion enterprise/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
from enterprise.api_client.enterprise_catalog import EnterpriseCatalogApiClient
from enterprise.api_client.lms import EnrollmentApiClient, ThirdPartyAuthApiClient
from enterprise.api_client.open_ai import chat_completion
from enterprise.api_client.sso_orchestrator import EnterpriseSSOOrchestratorApiClient
from enterprise.constants import (
ALL_ACCESS_CONTEXT,
AVAILABLE_LANGUAGES,
Expand Down Expand Up @@ -3691,6 +3692,8 @@ class EnterpriseCustomerSsoConfiguration(TimeStampedModel, SoftDeletableModel):
"""
all_objects = models.Manager()

SAP_SUCCESS_FACTORS = 'SAP_SUCCESS_FACTORS'

fields_locked_while_configuring = (
'metadata_url',
'metadata_xml',
Expand All @@ -3713,6 +3716,33 @@ class EnterpriseCustomerSsoConfiguration(TimeStampedModel, SoftDeletableModel):
'oauth_user_id',
)

sap_config_fields = (
'oauth_user_id',
'odata_api_request_timeout',
'odata_api_root_url',
'odata_api_timeout_interval',
'odata_client_id',
'odata_company_id',
'sapsf_oauth_root_url',
'sapsf_private_key',
)

base_saml_config_fields = (
'uuid',
'metadata_url',
'metadata_xml',
'entity_id',
'user_id_attribute',
'full_name_attribute',
'first_name_attribute',
'last_name_attribute',
'email_attribute',
'username_attribute',
'country_attribute',
'active',
'update_from_metadata',
)

class Meta:
app_label = 'enterprise'
verbose_name = _('Enterprise Customer SSO Configuration')
Expand Down Expand Up @@ -3800,6 +3830,12 @@ class Meta:
max_length=128,
)

first_name_attribute = models.CharField(
blank=True,
null=True,
max_length=128,
)

last_name_attribute = models.CharField(
blank=True,
null=True,
Expand Down Expand Up @@ -3937,7 +3973,7 @@ def save(self, *args, **kwargs):
# ensure we lock configurable fields within the table.
if old_instance and self.is_pending_configuration():
for field in self.fields_locked_while_configuring:
if getattr(old_instance, field) != getattr(self, field):
if str(getattr(old_instance, field)) != str(getattr(self, field)):
raise ValidationError(
{
field: _(
Expand All @@ -3952,3 +3988,41 @@ def is_pending_configuration(self):
Returns True if the configuration has been submitted but not completed configuration.
"""
return self.submitted_at and not self.configured_at

def submit_for_configuration(self, updating_existing_record=False):
"""
Submit the configuration to the SSO orchestration api.
"""
if self.is_pending_configuration():
raise ValidationError(
{
"is_pending_configuration": _(
"Record has already been submitted for configuration."
)
}
)
is_sap = False
sap_data = {}
if self.identity_provider == self.SAP_SUCCESS_FACTORS:
for field in self.sap_config_fields:
sap_data[utils.camelCase(field)] = getattr(self, field)
is_sap = True

config_data = {}
for field in self.base_saml_config_fields:
config_data[utils.camelCase(field)] = getattr(self, field)

EnterpriseSSOOrchestratorApiClient().configure_sso_orchestration_record(
config_data=config_data,
config_pk=self.pk,
enterprise_data={
"name": self.enterprise_customer.name,
"slug": self.enterprise_customer.slug,
"uuid": str(self.enterprise_customer.uuid),
},
is_sap=is_sap,
updating_existing_record=updating_existing_record,
sap_config_data=sap_data,
)
self.submitted_at = localized_utcnow()
self.save()
5 changes: 5 additions & 0 deletions enterprise/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,3 +358,8 @@ def root(*args):
LEARNER_ENGAGEMENT_PROMPT_FOR_NON_ACTIVE_CONTRACT = 'Create learner engagement report for non active contracts.'
LEARNER_PROGRESS_PROMPT_FOR_ACTIVE_CONTRACT = 'Create learner progress report for active contracts.'
LEARNER_PROGRESS_PROMPT_FOR_NON_ACTIVE_CONTRACT = 'Create learner progress report for non active contracts.'

ENTERPRISE_SSO_ORCHESTRATOR_WORKER_USERNAME = 'username'
ENTERPRISE_SSO_ORCHESTRATOR_WORKER_PASSWORD = 'password'
ENTERPRISE_SSO_ORCHESTRATOR_BASE_URL = 'https://foobar.com'
ENTERPRISE_SSO_ORCHESTRATOR_CONFIGURE_PATH = 'configure'
Loading

0 comments on commit 712dccd

Please sign in to comment.