diff --git a/cla-backend-go/serverless.yml b/cla-backend-go/serverless.yml index 38511351a..8f96b02f9 100644 --- a/cla-backend-go/serverless.yml +++ b/cla-backend-go/serverless.yml @@ -233,8 +233,9 @@ provider: SF_USERNAME: ${file(./env.json):sf-username, ssm:/cla-sf-username-${opt:stage}} SF_PASSWORD: ${file(./env.json):sf-password, ssm:/cla-sf-password-${opt:stage}} DOCRAPTOR_API_KEY: ${file(./env.json):doc-raptor-api-key, ssm:/cla-doc-raptor-api-key-${opt:stage}} - DOCUSIGN_AUTH_SERVER: ${file(./env.json):docusign-auth-server, ssm:/cla-docusign-auth-server-${sls:stage}} - DOCUSIGN_USER_ID: ${file(./env.json):docusign-user-id, ssm:/cla-docusign-user-id-${sls:stage}} + DOCUSIGN_ROOT_URL: ${file(./env.json):docusign-root-url, ssm:/cla-docusign-root-url-${opt:stage}} + DOCUSIGN_USERNAME: ${file(./env.json):docusign-username, ssm:/cla-docusign-username-${opt:stage}} + DOCUSIGN_PASSWORD: ${file(./env.json):docusign-password, ssm:/cla-docusign-password-${opt:stage}} DOCUSIGN_INTEGRATOR_KEY: ${file(./env.json):docusign-integrator-key, ssm:/cla-docusign-integrator-key-${opt:stage}} CLA_API_BASE: ${file(./env.json):cla-api-base, ssm:/cla-api-base-${opt:stage}} CLA_CONTRIBUTOR_BASE: ${file(./env.json):cla-contributor-base, ssm:/cla-contributor-base-${opt:stage}} diff --git a/cla-backend/cla/models/docusign_models.py b/cla-backend/cla/models/docusign_models.py index 2f6f065ab..756335585 100644 --- a/cla-backend/cla/models/docusign_models.py +++ b/cla-backend/cla/models/docusign_models.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: MIT """ -Easily perform signing workflows using DocuSign signing service with docusign_esign. +Easily perform signing workflows using DocuSign signing service with pydocusign. NOTE: This integration uses DocuSign's Legacy Authentication REST API Integration. https://developers.docusign.com/esign-rest-api/guides/post-go-live @@ -14,31 +14,30 @@ import os import urllib.request import uuid -import base64 import xml.etree.ElementTree as ET from typing import Any, Dict, List, Optional from urllib.parse import urlparse import cla +import pydocusign # type: ignore import requests from attr import dataclass from cla.controllers.lf_group import LFGroup from cla.models import DoesNotExist, signing_service_interface from cla.models.dynamo_models import (Company, Document, Event, Gerrit, Project, Signature, User) -import docusign_esign -from docusign_esign.client.api_exception import ApiException from cla.models.event_types import EventType from cla.models.s3_storage import S3Storage from cla.user_service import UserService from cla.utils import (append_email_help_sign_off_content, get_corporate_url, get_email_help_content, get_project_cla_group_instance) +from pydocusign.exceptions import DocuSignException # type: ignore api_base_url = os.environ.get('CLA_API_BASE', '') - -ds_user_id = os.environ.get('DOCUSIGN_USER_ID', '') -ds_auth_url = os.environ.get('DOCUSIGN_AUTH_SERVER', '') -ds_client_id = os.environ.get('DOCUSIGN_INTEGRATOR_KEY', '') +root_url = os.environ.get('DOCUSIGN_ROOT_URL', '') +username = os.environ.get('DOCUSIGN_USERNAME', '') +password = os.environ.get('DOCUSIGN_PASSWORD', '') +integrator_key = os.environ.get('DOCUSIGN_INTEGRATOR_KEY', '') lf_group_client_url = os.environ.get('LF_GROUP_CLIENT_URL', '') lf_group_client_id = os.environ.get('LF_GROUP_CLIENT_ID', '') @@ -97,82 +96,35 @@ class DocuSign(signing_service_interface.SigningService): 'signed_date': '{http://www.docusign.net/API/3.0}Signed', } - SCOPES = [ - "signature", "impersonation" - ] def __init__(self): - self.ds_access_token = "" - self.ds_base_url = "" - self.ds_account_id = "" + self.client = None self.s3storage = None - def get_private_key(self): - return cla.config.DOCUSIGN_PRIVATE_KEY - def initialize(self, config): - api_client = docusign_esign.ApiClient() - api_client.set_base_path(ds_auth_url) - api_client.set_oauth_host_name(ds_auth_url) - err = self.setAuthToken() - if err != None: - return err - - self.s3storage = S3Storage() - self.s3storage.initialize(None) + self.client = pydocusign.DocuSignClient(root_url=root_url, + username=username, + password=password, + integrator_key=integrator_key) - def get_access_token(self): try: - """Get the jwt token""" - api_client = docusign_esign.ApiClient() - api_client.set_base_path(ds_auth_url) - ds_private_key = self.get_private_key() - - response = api_client.request_jwt_user_token( - client_id=ds_client_id, - user_id=ds_user_id, - oauth_host_name=ds_auth_url, - private_key_bytes=ds_private_key, - expires_in=4000, - scopes=self.SCOPES - ) - - return response + login_data = self.client.login_information() + login_account = login_data['loginAccounts'][0] + base_url = login_account['baseUrl'] + account_id = login_account['accountId'] + url = urlparse(base_url) + parsed_root_url = '{}://{}/restapi/v2'.format(url.scheme, url.netloc) except Exception as e: cla.log.error('Error logging in to DocuSign: {}'.format(e)) return {'errors': {'Error initializing DocuSign'}} - - def setAuthToken(self): - api_client = docusign_esign.ApiClient() - api_client.set_base_path(ds_auth_url) - api_client.set_oauth_host_name(ds_auth_url) - - token_response = self.get_access_token() - try: - if token_response.access_token != None and token_response.access_token!= "": - self.ds_access_token = token_response.access_token - else: - return {'errors': {'Error initializing DocuSign'}} - except Exception as e: - cla.log.error('could not gnerate access_token: {}'.format(e)) - return {'errors': {'Error initializing DocuSign'}} - - try: - user_info = api_client.get_user_info(token_response.access_token) - accounts = user_info.get_accounts() - self.ds_base_url = accounts[0].base_uri + "/restapi" - self.ds_account_id = accounts[0].account_id - except Exception as e: - cla.log.error('Error logging in to DocuSign: {}'.format(e)) - return {'errors': {'Error initializing DocuSign'}} - return None - def get_api_client(self): - """Create api client and construct API headers""" - api_client = docusign_esign.ApiClient() - api_client.host = self.ds_base_url - api_client.set_default_header(header_name="Authorization", header_value=f"Bearer {self.ds_access_token}") - - return api_client + self.client = pydocusign.DocuSignClient(root_url=parsed_root_url, + account_url=base_url, + account_id=account_id, + username=username, + password=password, + integrator_key=integrator_key) + self.s3storage = S3Storage() + self.s3storage.initialize(None) def request_individual_signature(self, project_id, user_id, return_url=None, return_url_type="github", callback_url=None, preferred_email=None): @@ -1341,16 +1293,7 @@ def populate_sign_url(self, signature, callback_url=None, f'for project {project.get_project_name()} expired. A new session will be in place for ' 'your signing process.') cla.log.debug(message) - env = docusign_esign.Envelope() - env.status = 'voided' - env.voided_reason = message - api_client = self.get_api_client() - envelope_api = docusign_esign.EnvelopesApi(api_client) - envelope_api.update( - account_id=self.ds_account_id, - envelope_id=envelope_id, - envelope=env - ) + self.client.void_envelope(envelope_id, message) except Exception as e: cla.log.warning(f'{fn} - {sig_type} - DocuSign error while voiding the envelope - ' f'regardless, continuing on..., error: {e}') @@ -1390,15 +1333,14 @@ def populate_sign_url(self, signature, callback_url=None, project_names=project_names)) cla.log.debug(f'populate_sign_url - {sig_type} - generating a docusign signer object form email with' f'name: {signatory_name}, email: {signatory_email}, subject: {email_subject}') - signer = docusign_esign.Signer(email=signatory_email, + signer = pydocusign.Signer(email=signatory_email, name=signatory_name, - recipient_id=1, + recipientId=1, tabs=tabs, - email_notification = { - 'emailBody': email_body, - 'emailSubject': email_subject, - 'supportedLanguage': 'en', - }) + emailSubject=email_subject, + emailBody=email_body, + supportedLanguage='en', + ) else: # This will be the Initial CLA Manager signatory_name = user_signature_name @@ -1422,14 +1364,13 @@ def populate_sign_url(self, signature, callback_url=None, user_identifier = signatory_email else: user_identifier = signatory_name - signer = docusign_esign.Signer(email=signatory_email, name=signatory_name, - recipient_id=1, client_user_id=signature.get_signature_id(), + signer = pydocusign.Signer(email=signatory_email, name=signatory_name, + recipientId=1, clientUserId=signature.get_signature_id(), tabs=tabs, - email_notification = { - 'emailBody': 'CLA Sign Request for {}'.format(user_identifier), - 'emailSubject': email_subject, - 'supportedLanguage': 'en', - }) + emailSubject=email_subject, + emailBody='CLA Sign Request for {}'.format(user_identifier), + supportedLanguage='en', + ) content_type = document.get_document_content_type() if document.get_document_s3_url() is not None: @@ -1440,52 +1381,53 @@ def populate_sign_url(self, signature, callback_url=None, else: content = document.get_document_content() pdf = io.BytesIO(content) - content_bytes = pdf.read() - base64_file_content = base64.b64encode(content_bytes).decode("ascii") + doc_name = document.get_document_name() cla.log.debug(f'{fn} - {sig_type} - docusign document ' f'name: {doc_name}, id: {document_id}, content type: {content_type}') - document = docusign_esign.Document(name=doc_name, document_id=document_id, document_base64=base64_file_content, file_extension="pdf") + document = pydocusign.Document(name=doc_name, documentId=document_id, data=pdf) if callback_url is not None: # Webhook properties for callbacks after the user signs the document. # Ensure that a webhook is returned on the status "Completed" where # all signers on a document finish signing the document. recipient_events = [{"recipientEventStatusCode": "Completed"}] - event_notification = docusign_esign.EventNotification(url=callback_url, - logging_enabled=True, - recipient_events=recipient_events) - envelope = docusign_esign.EnvelopeDefinition( + event_notification = pydocusign.EventNotification(url=callback_url, + loggingEnabled=True, + recipientEvents=recipient_events) + envelope = pydocusign.Envelope( documents=[document], - email_subject=f'EasyCLA: CLA Signature Request for {project.get_project_name()}', - email_blurb='CLA Sign Request', - event_notification=event_notification, - status="sent", - recipients=docusign_esign.Recipients(signers=[signer])) + emailSubject=f'EasyCLA: CLA Signature Request for {project.get_project_name()}', + emailBlurb='CLA Sign Request', + eventNotification=event_notification, + status=pydocusign.Envelope.STATUS_SENT, + recipients=[signer]) else: - envelope = docusign_esign.EnvelopeDefinition( + envelope = pydocusign.Envelope( documents=[document], - email_subject=f'EasyCLA: CLA Signature Request for {project.get_project_name()}', - email_blurb='CLA Sign Request', - status="sent", - recipients=docusign_esign.Recipients(signers=[signer])) - envelope_result = self.prepare_sign_request(envelope) + emailSubject=f'EasyCLA: CLA Signature Request for {project.get_project_name()}', + emailBlurb='CLA Sign Request', + status=pydocusign.Envelope.STATUS_SENT, + recipients=[signer]) + + envelope = self.prepare_sign_request(envelope) if not send_as_email: + recipient = envelope.recipients[0] + # The URL the user will be redirected to after signing. # This route will be in charge of extracting the signature's return_url and redirecting. - return_url = os.path.join(api_base_url, 'v2/return-url', str(signer.client_user_id)) + return_url = os.path.join(api_base_url, 'v2/return-url', str(recipient.clientUserId)) cla.log.debug(f'populate_sign_url - {sig_type} - generating signature sign_url, ' f'using return-url as: {return_url}') - sign_url = self.get_sign_url(envelope_result.envelope_id, signer, return_url) + sign_url = self.get_sign_url(envelope, recipient, return_url) cla.log.debug(f'populate_sign_url - {sig_type} - setting signature sign_url as: {sign_url}') - signature.set_signature_sign_url(sign_url.url) + signature.set_signature_sign_url(sign_url) # Save Envelope ID in signature. - print(envelope_result) cla.log.debug(f'{fn} - {sig_type} - saving signature to database...') - signature.set_signature_envelope_id(envelope_result.envelope_id) + signature.set_signature_envelope_id(envelope.envelopeId) signature.save() cla.log.debug(f'{fn} - {sig_type} - saved signature to database - id: {signature.get_signature_id()}...') cla.log.debug(f'populate_sign_url - {sig_type} - complete') @@ -1955,11 +1897,11 @@ def get_signed_document(self, envelope_id, user): fn = 'models.docusign_models.get_signed_document' cla.log.debug(f'{fn} - fetching signed CLA document for envelope: {envelope_id}') - + envelope = pydocusign.Envelope() + envelope.envelopeId = envelope_id + try: - api_client = self.get_api_client() - envelope_api = docusign_esign.EnvelopesApi(api_client) - documents = envelope_api.list_documents(account_id=self.ds_account_id, envelope_id=envelope_id) + documents = envelope.get_document_list(self.client) except Exception as err: cla.log.error(f'{fn} - unknown error when trying to load signed document: {err}') return @@ -1970,17 +1912,17 @@ def get_signed_document(self, envelope_id, user): return document = documents[0] - if 'document_id' not in document: + if 'documentId' not in document: cla.log.error(f'{fn} - not document ID found in document response: {document}') return try: # TODO: Also send the signature certificate? envelope.get_certificate() - document_file = envelope_api.get_document(account_id=self.ds_account_id,document_id=document['document_id'],envelope_id=envelope_id) + document_file = envelope.get_document(document['documentId'], self.client) return document_file.read() except Exception as err: cla.log.error('{fn} - unknown error when trying to fetch signed document content ' - f'for document ID {document["document_id"]}, error: {err}') + f'for document ID {document["documentId"]}, error: {err}') return def send_signed_document(self, signature, document_data, user, icla=True): @@ -2027,51 +1969,37 @@ def get_document_resource(self, url): # pylint: disable=no-self-use """ return urllib.request.urlopen(url) - def prepare_sign_request(self, envelope_definition): + def prepare_sign_request(self, envelope): """ Mockable method for sending a signature request to DocuSign. :param envelope: The envelope to send to DocuSign. - :type envelope: docusign_esign.Envelope + :type envelope: pydocusign.Envelope :return: The new envelope to work with after the request has been sent. - :rtype: docusign_esign.Envelope + :rtype: pydocusign.Envelope """ try: - api_client = self.get_api_client() - envelope_api = docusign_esign.EnvelopesApi(api_client) - result = envelope_api.create_envelope(account_id=self.ds_account_id, envelope_definition=envelope_definition) - return result - except ApiException as err: + self.client.create_envelope_from_documents(envelope) + envelope.get_recipients() + return envelope + except DocuSignException as err: cla.log.error(f'prepare_sign_request - error while fetching DocuSign envelope recipients: {err}') - def get_sign_url(self, envelope_id, recipient, return_url): # pylint:disable=no-self-use + def get_sign_url(self, envelope, recipient, return_url): # pylint:disable=no-self-use """ Mockable method for getting a signing url. :param envelope: The envelope in question. - :type envelope: docusign_esign.Envelope + :type envelope: pydocusign.Envelope :param recipient: The recipient inside this envelope. - :type recipient: docusign_esign.Recipient + :type recipient: pydocusign.Recipient :param return_url: The URL to return the user after successful signing. :type return_url: string :return: A URL for the recipient to hit for signing. :rtype: string """ - recipient_view_request = docusign_esign.RecipientViewRequest( - authentication_method="None", - client_user_id=recipient.client_user_id, - recipient_id="1", - return_url=return_url, - user_name=recipient.name, - email=recipient.email - ) - api_client = self.get_api_client() - envelope_api = docusign_esign.EnvelopesApi(api_client) - return envelope_api.create_recipient_view( - account_id=self.ds_account_id, - envelope_id=envelope_id, - recipient_view_request=recipient_view_request - ) + return envelope.post_recipient_view(recipient, returnUrl=return_url) + class MockDocuSign(DocuSign): """ @@ -2155,29 +2083,29 @@ def get_docusign_tabs_from_document(document: Document, :type document: cla.models.model_interfaces.Document :param document_id: The ID of the document to use for grouping of the tabs. :type document_id: int - :return: List of formatted tabs for consumption by docusign_esign. - :rtype: [docusign_esign.Tabs] + :return: List of formatted tabs for consumption by pydocusign. + :rtype: [pydocusign.Tab] """ tabs = [] for tab in document.get_document_tabs(): args = { - 'document_id': document_id, - 'page_number': tab.get_document_tab_page(), - 'x_position': tab.get_document_tab_position_x(), - 'y_position': tab.get_document_tab_position_y(), + 'documentId': document_id, + 'pageNumber': tab.get_document_tab_page(), + 'xPosition': tab.get_document_tab_position_x(), + 'yPosition': tab.get_document_tab_position_y(), 'width': tab.get_document_tab_width(), 'height': tab.get_document_tab_height(), - 'custom_tab_id': tab.get_document_tab_id(), - 'tab_label': tab.get_document_tab_id(), + 'customTabId': tab.get_document_tab_id(), + 'tabLabel': tab.get_document_tab_id(), 'name': tab.get_document_tab_name() } if tab.get_document_tab_anchor_string() is not None: # Set only when anchor string exists - args['anchor_string'] = tab.get_document_tab_anchor_string() - args['anchor_ignore_if_not_present'] = tab.get_document_tab_anchor_ignore_if_not_present() - args['anchor_x_offset'] = tab.get_document_tab_anchor_x_offset() - args['anchor_y_offset'] = tab.get_document_tab_anchor_y_offset() + args['anchorString'] = tab.get_document_tab_anchor_string() + args['anchorIgnoreIfNotPresent'] = tab.get_document_tab_anchor_ignore_if_not_present() + args['anchorXOffset'] = tab.get_document_tab_anchor_x_offset() + args['anchorYOffset'] = tab.get_document_tab_anchor_y_offset() # Remove x,y coordinates since offsets will define them # del args['xPosition'] # del args['yPosition'] @@ -2188,27 +2116,27 @@ def get_docusign_tabs_from_document(document: Document, tab_type = tab.get_document_tab_type() if tab_type == 'text': - tab_class = docusign_esign.Text + tab_class = pydocusign.TextTab elif tab_type == 'text_unlocked': - tab_class = docusign_esign.Text - args['locked'] = "false" + tab_class = TextUnlockedTab + args['locked'] = False elif tab_type == 'text_optional': - tab_class = docusign_esign.Text + tab_class = TextOptionalTab # https://developers.docusign.com/docs/esign-rest-api/reference/envelopes/enveloperecipienttabs/create/#schema__enveloperecipienttabs_texttabs_required # required: string - When true, the signer is required to fill out this tab. - args['required'] = "false" + args['required'] = False elif tab_type == 'number': - tab_class = docusign_esign.Number + tab_class = pydocusign.NumberTab elif tab_type == 'sign': - tab_class = docusign_esign.SignHere + tab_class = pydocusign.SignHereTab elif tab_type == 'sign_optional': - tab_class = docusign_esign.SignHere + tab_class = pydocusign.SignHereTab # https://developers.docusign.com/docs/esign-rest-api/reference/envelopes/enveloperecipienttabs/create/#schema__enveloperecipienttabs_signheretabs_optional # optional: string - When true, the recipient does not need to complete this tab to # complete the signing process. args['optional'] = True elif tab_type == 'date': - tab_class = docusign_esign.DateSigned + tab_class = pydocusign.DateSignedTab else: cla.log.warning('Invalid tab type specified (%s) in document file ID %s', tab_type, document.get_document_file_id()) @@ -2216,8 +2144,8 @@ def get_docusign_tabs_from_document(document: Document, tab_obj = tab_class(**args) tabs.append(tab_obj) - - return docusign_esign.Tabs(tabs) + + return tabs def populate_signature_from_icla_callback(content: str, icla_tree: ET, signature: Signature): @@ -2370,6 +2298,34 @@ def create_default_individual_values(user: User, preferred_email: str = None) -> return values + +class TextOptionalTab(pydocusign.Tab): + """Tab to show a free-form text field on the document. + """ + attributes = pydocusign.Tab._common_attributes + pydocusign.Tab._formatting_attributes + [ + 'name', + 'value', + 'height', + 'width', + 'locked', + 'required' + ] + tabs_name = 'textTabs' + + +class TextUnlockedTab(pydocusign.Tab): + """Tab to show a free-form text field on the document. + """ + attributes = pydocusign.Tab._common_attributes + pydocusign.Tab._formatting_attributes + [ + 'name', + 'value', + 'height', + 'width', + 'locked' + ] + tabs_name = 'textTabs' + + # managers and contributors are tuples of (name, email) def generate_manager_and_contributor_list(managers, contributors=None): lines = [] diff --git a/cla-backend/requirements.txt b/cla-backend/requirements.txt index 4d783524a..f14cee204 100644 --- a/cla-backend/requirements.txt +++ b/cla-backend/requirements.txt @@ -34,7 +34,6 @@ pluggy==0.13.1 py==1.10.0 pyasn1==0.4.8 pydocusign==2.2 -docusign-esign==3.22.0 PyGithub==1.55 PyJWT==2.7.0 pylint==1.5.2 diff --git a/cla-backend/serverless.yml b/cla-backend/serverless.yml index 3e2385869..fc35ff83d 100644 --- a/cla-backend/serverless.yml +++ b/cla-backend/serverless.yml @@ -318,8 +318,9 @@ provider: SF_USERNAME: ${file(./env.json):sf-username, ssm:/cla-sf-username-${sls:stage}} SF_PASSWORD: ${file(./env.json):sf-password, ssm:/cla-sf-password-${sls:stage}} DOCRAPTOR_API_KEY: ${file(./env.json):doc-raptor-api-key, ssm:/cla-doc-raptor-api-key-${sls:stage}} - DOCUSIGN_AUTH_SERVER: ${file(./env.json):docusign-auth-server, ssm:/cla-docusign-auth-server-${sls:stage}} - DOCUSIGN_USER_ID: ${file(./env.json):docusign-user-id, ssm:/cla-docusign-user-id-${sls:stage}} + DOCUSIGN_ROOT_URL: ${file(./env.json):docusign-root-url, ssm:/cla-docusign-root-url-${sls:stage}} + DOCUSIGN_USERNAME: ${file(./env.json):docusign-username, ssm:/cla-docusign-username-${sls:stage}} + DOCUSIGN_PASSWORD: ${file(./env.json):docusign-password, ssm:/cla-docusign-password-${sls:stage}} DOCUSIGN_INTEGRATOR_KEY: ${file(./env.json):docusign-integrator-key, ssm:/cla-docusign-integrator-key-${sls:stage}} CLA_API_BASE: ${file(./env.json):cla-api-base, ssm:/cla-api-base-${sls:stage}} CLA_CONTRIBUTOR_BASE: ${file(./env.json):cla-contributor-base, ssm:/cla-contributor-base-${sls:stage}}