From 1e706e98951f7acb9e336f87d829f106b412ff40 Mon Sep 17 00:00:00 2001 From: Harold Wanyama Date: Fri, 29 Sep 2023 16:01:01 +0300 Subject: [PATCH] [#4002] Feature/Docusign flow in Golang - Ported python flow for icla and ccla sign to golang - Handled the new docusign auth flow Signed-off-by: Harold Wanyama --- cla-backend-go/cmd/server.go | 4 +- cla-backend-go/project/common/helpers.go | 41 +- cla-backend-go/project/service/service.go | 4 +- cla-backend-go/signatures/converters.go | 1 + cla-backend-go/signatures/dbmodels.go | 2 + cla-backend-go/signatures/repository.go | 152 ++++ cla-backend-go/signatures/service.go | 65 ++ cla-backend-go/swagger/cla.v1.yaml | 3 + cla-backend-go/swagger/cla.v2.yaml | 49 +- .../common/cla-group-document-tab.yaml | 47 ++ .../swagger/common/cla-group-document.yaml | 8 + cla-backend-go/swagger/common/signature.yaml | 13 + cla-backend-go/users/service.go | 32 + cla-backend-go/v2/main/main.go | 47 -- cla-backend-go/v2/project/handlers.go | 2 +- cla-backend-go/v2/repositories/service.go | 91 +++ cla-backend-go/v2/sign/docusign.go | 448 ++++++++++- cla-backend-go/v2/sign/handlers.go | 58 +- cla-backend-go/v2/sign/jwt.go | 41 +- cla-backend-go/v2/sign/models.go | 246 +++++++ cla-backend-go/v2/sign/service.go | 693 +++++++++++++++++- cla-backend-go/v2/store/repository.go | 56 ++ 22 files changed, 1989 insertions(+), 114 deletions(-) create mode 100644 cla-backend-go/swagger/common/cla-group-document-tab.yaml delete mode 100644 cla-backend-go/v2/main/main.go create mode 100644 cla-backend-go/v2/sign/models.go diff --git a/cla-backend-go/cmd/server.go b/cla-backend-go/cmd/server.go index 150e607a5..e8477ee1b 100644 --- a/cla-backend-go/cmd/server.go +++ b/cla-backend-go/cmd/server.go @@ -318,7 +318,7 @@ func server(localMode bool) http.Handler { v2GithubActivityService := v2GithubActivity.NewService(gitV1Repository, githubOrganizationsRepo, eventsService, autoEnableService, emailService) v2ClaGroupService := cla_groups.NewService(v1ProjectService, templateService, v1ProjectClaGroupRepo, v1ClaManagerService, v1SignaturesService, metricsRepo, gerritService, v1RepositoriesService, eventsService) - v2SignService := sign.NewService(configFile.ClaV1ApiURL, v1CompanyRepo, v1CLAGroupRepo, v1ProjectClaGroupRepo, v1CompanyService, v2ClaGroupService) + v2SignService := sign.NewService(configFile.ClaV1ApiURL, v1CompanyRepo, v1CLAGroupRepo, v1ProjectClaGroupRepo, v1CompanyService, v2ClaGroupService, usersService, v1SignaturesService, storeRepository, v2RepositoriesService, githubOrganizationsService, gitlabOrganizationRepo) sessionStore, err := dynastore.New(dynastore.Path("/"), dynastore.HTTPOnly(), dynastore.TableName(configFile.SessionStoreTableName), dynastore.DynamoDB(dynamodb.New(awsSession))) if err != nil { @@ -363,7 +363,7 @@ func server(localMode bool) http.Handler { v2Company.Configure(v2API, v2CompanyService, v1ProjectClaGroupRepo, configFile.LFXPortalURL, configFile.CorporateConsoleV1URL) cla_manager.Configure(api, v1ClaManagerService, v1CompanyService, v1ProjectService, usersService, v1SignaturesService, eventsService, emailTemplateService) v2ClaManager.Configure(v2API, v2ClaManagerService, v1CompanyService, configFile.LFXPortalURL, configFile.CorporateConsoleV2URL, v1ProjectClaGroupRepo, userRepo) - sign.Configure(v2API, v2SignService) + sign.Configure(v2API, v2SignService, v2RepositoriesService, userRepo) cla_groups.Configure(v2API, v2ClaGroupService, v1ProjectService, v1ProjectClaGroupRepo, eventsService) v2GithubActivity.Configure(v2API, v2GithubActivityService) diff --git a/cla-backend-go/project/common/helpers.go b/cla-backend-go/project/common/helpers.go index 4a6e8a5a5..bbc8e825f 100644 --- a/cla-backend-go/project/common/helpers.go +++ b/cla-backend-go/project/common/helpers.go @@ -102,8 +102,7 @@ func GetCurrentDocument(ctx context.Context, docs []models.ClaGroupDocument) (mo continue } - // No previous, use the first... - if currentDoc == (models.ClaGroupDocument{}) { + if AreClaGroupDocumentsEqual(currentDoc, models.ClaGroupDocument{}) { currentDoc = doc currentDocVersion = version currentDocDateTime = dateTime @@ -127,3 +126,41 @@ func GetCurrentDocument(ctx context.Context, docs []models.ClaGroupDocument) (mo return currentDoc, nil } + +// AreClaGroupDocumentsEqual compares two cla group document models +func AreClaGroupDocumentsEqual(doc1, doc2 models.ClaGroupDocument) bool { + // Compare each field individually, including the DocumentTabs slice + if doc1.DocumentAuthorName != doc2.DocumentAuthorName { + return false + } + if doc1.DocumentContentType != doc2.DocumentContentType { + return false + } + if doc1.DocumentCreationDate != doc2.DocumentCreationDate { + return false + } + if doc1.DocumentFileID != doc2.DocumentFileID { + return false + } + if doc1.DocumentLegalEntityName != doc2.DocumentLegalEntityName { + return false + } + if doc1.DocumentMajorVersion != doc2.DocumentMajorVersion { + return false + } + if doc1.DocumentMinorVersion != doc2.DocumentMinorVersion { + return false + } + if doc1.DocumentName != doc2.DocumentName { + return false + } + if doc1.DocumentPreamble != doc2.DocumentPreamble { + return false + } + if doc1.DocumentS3URL != doc2.DocumentS3URL { + return false + } + + // If all comparisons passed, the structs are equal + return true +} diff --git a/cla-backend-go/project/service/service.go b/cla-backend-go/project/service/service.go index df4fc8c07..743cacc6d 100644 --- a/cla-backend-go/project/service/service.go +++ b/cla-backend-go/project/service/service.go @@ -212,7 +212,7 @@ func (s ProjectService) GetCLAGroupCurrentICLATemplateURLByID(ctx context.Contex } } - if currentDoc == (models.ClaGroupDocument{}) { + if common.AreClaGroupDocumentsEqual(currentDoc, models.ClaGroupDocument{}) { log.WithFields(f).WithError(err).Warn("problem determining current ICLA for this CLA Group") return "", &utils.CLAGroupICLANotConfigured{ CLAGroupID: claGroupID, @@ -288,7 +288,7 @@ func (s ProjectService) GetCLAGroupCurrentCCLATemplateURLByID(ctx context.Contex } } - if currentDoc == (models.ClaGroupDocument{}) { + if common.AreClaGroupDocumentsEqual(currentDoc, models.ClaGroupDocument{}) { log.WithFields(f).WithError(err).Warn("problem determining current CCLA for this CLA Group") return "", &utils.CLAGroupCCLANotConfigured{ CLAGroupID: claGroupID, diff --git a/cla-backend-go/signatures/converters.go b/cla-backend-go/signatures/converters.go index c09ccb0a4..f293f8de6 100644 --- a/cla-backend-go/signatures/converters.go +++ b/cla-backend-go/signatures/converters.go @@ -74,6 +74,7 @@ func (repo repository) buildProjectSignatureModels(ctx context.Context, results SignatureReferenceNameLower: dbSignature.SignatureReferenceNameLower, SignatureSigned: dbSignature.SignatureSigned, SignatureApproved: dbSignature.SignatureApproved, + SignatureSignURL: dbSignature.SignatureSignURL, SignatureMajorVersion: dbSignature.SignatureDocumentMajorVersion, SignatureMinorVersion: dbSignature.SignatureDocumentMinorVersion, Version: dbSignature.SignatureDocumentMajorVersion + "." + dbSignature.SignatureDocumentMinorVersion, diff --git a/cla-backend-go/signatures/dbmodels.go b/cla-backend-go/signatures/dbmodels.go index 8f606a067..2cbfb7979 100644 --- a/cla-backend-go/signatures/dbmodels.go +++ b/cla-backend-go/signatures/dbmodels.go @@ -17,8 +17,10 @@ type ItemSignature struct { SignatureReferenceNameLower string `json:"signature_reference_name_lower"` SignatureProjectID string `json:"signature_project_id"` SignatureReferenceType string `json:"signature_reference_type"` + SignatureEnvelopeID string `json:"signature_envelope_id"` SignatureType string `json:"signature_type"` SignatureUserCompanyID string `json:"signature_user_ccla_company_id"` + SignatureSignURL string `json:"signature_sign_url"` EmailApprovalList []string `json:"email_whitelist"` EmailDomainApprovalList []string `json:"domain_whitelist"` GitHubUsernameApprovalList []string `json:"github_whitelist"` diff --git a/cla-backend-go/signatures/repository.go b/cla-backend-go/signatures/repository.go index c9483dfbe..569ff5fc6 100644 --- a/cla-backend-go/signatures/repository.go +++ b/cla-backend-go/signatures/repository.go @@ -97,6 +97,9 @@ type SignatureRepository interface { EclaAutoCreate(ctx context.Context, signatureID string, autoCreateECLA bool) error ActivateSignature(ctx context.Context, signatureID string) error GetGitLabActiveMergeRequestMetadata(ctx context.Context, gitLabAuthorUsername, gitLabAuthorEmail string) (*ActiveGitLabPullRequest, error) + GetSignaturesByReference(ctx context.Context, referenceID, referenceType, projectID, userCCLACompanyID string, signatureSigned, signatureApproved bool) ([]*models.Signature, error) + CreateSignature(ctx context.Context, signature *models.Signature) (*models.Signature, error) + CreateOrUpdateSignature(ctx context.Context, signature *models.Signature) (*models.Signature, error) } type iclaSignatureWithDetails struct { @@ -132,6 +135,155 @@ func NewRepository(awsSession *session.Session, stage string, companyRepo compan } } +func (repo repository) CreateOrUpdateSignature(ctx context.Context, signature *models.Signature) (*models.Signature, error) { + f := logrus.Fields{ + "functionName": "v1.signatures.repository.CreateOrUpdateSignature", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "signature": signature, + } + + // Check if the signature already exists in DynamoDB + existingSignature, err := repo.GetSignature(ctx, signature.SignatureID) + if err != nil { + log.WithFields(f).Warnf("error checking if signature exists, error: %v", err) + return nil, err + } + + // If the signature exists, update it + if existingSignature != nil { + av, err := dynamodbattribute.MarshalMap(signature) + if err != nil { + log.WithFields(f).Warnf("error marshalling signature model, error: %v", err) + return nil, err + } + + input := &dynamodb.PutItemInput{ + Item: av, + TableName: aws.String(repo.signatureTableName), + } + + _, err = repo.dynamoDBClient.PutItem(input) + if err != nil { + log.WithFields(f).Warnf("error updating signature, error: %v", err) + return nil, err + } + return signature, nil + } + + // If the signature does not exist, create a new one + return repo.CreateSignature(ctx, signature) +} + +func (repo repository) CreateSignature(ctx context.Context, signature *models.Signature) (*models.Signature, error) { + f := logrus.Fields{ + "functionName": "v1.signatures.repository.CreateSignature", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "signature": signature, + } + + av, err := dynamodbattribute.MarshalMap(signature) + + if err != nil { + log.WithFields(f).Warnf("error marshalling signature model, error: %v", err) + return nil, err + } + + input := &dynamodb.PutItemInput{ + Item: av, + TableName: aws.String(repo.signatureTableName), + } + + _, err = repo.dynamoDBClient.PutItem(input) + + if err != nil { + log.WithFields(f).Warnf("error adding signature, error: %v", err) + return nil, err + } + + return signature, nil + +} + +// GetSignaturesByReference returns signatures by reference +func (repo repository) GetSignaturesByReference(ctx context.Context, referenceID, referenceType, projectID, userCCLACompanyID string, signatureSigned, signatureApproved bool) ([]*models.Signature, error) { + f := logrus.Fields{ + "functionName": "v1.signatures.repository.GetSignaturesByReference", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "referenceID": referenceID, + "referenceType": referenceType, + "projectID": projectID, + "userCCLACompanyID": userCCLACompanyID, + "signatureSigned": signatureSigned, + "signatureApproved": signatureApproved, + } + + log.WithFields(f).Debug("querying signature by reference...") + var filterAdded bool + + // These are the keys we want to match for an ICLA Signature with a given CLA Group and User ID + condition := expression.Key("signature_reference_id").Equal(expression.Value(referenceID)). + And(expression.Key("signature_reference_type").Equal(expression.Value(referenceType))) + + var filter expression.ConditionBuilder + filter = addAndCondition(filter, expression.Name("signature_project_id").Equal(expression.Value(projectID)), &filterAdded) + filter = addAndCondition(filter, expression.Name("signature_user_ccla_company_id").Equal(expression.Value(userCCLACompanyID)), &filterAdded) + filter = addAndCondition(filter, expression.Name("signature_signed").Equal(expression.Value(signatureSigned)), &filterAdded) + filter = addAndCondition(filter, expression.Name("signature_approved").Equal(expression.Value(signatureApproved)), &filterAdded) + + // If no query option was provided for approved and signed and our configuration default is to only show active signatures then we add the required query filters + if signatureSigned == false && signatureApproved == false && config.GetConfig().SignatureQueryDefault == utils.SignatureQueryDefaultActive { + filterAdded = true + log.WithFields(f).Debug("adding filter signature_approved: true and signature_signed: true") + filter = addAndCondition(filter, expression.Name("signature_approved").Equal(expression.Value(true)), &filterAdded) + filter = addAndCondition(filter, expression.Name("signature_signed").Equal(expression.Value(true)), &filterAdded) + } + + // Use the nice builder to create the expression + expr, err := expression.NewBuilder(). + WithKeyCondition(condition). + WithFilter(filter). + WithProjection(buildProjection()). + Build() + + if err != nil { + log.WithFields(f).Warnf("error building expression for signature query, error: %v", err) + return nil, err + } + + // Assemble the query input parameters + queryInput := &dynamodb.QueryInput{ + ExpressionAttributeNames: expr.Names(), + ExpressionAttributeValues: expr.Values(), + FilterExpression: expr.Filter(), + KeyConditionExpression: expr.KeyCondition(), + ProjectionExpression: expr.Projection(), + TableName: aws.String(repo.signatureTableName), + IndexName: aws.String(SignatureReferenceIndex), + } + + // Make the DynamoDB Query API call + results, queryErr := repo.dynamoDBClient.Query(queryInput) + if queryErr != nil { + log.WithFields(f).Warnf("error retrieving signature by reference, error: %v", queryErr) + return nil, queryErr + } + + // No match, didn't find it + if *results.Count == 0 { + return nil, nil + } + + // Convert the list of DB models to a list of response models + signatureList, modelErr := repo.buildProjectSignatureModels(ctx, results, "", LoadACLDetails) + if modelErr != nil { + log.WithFields(f).Warnf("error converting DB model to response model for signature, error: %v", modelErr) + return nil, modelErr + } + + return signatureList, nil + +} + // GetGithubOrganizationsFromApprovalList returns a list of GH organizations stored in the approval list func (repo repository) GetGithubOrganizationsFromApprovalList(ctx context.Context, signatureID string) ([]models.GithubOrg, error) { f := logrus.Fields{ diff --git a/cla-backend-go/signatures/service.go b/cla-backend-go/signatures/service.go index 5d7a6b709..9f286afd6 100644 --- a/cla-backend-go/signatures/service.go +++ b/cla-backend-go/signatures/service.go @@ -57,6 +57,7 @@ type SignatureService interface { GetCompanyIDsWithSignedCorporateSignatures(ctx context.Context, claGroupID string) ([]SignatureCompanyID, error) GetUserSignatures(ctx context.Context, params signatures.GetUserSignaturesParams) (*models.Signatures, error) InvalidateProjectRecords(ctx context.Context, projectID, note string) (int, error) + GetSignaturesByReference(ctx context.Context, referenceID, referenceType, projectID, userCCLACompanyID string, signatureSigned, signatureApproved bool) ([]*models.Signature, error) GetGithubOrganizationsFromApprovalList(ctx context.Context, signatureID string, githubAccessToken string) ([]models.GithubOrg, error) AddGithubOrganizationToApprovalList(ctx context.Context, signatureID string, approvalListParams models.GhOrgWhitelist, githubAccessToken string) ([]models.GithubOrg, error) @@ -69,9 +70,12 @@ type SignatureService interface { GetClaGroupICLASignatures(ctx context.Context, claGroupID string, searchTerm *string, approved, signed *bool, pageSize int64, nextKey string, withExtraDetails bool) (*models.IclaSignatures, error) GetClaGroupCCLASignatures(ctx context.Context, claGroupID string, approved, signed *bool) (*models.Signatures, error) GetClaGroupCorporateContributors(ctx context.Context, claGroupID string, companyID *string, pageSize *int64, nextKey *string, searchTerm *string) (*models.CorporateContributorList, error) + GetLatestSignature(ctx context.Context, userID string, companyID string, projectID string) (*models.Signature, error) createOrGetEmployeeModels(ctx context.Context, claGroupModel *models.ClaGroup, companyModel *models.Company, corporateSignatureModel *models.Signature) ([]*models.User, error) CreateOrUpdateEmployeeSignature(ctx context.Context, claGroupModel *models.ClaGroup, companyModel *models.Company, corporateSignatureModel *models.Signature) ([]*models.User, error) + CreateSignature(ctx context.Context, signature *models.Signature) (*models.Signature, error) + CreateOrUpdateSignature(ctx context.Context, signature *models.Signature) (*models.Signature, error) handleGitHubStatusUpdate(ctx context.Context, employeeUserModel *models.User) error } @@ -117,6 +121,62 @@ func NewService(repo SignatureRepository, companyService company.IService, users } } +// GetLatestSignatures returns the latest signatures for the specified user +func (s service) GetLatestSignature(ctx context.Context, userID string, companyID string, projectID string) (*models.Signature, error) { + + f := logrus.Fields{ + "functionName": "GetLatestSignature", + "userID": userID, + "companyID": companyID, + "projectID": projectID, + } + + log.WithFields(f).Debug("querying for user signatures...") + + if userID == "" || companyID == "" || projectID == "" { + return nil, errors.New("userID, companyID, and projectID cannot be empty") + } + signatures, err := s.GetSignaturesByReference(ctx, userID, "user", projectID, companyID, true, true) + if err != nil { + return nil, err + } + + latest := &models.Signature{} + + for _, sig := range signatures { + if latest == nil { + latest = sig + continue + } + if sig.SignatureMajorVersion > latest.SignatureMajorVersion { + latest = sig + continue + } + if sig.SignatureMajorVersion == latest.SignatureMajorVersion && sig.SignatureMinorVersion > latest.SignatureMinorVersion { + latest = sig + continue + } + } + + if latest == nil { + return nil, errors.New("unable to locate latest signature") + } + + log.WithFields(f).Debugf("latest signature: %+v", latest) + + return latest, nil + +} + +// CreateSignature creates a new signature +func (s service) CreateSignature(ctx context.Context, signature *models.Signature) (*models.Signature, error) { + return s.repo.CreateSignature(ctx, signature) +} + +func (s service) CreateOrUpdateSignature(ctx context.Context, signature *models.Signature) (*models.Signature, error) { + return s.repo.CreateOrUpdateSignature(ctx, signature) +} + // GetSignature returns the signature associated with the specified signature ID func (s service) GetSignature(ctx context.Context, signatureID string) (*models.Signature, error) { return s.repo.GetSignature(ctx, signatureID) @@ -143,6 +203,11 @@ func (s service) GetProjectSignatures(ctx context.Context, params signatures.Get return projectSignatures, nil } +// GetSignaturesByReference returns the list of signatures associated with the specified reference +func (s service) GetSignaturesByReference(ctx context.Context, referenceID, referenceType, projectID, userCCLACompanyID string, signatureSigned, signatureApproved bool) ([]*models.Signature, error) { + return s.repo.GetSignaturesByReference(ctx, referenceID, referenceType, projectID, userCCLACompanyID, signatureSigned, signatureApproved) +} + // CreateProjectSummaryReport generates a project summary report based on the specified input func (s service) CreateProjectSummaryReport(ctx context.Context, params signatures.CreateProjectSummaryReportParams) (*models.SignatureReport, error) { diff --git a/cla-backend-go/swagger/cla.v1.yaml b/cla-backend-go/swagger/cla.v1.yaml index beaa11886..c1b3ba69c 100644 --- a/cla-backend-go/swagger/cla.v1.yaml +++ b/cla-backend-go/swagger/cla.v1.yaml @@ -2854,6 +2854,9 @@ definitions: cla-group-document: $ref: './common/cla-group-document.yaml' + + cla-group-document-tab: + $ref: './common/cla-group-document-tab.yaml' create-cla-group-template: $ref: './common/create-cla-group-template.yaml' diff --git a/cla-backend-go/swagger/cla.v2.yaml b/cla-backend-go/swagger/cla.v2.yaml index 64eb14c05..3d6625960 100644 --- a/cla-backend-go/swagger/cla.v2.yaml +++ b/cla-backend-go/swagger/cla.v2.yaml @@ -4137,7 +4137,7 @@ paths: - name: input in: body schema: - $ref: '#/definitions/icla-signature-input' + $ref: '#/definitions/individual-signature-input' required: true responses: '200': @@ -4746,6 +4746,9 @@ definitions: cla-group-document: $ref: './common/cla-group-document.yaml' + + cla-group-document-tab: + $ref: './common/cla-group-document-tab.yaml' meta-field: $ref: './common/meta-field.yaml' @@ -5527,37 +5530,27 @@ definitions: corporate-contributor: $ref: './common/corporate-contributor.yaml' - icla-signature-input: + individual-signature-input: type: object - required: - - project_sfid - - company_sfid properties: - project_sfid: + project_id: + description: 'The CLA group ID' type: string - example: 'a0941000005ouJFAAY' - description: salesforce id of the project - company_sfid: + example: 'a1b86c26-d8e8-4fd8-9f8d-5c723d5dac9f' + user_id: + description: 'The User ID' type: string - example: '0014100000Te0fMAAR' - description: salesforce id of the company - send_as_email: - type: boolean - example: false - description: send signing request as email. This should be set to true when requestor is not signatory. - authority_name: + example: 'a1b86c26-d8e8-4fd8-9f8d-5c723d5dac9f' + return_url_type: + description: 'The return URL type of the repository' type: string - example: "Derk Miyamoto" - description: the name of the CLA signatory - minLength: 2 - maxLength: 255 - authority_email: - $ref: './common/properties/email.yaml' - description: the email of the CLA Signatory + enum: + - Gerrit + - Github + - GitLab return_url: + description: 'The URL to return the user to after signing is complete.' type: string - example: 'https://corporate.dev.lfcla.com/#/company/eb4d7d71-693f-4047-bf8d-10d0e7764969' - description: on signing the document, page will get redirected to this url. This is valid only when send_as_email is false format: uri @@ -5614,6 +5607,12 @@ definitions: signature_id: type: string description: id of the signature + user_id: + type: string + description: uuid for the user + project_id: + type: string + description: the cla group ID sign_url: type: string description: signing url diff --git a/cla-backend-go/swagger/common/cla-group-document-tab.yaml b/cla-backend-go/swagger/common/cla-group-document-tab.yaml new file mode 100644 index 000000000..2cfcb5f8d --- /dev/null +++ b/cla-backend-go/swagger/common/cla-group-document-tab.yaml @@ -0,0 +1,47 @@ + type: object + x-nullable: false + title: CLA Group Document Tab + properties: + documentTabType: + description: Type of the document tab (e.g., "sign", "text", etc.) + type: string + documentTabId: + description: ID for the document tab + type: string + documentTabName: + description: Name of the document tab + type: string + documentTabPage: + description: Page number where the document tab is located + type: integer + documentTabPositionX: + description: X-coordinate position of the document tab + type: integer + documentTabPositionY: + description: Y-coordinate position of the document tab + type: integer + documentTabWidth: + description: Width of the document tab + type: integer + documentTabHeight: + description: Height of the document tab + type: integer + documentTabIsLocked: + description: Indicates whether the document tab is locked (default is false) + type: string + documentTabIsRequired: + description: Indicates whether the document tab is required (default is true) + type: boolean + documentTabAnchorString: + description: Anchor string for the document tab (if applicable) + type: string + documentTabAnchorIgnoreIfNotPresent: + description: Indicates whether to ignore the tab if the anchor string is not present (default is true) + type: boolean + documentTabAnchorXOffset: + description: X-coordinate offset for the anchor (if applicable) + type: integer + documentTabAnchorYOffset: + description: Y-coordinate offset for the anchor (if applicable) + type: integer + \ No newline at end of file diff --git a/cla-backend-go/swagger/common/cla-group-document.yaml b/cla-backend-go/swagger/common/cla-group-document.yaml index 8f6d9e8bb..3b0c84a47 100644 --- a/cla-backend-go/swagger/common/cla-group-document.yaml +++ b/cla-backend-go/swagger/common/cla-group-document.yaml @@ -10,6 +10,9 @@ properties: description: the document name example: "Apache Style" type: string + documentContent: + description: the document Content + type: string documentFileID: description: the document file ID example: "fb4cc144-a76c-4c17-8a52-c648f158fded" @@ -46,3 +49,8 @@ properties: description: the document creation date example: '2019-08-01T06:55:09Z' type: string + documentTabs: + description: An array of document tab objects + type: array + items: + $ref: '#/definitions/cla-group-document-tab' diff --git a/cla-backend-go/swagger/common/signature.yaml b/cla-backend-go/swagger/common/signature.yaml index 57c764893..a3bfd31cd 100644 --- a/cla-backend-go/swagger/common/signature.yaml +++ b/cla-backend-go/swagger/common/signature.yaml @@ -28,6 +28,19 @@ properties: example: '2019-05-03T18:59:13.082304+0000' minLength: 18 maxLength: 64 + signatureSignUrl: + type: string + format: url + signatureReturnUrl: + type: string + format: uri + signatureCallbackUrl: + type: string + format: uri + signatureReturnUrlType: + type: string + signatureEnvelopeId: + type: string signatureSigned: type: boolean description: the signature signed flag - true or false value diff --git a/cla-backend-go/users/service.go b/cla-backend-go/users/service.go index 1a01605b5..8faf203dc 100644 --- a/cla-backend-go/users/service.go +++ b/cla-backend-go/users/service.go @@ -8,7 +8,11 @@ import ( "github.com/communitybridge/easycla/cla-backend-go/events" "github.com/communitybridge/easycla/cla-backend-go/gen/v1/models" + log "github.com/communitybridge/easycla/cla-backend-go/logging" "github.com/communitybridge/easycla/cla-backend-go/user" + "github.com/communitybridge/easycla/cla-backend-go/utils" + "github.com/go-openapi/strfmt" + "github.com/sirupsen/logrus" ) // Service interface for users @@ -26,6 +30,7 @@ type Service interface { GetUserByGitLabUsername(gitlabUsername string) (*models.User, error) SearchUsers(field string, searchTerm string, fullMatch bool) (*models.Users, error) UpdateUserCompanyID(userID, companyID, note string) error + GetUserEmail(user *models.User, preferredEmail string) (string, error) } type service struct { @@ -41,6 +46,33 @@ func NewService(repo UserRepository, events events.Service) Service { } } +func (s service) GetUserEmail(user *models.User, preferredEmail string) (string, error) { + f := logrus.Fields{ + "functionName": "GetUserEmail", + "userID": user.UserID, + "preferredEmail": preferredEmail, + } + + if preferredEmail != "" && user.LfEmail != "" && user.LfEmail == strfmt.Email(preferredEmail) { + log.WithFields(f).Debug("user email matches preferred email") + return user.LfEmail.String(), nil + } + + if preferredEmail != "" && utils.StringInSlice(preferredEmail, user.Emails) { + log.WithFields(f).Debug("user email matches preferred email") + return preferredEmail, nil + } + + if len(user.Emails) > 0 { + log.WithFields(f).Debug("returning first user email") + return user.Emails[0], nil + } + + log.WithFields(f).Debug("unable to find user email") + return "", errors.New("unable to find user email") + +} + // CreateUser attempts to create a new user based on the specified model func (s service) CreateUser(user *models.User, claUser *user.CLAUser) (*models.User, error) { userModel, err := s.repo.CreateUser(user) diff --git a/cla-backend-go/v2/main/main.go b/cla-backend-go/v2/main/main.go deleted file mode 100644 index d220a74c4..000000000 --- a/cla-backend-go/v2/main/main.go +++ /dev/null @@ -1,47 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/communitybridge/easycla/cla-backend-go/v2/docusign_auth" -) - -func main() { - integrationKey := "557677f2-1e2f-4955-aaa6-1ef44630e01d" - userID := "3a1d118f-3083-4c25-8306-11b7400f7c03" - privateKey :=` - -----BEGIN RSA PRIVATE KEY----- - MIIEogIBAAKCAQEAh0M2mIGaJjP8S/FxZR7nRsatCR/KpCPBFBbxalZffykqtTID - KNeDhJ5RvJKAVoJlLaLoUYSYloVaeSAwQdbn4F+Lsnll3mCGocwdl/W8998Lc/Ln - MaNQhpekBoXaq8vbj251jxnRcsdI9yl/YyQo3jZnM77OWtEF7dyvS6V9cMprT2Ca - eIJPj0Ck3/P9rGwE3DdiEXZDetgkuNQyMavfvLKltCNu3qQXFA95PXlHs2E57OrS - CNIQT37jfInXuIyCoGjDSq+U3ZE047UG5Id8/OFvcP1z5iUdwvueoEximt/kSvR0 - ZcNfjqyJnnYq80OKwTdalZEOzAEkt/U2QuB9jwIDAQABAoIBAB01GstopOgd7ptZ - efJrb2ZdjUy8lC3IWK9lWuDq4LkdIw84Su1dSBVxeFXfTp4fjwiBNmgv2SEbj5M7 - K6Bz7uMIzqoNw7z2m+vBHxzKn/DoNVlmuJyD1uYRRYZxDext2y3IHNN3MD54IN3a - FJtMWhTNq5BFYdrDauPXdPTBOeqKQgMoQl6OwHIx4WJXYxRgIgrvBYRzLSwG/u4V - tAvj1/J0PSSOY5A1qn6L2ii9abVElFr2zZC5MtG3oG1TLCidzxZFt/5qSHGo+0tU - l/7YRXWxadfYrPmS9akbC7SQn9WJQdIlfejtuQ6hmKTCCjhTp2BUba3kTI3PcM46 - GzW3WQECgYEA08lwWFZng3bm3KrDx3ljjZ+cuL8vOXfLeYuaIPmNu5eXrR5ZR9Ar - UyUmnZbL8rhK9dNCmRGX293uuppwJolfMAy+kcvQ4HsRQ5tOgXmP8VhKzA4RRc+h - I7uIGQ7NmKI6Wm/wJ+T3Okw0h8ze8MW66/D5IHDQSoGQZH9kkA9+MQECgYEAo4AS - jB/b/SWhwrRUI8m+5Tjy5WO9ZkRZ54zpRnAr5HsjSBgb72IGbJKwleKwb3FxwPTQ - ZlgKP2O5rGzZOEZcIryIliCpV4C/XeFVwUKxo/A5jkL+U9ZQwoA0/0ssdE/7uLzG - FiKcFK7OhsFg+dNEX1ForM9cGN5OzY8sGzylHo8CgYAc0Nq1WkRJUeNFgQKUYILY - ITB8vp6ZTiBkUEdPV0Uekhi0GF4DdGKAtJxVctAbHVItsmnsU8V6x+6UezDpPWWz - Lvi686Ve9b+6mCYNXdHk/6NlskBNZFvDdd+lsSruKpyP840UkIXG69l15L0su2qc - cbQj4tWkXY6c7exr4X/FAQKBgDmGTABFDU9puBoa/CeDSci4Wq1ehDrA/ai8KS8B - NFA1CtrIsLtuj7gPfFWf5levYEh1WgVIIILhAWiq+1oTV0NZdezsHOiOgcX0DAns - /zcgw/9LjtPMaamlFgBkYIWjxnre4ArVrniQcFV1IDuFm16189ApPMv7G1qzbt8+ - XRH9AoGAceSrZLbceDkqgprTTV1BEOFY+Ti9edBIUW+CqN6KkB66OXHGWlab/PZc - OCHRPAjEijZcVXm8IPC2yi/s0agQAB8dKO2L1X0EtvxkWSg2s/YXFpp0QccQToTo - lRFb9injNzixUOq18Z62XcC/yqMB7QtgRw5x5OQk0HNWpL7h6KM= - -----END RSA PRIVATE KEY----- - ` - token, err := docusignauth.GetAccessToken(integrationKey, userID, privateKey) - if err != nil { - fmt.Println(err) - } - - fmt.Println(token) -} \ No newline at end of file diff --git a/cla-backend-go/v2/project/handlers.go b/cla-backend-go/v2/project/handlers.go index f936bdebe..369c7c165 100644 --- a/cla-backend-go/v2/project/handlers.go +++ b/cla-backend-go/v2/project/handlers.go @@ -338,7 +338,7 @@ func buildSFProjectSummary(sfProject *v2ProjectServiceModels.ProjectOutputDetail return &models.SfProjectSummary{ EntityName: utils.StringValue(sfProject.EntityName), EntityType: sfProject.EntityType, - Funding: sfProject.Funding, + Funding: *sfProject.Funding, ID: sfProject.ID, LfSupported: sfProject.LFSponsored, Name: sfProject.Name, diff --git a/cla-backend-go/v2/repositories/service.go b/cla-backend-go/v2/repositories/service.go index a9dfe6385..13ba2777a 100644 --- a/cla-backend-go/v2/repositories/service.go +++ b/cla-backend-go/v2/repositories/service.go @@ -5,8 +5,11 @@ package repositories import ( "context" + "encoding/json" "errors" "fmt" + "io" + "net/http" "strconv" "github.com/communitybridge/easycla/cla-backend-go/events" @@ -37,6 +40,12 @@ import ( v2ProjectService "github.com/communitybridge/easycla/cla-backend-go/v2/project-service" ) +type Email struct { + Email string `json:"email"` + Verified bool `json:"verified"` + Primary bool `json:"primary"` +} + // ServiceInterface contains functions of the repositories service type ServiceInterface interface { // GitHub @@ -51,6 +60,7 @@ type ServiceInterface interface { GitHubDisableCLAGroupRepositories(ctx context.Context, claGroupID string) error GitHubGetProtectedBranch(ctx context.Context, projectSFID, repositoryID, branchName string) (*v2Models.GithubRepositoryBranchProtection, error) GitHubUpdateProtectedBranch(ctx context.Context, projectSFID, repositoryID string, input *v2Models.GithubRepositoryBranchProtectionInput) (*v2Models.GithubRepositoryBranchProtection, error) + GithubGetPrimaryUserEmail(ctx context.Context, request *http.Request) (*string, error) // GitLab @@ -84,6 +94,20 @@ type GitLabOrgRepo interface { DeleteGitLabOrganizationByFullPath(ctx context.Context, projectSFID, gitlabOrgFullPath string) error } +type contextKey string + +func (c contextKey) String() string { + return string(c) +} + +func GetRequestSession(r *http.Request) (map[string]interface{}, error) { + session := r.Context().Value(contextKey("session")) + if session == nil { + return nil, errors.New("session not found") + } + return session.(map[string]interface{}), nil +} + // Service is the service model/structure type Service struct { gitV1Repository v1Repositories.RepositoryInterface @@ -114,6 +138,73 @@ func NewService(gitV1Repository *v1Repositories.Repository, gitV2Repository Repo } } +func fetchGithubEmails(session map[string]interface{}) ([]Email, error) { + f := logrus.Fields{ + "functionName": "fetchGithubEmails", + } + log.WithFields(f).Debugf("fetching user emails from token") + + token, ok := session["github_oauth2_token"].(string) + if !ok || token == "" { + return nil, errors.New("invalid github oauth2 token") + } + client := &http.Client{} + req, err := http.NewRequest("GET", "https://api.github.com/user/emails", nil) + if err != nil { + return nil, errors.New("error creating request") + } + + req.Header.Add("Authorization", "Bearer "+token) + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + + if err != nil { + log.WithFields(f).Debug("could not read response body :%s", err) + return nil, nil + } + var emails []Email + err = json.Unmarshal(body, &emails) + if err != nil { + log.Debugf("unable to unmarshal response body") + return emails, err + } + + return emails, nil +} + +func (s *Service) GithubGetPrimaryUserEmail(ctx context.Context, request *http.Request) (*string, error) { + f := logrus.Fields{ + "functionName": "v2.repositories.service.GithubGetPrimaryUserEmail", + } + session, err := GetRequestSession(request) + + if err != nil { + log.WithFields(f).Debug("failed to get session") + return nil, nil + } + emails, err := fetchGithubEmails(session) + + if err != nil { + log.WithFields(f).Debug("failed to fetch github emails") + return nil, nil + } + + for _, email := range emails { + if email.Verified && email.Primary { + return &email.Email, nil + } + } + + log.WithFields(f).Debug("unable to get emails") + + return nil, nil +} + // GitHubAddRepositories adds the specified GitHub repository to the specified project func (s *Service) GitHubAddRepositories(ctx context.Context, projectSFID string, input *models.GithubRepositoryInput) ([]*v1Models.GithubRepository, error) { f := logrus.Fields{ diff --git a/cla-backend-go/v2/sign/docusign.go b/cla-backend-go/v2/sign/docusign.go index 394a332a6..0a97b07cc 100644 --- a/cla-backend-go/v2/sign/docusign.go +++ b/cla-backend-go/v2/sign/docusign.go @@ -4,15 +4,455 @@ package sign import ( + "bytes" "context" - "log" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + v1Models "github.com/communitybridge/easycla/cla-backend-go/gen/v1/models" + log "github.com/communitybridge/easycla/cla-backend-go/logging" + "github.com/communitybridge/easycla/cla-backend-go/utils" + "github.com/sirupsen/logrus" +) + +var ( + baseURL = utils.GetProperty("DOCUSIGN_ROOT_URL") + authServer = utils.GetProperty("DOCUSIGN_AUTH_SERVER") ) +func (s *service) getDocumentTabsFromDocument(ctx context.Context, document v1Models.ClaGroupDocument, documentID string, defaultValues map[string]interface{}) (DocuSignTab, error) { + f := logrus.Fields{ + "functionName": "sign.getDocumentTabsFromDocument", + "documentID": documentID, + } + log.WithFields(f).Debug("getting document tabs from document") + + docusignTab := DocuSignTab{} + + for _, tab := range document.DocumentTabs { + + args := DocuSignTabDetails{ + DocumentId: documentID, + TabLabel: tab.DocumentTabID, + PageNumber: strconv.Itoa(int(tab.DocumentTabPage)), + XPosition: strconv.Itoa(int(tab.DocumentTabPositionX)), + YPosition: strconv.Itoa(int(tab.DocumentTabPositionY)), + Width: strconv.Itoa(int(tab.DocumentTabWidth)), + Height: strconv.Itoa(int(tab.DocumentTabHeight)), + Name: tab.DocumentTabName, + TabType: tab.DocumentTabType, + } + + if tab.DocumentTabAnchorString != "" { + args.AnchorString = tab.DocumentTabAnchorString + args.AnchorIgnoreIfNotPresent = strconv.FormatBool(tab.DocumentTabAnchorIgnoreIfNotPresent) + args.AnchorXOffset = strconv.Itoa(int(tab.DocumentTabAnchorXOffset)) + args.AnchorYOffset = strconv.Itoa(int(tab.DocumentTabAnchorYOffset)) + } + + if value, ok := defaultValues[tab.DocumentTabID]; ok { + args.Value = value.(string) + } + + tagType := tab.DocumentTabType + switch tagType { + case "text": + docusignTab.TextTabs = append(docusignTab.TextTabs, args) + case "text_unlocked": + docusignTab.TextUnlockedTabs = append(docusignTab.TextUnlockedTabs, args) + case "text_optional": + docusignTab.TextOptionalTabs = append(docusignTab.TextOptionalTabs, args) + args.Required = "false" + case "number": + docusignTab.NumberTabs = append(docusignTab.NumberTabs, args) + case "sign": + docusignTab.SignHereTabs = append(docusignTab.SignHereTabs, args) + case "sign_optional": + docusignTab.SignHereTabs = append(docusignTab.SignHereTabs, args) + args.Optional = "true" + case "date": + docusignTab.DateSignedTabs = append(docusignTab.DateSignedTabs, args) + default: + log.WithFields(f).Warnf("invalid tab type specified (%s) in document file ID %s", tagType, document.DocumentFileID) + continue + } + } + + return docusignTab, nil +} + +func (s *service) voidDocument(ctx context.Context, documentID, message string) error { + f := logrus.Fields{ + "functionName": "sign.voidDocument", + "documentID": documentID, + } + log.WithFields(f).Debug("voiding document") + + accessToken, err := s.getAccessToken(ctx) + if err != nil { + log.WithFields(f).WithError(err).Warn("unable to get access token") + return err + } + + url := utils.GetProperty("DOCUSIGN_ROOT_URL") + "/v2.1/accounts/" + utils.GetProperty("DOCUSIGN_ACCOUNT_ID") + "/envelopes/" + documentID + "/views/recipient" + + req, err := http.NewRequest("DELETE", url, nil) + if err != nil { + log.WithFields(f).WithError(err).Warn("unable to create request") + return err + } + + req.Header.Add("Authorization", "Bearer "+accessToken) + + // Send the request using the http client + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + log.WithFields(f).WithError(err).Warn("unable to send request") + return err + } + + defer func() { + closeErr := resp.Body.Close() + if closeErr != nil { + log.WithFields(f).WithError(closeErr).Warn("unable to close response body") + } + }() + + if resp.StatusCode != http.StatusOK { + log.WithFields(f).Warnf("non-200 response code from docusign: %d", resp.StatusCode) + return err + } + + return nil +} + +// getAccessToken returns an access token for the docusign api func (s *service) getAccessToken(ctx context.Context) (string, error) { f := logrus.Fields{ "functionName": "sign.getAccessToken", } + jwtAssertion, err := jwtToken() + if err != nil { + log.WithFields(f).WithError(err).Warn("unable to generate jwt token") + return "", err + } + + tokenRequestBody := DocuSignGetTokenRequest{ + GrantType: "urn:ietf:params:oauth:grant-type:jwt-bearer", + Assertion: jwtAssertion, + } + + tokenRequestBodyString, marshallErr := json.Marshal(tokenRequestBody) + if marshallErr != nil { + log.WithFields(f).WithError(marshallErr).Warn("unable to marshal token request body") + return "", marshallErr + } + + // Create the request + url := utils.GetProperty("DOCUSIGN_AUTH_SERVER") + "/oauth/token" + + req, err := http.NewRequest("POST", url, strings.NewReader(string(tokenRequestBodyString))) + + if err != nil { + log.WithFields(f).WithError(err).Warn("unable to create request") + return "", err + } + + // Set the content type header, as well as the expected response type + + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Accept", "application/json") + + // Send the request using the http client + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + log.WithFields(f).WithError(err).Warn("unable to send request") + return "", err + } + + defer func() { + closeErr := resp.Body.Close() + if closeErr != nil { + log.WithFields(f).WithError(closeErr).Warn("unable to close response body") + } + }() + + // Parse the response + responsePayload, readErr := io.ReadAll(resp.Body) + if readErr != nil { + log.WithFields(f).WithError(readErr).Warn("unable to read response body") + return "", readErr + } + + if resp.StatusCode != http.StatusOK { + log.WithFields(f).Warnf("non-200 response code from docusign: %d", resp.StatusCode) + return "", err + } + + var tokenResponse DocuSignGetTokenResponse + unmarshallErr := json.Unmarshal(responsePayload, &tokenResponse) + if unmarshallErr != nil { + log.WithFields(f).WithError(unmarshallErr).Warn("unable to unmarshal response body") + return "", unmarshallErr + } + + return tokenResponse.AccessToken, nil +} + +func (s *service) getAccountID(ctx context.Context, accessToken string) (string, error) { + f := logrus.Fields { + "functionName": "sign.getAccountID", + } + + log.WithFields(f).Debug("getting docusign accountID...") + + client := &http.Client{} + url := fmt.Sprintf("%s/oauth/userinfo", authServer) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer " + accessToken) + + res, err := client.Do(req) + if err != nil { + return "", err + } + + body, err := io.ReadAll(res.Body) + if err != nil { + fmt.Printf("Request failed: %s", err) + return "", err + } + // fmt.Printf("Body: %s\n", body) + res.Body.Close() + + // decode the response to JSON + var accountId AccountId + jsonErr := json.Unmarshal(body, &accountId) + if jsonErr != nil { + fmt.Printf("There was an error decoding the JSON. err = %s", jsonErr) + return "", jsonErr + } + + return accountId.Accounts[0].AccountID, nil +} + +// PrepareSignEvent initiates the sign workflow + +func (s *service) PrepareSignEvent(ctx context.Context, envelope DocuSignEnvelopeRequest) (*DocuSignEnvelopeResponse, error) { + f := logrus.Fields{ + "functionName": "sign.PrepareSignEvent", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + } + + log.WithFields(f).Debug("preparing sign event") + + requestBodyJSON, err := json.Marshal(envelope) + if err != nil { + log.WithFields(f).WithError(err).Debug("unable to marshall envelop") + return nil, err + } + + accessToken, err := s.getAccessToken(ctx) + if err != nil { + log.WithFields(f).WithError(err).Warn("unable to get access token") + return nil, err + } + + // Get accountID + log.WithFields(f).Debugf("getting account ID") + accountID, err := s.getAccountID(ctx, accessToken) + if err != nil { + return nil, err + } + + //Docusign API endpoint + apiURL := fmt.Sprintf("%s/restapi/v2.1/accounts/%s/envelopes", baseURL, accountID) + + // Create the HTTP request with the JSON payload + req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(requestBodyJSON)) + + if err != nil { + return nil, err + } + + // Set Docusign authentication headers + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Content-Type", "application/json") + + // Send the Http request + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + //Parse the response JSON + var envelopeResponse DocuSignEnvelopeResponse + decoder := json.NewDecoder(resp.Body) + if err := decoder.Decode(&envelopeResponse); err != nil { + return nil, err + } + + return &envelopeResponse, nil + +} + +func ClaSignatoryEmailContent(params ClaSignatoryEmailParams) (string, string) { + projectNamesList := strings.Join(params.ProjectNames, ", ") + + emailSubject := fmt.Sprintf("EasyCLA: CLA Signature Request for %s", params.ClaGroupName) + emailBody := fmt.Sprintf("

Hello %s,

", params.SignatoryName) + emailBody += fmt.Sprintf("

This is a notification email from EasyCLA regarding the project(s) %s associated with the CLA Group %s. %s has designated you as an authorized signatory for the organization %s. In order for employees of your company to contribute to any of the above project(s), they must do so under a Contributor License Agreement signed by someone with authority on behalf of your company.

", projectNamesList, params.ClaGroupName, params.ClaManagerName, params.CompanyName) + emailBody += fmt.Sprintf("

After you sign, %s (as the initial CLA Manager for your company) will be able to maintain the list of specific employees authorized to contribute to the project(s) under this signed CLA.

", params.ClaManagerName) + emailBody += fmt.Sprintf("

If you are authorized to sign on your company’s behalf, and if you approve %s as your initial CLA Manager, please review the document and sign the CLA. If you have questions, or if you are not an authorized signatory of this company, please contact the requester at %s.

", params.ClaManagerName, params.ClaManagerEmail) + emailBody = appendEmailHelpSignOffContent(emailBody, params.ProjectVersion) + + return emailSubject, emailBody +} + +func appendEmailHelpSignOffContent(body, projectVersion string) string { + // Helper method which appends the help and sign off content to the email body + return body + getEmailHelpContent(projectVersion == "v2") + getEmailSignOffContent() +} + +func getEmailHelpContent(isV2 bool) string { + // Implement the logic to generate email help content + // You can customize the content based on the value of isV2 + if isV2 { + return "

Email help content for v2

" + } + return "

Email help content for other versions

" +} + +func getEmailSignOffContent() string { + // Implement the logic to generate email sign-off content + return "

Email sign-off content

" +} + +// def get_document_resource(self, url): # pylint: disable=no-self-use +// """ +// Mockable method to fetch the PDF for signing. + +// :param url: The URL of the PDF file to sign. +// :type url: string +// :return: A resource that can be read()'d. +// :rtype: Resource +// """ +// return urllib.request.urlopen(url) + +func GetDocumentResource(ctx context.Context, url string) ([]byte, error) { + f := logrus.Fields{ + "functionName": "sign.GetDocumentResource", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "url": url, + } + + log.WithFields(f).Debug("getting document resource") + + // Create the request + req, err := http.NewRequest("GET", url, nil) + if err != nil { + log.WithFields(f).WithError(err).Warn("unable to create request") + return nil, err + } + + // Send the request using the http client + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + log.WithFields(f).WithError(err).Warn("unable to send request") + return nil, err + } + + defer func() { + closeErr := resp.Body.Close() + if closeErr != nil { + log.WithFields(f).WithError(closeErr).Warn("unable to close response body") + } + }() + + // Parse the response + responsePayload, readErr := io.ReadAll(resp.Body) + if readErr != nil { + log.WithFields(f).WithError(readErr).Warn("unable to read response body") + return nil, readErr + } + + if resp.StatusCode != http.StatusOK { + log.WithFields(f).Warnf("non-200 response code from docusign: %d", resp.StatusCode) + return nil, err + } + + return responsePayload, nil + +} + +// Get the recipient view + +func (s *service) GetRecipientView(ctx context.Context, returnURL, recipientName, recipientEmail, envelopeID string) (string, error) { + f := logrus.Fields{ + "functionName": "sign.GetRecipientView", + } + accessToken, err := s.getAccessToken(ctx) + if err != nil { + return "", err + } + + log.WithFields(f).Debug("creating JSON payload with recipient information...") + + recipient := map[string]interface{}{ + "returnURL": returnURL, + "email": recipientEmail, + "name": recipientName, + } + + payload, err := json.Marshal(recipient) + if err != nil { + log.WithFields(f).Debugf("Error marshalling Json: %+v", err) + return "", err + } + + // Create request + + url := baseURL + "/envelopes/ENVELOPE_ID/views/recipient" + req, err := http.NewRequest("POST", url, bytes.NewBuffer(payload)) + if err != nil { + return "", err + } + + // Set authentication headers + req.Header.Add("Authorization", "Bearer "+accessToken) + req.Header.Add("Content-Type", "application/json") + + // Send the request + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + fmt.Println("Error sending HTTP request:", err) + return "", err + } + defer resp.Body.Close() + + // Handle the response + if resp.StatusCode == http.StatusCreated { + // Success: the recipient view URL should be in the response body + fmt.Println("Recipient View URL:", resp.Header.Get("Location")) + return resp.Header.Get("Location"), nil + } else { + // Error handling + fmt.Println("Error:", resp.Status) + // Handle error response as needed + return "", errors.New(resp.Status) + } - // Get the access token - jwtAssertion, jwterr := jwtToken() -} \ No newline at end of file +} diff --git a/cla-backend-go/v2/sign/handlers.go b/cla-backend-go/v2/sign/handlers.go index d3c743acc..7d8b7a37c 100644 --- a/cla-backend-go/v2/sign/handlers.go +++ b/cla-backend-go/v2/sign/handlers.go @@ -4,12 +4,12 @@ package sign import ( - "context" "errors" "fmt" "strings" log "github.com/communitybridge/easycla/cla-backend-go/logging" + userMod "github.com/communitybridge/easycla/cla-backend-go/user" "github.com/sirupsen/logrus" "github.com/communitybridge/easycla/cla-backend-go/projects_cla_groups" @@ -20,12 +20,12 @@ import ( "github.com/communitybridge/easycla/cla-backend-go/gen/v2/restapi/operations/sign" "github.com/communitybridge/easycla/cla-backend-go/utils" "github.com/communitybridge/easycla/cla-backend-go/v2/organization-service/client/organizations" + v2Repos "github.com/communitybridge/easycla/cla-backend-go/v2/repositories" "github.com/go-openapi/runtime/middleware" - ) // Configure API call -func Configure(api *operations.EasyclaAPI, service Service) { +func Configure(api *operations.EasyclaAPI, service Service, repoService v2Repos.ServiceInterface, userService userMod.RepositoryService) { // Retrieve a list of available templates api.SignRequestCorporateSignatureHandler = sign.RequestCorporateSignatureHandlerFunc( func(params sign.RequestCorporateSignatureParams, user *auth.User) middleware.Responder { @@ -80,21 +80,51 @@ func Configure(api *operations.EasyclaAPI, service Service) { api.SignRequestIndividualSignatureHandler = sign.RequestIndividualSignatureHandlerFunc( func(params sign.RequestIndividualSignatureParams) middleware.Responder { - reqId := utils.GetRequestID(params.XREQUESTID) - ctx := context.WithValue(params.HTTPRequest.Context(), utils.XREQUESTID, reqId) + reqID := utils.GetRequestID(params.XREQUESTID) + ctx := utils.NewContext() f := logrus.Fields{ "functionName": "v2.sign.handlers.SignRequestIndividualSignatureHandler", utils.XREQUESTID: ctx.Value(utils.XREQUESTID), - "CompanyID": params.Input.CompanySfid, - "ProjectSFID": params.Input.ProjectSfid, - "authorityName": params.Input.AuthorityName, - "authorityEmail": params.Input.AuthorityEmail, + "userID": params.Input.UserID, + "projectID": params.Input.ProjectID, + "repoType": params.Input.ReturnURLType, + "repoURL": params.Input.ReturnURL, } - log.WithFields(f).Debug("processing request") - resp, err := service.RequestIndividualSignature(ctx, params.Input) - if err != nil { - log.WithFields(f).WithError(err).Warn("problem requesting individual signature") - return sign.NewRequestIndividualSignatureBadRequest().WithPayload(errorResponse(reqId, err)) + + log.WithFields(f).Debug("request individual signature") + var resp *models.IndividualSignatureOutput + var err error + if params.Input.ReturnURLType != "" { + switch strings.ToLower(params.Input.ReturnURLType) { + case "gerrit": + log.WithFields(f).Debug("request individual signature - gerrit") + resp, err = service.RequestIndividualSignatureGerrit(ctx, params.Input.ProjectID, params.Input.UserID, params.Input.ReturnURL.String()) + + case "github", "gitlab": + var primaryUserEmail *string + log.WithFields(f).Debug("request individual signature - github/gitlab") + if strings.ToLower(params.Input.ReturnURLType) == "github" { + primaryUserEmail, err = repoService.GithubGetPrimaryUserEmail(ctx, params.HTTPRequest) + if err != nil { + return sign.NewRequestIndividualSignatureBadRequest().WithPayload(errorResponse(reqID, err)) + } + } else { + user, err := userService.GetUser(params.Input.UserID) + if err != nil { + log.WithFields(f).Debugf("unable to lookup user by ID: %s, error: %+v", params.Input.UserID, err) + return sign.NewRequestIndividualSignatureBadRequest().WithPayload(errorResponse(reqID, err)) + } + log.WithFields(f).Debugf("user lookup by ID: %s, result: %+v", params.Input.UserID, user) + primaryUserEmail = &user.UserEmails[0] + } + resp, err = service.RequestIndividualSignature(ctx, params.Input.ProjectID, params.Input.UserID, params.Input.ReturnURL.String(), params.Input.ReturnURLType, *primaryUserEmail) + if err != nil { + return sign.NewRequestIndividualSignatureBadRequest().WithPayload(errorResponse(reqID, err)) + } + } + if err != nil { + return sign.NewRequestIndividualSignatureBadRequest().WithPayload(errorResponse(reqID, err)) + } } return sign.NewRequestIndividualSignatureOK().WithPayload(resp) }) diff --git a/cla-backend-go/v2/sign/jwt.go b/cla-backend-go/v2/sign/jwt.go index ac954d3e8..2c000bb4c 100644 --- a/cla-backend-go/v2/sign/jwt.go +++ b/cla-backend-go/v2/sign/jwt.go @@ -4,14 +4,47 @@ package sign import ( + "os" + "time" + + log "github.com/communitybridge/easycla/cla-backend-go/logging" + "github.com/communitybridge/easycla/cla-backend-go/utils" "github.com/golang-jwt/jwt" "github.com/sirupsen/logrus" ) -const - func jwtToken() (string, error) { + f := logrus.Fields{ + "functionName": "sign.jwtToken", + } + log.WithFields(f).Debug("generating jwt token") + + now := time.Now() claims := jwt.MapClaims{ - "iss": , + "iss": utils.GetProperty("DOCUSIGN_INTEGRATOR_KEY"), + "sub": utils.GetProperty("DOCUSIGN_USER_ID"), + "aud": utils.GetProperty("DOCUSIGN_AUTH_SERVER"), + "iat": now.Unix(), + "exp": now.Add(time.Hour).Unix(), + "scope": "signature impersonation", } -} \ No newline at end of file + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + + token.Header["alg"] = "RS256" + token.Header["typ"] = "JWT" + + key, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(os.Getenv("DOCUSIGN_PRIVATE_KEY"))) + if err != nil { + log.WithFields(f).WithError(err).Warn("unable to parse rsa private key") + return "", err + } + + signedToken, err := token.SignedString(key) + if err != nil { + log.WithFields(f).WithError(err).Warn("unable to sign jwt token") + return "", err + } + + return signedToken, nil +} diff --git a/cla-backend-go/v2/sign/models.go b/cla-backend-go/v2/sign/models.go new file mode 100644 index 000000000..c01529073 --- /dev/null +++ b/cla-backend-go/v2/sign/models.go @@ -0,0 +1,246 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package sign + +// DocuSignGetTokenRequest is the request body for getting a token from DocuSign +type DocuSignGetTokenRequest struct { + GrantType string `json:"grant_type"` + Assertion string `json:"assertion"` +} + +// DocuSignGetTokenResponse is the response body for getting a token from DocuSign +type DocuSignGetTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` +} + +// DocuSignTab is the data model for a tab from DocuSign +type DocuSignTab struct { + ApproveTabs []DocuSignTabDetails `json:"approveTabs,omitempty"` + CheckBoxTabs []DocuSignTabDetails `json:"checkboxTabs,omitempty"` + CommentThreadTabs []DocuSignTabDetails `json:"commentThreadTabs,omitempty"` + CommissionCountyTabs []DocuSignTabDetails `json:"commissionCountyTabs,omitempty"` + CommissionExpirationTabs []DocuSignTabDetails `json:"commissionExpirationTabs,omitempty"` + CommissionNumberTabs []DocuSignTabDetails `json:"commissionNumberTabs,omitempty"` + CommissionStateTabs []DocuSignTabDetails `json:"commissionStateTabs,omitempty"` + CompanyTabs []DocuSignTabDetails `json:"companyTabs,omitempty"` + DateSignedTabs []DocuSignTabDetails `json:"dateSignedTabs,omitempty"` + DateTabs []DocuSignTabDetails `json:"dateTabs,omitempty"` + DeclinedTabs []DocuSignTabDetails `json:"declineTabs,omitempty"` + DrawTabs []DocuSignTabDetails `json:"drawTabs,omitempty"` + EmailAddressTabs []DocuSignTabDetails `json:"emailAddressTabs,omitempty"` + EmailTabs []DocuSignTabDetails `json:"emailTabs,omitempty"` + EnvelopeIdTabs []DocuSignTabDetails `json:"envelopeIdTabs,omitempty"` + FirstNameTabs []DocuSignTabDetails `json:"firstNameTabs,omitempty"` + FormulaTabs []DocuSignTabDetails `json:"formulaTab,omitempty"` + FullNameTabs []DocuSignTabDetails `json:"fullNameTabs,omitempty"` + InitialHereTabs []DocuSignTabDetails `json:"initialHereTabs,omitempty"` + LastNameTabs []DocuSignTabDetails `json:"lastNameTabs,omitempty"` + ListTabs []DocuSignTabDetails `json:"listTabs,omitempty"` + NotarizeTabs []DocuSignTabDetails `json:"notarizeTabs,omitempty"` + NotarySealTabs []DocuSignTabDetails `json:"notarySealTabs,omitempty"` + NoteTabs []DocuSignTabDetails `json:"noteTabs,omitempty"` + NumberTabs []DocuSignTabDetails `json:"numberTabs,omitempty"` + NumericalTabs []DocuSignTabDetails `json:"numericalTabs,omitempty"` + PhoneNumberTabs []DocuSignTabDetails `json:"phoneNumberTabs,omitempty"` + PolyLineOverlayTabs []DocuSignTabDetails `json:"polyLineOverlayTabs,omitempty"` + PrefillTabs []DocuSignTabDetails `json:"prefillTabs,omitempty"` + RadioGroupTabs []DocuSignTabDetails `json:"radioGroupTabs,omitempty"` + SignerAttachmentTabs []DocuSignTabDetails `json:"signerAttachmentTabs,omitempty"` + SignHereTabs []DocuSignTabDetails `json:"signHereTabs,omitempty"` + SmartSectionTabs []DocuSignTabDetails `json:"smartSectionTabs,omitempty"` + SSNTabs []DocuSignTabDetails `json:"ssnTabs,omitempty"` + TabGroups []DocuSignTabDetails `json:"tabGroupTabs,omitempty"` + TextTabs []DocuSignTabDetails `json:"textTabs,omitempty"` + TitleTabs []DocuSignTabDetails `json:"titleTabs,omitempty"` + ViewTabs []DocuSignTabDetails `json:"viewTabs,omitempty"` + ZipTabs []DocuSignTabDetails `json:"zipTabs,omitempty"` + TextUnlockedTabs []DocuSignTabDetails `json:"textUnlockedTabs,omitempty"` + TextOptionalTabs []DocuSignTabDetails `json:"textOptionalTabs,omitempty"` +} + +// DocuSignTabDetails is the data model for a tab from DocuSign +type DocuSignTabDetails struct { + AnchorCaseSensitive string `json:"anchorCaseSensitive,omitempty"` // anchor case sensitive flag, "true" or "false" + AnchorIgnoreIfNotPresent string `json:"anchorIgnoreIfNotPresent,omitempty"` // When true, this tab is ignored if the anchorString is not found in the document. + AnchorHorizontalAlignment string `json:"anchorHorizontalAlignment,omitempty"` // This property controls how anchor tabs are aligned in relation to the anchor text. Possible values are : left: Aligns the left side of the tab with the beginning of the first character of the matching anchor word. This is the default value. right: Aligns the tab’s left side with the last character of the matching anchor word. + AnchorMatchWholeWord string `json:"anchorMatchWholeWord,omitempty"` // When true, the text string in a document must match the value of the anchorString property in its entirety for an anchor tab to be created. The default value is false. For example, when set to true, if the input is man then man will match but manpower, fireman, and penmanship will not. When false, if the input is man then man, manpower, fireman, and penmanship will all match. + AnchorString string `json:"anchorString,omitempty"` // Specifies the string to find in the document and use as the basis for tab placement + AnchorUnits string `json:"anchorUnits,omitempty"` // anchor units, pixels, cms, mms + AnchorXOffset string `json:"anchorXOffset,omitempty"` // anchor x offset + AnchorYOffset string `json:"anchorYOffset,omitempty"` // anchor y offset + Bold string `json:"bold,omitempty"` // bold flag, "true" or "false" + DocumentId string `json:"documentId,omitempty"` // Specifies the document ID number that the tab is placed on. This must refer to an existing Document's ID attribute. + Font string `json:"font,omitempty"` // font + FontSize string `json:"fontSize,omitempty"` // font size + Height string `json:"height,omitempty"` // The height of the tab in pixels. Must be an integer. + Locked string `json:"locked,omitempty"` // locked flag, "true" or "false" + MinNumericalValue string `json:"minNumericalValue,omitempty"` // minimum numerical value, such as "0", used for validation of numerical tabs + MaxNumericalValue string `json:"maxNumericalValue,omitempty"` // maximum numerical value, such as "100", used for validation of numerical tabs + Name string `json:"name,omitempty"` // The name of the tab. For example, Sign Here or Initial Here. If the tooltip attribute is not set, this value will be displayed as the custom tooltip text. + Optional string `json:"optional,omitempty"` // When true, the recipient does not need to complete this tab to complete the signing process + PageNumber string `json:"pageNumber,omitempty"` // Specifies the page number on which the tab is located. Must be 1 for supplemental documents. + Required string `json:"required,omitempty"` // When true, the signer is required to fill out this tab + TabId string `json:"tabId,omitempty"` // tab idj + TabLabel string `json:"tabLabel,omitempty"` // label + TabOrder string `json:"tabOrder,omitempty"` // A positive integer that sets the order the tab is navigated to during signing. Tabs on a page are navigated to in ascending order, starting with the lowest number and moving to the highest. If two or more tabs have the same tabOrder value, the normal auto-navigation setting behavior for the envelope is used. + TabType string `json:"tabType,omitempty"` // Indicates type of tab (for example: signHere or initialHere) + ToolTip string `json:"toolTip,omitempty"` // The text of a tooltip that appears when a user hovers over a form field or tab. + Width string `json:"width,omitempty"` // The width of the tab in pixels. Must be an integer. This is not applicable to Sign Here tab. + XPosition string `json:"xPosition,omitempty"` // x position + YPosition string `json:"yPosition,omitempty"` // x position + ValidationType string `json:"validationType,omitempty"` // validation type, "string", "number", "date", "zipcode", "currency" + Value string `json:"value,omitempty"` +} + +type ClaSignatoryEmailParams struct { + ClaGroupName string + SignatoryName string + ProjectNames []string + ClaManagerName string + CompanyName string + ClaManagerEmail string + ProjectVersion string +} + +// DocuSignRecipient is the data model for an editor or signer from DocuSign +type DocuSignRecipient struct { + RecipientId string `json:"recipientId,omitempty"` // Unique for the recipient. It is used by the tab element to indicate which recipient is to sign the document. + + ClientUserId string `json:"clientUserId,omitempty"` // Specifies whether the recipient is embedded or remote. If the clientUserId property is not null then the recipient is embedded. Use this field to associate the signer with their userId in your app. Authenticating the user is the responsibility of your app when you use embedded signing. + + /* The recipient type, as specified by the following values: + agent: Agent recipients can add name and email information for recipients that appear after the agent in routing order. + carbonCopy: Carbon copy recipients get a copy of the envelope but don't need to sign, initial, date, or add information to any of the documents. This type of recipient can be used in any routing order. + certifiedDelivery: Certified delivery recipients must receive the completed documents for the envelope to be completed. They don't need to sign, initial, date, or add information to any of the documents. + editor: Editors have the same management and access rights for the envelope as the sender. Editors can add name and email information, add or change the routing order, set authentication options, and can edit signature/initial tabs and data fields for the remaining recipients. + inPersonSigner: In-person recipients are DocuSign users who act as signing hosts in the same physical location as the signer. + intermediaries: Intermediary recipients can optionally add name and email information for recipients at the same or subsequent level in the routing order. + seal: Electronic seal recipients represent legal entities. + signer: Signers are recipients who must sign, initial, date, or add data to form fields on the documents in the envelope. + witness: Witnesses are recipients whose signatures affirm that the identified signers have signed the documents in the envelope. + */ + RecipientType string `json:"recipientType,omitempty"` + + RoleName string `json:"roleName,omitempty"` // Optional element. Specifies the role name associated with the recipient. This property is required when you are working with template recipients. + + RoutingOrder string `json:"routingOrder,omitempty"` // Specifies the routing order of the recipient in the envelope. + + Name string `json:"name,omitempty"` // The full legal name of the recipient. Maximum Length: 100 characters. Note: You must always set a value for this property in requests, even if firstName and lastName are set. + FirstName string `json:"firstName,omitempty"` // recipient's first name (50 characters maximum) + LastName string `json:"lastName,omitempty"` // recipient's last name + Email string `json:"email,omitempty"` // recipient's email address + Note string `json:"note,omitempty"` // A note sent to the recipient in the signing email. This note is unique to this recipient. In the user interface, it appears near the upper left corner of the document on the signing screen. Maximum Length: 1000 characters. + + Tabs DocuSignTab `json:"tabs"` // The tabs associated with the recipient. The tabs property enables you to programmatically position tabs on the document. For example, you can specify that the SIGN_HERE tab is placed at a given (x,y) location on the document. You can also specify the font, font color, font size, and other properties of the text in the tab. You can also specify the location and size of the tab. For example, you can specify that the tab is 50 pixels wide and 20 pixels high. You can also specify the page number on which the tab is located and whether the tab is located in a document, a template, or an inline template. For more information about tabs, see the Tabs section of the REST API documentation. +} + +// DocuSignEnvelopeRequest is the request body for an envelope from DocuSign, see: https://developers.docusign.com/docs/esign-rest-api/reference/envelopes/envelopes/create/ +type DocuSignEnvelopeRequest struct { + EnvelopeId string `json:"envelopeId,omitempty"` // The envelope ID of the envelope + EnvelopeIdStamping string `json:"envelopeIdStamping,omitempty"` // When true, Envelope ID Stamping is enabled. After a document or attachment is stamped with an Envelope ID, the ID is seen by all recipients and becomes a permanent part of the document and cannot be removed. + TemplateId string `json:"templateId,omitempty"` // The ID of the template. If a value is not provided, DocuSign generates a value. + Documents []DocuSignDocument `json:"document,omitempty"` // A data model containing details about the documents associated with the envelope + DocumentBase64 string `json:"documentBase64,omitempty"` // The document's bytes. This field can be used to include a base64 version of the document bytes within an envelope definition instead of sending the document using a multi-part HTTP request. The maximum document size is smaller if this field is used due to the overhead of the base64 encoding. + DocumentsCombinedUri string `json:"documentsCombinedUri,omitempty"` // The URI for retrieving all of the documents associated with the envelope as a single PDF file. + DocumentsUri string `json:"documentsUri,omitempty"` // The URI for retrieving all of the documents associated with the envelope as separate files. + EmailSubject string `json:"emailSubject,omitempty"` // EmailSubject - The subject line of the email message that is sent to all recipients. + EmailBlurb string `json:"emailBlurb,omitempty"` // EmailBlurb - This is the same as the email body. If specified it is included in email body for all envelope recipients. + Recipients DocuSignRecipientType `json:"recipients,omitempty"` + // TemplateRoles []DocuSignTemplateRole `json:"templateRoles,omitempty"` + + /* Status + Indicates the envelope status. Valid values when creating an envelope are: + + created: The envelope is created as a draft. It can be modified and sent later. + sent: The envelope will be sent to the recipients after the envelope is created. + + You can query these additional statuses once the recipients have interacted with the envelope. + + completed: The recipients have finished working with the envelope: the documents are signed and all required tabs are filled in. + declined: The envelope has been declined by the recipients. + delivered: The envelope has been delivered to the recipients. + signed: The envelope has been signed by the recipients. + voided: The envelope is no longer valid and recipients cannot access or sign the envelope. + + */ + Status string `json:"status,omitempty"` + EventNotification DocusignEventNotification `json:"eventNotification,omitempty"` +} + +// DocuSignDocument is the data model for a document from DocuSign +type DocuSignDocument struct { + DocumentId string `json:"documentId,omitempty"` // Specifies the document ID of this document. This value is used by tabs to determine which document they appear in. + DocumentBase64 string `json:"documentBase64,omitempty"` // The document's bytes. This field can be used to include a base64 version of the document bytes within an envelope definition instead of sending the document using a multi-part HTTP request. The maximum document size is smaller if this field is used due to the overhead of the base64 encoding.0:w + FileExtension string `json:"fileExtension,omitempty"` // The file extension type of the document. Non-PDF documents are converted to PDF. If the document is not a PDF, fileExtension is required. If you try to upload a non-PDF document without a fileExtension, you will receive an "unable to load document" error message. The file extension type of the document. If the document is not a PDF it is converted to a PDF. + FileFormatHint string `json:"fileFormatHint,omitempty"` + IncludeInDownload string `json:"includeInDownload,omitempty"` // When set to true, the document is included in the combined document download. + Name string `json:"name,omitempty"` // The name of the document. This is the name that appears in the list of documents when managing an envelope. + Order string `json:"order,omitempty"` // The order in which to sort the results. Valid values are: asc, desc +} + +// DocuSignRecipientType is the data model for a recipient from DocuSign +type DocuSignRecipientType struct { + Agents []DocuSignRecipient `json:"agent,omitempty"` + CarbonCopies []DocuSignRecipient `json:"carbonCopy,omitempty"` + CertifiedDeliveries []DocuSignRecipient `json:"certifiedDelivery,omitempty"` + Editors []DocuSignRecipient `json:"editor,omitempty"` + InPersonSigners []DocuSignRecipient `json:"inPersonSigner,omitempty"` + Intermediaries []DocuSignRecipient `json:"intermediary,omitempty"` + Notaries []DocuSignRecipient `json:"notaryRecipient,omitempty"` + Participants []DocuSignRecipient `json:"participant,omitempty"` + Seals []DocuSignRecipient `json:"seals,omitempty"` // A list of electronic seals to apply to documents. + Signers []DocuSignRecipient `json:"signers,omitempty"` // A list of signers on the envelope. + Witnesses []DocuSignRecipient `json:"witness,omitempty"` // A list of signers who act as witnesses for an envelope. + RecipientCount string `json:"recipientCount,omitempty"` // The number of recipients in the envelope. +} + +// DocusignRecipientEvent is the data model for a recipient event from DocuSign +type DocusignRecipientEvent struct { + RecipientEventStatusCode string `json:"recipientEventStatusCode"` + RecipientEventStatus string `json:"recipientEventStatus"` + RecipientEventDateTime string `json:"recipientEventDateTime"` +} + +//DocusignEventNotification is the data model for a event notification from DocuSign + +type DocusignEventNotification struct { + URL string `json:"url"` + LoggingEnabled string `json:"loggingEnabled"` + RecipientEvents []DocusignRecipientEvent `json:"recipientEvents"` +} + +// DocuSignEnvelopeResponse is the response body for an envelope from DocuSign, see: https://developers.docusign.com/docs/esign-rest-api/reference/envelopes/envelopes/update/ +type DocuSignEnvelopeResponse struct { + EnvelopeId string `json:"envelopeId,omitempty"` + ErrorDetails struct { + ErrorCode string `json:"errorCode,omitempty"` + Message string `json:"message,omitempty"` + } `json:"errorDetails,omitempty"` +} + +// AccountId +type AccountId struct { + Sub string `json:"sub"` + Name string `json:"name"` + GivenName string `json:"given_name"` + FamilyName string `json:"family_name"` + Created string `json:"created"` + Email string `json:"email"` + Accounts []struct { + AccountID string `json:"account_id"` + IsDefault bool `json:"is_default"` + AccountName string `json:"account_name"` + BaseURI string `json:"base_uri"` + Organization struct { + OrganizationID string `json:"organization_id"` + Links []struct { + Rel string `json:"rel"` + Href string `json:"href"` + } `json:"links"` + } `json:"organization"` + } `json:"accounts"` +} diff --git a/cla-backend-go/v2/sign/service.go b/cla-backend-go/v2/sign/service.go index 99b282687..b5685ee62 100644 --- a/cla-backend-go/v2/sign/service.go +++ b/cla-backend-go/v2/sign/service.go @@ -6,16 +6,24 @@ package sign import ( "bytes" "context" + "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "os" + "path" + "strconv" "strings" + "time" + "github.com/communitybridge/easycla/cla-backend-go/project/common" "github.com/communitybridge/easycla/cla-backend-go/projects_cla_groups" "github.com/communitybridge/easycla/cla-backend-go/v2/cla_groups" + "github.com/communitybridge/easycla/cla-backend-go/v2/store" + "github.com/go-openapi/strfmt" + "github.com/gofrs/uuid" "github.com/sirupsen/logrus" @@ -24,7 +32,12 @@ import ( organizationService "github.com/communitybridge/easycla/cla-backend-go/v2/organization-service" + ghOrgService "github.com/communitybridge/easycla/cla-backend-go/github_organizations" + signatureService "github.com/communitybridge/easycla/cla-backend-go/signatures" + claUser "github.com/communitybridge/easycla/cla-backend-go/users" + gitlabOrgService "github.com/communitybridge/easycla/cla-backend-go/v2/gitlab_organizations" projectService "github.com/communitybridge/easycla/cla-backend-go/v2/project-service" + repositoryService "github.com/communitybridge/easycla/cla-backend-go/v2/repositories" userService "github.com/communitybridge/easycla/cla-backend-go/v2/user-service" log "github.com/communitybridge/easycla/cla-backend-go/logging" @@ -33,13 +46,14 @@ import ( v1Models "github.com/communitybridge/easycla/cla-backend-go/gen/v1/models" "github.com/communitybridge/easycla/cla-backend-go/gen/v2/models" "github.com/communitybridge/easycla/cla-backend-go/utils" - "github.com/communitybridge/easycla/cla-backend-go/v2/docusign_auth" + // docusignauth "github.com/communitybridge/easycla/cla-backend-go/v2/docusign_auth" ) var ( integrationKey = os.Getenv("DOCUSIGN_INTEGRATOR_KEY") userGUID = os.Getenv("DOCUSIGN_USER_ID") privateKey = os.Getenv("DOCUSIGN_PRIVATE_KEY") + apiBasePath = os.Getenv("API_BASE_PATH") ) // constants @@ -61,8 +75,16 @@ type ProjectRepo interface { // Service interface defines the sign service methods type Service interface { + getAccessToken(ctx context.Context) (string, error) + getAccountID(ctx context.Context, accessToken string) (string, error) + getDocumentTabsFromDocument(ctx context.Context, document v1Models.ClaGroupDocument, documentID string, defaultValues map[string]interface{}) (DocuSignTab, error) + voidDocument(ctx context.Context, documentID, message string) error + PrepareSignEvent(ctx context.Context, envelope DocuSignEnvelopeRequest) (*DocuSignEnvelopeResponse, error) + GetRecipientView(ctx context.Context, returnURL, recipientName, recipientEmail, envelopeID string) (string, error) + RequestCorporateSignature(ctx context.Context, lfUsername string, authorizationHeader string, input *models.CorporateSignatureInput) (*models.CorporateSignatureOutput, error) - RequestIndividualSignature(ctx context.Context, input *models.IclaSignatureInput) (*models.IndividualSignatureOutput, error) + RequestIndividualSignature(ctx context.Context, projectID, userID, returnURL, returnURLType, preferredEmail string) (*models.IndividualSignatureOutput, error) + RequestIndividualSignatureGerrit(ctx context.Context, projectID, userID, returnURL string) (*models.IndividualSignatureOutput, error) } // service @@ -73,10 +95,16 @@ type service struct { projectClaGroupsRepo projects_cla_groups.Repository companyService company.IService claGroupService cla_groups.Service + claUserService claUser.Service + signatureService signatureService.SignatureService + storeRepo store.Repository + repositoryService repositoryService.ServiceInterface + ghOrgService ghOrgService.Service + gitlabOrgService gitlabOrgService.RepositoryInterface } // NewService returns an instance of v2 project service -func NewService(apiURL string, compRepo company.IRepository, projectRepo ProjectRepo, pcgRepo projects_cla_groups.Repository, compService company.IService, claGroupService cla_groups.Service) Service { +func NewService(apiURL string, compRepo company.IRepository, projectRepo ProjectRepo, pcgRepo projects_cla_groups.Repository, compService company.IService, claGroupService cla_groups.Service, claUserService claUser.Service, signatureService signatureService.SignatureService, storeRepo store.Repository, repositoryService repositoryService.ServiceInterface, ghorgService ghOrgService.Service, gitlabOrgService gitlabOrgService.RepositoryInterface) Service { return &service{ ClaV1ApiURL: apiURL, companyRepo: compRepo, @@ -84,6 +112,11 @@ func NewService(apiURL string, compRepo company.IRepository, projectRepo Project projectClaGroupsRepo: pcgRepo, companyService: compService, claGroupService: claGroupService, + claUserService: claUserService, + signatureService: signatureService, + storeRepo: storeRepo, + ghOrgService: ghorgService, + gitlabOrgService: gitlabOrgService, } } @@ -311,25 +344,659 @@ func (s *service) RequestCorporateSignature(ctx context.Context, lfUsername stri return out.toModel(), nil } -func (s *service) RequestIndividualSignature(ctx context.Context, input *models.IclaSignatureInput) (*models.IndividualSignatureOutput, error) { +func (s *service) RequestIndividualSignature(ctx context.Context, projectID, userID, returnURL, returnURLType, preferredEmail string) (*models.IndividualSignatureOutput, error) { f := logrus.Fields{ - "functionName": "sign.RequestIndividualSignature", - "authorityEmail": input.AuthorityEmail, - "authorityName": input.AuthorityName, - "companySFID": input.CompanySfid, - "projectSFID": input.ProjectSfid, + "functionName": "sign.RequestIndividualSignature", + "projectID": projectID, + "userID": userID, + "returnURL": returnURL, + "returnURLType": returnURLType, + "preferredEmail": preferredEmail, + } + + var signatureOutput *models.IndividualSignatureOutput + + // 1. ensure this is a valid user + log.WithFields(f).Debugf("getting user by id: %s", userID) + user, err := s.claUserService.GetUser(userID) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to get user by id: %s", userID) + return nil, err + } + if user.UserID == "" { + log.WithFields(f).WithError(err).Warnf("unable to get user by id: %s", userID) + return nil, errors.New("user not found") + } + + // 2. ensure this is a valid project + log.WithFields(f).Debugf("getting project by id: %s", projectID) + project, err := s.projectRepo.GetCLAGroupByID(ctx, projectID, DontLoadRepoDetails) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to get project by id: %s", projectID) + return nil, err + } + + if project == nil { + log.WithFields(f).WithError(err).Warnf("unable to get project by id: %s", projectID) + return nil, errors.New("cla group not found") + } + + // 3. get latest signature + log.WithFields(f).Debugf("checking for active signature object with this project: %s", projectID) + latestSignature, err := s.signatureService.GetLatestSignature(ctx, userID, "", projectID) + + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to get latest signature by project id: %s", projectID) + return nil, err + } + + proj, err := s.projectRepo.GetCLAGroupByID(ctx, projectID, DontLoadRepoDetails) + + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to get project by id: %s", projectID) + return nil, err + } + + if proj == nil { + log.WithFields(f).WithError(err).Warnf("unable to get project by id: %s", projectID) + return nil, errors.New("cla group not found") + } + + lastDocument, err := common.GetCurrentDocument(ctx, proj.ProjectIndividualDocuments) + + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to get latest document by project id: %s", projectID) + return nil, err + } + + log.WithFields(f).Debugf("latest_document: %+v", lastDocument) + + defaultCLAValues := s.createDefaultValues(ctx, user, preferredEmail) + log.WithFields(f).Debugf("defaultCLAValues: %+v", defaultCLAValues) + + // 4 check for active signature object with this project + signatureMetadata, err := s.storeRepo.GetActiveSignatureMetaData(ctx, userID) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to get active signature metadata by user id: %s", userID) + return nil, err + } + + if signatureMetadata == nil { + log.WithFields(f).WithError(err).Warnf("unable to get active signature metadata by user id: %s", userID) + return nil, errors.New("signature metadata not found") + } + + log.WithFields(f).Debugf("signatureMetadata: %+v", signatureMetadata) + + var callBackURL string + + if strings.ToLower(returnURLType) == "github" { + callBackURL, err = s.getSignatureCallbackURL(ctx, userID, signatureMetadata) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to get signature callback url by user id: %s", userID) + return nil, err + } + } else if strings.ToLower(returnURLType) == "gitlab" { + callBackURL, err = s.getSignatureCallbackURLGitLab(ctx, userID, signatureMetadata) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to get signature callback url by user id: %s", userID) + return nil, err + } + } + + log.WithFields(f).Debugf("callBackURL: %+v", callBackURL) + + if latestSignature != nil && lastDocument.DocumentMajorVersion == latestSignature.SignatureMajorVersion { + log.WithFields(f).Debugf("signature already exist for this project: %s", projectID) + + // regenerate and set the signing url - this will update the signature record + err := s.populateSignURL(ctx, latestSignature, callBackURL, "", "", false, "", "", defaultCLAValues, preferredEmail) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to populate sign url by user id: %s", userID) + return nil, err + } + + signatureOutput = &models.IndividualSignatureOutput{ + SignURL: latestSignature.SignatureSignURL, + SignatureID: latestSignature.SignatureID, + UserID: userID, + ProjectID: projectID, + } + + return signatureOutput, nil + } + + // 5. get signature return url + if returnURL == "" { + log.WithFields(f).Debugf("return url is empty, setting default return url") + returnURL = "" + } + + if returnURL == "" { + log.WithFields(f).Debug("No active signature found for user - cannot generate returnURL without knowing where the user came from") + return &models.IndividualSignatureOutput{ + UserID: userID, + ProjectID: projectID, + }, errors.New("no active signature found for user - cannot generate returnURL without knowing where the user came from") + } + + document, err := common.GetCurrentDocument(ctx, proj.ProjectIndividualDocuments) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to get latest document by project id: %s", projectID) + return nil, err + } + + aclUser := v1Models.User{} + + if returnURLType == "github" { + aclUser.GithubID = user.GithubID + } else if returnURLType == "gitlab" { + aclUser.GitlabID = user.GitlabID } - log.WithFields(f).Debug("Get Access Token for DocuSign") - accessToken, err := docusignauth.GetAccessToken(integrationKey, userGUID, privateKey) + log.WithFields(f).Debugf("creating new signature object for user: %+v", aclUser) + + // 7. create new signature object + signature := &v1Models.Signature{ + SignatureID: uuid.Must(uuid.NewV4()).String(), + SignatureReferenceID: userID, + SignatureReferenceName: user.Username, + SignatureReferenceType: utils.SignatureReferenceTypeUser, + SignatureType: utils.SignatureTypeCLA, + SignatureReturnURL: strfmt.URI(returnURL), + SignatureReturnURLType: returnURLType, + ProjectID: projectID, + SignatureMajorVersion: document.DocumentMajorVersion, + SignatureMinorVersion: document.DocumentMinorVersion, + SignatureCreated: utils.TimeToString(time.Now()), + SignatureModified: utils.TimeToString(time.Now()), + SignatureCallbackURL: strfmt.URI(callBackURL), + SignatureSigned: false, + SignatureApproved: true, + SignatureACL: []v1Models.User{ + aclUser, + }, + } + + created, err := s.signatureService.CreateSignature(ctx, signature) if err != nil { - log.WithFields(f).WithError(err).Warn("unable to get access token for DocuSign") + log.WithFields(f).WithError(err).Warnf("unable to create signature by user id: %s", userID) return nil, err } - log.WithFields(f).Debugf("access token: %s", accessToken) + log.WithFields(f).Debugf("created signature: %+v", created) + + // 8. populate sign url + err = s.populateSignURL(ctx, created, callBackURL, "", "", false, "", "", defaultCLAValues, preferredEmail) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to populate sign url by user id: %s", userID) + return nil, err + } + + return &models.IndividualSignatureOutput{ + SignURL: created.SignatureSignURL, + SignatureID: created.SignatureID, + UserID: userID, + ProjectID: projectID, + }, nil + +} + +func (s service) populateSignURL(ctx context.Context, signature *v1Models.Signature, callbackURL, + authorityOrSignatoryName, authorityOrSignatoryEmail string, sendAsEmail bool, claManagerName, claManagerEmail string, + defaultValues map[string]interface{}, preferredEmail string) error { + + f := logrus.Fields{ + "functionName": "sign.populateSignURL", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "callbackURL": callbackURL, + "authorityOrSignatoryName": authorityOrSignatoryName, + "authorityOrSignatoryEmail": authorityOrSignatoryEmail, + "sendAsEmail": sendAsEmail, + "claManagerName": claManagerName, + "claManagerEmail": claManagerEmail, + "preferredEmail": preferredEmail, + } + + log.WithFields(f).Debugf("populateSignURL - signature: %+v", signature) + + userSignatureName := "Unknown" + userSignatureEmail := "Unknown" + signatureType := signature.SignatureReferenceType + var company *v1Models.Company + + if signatureType == "company" { + userSignatureName = claManagerName + userSignatureEmail = claManagerEmail + log.WithFields(f).Debugf("company signature - userSignatureName: %s, userSignatureEmail: %s", userSignatureName, userSignatureEmail) + + //Grab the company id from the signature + company, err := s.companyRepo.GetCompany(ctx, signature.SignatureReferenceID) + if err != nil { + return err + } + log.WithFields(f).Debugf("loaded company: %+v", company) + } else if signatureType == "user" { + user, err := s.claUserService.GetUser(signature.SignatureReferenceID) + if err != nil { + return err + } + if user != nil { + userSignatureName = user.Username + userSignatureEmail = getUserEmail(user, preferredEmail) + } + log.WithFields(f).Debugf("user signature - userSignatureName: %s, userSignatureEmail: %s", userSignatureName, userSignatureEmail) + } else { + return errors.New("invalid signature type") + } + + // Fetch the document template to sign + claGroup, err := s.claGroupService.GetCLAGroup(ctx, signature.ProjectID) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to lookup CLA Group by ID: %s", signature.ProjectID) + return err + } + if claGroup == nil { + log.WithFields(f).WithError(err).Warnf("unable to lookup CLA Group by ID: %s", signature.ProjectID) + return errors.New("invalid CLA Group ID") + } + + var document v1Models.ClaGroupDocument + var signDocument DocuSignDocument + + // Load the appropriate document template + if signatureType == "company" { + if len(claGroup.ProjectCorporateDocuments) == 0 { + log.WithFields(f).Warnf("company signature - missing corporate documents in the CLA Group configuration") + return errors.New("missing corporate documents in the CLA Group configuration") + } + document, err = common.GetCurrentDocument(ctx, claGroup.ProjectCorporateDocuments) + if err != nil { + log.WithFields(f).WithError(err).Warnf("company signature - unable to get latest document by project id: %s", signature.ProjectID) + return err + } + log.WithFields(f).Debugf("company signature - document: %+v", document) + + } else if signatureType == "user" { + if len(claGroup.ProjectIndividualDocuments) == 0 { + log.WithFields(f).Warnf("user signature - missing individual documents in the CLA Group configuration") + return errors.New("missing individual documents in the CLA Group configuration") + } + document, err = common.GetCurrentDocument(ctx, claGroup.ProjectIndividualDocuments) + if err != nil { + log.WithFields(f).WithError(err).Warnf("company signature - unable to get latest document by project id: %s", signature.ProjectID) + return err + } + log.WithFields(f).Debugf("user signature - document: %+v", document) + } + + // Void the existing envelope to prevent multiple envelopes from being created + envelopeID := signature.SignatureEnvelopeID + if envelopeID != "" { + msg := fmt.Sprintf("You are getting this message because youd Docusign session for project: %s expired. A new session has been created for you.", claGroup.ProjectName) + log.WithFields(f).Debug(msg) + // docusign void envelope + err = s.voidDocument(ctx, envelopeID, msg) + if err != nil { + return err + } + } + + randomUUID, err := uuid.NewV4() + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to generate UUID for signature: %+v", signature) + return err + } + + documentID := uint16(randomUUID[0])<<8 + uint16(randomUUID[1]) + var signer DocuSignRecipient + var subject string + var body string + + // getting Tabs from document + tabs, err := s.getDocumentTabsFromDocument(ctx, document, strconv.FormatUint(uint64(documentID), 10), defaultValues) + + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to get document tabs from document: %+v", document) + return err + } + + if sendAsEmail { + log.WithFields(f).Debugf(" %s - assigning signatory name/email: %s/%s", signatureType, authorityOrSignatoryName, authorityOrSignatoryEmail) + + // sending email to authority + signatoryEmail := authorityOrSignatoryEmail + signatoryName := authorityOrSignatoryName + var companyName string + + projectName := claGroup.ProjectName + claGroupName := projectName + if company != nil { + companyName = company.CompanyName + } + + // Get Project Cla Groups + projectCLAGroups, err := s.projectClaGroupsRepo.GetProjectsIdsForClaGroup(ctx, claGroup.ProjectID) + + projectNames := []string{} + + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to get project cla groups for cla group id: %s", claGroup.ProjectID) + return err + } + + log.WithFields(f).Debugf("found %d project CLA groups for CLA Group: %s", len(projectCLAGroups), claGroup.ProjectID) + + for _, projectCLAGroup := range projectCLAGroups { + projectNames = append(projectNames, projectCLAGroup.ProjectName) + } + + emailParams := ClaSignatoryEmailParams{ + ClaGroupName: claGroupName, + CompanyName: companyName, + SignatoryName: signatoryName, + ClaManagerName: claManagerName, + ClaManagerEmail: claManagerEmail, + ProjectVersion: claGroup.Version, + ProjectNames: projectNames, + } + log.WithFields(f).Debugf(" %s - sending document as email to signatory: %s/%s for project: %s and company: %s", signatureType, signatoryName, signatoryEmail, projectName, companyName) + + subject, body = ClaSignatoryEmailContent(emailParams) + + log.WithFields(f).Debugf("%s - generating a docusign signer object form email with name: %s and email: %s, subject: %s", signatureType, signatoryName, signatoryEmail, subject) + + signer = DocuSignRecipient{ + Name: signatoryName, + Email: signatoryEmail, + RecipientId: "1", + Tabs: tabs, + } + } else { + // This will be the Initial CLA Manager + + signatoryName := userSignatureName + signatoryEmail := userSignatureEmail + + log.WithFields(f).Debugf("%s - generating a docusign signer object form user with name: %s and email: %s", signatureType, signatoryName, signatoryEmail) + + // # Max length for emailSubject is 100 characters - guard/truncate if necessary + subject = fmt.Sprintf("EasyCLA: CLA Signature Request for %s", claGroup.ProjectName) + if len(subject) > 100 { + subject = subject[:97] + "..." + } else { + subject = subject + } + + var userIdentifier string = "" + + if signatureType == "company" { + userIdentifier = company.CompanyName + } else { + if signatoryName == "Unknown" || signatoryName == "" { + userIdentifier = signatoryEmail + } else { + userIdentifier = signatoryName + } + } + + body = fmt.Sprintf("CLA Sign Request for %s", userIdentifier) + + signer = DocuSignRecipient{ + Name: signatoryName, + Email: signatoryEmail, + RecipientId: "1", + Tabs: tabs, + ClientUserId: signature.SignatureID, + } + } + + contentType := document.DocumentContentType + var pdf []byte + + if document.DocumentS3URL != "" { + log.WithFields(f).Debugf("%s - getting document resource from s3 url: %s", signatureType, document.DocumentS3URL) + pdf, err = GetDocumentResource(ctx, document.DocumentS3URL) + if err != nil { + log.WithFields(f).WithError(err).Warnf("%s - unable to get document resource from s3 url: %s", signatureType, document.DocumentS3URL) + return err + } + } else if strings.HasPrefix(contentType, "url+") { + pdfUrl := document.DocumentContent + log.WithFields(f).Debugf("%s - getting document resource from url: %s", signatureType, pdfUrl) + pdf, err = GetDocumentResource(ctx, pdfUrl) + if err != nil { + log.WithFields(f).WithError(err).Warnf("%s - unable to get document resource from url: %s", signatureType, pdfUrl) + return err + } + } else { + log.WithFields(f).Debugf("%s - getting document resource from content", signatureType) + pdf = []byte(document.DocumentContent) + } + + docName := document.DocumentName + log.WithFields(f).Debugf("%s - docusign document name: %s, id: %d, content type: %s", signatureType, docName, documentID, contentType) + + signDocument = DocuSignDocument{ + Name: docName, + DocumentId: strconv.FormatUint(uint64(documentID), 10), + DocumentBase64: base64.StdEncoding.EncodeToString(pdf), + } + + log.WithFields(f).Debugf("%s - generating a docusign envelope object for project: %s", signatureType, claGroup.ProjectName) + + envelopeRequest := &DocuSignEnvelopeRequest{} + + if callbackURL != "" { + // 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. + recipientEvents := []DocusignRecipientEvent{ + { + RecipientEventStatusCode: "Completed", + }, + } + + eventNotification := DocusignEventNotification{ + URL: callbackURL, + LoggingEnabled: "true", + RecipientEvents: recipientEvents, + } + + envelopeRequest = &DocuSignEnvelopeRequest{ + Documents: []DocuSignDocument{ + signDocument, + }, + EmailSubject: subject, + EmailBlurb: body, + EventNotification: eventNotification, + Status: "sent", + Recipients: DocuSignRecipientType{ + Signers: []DocuSignRecipient{ + signer, + }, + }, + } + + } else { + envelopeRequest = &DocuSignEnvelopeRequest{ + Documents: []DocuSignDocument{ + signDocument, + }, + EmailSubject: subject, + EmailBlurb: body, + Status: "sent", + Recipients: DocuSignRecipientType{ + Signers: []DocuSignRecipient{ + signer, + }, + }, + } + } + + envResponse, err := s.PrepareSignEvent(ctx, *envelopeRequest) + if err != nil { + return err + } + + if !sendAsEmail { + returnURL := path.Join(s.ClaV1ApiURL, "v2/return_url", signer.ClientUserId) + log.WithFields(f).Debugf("generating signature sign url using return_url as : %s", returnURL) + signUrl, err := s.GetRecipientView(ctx, returnURL, signer.Name, signer.Email, envResponse.EnvelopeId) + if err != nil { + return err + } + signature.SignatureSignURL = signUrl + } + + // save envelopeID in signature + signature.SignatureEnvelopeID = envResponse.EnvelopeId + + // Update signature + log.WithFields(f).Debug("saving signature to database") + _, err = s.signatureService.CreateOrUpdateSignature(ctx, signature) + if err != nil { + return err + } + + log.WithFields(f).Debug("populate sign url complete.") + return nil +} + +func (s service) getSignatureCallbackURL(ctx context.Context, userID string, signatureMetadata *store.Data) (string, error) { + f := logrus.Fields{ + "functionName": "sign.getSignatureCallbackURL", + "userID": userID, + "signatureMetadata": signatureMetadata, + } + + var err error + if signatureMetadata == nil { + signatureMetadata, err = s.storeRepo.GetActiveSignatureMetaData(ctx, userID) + if err != nil { + return "", err + } + } + + if signatureMetadata == nil { + log.WithFields(f).WithError(err).Warnf("unable to get active signature metadata by user id: %s", userID) + return "", errors.New("signature metadata not found") + } + + // Get Github ID from metadata + repositoryID := signatureMetadata.RepositoryID + + // Get installationID + repository, err := s.repositoryService.GitHubGetRepositoryByExternalID(ctx, repositoryID) + if err != nil { + return "", err + } + + ghOrg, err := s.ghOrgService.GetGitHubOrganizationByName(ctx, repository.RepositoryOrganizationName) + if err != nil { + return "", err + } + + installationID := strconv.FormatInt(ghOrg.OrganizationInstallationID, 10) + pullRequestID := signatureMetadata.PullRequestID + + return path.Join(apiBasePath, "v2/signed/individual", installationID, repositoryID, pullRequestID), nil +} + +func (s service) getSignatureCallbackURLGitLab(ctx context.Context, userID string, signatureMetadata *store.Data) (string, error) { + f := logrus.Fields{ + "functionName": "sign.getSignatureCallbackURLGitLab", + "userID": userID, + "signatureMetadata": signatureMetadata, + } + + var err error + if signatureMetadata == nil { + signatureMetadata, err = s.storeRepo.GetActiveSignatureMetaData(ctx, userID) + if err != nil { + return "", err + } + } + + if signatureMetadata == nil { + log.WithFields(f).WithError(err).Warnf("unable to get active signature metadata by user id: %s", userID) + return "", errors.New("signature metadata not found") + } + + // format repositoryID to int64 + repositoryID, err := strconv.ParseInt(signatureMetadata.RepositoryID, 10, 64) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to convert repositoryID to int: %s", signatureMetadata.RepositoryID) + return "", err + } + + gitlabRepository, err := s.repositoryService.GitLabGetRepositoryByExternalID(ctx, repositoryID) + if err != nil { + return "", err + } + + gitlabOrg, err := s.gitlabOrgService.GetGitLabOrganizationByName(ctx, gitlabRepository.RepositoryOrganizationName) + if err != nil { + return "", err + } + + if gitlabOrg.OrganizationID == "" { + return "", errors.New("gitlab organization not found") + } + + return path.Join(apiBasePath, "v2/signed/gitlab/individual", userID, gitlabOrg.OrganizationID, signatureMetadata.RepositoryID, signatureMetadata.MergeRequestID), nil + +} + +type IndividualValues struct { + FullName string + Email string + PublicName string +} + +func (s *service) RequestIndividualSignatureGerrit(ctx context.Context, projectID, userID, returnURL string) (*models.IndividualSignatureOutput, error) { return nil, nil +} + +func (s *service) createDefaultValues(ctx context.Context, user *v1Models.User, prefferedEmail string) map[string]interface{} { + + individualValues := make(map[string]interface{}) + if user == nil { + return individualValues + } + + if user.Username != "" { + individualValues["full_name"] = user.Username + individualValues["public_name"] = user.Username + } + + email := getUserEmail(user, prefferedEmail) + if email != "" { + individualValues["email"] = email + } + + return individualValues +} + +// getUserEmail returns the user email +func getUserEmail(user *v1Models.User, prefferedEmail string) string { + if user == nil { + return "" + } + + if prefferedEmail != "" && utils.StringInSlice(prefferedEmail, user.Emails) { + return prefferedEmail + } + + if user.LfEmail != "" { + return user.LfEmail.String() + } + + if len(user.Emails) > 0 { + return user.Emails[0] + } + return "" } func requestCorporateSignature(authToken string, apiURL string, input *requestCorporateSignatureInput) (*requestCorporateSignatureOutput, error) { diff --git a/cla-backend-go/v2/store/repository.go b/cla-backend-go/v2/store/repository.go index 1d58861d6..d96b76382 100644 --- a/cla-backend-go/v2/store/repository.go +++ b/cla-backend-go/v2/store/repository.go @@ -5,6 +5,7 @@ package store import ( "context" + "encoding/json" "fmt" "github.com/sirupsen/logrus" @@ -24,9 +25,19 @@ type DBStore struct { Expire int64 `dynamodbav:"expire"` } +type Data struct { + GithubAuthorUsername string `json:"github_author_username"` + GithubAuthorEmail string `json:"github_author_email"` + ClaGroupID string `json:"cla_group_id"` + RepositoryID string `json:"repository_id"` + PullRequestID string `json:"pull_request_id"` + MergeRequestID string `json:"merge_request_id"` +} + // Repository interface type Repository interface { SetActiveSignatureMetaData(ctx context.Context, key string, expire int64, value string) error + GetActiveSignatureMetaData(ctx context.Context, key string) (*Data, error) } type repo struct { @@ -85,3 +96,48 @@ func (r repo) SetActiveSignatureMetaData(ctx context.Context, key string, expire return nil } + +func (r repo) GetActiveSignatureMetaData(ctx context.Context, key string) (*Data, error) { + f := logrus.Fields{ + "functionName": "v2.store.repository.GetActiveSignatureMetaData", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "key": key, + } + + result, err := r.dynamoDBClient.GetItem(&dynamodb.GetItemInput{ + TableName: &r.storeTableName, + Key: map[string]*dynamodb.AttributeValue{ + "key": { + S: &key, + }, + }, + }) + + if err != nil { + log.WithFields(f).WithError(err).Warn("problem getting store record") + return nil, err + } + + if result.Item == nil { + log.WithFields(f).Debug("no store record found") + return nil, nil + } + + store := DBStore{} + err = dynamodbattribute.UnmarshalMap(result.Item, &store) + if err != nil { + log.WithFields(f).WithError(err).Warn("problem unmarshalling store record") + return nil, err + } + + data := Data{} + err = json.Unmarshal([]byte(store.Value), &data) + if err != nil { + log.WithFields(f).WithError(err).Warn("problem unmarshalling store record value") + return nil, err + } + + log.WithFields(f).Debugf("Signature meta record data retrieved: %+v ", store) + + return &data, nil +}