diff --git a/cla-backend-go/cmd/server.go b/cla-backend-go/cmd/server.go index a8b814281..c84f15ab1 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.ClaAPIV4Base, configFile.ClaV1ApiURL, v1CompanyRepo, v1CLAGroupRepo, v1ProjectClaGroupRepo, v1CompanyService, v2ClaGroupService, configFile.DocuSignPrivateKey, usersService, v1SignaturesService, storeRepository, v1RepositoriesService, githubOrganizationsService, gitlabOrganizationsService) + v2SignService := sign.NewService(configFile.ClaAPIV4Base, configFile.ClaV1ApiURL, v1CompanyRepo, v1CLAGroupRepo, v1ProjectClaGroupRepo, v1CompanyService, v2ClaGroupService, configFile.DocuSignPrivateKey, usersService, v1SignaturesService, storeRepository, v1RepositoriesService, githubOrganizationsService, gitlabOrganizationsService, configFile.CLALandingPage, configFile.CLALogoURL, emailService, eventsService) sessionStore, err := dynastore.New(dynastore.Path("/"), dynastore.HTTPOnly(), dynastore.TableName(configFile.SessionStoreTableName), dynastore.DynamoDB(dynamodb.New(awsSession))) if err != nil { diff --git a/cla-backend-go/emails/docusign_templates.go b/cla-backend-go/emails/docusign_templates.go new file mode 100644 index 000000000..b96781297 --- /dev/null +++ b/cla-backend-go/emails/docusign_templates.go @@ -0,0 +1,47 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package emails + +type DocumentSignedTemplateParams struct { + CommonEmailParams + CLAGroupTemplateParams + ICLA bool + PdfLink string +} + +const ( + // DocumentSignedTemplateName is email template name for DocumentSignedTemplate + DocumentSignedTemplateName = "DocumentSignedTemplate" + + // DocumentSignedTemplate is email template for + DocumentSignedICLATemplate = ` +
Hello {{.RecipientName}},
+This is a notification email from EasyCLA regarding the project {{.Project.ExternalProjectName}}.
+The CLA has now been signed. You can download the signed CLA as a PDF here.
+ ` + + DocumentSignedCCLATemplate = ` +Hello {{.RecipientName}},
+This is a notification email from EasyCLA regarding the project {{.Project.ExternalProjectName}}.
+The CLA has now been signed. You can download the signed CLA as a PDF here, or from within the EasyCLA CLA Manager console .
+ ` +) + +// RenderDocumentSignedTemplate renders RenderDocumentSignedTemplate +func RenderDocumentSignedTemplate(svc EmailTemplateService, claGroupModelVersion, projectSFID string, params DocumentSignedTemplateParams) (string, error) { + claGroupParams, err := svc.GetCLAGroupTemplateParamsFromProjectSFID(claGroupModelVersion, projectSFID) + if err != nil { + return "", err + } + + params.CLAGroupTemplateParams = claGroupParams + var template string + if params.ICLA { + template = DocumentSignedICLATemplate + } else { + template = DocumentSignedCCLATemplate + } + + return RenderTemplate(claGroupModelVersion, DocumentSignedTemplateName, template, params) +} diff --git a/cla-backend-go/events/event_data.go b/cla-backend-go/events/event_data.go index cc8b0cc9d..074d49bda 100644 --- a/cla-backend-go/events/event_data.go +++ b/cla-backend-go/events/event_data.go @@ -445,6 +445,12 @@ type SignatureAutoCreateECLAUpdatedEventData struct { AutoCreateECLA bool } +type IndividualSignatureSignedEventData struct { + ProjectName string + Username string + ProjectID string +} + // GetEventDetailsString returns the details string for this event func (ed *SignatureAutoCreateECLAUpdatedEventData) GetEventDetailsString(args *LogEventArgs) (string, bool) { @@ -2725,3 +2731,15 @@ func (ed *SignatureAutoCreateECLAUpdatedEventData) GetEventSummaryString(args *L data = data + "." return data, false } + +func (ed *IndividualSignatureSignedEventData) GetEventSummaryString(args *LogEventArgs) (string, bool) { + data := fmt.Sprintf("The user %s signed an individual CLA for project %s with project ID: %s", + args.LfUsername, ed.ProjectName, ed.ProjectID) + return data, false +} + +func (ed *IndividualSignatureSignedEventData) GetEventDetailsString(args *LogEventArgs) (string, bool) { + data := fmt.Sprintf("The user %s signed an individual CLA for project %s", + args.LfUsername, ed.ProjectName) + return data, false +} diff --git a/cla-backend-go/events/event_types.go b/cla-backend-go/events/event_types.go index 5e208b550..25962bbb9 100644 --- a/cla-backend-go/events/event_types.go +++ b/cla-backend-go/events/event_types.go @@ -96,4 +96,6 @@ const ( ProjectServiceCLAEnabled = "project.service.cla.enabled" ProjectServiceCLADisabled = "project.service.cla.disabled" SignatureAutoCreateECLAUpdated = "signature.auto_create_ecla.updated" + + IndividualSignatureSigned = "individual.signature.signed" ) diff --git a/cla-backend-go/signatures/converters.go b/cla-backend-go/signatures/converters.go index b1b91a6f1..c18fb7162 100644 --- a/cla-backend-go/signatures/converters.go +++ b/cla-backend-go/signatures/converters.go @@ -158,6 +158,7 @@ func (repo repository) buildProjectSignatureModels(ctx context.Context, results go func() { defer swg.Done() for _, userName := range sigACL { + log.WithFields(f).Debugf("looking up user by user name: %s", userName) if loadACLDetails { userModel, userErr := repo.usersRepo.GetUserByUserName(userName, true) if userErr != nil { diff --git a/cla-backend-go/signatures/dbmodels.go b/cla-backend-go/signatures/dbmodels.go index 695ad7407..9680b87da 100644 --- a/cla-backend-go/signatures/dbmodels.go +++ b/cla-backend-go/signatures/dbmodels.go @@ -44,6 +44,7 @@ type ItemSignature struct { UserDocusignName string `json:"user_docusign_name"` UserDocusignDateSigned string `json:"user_docusign_date_signed"` AutoCreateECLA bool `json:"auto_create_ecla"` + UserDocusignRawXML string `json:"user_docusign_raw_xml"` } // DBManagersModel is a database model for only the ACL/Manager column diff --git a/cla-backend-go/signatures/handlers.go b/cla-backend-go/signatures/handlers.go index 748f7eb76..5b4c02406 100644 --- a/cla-backend-go/signatures/handlers.go +++ b/cla-backend-go/signatures/handlers.go @@ -384,7 +384,7 @@ func Configure(api *operations.ClaAPI, service SignatureService, sessionStore *d api.SignaturesGetUserSignaturesHandler = signatures.GetUserSignaturesHandlerFunc(func(params signatures.GetUserSignaturesParams, claUser *user.CLAUser) middleware.Responder { reqID := utils.GetRequestID(params.XREQUESTID) ctx := context.WithValue(context.Background(), utils.XREQUESTID, reqID) // nolint - userSignatures, err := service.GetUserSignatures(ctx, params) + userSignatures, err := service.GetUserSignatures(ctx, params, nil) if err != nil { log.Warnf("error retrieving user signatures for userID: %s, error: %+v", params.UserID, err) return signatures.NewGetUserSignaturesBadRequest().WithXRequestID(reqID).WithPayload(errorResponse(err)) diff --git a/cla-backend-go/signatures/repository.go b/cla-backend-go/signatures/repository.go index 6aa528471..607c5bd49 100644 --- a/cla-backend-go/signatures/repository.go +++ b/cla-backend-go/signatures/repository.go @@ -72,6 +72,7 @@ type SignatureRepository interface { InvalidateProjectRecord(ctx context.Context, signatureID, note string) error UpdateEnvelopeDetails(ctx context.Context, signatureID, envelopeID string, signURL *string) (*models.Signature, error) CreateSignature(ctx context.Context, signature *ItemSignature) error + UpdateSignature(ctx context.Context, signatureID string, updates map[string]interface{}) error GetSignature(ctx context.Context, signatureID string) (*models.Signature, error) GetActivePullRequestMetadata(ctx context.Context, gitHubAuthorUsername, gitHubAuthorEmail string) (*ActivePullRequest, error) @@ -89,7 +90,7 @@ type SignatureRepository interface { CreateProjectCompanyEmployeeSignature(ctx context.Context, companyModel *models.Company, claGroupModel *models.ClaGroup, employeeUserModel *models.User) error GetCompanySignatures(ctx context.Context, params signatures.GetCompanySignaturesParams, pageSize int64, loadACL bool) (*models.Signatures, error) GetCompanyIDsWithSignedCorporateSignatures(ctx context.Context, claGroupID string) ([]SignatureCompanyID, error) - GetUserSignatures(ctx context.Context, params signatures.GetUserSignaturesParams, pageSize int64) (*models.Signatures, error) + GetUserSignatures(ctx context.Context, params signatures.GetUserSignaturesParams, pageSize int64, projectID *string) (*models.Signatures, error) ProjectSignatures(ctx context.Context, projectID string) (*models.Signatures, error) UpdateApprovalList(ctx context.Context, claManager *models.User, claGroupModel *models.ClaGroup, companyID string, params *models.ApprovalList, eventArgs *events.LogEventArgs) (*models.Signature, error) AddCLAManager(ctx context.Context, signatureID, claManagerID string) (*models.Signature, error) @@ -166,6 +167,73 @@ func (repo repository) CreateSignature(ctx context.Context, signature *ItemSigna } +// UpdateSignature updates an existing signature +func (repo repository) UpdateSignature(ctx context.Context, signatureID string, updates map[string]interface{}) error { + f := logrus.Fields{ + "functionName": "v1.signatures.repository.UpdateSignature", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "signatureID": signatureID, + } + + if len(updates) == 0 { + log.WithFields(f).Warnf("no updates provided") + return errors.New("no updates provided") + } + + var updateExpression strings.Builder + updateExpression.WriteString("SET ") + attributeValues := make(map[string]*dynamodb.AttributeValue) + expressionAttributeNames := make(map[string]*string) + + count := 1 + for attr, val := range updates { + attrPlaceholder := fmt.Sprintf("#A%d", count) + valPlaceholder := fmt.Sprintf(":v%d", count) + + if count > 1 && count <= len(updates) { + updateExpression.WriteString(", ") + } + updateExpression.WriteString(fmt.Sprintf("%s = %s", attrPlaceholder, valPlaceholder)) + + expressionAttributeNames[attrPlaceholder] = aws.String(attr) + av, err := dynamodbattribute.Marshal(val) + if err != nil { + return err + } + attributeValues[valPlaceholder] = av + + count++ + } + + log.WithFields(f).Debugf("updating signature using expression: %s", updateExpression.String()) + log.WithFields(f).Debugf("expression attribute names : %+v", expressionAttributeNames) + + input := &dynamodb.UpdateItemInput{ + ExpressionAttributeValues: attributeValues, + TableName: aws.String(repo.signatureTableName), + Key: map[string]*dynamodb.AttributeValue{ + "signature_id": { + S: aws.String(signatureID), + }, + }, + UpdateExpression: aws.String(updateExpression.String()), + ExpressionAttributeNames: expressionAttributeNames, + ReturnValues: aws.String("UPDATED_NEW"), + } + + // perform the update + _, err := repo.dynamoDBClient.UpdateItem(input) + if err != nil { + log.WithFields(f).Warnf("error updating signature, error: %v", err) + return err + } + + log.WithFields(f).Debugf("successfully updated signature") + + return 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{ @@ -2659,7 +2727,7 @@ func (repo repository) GetCompanyIDsWithSignedCorporateSignatures(ctx context.Co } // GetUserSignatures returns a list of user signatures for the specified user -func (repo repository) GetUserSignatures(ctx context.Context, params signatures.GetUserSignaturesParams, pageSize int64) (*models.Signatures, error) { +func (repo repository) GetUserSignatures(ctx context.Context, params signatures.GetUserSignaturesParams, pageSize int64, projectID *string) (*models.Signatures, error) { f := logrus.Fields{ "functionName": "v1.signatures.repository.GetUserSignatures", utils.XREQUESTID: ctx.Value(utils.XREQUESTID), @@ -2667,8 +2735,15 @@ func (repo repository) GetUserSignatures(ctx context.Context, params signatures. // This is the keys we want to match condition := expression.Key("signature_reference_id").Equal(expression.Value(params.UserID)) + expressionBuilder := expression.NewBuilder().WithKeyCondition(condition).WithProjection(buildProjection()) + + if projectID != nil { + filterExpression := expression.Name("signature_project_id").Equal(expression.Value(*projectID)) + expressionBuilder = expressionBuilder.WithFilter(filterExpression) + } + // Use the nice builder to create the expression - expr, err := expression.NewBuilder().WithKeyCondition(condition).WithProjection(buildProjection()).Build() + expr, err := expressionBuilder.Build() if err != nil { log.WithFields(f).Warnf("error building expression for user signature query, userID: %s, error: %v", params.UserID, err) @@ -2715,6 +2790,8 @@ func (repo repository) GetUserSignatures(ctx context.Context, params signatures. return nil, errQuery } + log.WithFields(f).Debugf("query results count: %d", len(results.Items)) + // Convert the list of DB models to a list of response models signatureList, modelErr := repo.buildProjectSignatureModels(ctx, results, "", LoadACLDetails) if modelErr != nil { diff --git a/cla-backend-go/signatures/service.go b/cla-backend-go/signatures/service.go index 29cce20e2..f3ac6139e 100644 --- a/cla-backend-go/signatures/service.go +++ b/cla-backend-go/signatures/service.go @@ -52,9 +52,10 @@ type SignatureService interface { GetProjectCompanyEmployeeSignatures(ctx context.Context, params signatures.GetProjectCompanyEmployeeSignaturesParams, criteria *ApprovalCriteria) (*models.Signatures, error) GetCompanySignatures(ctx context.Context, params signatures.GetCompanySignaturesParams) (*models.Signatures, error) GetCompanyIDsWithSignedCorporateSignatures(ctx context.Context, claGroupID string) ([]SignatureCompanyID, error) - GetUserSignatures(ctx context.Context, params signatures.GetUserSignaturesParams) (*models.Signatures, error) + GetUserSignatures(ctx context.Context, params signatures.GetUserSignaturesParams, projectID *string) (*models.Signatures, error) InvalidateProjectRecords(ctx context.Context, projectID, note string) (int, error) CreateSignature(ctx context.Context, signature *ItemSignature) error + UpdateSignature(ctx context.Context, signatureID string, updates map[string]interface{}) 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) @@ -72,6 +73,7 @@ type SignatureService interface { CreateOrUpdateEmployeeSignature(ctx context.Context, claGroupModel *models.ClaGroup, companyModel *models.Company, corporateSignatureModel *models.Signature) ([]*models.User, error) UpdateEnvelopeDetails(ctx context.Context, signatureID, envelopeID string, signURL *string) (*models.Signature, error) handleGitHubStatusUpdate(ctx context.Context, employeeUserModel *models.User) error + ProcessEmployeeSignature(ctx context.Context, companyModel *models.Company, claGroupModel *models.ClaGroup, user *models.User) (*bool, error) } type service struct { @@ -112,6 +114,11 @@ func (s service) GetSignature(ctx context.Context, signatureID string) (*models. return s.repo.GetSignature(ctx, signatureID) } +// UpdateSignature updates the specified signature +func (s service) UpdateSignature(ctx context.Context, signatureID string, updates map[string]interface{}) error { + return s.repo.UpdateSignature(ctx, signatureID, updates) +} + // GetIndividualSignature returns the signature associated with the specified CLA Group and User ID func (s service) GetIndividualSignature(ctx context.Context, claGroupID, userID string, approved, signed *bool) (*models.Signature, error) { return s.repo.GetIndividualSignature(ctx, claGroupID, userID, approved, signed) @@ -228,7 +235,7 @@ func (s service) GetCompanyIDsWithSignedCorporateSignatures(ctx context.Context, } // GetUserSignatures returns the list of user signatures associated with the specified user -func (s service) GetUserSignatures(ctx context.Context, params signatures.GetUserSignaturesParams) (*models.Signatures, error) { +func (s service) GetUserSignatures(ctx context.Context, params signatures.GetUserSignaturesParams, projectID *string) (*models.Signatures, error) { const defaultPageSize int64 = 10 var pageSize = defaultPageSize @@ -236,7 +243,7 @@ func (s service) GetUserSignatures(ctx context.Context, params signatures.GetUse pageSize = *params.PageSize } - userSignatures, err := s.repo.GetUserSignatures(ctx, params, pageSize) + userSignatures, err := s.repo.GetUserSignatures(ctx, params, pageSize, projectID) if err != nil { return nil, err } @@ -1160,7 +1167,7 @@ func (s service) hasUserSigned(ctx context.Context, user *models.User, projectID return &hasSigned, &companyAffiliation, claGroupModelErr } - employeeSigned, err := s.processEmployeeSignature(ctx, companyModel, claGroupModel, user) + employeeSigned, err := s.ProcessEmployeeSignature(ctx, companyModel, claGroupModel, user) if err != nil { log.WithFields(f).WithError(err).Warnf("problem looking up employee signature for company: %s", companyID) @@ -1177,7 +1184,7 @@ func (s service) hasUserSigned(ctx context.Context, user *models.User, projectID return &hasSigned, &companyAffiliation, nil } -func (s service) processEmployeeSignature(ctx context.Context, companyModel *models.Company, claGroupModel *models.ClaGroup, user *models.User) (*bool, error) { +func (s service) ProcessEmployeeSignature(ctx context.Context, companyModel *models.Company, claGroupModel *models.ClaGroup, user *models.User) (*bool, error) { f := logrus.Fields{ "functionName": "v2.signatures.service.processEmployeeSignature", utils.XREQUESTID: ctx.Value(utils.XREQUESTID), diff --git a/cla-backend-go/swagger/cla.v2.yaml b/cla-backend-go/swagger/cla.v2.yaml index a15bbe795..88da007f5 100644 --- a/cla-backend-go/swagger/cla.v2.yaml +++ b/cla-backend-go/swagger/cla.v2.yaml @@ -4161,8 +4161,30 @@ paths: description: Receives XML data when an individual signs a document in DocuSign. security: [ ] operationId: iclaCallbackGithub + consumes: + - text/xml parameters: - $ref: "#/parameters/x-request-id" + - in: header + name: accept-encoding + type: string + required: false + default: gzip + - in: header + name: connection + type: string + required: false + default: Keep-Alive + - in: header + name: content-type + type: string + required: true + default: 'text/xml; charset=utf-8' + - in: header + name: user-agent + type: string + required: false + default: docusign - name: installation_id in: path required: true @@ -4175,17 +4197,19 @@ paths: in: path required: true type: string - - name: body + - name: envelopeInformation in: body required: true + description: XML payload with DocuSign envelope information schema: - type: object - additionalProperties: true + $ref: '#/definitions/DocuSignEnvelopeInformation' responses: '200': description: Successfully received and processed the callback data. '400': description: Invalid request. + '415': + description: Invalid format. tags: - sign @@ -4650,6 +4674,62 @@ definitions: event: $ref: './common/event.yaml' + #-------------------------------------- + # Docusign Webhook Payload + #____________________________________________ + DocuSignEnvelopeInformation: + type: object + properties: + EnvelopeStatus: + type: object + properties: + EnvelopeID: + type: string + Status: + type: string + RecipientStatuses: + type: array + items: + $ref: '#/definitions/RecipientStatus' + FormData: + type: object + properties: + xfdf: + type: object + properties: + fields: + type: array + items: + $ref: '#/definitions/Field' + xml: + name: DocuSignEnvelopeInformation + + RecipientStatus: + type: object + properties: + Type: + type: string + Email: + type: string + UserName: + type: string + Status: + type: string + ClientUserId: + type: string + xml: + name: RecipientStatus + + Field: + type: object + properties: + name: + type: string + value: + type: string + xml: + name: field + # --------------------------------------------------------------------------- # GitHub Definitions # --------------------------------------------------------------------------- diff --git a/cla-backend-go/users/repository.go b/cla-backend-go/users/repository.go index 3cb832ee5..2cb8c15d8 100644 --- a/cla-backend-go/users/repository.go +++ b/cla-backend-go/users/repository.go @@ -35,6 +35,7 @@ import ( type UserRepository interface { CreateUser(user *models.User) (*models.User, error) Save(user *models.UserUpdate) (*models.User, error) + UpdateUser(userID string, updates map[string]interface{}) (*models.User, error) Delete(userID string) error GetUser(userID string) (*models.User, error) GetUserByLFUserName(lfUserName string) (*models.User, error) @@ -213,6 +214,63 @@ func (repo repository) CreateUser(user *models.User) (*models.User, error) { return user, err } +func (repo repository) UpdateUser(userID string, updates map[string]interface{}) (*models.User, error) { + f := logrus.Fields{ + "functionName": "users.repository.UpdateUser", + "userID": userID, + } + + log.WithFields(f).Debugf("Updating user: %s with updates: %+v", userID, updates) + + if len(updates) == 0 { + return nil, errors.New(400, "no updates provided") + } + + var updateExpression strings.Builder + updateExpression.WriteString("SET ") + attributeValues := make(map[string]*dynamodb.AttributeValue) + attributeNames := make(map[string]*string) + + count := 1 + for key, value := range updates { + attrPlaceholder := fmt.Sprintf("#A%d", count) + valPlaceholder := fmt.Sprintf(":v%d", count) + + if count > 1 { + updateExpression.WriteString(", ") + } + updateExpression.WriteString(fmt.Sprintf("%s = %s", attrPlaceholder, valPlaceholder)) + attributeNames[attrPlaceholder] = aws.String(key) + + av, err := dynamodbattribute.Marshal(value) + if err != nil { + return nil, err + } + attributeValues[valPlaceholder] = av + + count++ + } + + input := &dynamodb.UpdateItemInput{ + ExpressionAttributeNames: attributeNames, + ExpressionAttributeValues: attributeValues, + Key: map[string]*dynamodb.AttributeValue{ + "user_id": { + S: aws.String(userID), + }, + }, + TableName: aws.String(repo.tableName), + UpdateExpression: aws.String(updateExpression.String()), + } + + _, err := repo.dynamoDBClient.UpdateItem(input) + if err != nil { + return nil, err + } + + return repo.GetUser(userID) +} + func (repo repository) getUserByUpdateModel(user *models.UserUpdate) (*models.User, error) { // Log fields f := logrus.Fields{ diff --git a/cla-backend-go/users/service.go b/cla-backend-go/users/service.go index 1a01605b5..c57e6897b 100644 --- a/cla-backend-go/users/service.go +++ b/cla-backend-go/users/service.go @@ -15,6 +15,7 @@ import ( type Service interface { CreateUser(user *models.User, claUser *user.CLAUser) (*models.User, error) Save(user *models.UserUpdate, claUser *user.CLAUser) (*models.User, error) + UpdateUser(userID string, updates map[string]interface{}) (*models.User, error) Delete(userID string, claUser *user.CLAUser) error GetUser(userID string) (*models.User, error) GetUserByLFUserName(lfUserName string) (*models.User, error) @@ -65,6 +66,22 @@ func (s service) CreateUser(user *models.User, claUser *user.CLAUser) (*models.U return userModel, nil } +func (s service) UpdateUser(userID string, updates map[string]interface{}) (*models.User, error) { + userModel, err := s.repo.UpdateUser(userID, updates) + if err != nil { + return nil, err + } + + // Log the event + s.events.LogEvent(&events.LogEventArgs{ + EventType: events.UserUpdated, + UserID: userID, + EventData: &events.UserUpdatedEventData{}, + }) + + return userModel, nil +} + // Save saves/updates the user record func (s service) Save(user *models.UserUpdate, claUser *user.CLAUser) (*models.User, error) { userModel, err := s.repo.Save(user) diff --git a/cla-backend-go/v2/sign/docusign.go b/cla-backend-go/v2/sign/docusign.go index d68be6ca5..080fd9f3d 100644 --- a/cla-backend-go/v2/sign/docusign.go +++ b/cla-backend-go/v2/sign/docusign.go @@ -565,3 +565,61 @@ func (s *service) GetSignURL(email, recipientID, userName, clientUserId, envelop return viewResponse.URL, nil } + +func (s service) getSignedDocument(ctx context.Context, envelopeID, documentID string) ([]byte, error) { + f := logrus.Fields{ + "functionName": "v2.getSignedDocument", + "envelopeID": envelopeID, + } + + // Get the access token + accessToken, err := s.getAccessToken(ctx) + + if err != nil { + log.WithFields(f).WithError(err).Warnf("problem getting the access token") + return nil, err + } + + // Create the request + url := fmt.Sprintf("%s/accounts/%s/envelopes/%s/documents/%s", utils.GetProperty("DOCUSIGN_ROOT_URL"), utils.GetProperty("DOCUSIGN_ACCOUNT_ID"), envelopeID, documentID) + + req, err := http.NewRequest("GET", url, nil) + + if err != nil { + log.WithFields(f).WithError(err).Warnf("problem creating the HTTP request") + return nil, err + } + + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + + // Make the request + client := &http.Client{} + + resp, err := client.Do(req) + + if err != nil { + log.WithFields(f).WithError(err).Warnf("problem making the HTTP request") + return nil, err + } + + defer func() { + if err = resp.Body.Close(); err != nil { + log.WithFields(f).WithError(err).Warnf("problem closing the response body") + } + }() + + responsePayload, err := io.ReadAll(resp.Body) + + if err != nil { + log.WithFields(f).WithError(err).Warnf("problem reading the response body") + return nil, err + } + + if resp.StatusCode != http.StatusOK { + log.WithFields(f).Warnf("problem making the HTTP request - status code: %d - response : %s", resp.StatusCode, string(responsePayload)) + return nil, errors.New("problem making the HTTP request") + } + + return responsePayload, nil + +} diff --git a/cla-backend-go/v2/sign/handlers.go b/cla-backend-go/v2/sign/handlers.go index 762320bfe..dcf1968f1 100644 --- a/cla-backend-go/v2/sign/handlers.go +++ b/cla-backend-go/v2/sign/handlers.go @@ -4,10 +4,13 @@ package sign import ( + "bytes" "context" "encoding/json" "errors" "fmt" + "io" + "net/http" "strings" log "github.com/communitybridge/easycla/cla-backend-go/logging" @@ -24,6 +27,32 @@ import ( "github.com/go-openapi/runtime/middleware" ) +var ( + // payload is the payload for the docusign callback + iclaGitHubPayload []byte +) + +// docusignMiddleware is used to get access to xml request body +func docusignMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f := logrus.Fields{ + "functionName": "v2.sign.handlers.docusignMiddleware", + } + var err error + log.WithFields(f).Debug("docusign middleware...") + iclaGitHubPayload, err = io.ReadAll(r.Body) + if err != nil { + log.Warnf("unable to read request body") + return + } + r.Body.Close() + r.Body = io.NopCloser(bytes.NewBuffer(iclaGitHubPayload)) + log.WithFields(f).Debugf("docusign middleware...payload: %s", string(iclaGitHubPayload)) + // call the next middleware + next.ServeHTTP(w, r) + }) +} + // Configure API call func Configure(api *operations.EasyclaAPI, service Service, userService users.Service) { // Retrieve a list of available templates @@ -131,13 +160,8 @@ func Configure(api *operations.EasyclaAPI, service Service, userService users.Se "functionName": "v2.sign.handlers.SignIclaCallbackGithubHandler", utils.XREQUESTID: ctx.Value(utils.XREQUESTID), } - jsonBytes, marshalErr := json.Marshal(params.Body) - if marshalErr != nil { - log.WithFields(f).WithError(marshalErr).Warn("unable to marshal github callback body") - return sign.NewIclaCallbackGithubBadRequest() - } - err := service.SignedIndividualCallbackGithub(ctx, jsonBytes, params.InstallationID, params.ChangeRequestID, params.GithubRepositoryID) + err := service.SignedIndividualCallbackGithub(ctx, iclaGitHubPayload, params.InstallationID, params.ChangeRequestID, params.GithubRepositoryID) if err != nil { return sign.NewIclaCallbackGithubBadRequest() } @@ -177,6 +201,7 @@ func Configure(api *operations.EasyclaAPI, service Service, userService users.Se "functionName": "v2.sign.handlers.SignIclaCallbackGerritHandler", utils.XREQUESTID: ctx.Value(utils.XREQUESTID), } + log.WithFields(f).Debug("gerrit callback") payload, marshalErr := json.Marshal(params.Body) if marshalErr != nil { @@ -212,6 +237,8 @@ func Configure(api *operations.EasyclaAPI, service Service, userService users.Se } return sign.NewCclaCallbackOK() }) + + api.AddMiddlewareFor("POST", "/signed/individual/{installation_id}/{github_repository_id}/{change_request_id}", docusignMiddleware) } type codedResponse interface { diff --git a/cla-backend-go/v2/sign/helpers.go b/cla-backend-go/v2/sign/helpers.go new file mode 100644 index 000000000..c9520326e --- /dev/null +++ b/cla-backend-go/v2/sign/helpers.go @@ -0,0 +1,223 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package sign + +import ( + "context" + "errors" + "fmt" + + "github.com/communitybridge/easycla/cla-backend-go/gen/v1/models" + "github.com/communitybridge/easycla/cla-backend-go/github" + log "github.com/communitybridge/easycla/cla-backend-go/logging" + "github.com/communitybridge/easycla/cla-backend-go/utils" + "github.com/sirupsen/logrus" +) + +// updateChangeRequest is a helper function that updates PR - typically after the docusign is completed +func (s service) updateChangeRequest(ctx context.Context, installationID, repositoryID, pullRequestID int64, projectID string) error { + f := logrus.Fields{ + "functionName": "v1.signatures.service.updateChangeRequest", + "repositoryID": repositoryID, + "pullRequestID": pullRequestID, + "projectID": projectID, + } + + githubRepository, ghErr := github.GetGitHubRepository(ctx, installationID, repositoryID) + if ghErr != nil { + log.WithFields(f).WithError(ghErr).Warn("unable to get github repository") + return ghErr + } + if githubRepository == nil || githubRepository.Owner == nil { + msg := "unable to get github repository - repository response is nil or owner is nil" + log.WithFields(f).Warn(msg) + return errors.New(msg) + } + // log.WithFields(f).Debugf("githubRepository: %+v", githubRepository) + if githubRepository.Name == nil || githubRepository.Owner.Login == nil { + msg := fmt.Sprintf("unable to get github repository - missing repository name or owner name for repository ID: %d", repositoryID) + log.WithFields(f).Warn(msg) + return errors.New(msg) + } + + gitHubOrgName := utils.StringValue(githubRepository.Owner.Login) + gitHubRepoName := utils.StringValue(githubRepository.Name) + + // Fetch committers + log.WithFields(f).Debugf("fetching commit authors for PR: %d using repository owner: %s, repo: %s", pullRequestID, gitHubOrgName, gitHubRepoName) + authors, latestSHA, authorsErr := github.GetPullRequestCommitAuthors(ctx, installationID, int(pullRequestID), gitHubOrgName, gitHubRepoName) + if authorsErr != nil { + log.WithFields(f).WithError(authorsErr).Warnf("unable to get commit authors for %s/%s for PR: %d", gitHubOrgName, gitHubRepoName, pullRequestID) + return authorsErr + } + log.WithFields(f).Debugf("found %d commit authors for %s/%s for PR: %d", len(authors), gitHubOrgName, gitHubRepoName, pullRequestID) + + signed := make([]*github.UserCommitSummary, 0) + unsigned := make([]*github.UserCommitSummary, 0) + + // triage signed and unsigned users + log.WithFields(f).Debugf("triaging %d commit authors for PR: %d using repository %s/%s", + len(authors), pullRequestID, gitHubOrgName, gitHubRepoName) + for _, userSummary := range authors { + + if !userSummary.IsValid() { + log.WithFields(f).Debugf("invalid user summary: %+v", *userSummary) + unsigned = append(unsigned, userSummary) + continue + } + + commitAuthorID := userSummary.GetCommitAuthorID() + commitAuthorUsername := userSummary.GetCommitAuthorUsername() + commitAuthorEmail := userSummary.GetCommitAuthorEmail() + + log.WithFields(f).Debugf("checking user - sha: %s, user ID: %s, username: %s, email: %s", + userSummary.SHA, commitAuthorID, commitAuthorUsername, commitAuthorEmail) + + var user *models.User + var userErr error + + if commitAuthorID != "" { + log.WithFields(f).Debugf("looking up user by ID: %s", commitAuthorID) + user, userErr = s.userService.GetUserByGitHubID(commitAuthorID) + if userErr != nil { + log.WithFields(f).WithError(userErr).Warnf("unable to get user by github id: %s", commitAuthorID) + } + if user != nil { + log.WithFields(f).Debugf("found user by ID: %s", commitAuthorID) + } + } + if user == nil && commitAuthorUsername != "" { + log.WithFields(f).Debugf("looking up user by username: %s", commitAuthorUsername) + user, userErr = s.userService.GetUserByGitHubUsername(commitAuthorUsername) + if userErr != nil { + log.WithFields(f).WithError(userErr).Warnf("unable to get user by github username: %s", commitAuthorUsername) + } + if user != nil { + log.WithFields(f).Debugf("found user by username: %s", commitAuthorUsername) + } + } + if user == nil && commitAuthorEmail != "" { + log.WithFields(f).Debugf("looking up user by email: %s", commitAuthorEmail) + user, userErr = s.userService.GetUserByEmail(commitAuthorEmail) + if userErr != nil { + log.WithFields(f).WithError(userErr).Warnf("unable to get user by user email: %s", commitAuthorEmail) + } + if user != nil { + log.WithFields(f).Debugf("found user by email: %s", commitAuthorEmail) + } + } + + if user == nil { + log.WithFields(f).Debugf("unable to find user for commit author - sha: %s, user ID: %s, username: %s, email: %s", + userSummary.SHA, commitAuthorID, commitAuthorUsername, commitAuthorEmail) + unsigned = append(unsigned, userSummary) + continue + } + + log.WithFields(f).Debugf("checking to see if user has signed an ICLA or ECLA for project: %s", projectID) + userSigned, companyAffiliation, signedErr := s.hasUserSigned(ctx, user, projectID) + if signedErr != nil { + log.WithFields(f).WithError(signedErr).Warnf("has user signed error - user: %+v, project: %s", user, projectID) + unsigned = append(unsigned, userSummary) + continue + } + + if companyAffiliation != nil { + userSummary.Affiliated = *companyAffiliation + } + + if userSigned != nil { + userSummary.Authorized = *userSigned + if userSummary.Authorized { + signed = append(signed, userSummary) + } else { + unsigned = append(unsigned, userSummary) + } + } + } + + log.WithFields(f).Debugf("commit authors status => signed: %+v and missing: %+v", signed, unsigned) + + // update pull request + updateErr := github.UpdatePullRequest(ctx, installationID, int(pullRequestID), gitHubOrgName, gitHubRepoName, githubRepository.ID, *latestSHA, signed, unsigned, s.ClaV1ApiURL, s.claLandingPage, s.claLogoURL) + if updateErr != nil { + log.WithFields(f).Debugf("unable to update PR: %d", pullRequestID) + return updateErr + } + + return nil +} + +// hasUserSigned checks to see if the user has signed an ICLA or ECLA for the project, returns: +// false, false, nil if user is not authorized for ICLA or ECLA +// false, false, some error if user is not authorized for ICLA or ECLA - we has some problem looking up stuff +// true, false, nil if user has an ICLA (authorized, but not company affiliation, no error) +// true, true, nil if user has an ECLA (authorized, with company affiliation, no error) +func (s service) hasUserSigned(ctx context.Context, user *models.User, projectID string) (*bool, *bool, error) { + f := logrus.Fields{ + "functionName": "v1.signatures.service.updateChangeRequest", + "projectID": projectID, + "user": user, + } + var hasSigned bool + var companyAffiliation bool + + approved := true + signed := true + + // Check for ICLA + log.WithFields(f).Debugf("checking to see if user has signed an ICLA") + signature, sigErr := s.signatureService.GetIndividualSignature(ctx, projectID, user.UserID, &approved, &signed) + if sigErr != nil { + log.WithFields(f).WithError(sigErr).Warnf("problem checking for ICLA signature for user: %s", user.UserID) + return &hasSigned, &companyAffiliation, sigErr + } + if signature != nil { + hasSigned = true + log.WithFields(f).Debugf("ICLA signature check passed for user: %+v on project : %s", user, projectID) + return &hasSigned, &companyAffiliation, nil // ICLA passes, no company affiliation + } else { + log.WithFields(f).Debugf("ICLA signature check failed for user: %+v on project: %s - ICLA not signed", user, projectID) + } + + // Check for Employee Acknowledgment ECLA + companyID := user.CompanyID + log.WithFields(f).Debugf("checking to see if user has signed a ECLA for company: %s", companyID) + + if companyID != "" { + companyAffiliation = true + + // Get employee signature + log.WithFields(f).Debugf("ECLA signature check - user has a company: %s - looking for user's employee acknowledgement...", companyID) + + // Load the company - make sure it is valid + companyModel, compModelErr := s.companyService.GetCompany(ctx, companyID) + if compModelErr != nil { + log.WithFields(f).WithError(compModelErr).Warnf("problem looking up company: %s", companyID) + return &hasSigned, &companyAffiliation, compModelErr + } + + // Load the CLA Group - make sure it is valid + claGroupModel, claGroupModelErr := s.claGroupService.GetCLAGroup(ctx, projectID) + if claGroupModelErr != nil { + log.WithFields(f).WithError(claGroupModelErr).Warnf("problem looking up project: %s", projectID) + return &hasSigned, &companyAffiliation, claGroupModelErr + } + + employeeSigned, err := s.signatureService.ProcessEmployeeSignature(ctx, companyModel, claGroupModel, user) + + if err != nil { + log.WithFields(f).WithError(err).Warnf("problem looking up employee signature for company: %s", companyID) + return &hasSigned, &companyAffiliation, err + } + if employeeSigned != nil { + hasSigned = *employeeSigned + } + + } else { + log.WithFields(f).Debugf("ECLA signature check - user does not have a company ID assigned - skipping...") + } + + return &hasSigned, &companyAffiliation, nil +} diff --git a/cla-backend-go/v2/sign/models.go b/cla-backend-go/v2/sign/models.go index 9b053db76..9951f612d 100644 --- a/cla-backend-go/v2/sign/models.go +++ b/cla-backend-go/v2/sign/models.go @@ -6,7 +6,6 @@ package sign import ( "database/sql" "encoding/xml" - "time" ) // DocuSignGetTokenRequest is the request body for getting a token from DocuSign @@ -311,9 +310,9 @@ type IndividualMembershipDocuSignDBSummaryModel struct { DocuSignEnvelopeID string `db:"docusign_envelope_id"` DocuSignEnvelopeCreatedAt string `db:"docusign_envelope_created_at"` DocuSignEnvelopeSigningStatus string `db:"docusign_envelope_signing_status"` - DocuSignEnvelopeSigningUpdatedAt time.Time `db:"docusign_envelope_signing_updated_at"` + DocuSignEnvelopeSigningUpdatedAt string `db:"docusign_envelope_signing_updated_at"` Memo sql.NullString `db:"memo"` - //DocuSignEnvelopeSignedDate time.Time `json:"docusign_envelope_signed_date"` + //DocuSignEnvelopeSignedDate string `json:"docusign_envelope_signed_date"` } type ClaSignatoryEmailParams struct { @@ -334,6 +333,15 @@ type DocuSignEventNotification struct { URL string `json:"url"` LoggingEnabled bool `json:"loggingEnabled"` EnvelopeEvents []DocuSignRecipientEvent `json:"envelopeEvents"` + // EventData EventData `json:"eventData"` + // RequireAcknowledgment string `json:"requireAcknowledgment"` +} + +// EventData represents the eventData attribute in DocusignEventNotification. +type EventData struct { + Version string `json:"version,omitempty"` + Format string `json:"format,omitempty"` + IncludeData []string `json:"includeData,omitempty"` } type Recipient struct { @@ -407,7 +415,7 @@ type DocuSignWebhookModel struct { ConfigurationID int `json:"configurationId"` // 10418598 Data DocuSignWebhookData `json:"data"` Event string `json:"event"` // envelope-sent, envelope-completed - GeneratedDateTime time.Time `json:"generatedDateTime"` // generated_date_time + GeneratedDateTime string `json:"generatedDateTime"` // generated_date_time URI string `json:"uri"` // /restapi/v2.1/accounts/77c754e9-4016-4ccc-957f-15eaa18f2d22/envelopes/016d4678-bf5c-41f3-b7c9-5c58606cdb4a } @@ -428,7 +436,7 @@ type DocuSignEnvelopeSummary struct { AutoNavigation string `json:"autoNavigation"` // "true" BurnDefaultTabData string `json:"burnDefaultTabData"` // "false" CertificateURI string `json:"certificateUri"` // /envelopes/016d4678-bf5c-41f3-b7c9-5c58606cdb4a/documents/summary - CreatedDateTime time.Time `json:"createdDateTime"` // 2023-05-26T18:55:47.18Z + CreatedDateTime string `json:"createdDateTime"` // 2023-05-26T18:55:47.18Z CustomFieldsURI string `json:"customFieldsUri"` // /envelopes/016d4678-bf5c-41f3-b7c9-5c58606cdb4a/custom_fields DocumentsCombinedURI string `json:"documentsCombinedUri"` // /envelopes/016d4678-bf5c-41f3-b7c9-5c58606cdb4a/documents/combined DocumentsURI string `json:"documentsUri"` // /envelopes/016d4678-bf5c-41f3-b7c9-5c58606cdb4a/documents @@ -440,25 +448,25 @@ type DocuSignEnvelopeSummary struct { EnvelopeMetadata EnvelopeMetadata `json:"envelopeMetadata"` EnvelopeURI string `json:"envelopeUri"` // /envelopes/016d4678-bf5c-41f3-b7c9-5c58606cdb4a ExpiresAfter string `json:"expiresAfter"` // 120 - ExpireDateTime time.Time `json:"expireDateTime"` // 2023-05-26T18:55:48.257Z + ExpireDateTime string `json:"expireDateTime"` // 2023-05-26T18:55:48.257Z ExpireEnabled string `json:"expireEnabled"` // "true" HasComments string `json:"hasComments"` // "false" HasFormDataChanged string `json:"hasFormDataChanged"` // "false" - InitialSendDateTime time.Time `json:"initialSendDateTime"` // 2023-05-26T18:55:48.257Z + InitialSendDateTime string `json:"initialSendDateTime"` // 2023-05-26T18:55:48.257Z Is21CFRPart11 string `json:"is21CFRPart11"` // "false" IsDynamicEnvelope string `json:"isDynamicEnvelope"` // "false" IsSignatureProviderEnvelope string `json:"isSignatureProviderEnvelope"` // "false" - LastModifiedDateTime time.Time `json:"lastModifiedDateTime"` // 2023-05-26T18:55:48.257Z + LastModifiedDateTime string `json:"lastModifiedDateTime"` // 2023-05-26T18:55:48.257Z NotificationURI string `json:"notificationUri"` // /envelopes/016d4678-bf5c-41f3-b7c9-5c58606cdb4a/notification PurgeState string `json:"purgeState"` // unpurged Recipients Recipients `json:"recipients"` RecipientsURI string `json:"recipientsUri"` // /envelopes/016d4678-bf5c-41f3-b7c9-5c58606cdb4a/recipients Sender Sender `json:"sender"` - SentDateTime time.Time `json:"sentDateTime"` // 2023-05-26T18:55:48.257Z + SentDateTime string `json:"sentDateTime"` // 2023-05-26T18:55:48.257Z SignerCanSignOnMobile string `json:"signerCanSignOnMobile"` // "true" SignerLocation string `json:"signerLocation"` // online Status string `json:"status"` // sent - StatusChangedDateTime time.Time `json:"statusChangedDateTime"` // 2023-05-26T18:55:48.257Z + StatusChangedDateTime string `json:"statusChangedDateTime"` // 2023-05-26T18:55:48.257Z TemplatesURI string `json:"templatesUri"` // /envelopes/016d4678-bf5c-41f3-b7c9-5c58606cdb4a/templates0:w } @@ -484,21 +492,21 @@ type EnvelopeMetadata struct { } type WebhookSigner struct { - CompletedCount string `json:"completedCount"` // 0 - CreationReason string `json:"creationReason"` // sender - DeliveryMethod string `json:"deliveryMethod"` // email - Email string `json:"email"` // test@test - IsBulkRecipient string `json:"isBulkRecipient"` // "false" - Name string `json:"name"` // Test DocuSign - RecipientID string `json:"recipientId"` // 1 - RecipientIDGuid string `json:"recipientIdGuid"` // 9fd66d5d-7396-4b80-a85e-2a7e536471b1 - ReceipientType string `json:"recipientType"` // signer - RequireIdLookup string `json:"requireIdLookup"` // "false" - RequireUploadSignature string `json:"requireUploadSignature"` // "false" - RoutingOrder string `json:"routingOrder"` // 1 - SentDateTime time.Time `json:"sentDateTime"` // 2023-05-26T18:55:48.257Z - Status string `json:"status"` // sent - UserId string `json:"userId"` // 9fd66d5d-7396-4b80-a85e-2a7e536471b1 + CompletedCount string `json:"completedCount"` // 0 + CreationReason string `json:"creationReason"` // sender + DeliveryMethod string `json:"deliveryMethod"` // email + Email string `json:"email"` // test@test + IsBulkRecipient string `json:"isBulkRecipient"` // "false" + Name string `json:"name"` // Test DocuSign + RecipientID string `json:"recipientId"` // 1 + RecipientIDGuid string `json:"recipientIdGuid"` // 9fd66d5d-7396-4b80-a85e-2a7e536471b1 + ReceipientType string `json:"recipientType"` // signer + RequireIdLookup string `json:"requireIdLookup"` // "false" + RequireUploadSignature string `json:"requireUploadSignature"` // "false" + RoutingOrder string `json:"routingOrder"` // 1 + SentDateTime string `json:"sentDateTime"` // 2023-05-26T18:55:48.257Z + Status string `json:"status"` // sent + UserId string `json:"userId"` // 9fd66d5d-7396-4b80-a85e-2a7e536471b1 } type Sender struct { @@ -508,3 +516,119 @@ type Sender struct { UserName string `json:"userName"` // Test DocuSign UserID string `json:"userId"` // 9fd66d5d-7396-4b80-a85e-2a7e536471b1 } + +// DocuSignEnvelopeInformation is the root element +type DocuSignEnvelopeInformation struct { + XMLName xml.Name `xml:"DocuSignEnvelopeInformation"` + EnvelopeStatus EnvelopeStatus `xml:"EnvelopeStatus"` + // Additional fields can be added here if needed + FormData string `xml:"FormData"` +} + +// EnvelopeStatus represents the