From 39f8309630d3bde2be673d2d297ececb4108caca 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 | 50 +- cla-backend-go/project/service/service.go | 5 +- cla-backend-go/signatures/converters.go | 1 + cla-backend-go/signatures/dbmodels.go | 2 + cla-backend-go/signatures/repository.go | 112 ++++ cla-backend-go/signatures/service.go | 60 +++ cla-backend-go/swagger/cla.v1.yaml | 3 + cla-backend-go/swagger/cla.v2.yaml | 49 +- .../common/cla-group-document-tab.yaml | 46 ++ .../swagger/common/cla-group-document.yaml | 5 + cla-backend-go/swagger/common/signature.yaml | 13 + cla-backend-go/users/service.go | 32 ++ cla-backend-go/v2/main/main.go | 6 +- cla-backend-go/v2/repositories/service.go | 91 ++++ cla-backend-go/v2/sign/docusign.go | 126 ++++- cla-backend-go/v2/sign/handlers.go | 58 +- cla-backend-go/v2/sign/jwt.go | 41 +- cla-backend-go/v2/sign/models.go | 18 + cla-backend-go/v2/sign/service.go | 508 +++++++++++++++++- cla-backend-go/v2/store/repository.go | 56 ++ 21 files changed, 1217 insertions(+), 69 deletions(-) create mode 100644 cla-backend-go/swagger/common/cla-group-document-tab.yaml 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..ab31bfa08 100644 --- a/cla-backend-go/project/common/helpers.go +++ b/cla-backend-go/project/common/helpers.go @@ -102,8 +102,15 @@ func GetCurrentDocument(ctx context.Context, docs []models.ClaGroupDocument) (mo continue } - // No previous, use the first... - if currentDoc == (models.ClaGroupDocument{}) { + // // No previous, use the first... + // if currentDoc == (models.ClaGroupDocument{}) { + // currentDoc = doc + // currentDocVersion = version + // currentDocDateTime = dateTime + // continue + // } + + if AreClaGroupDocumentsEqual(currentDoc, models.ClaGroupDocument{}) { currentDoc = doc currentDocVersion = version currentDocDateTime = dateTime @@ -127,3 +134,42 @@ 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..997487a3e 100644 --- a/cla-backend-go/project/service/service.go +++ b/cla-backend-go/project/service/service.go @@ -212,7 +212,8 @@ 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 +289,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..afe1dade0 100644 --- a/cla-backend-go/signatures/repository.go +++ b/cla-backend-go/signatures/repository.go @@ -97,6 +97,8 @@ 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) } type iclaSignatureWithDetails struct { @@ -132,6 +134,116 @@ func NewRepository(awsSession *session.Session, stage string, companyRepo compan } } +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..820fec7eb 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,11 @@ 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) handleGitHubStatusUpdate(ctx context.Context, employeeUserModel *models.User) error } @@ -117,6 +120,58 @@ 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) +} + // 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 +198,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..443f752de --- /dev/null +++ b/cla-backend-go/swagger/common/cla-group-document-tab.yaml @@ -0,0 +1,46 @@ + 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: boolean + 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..41ab09a65 100644 --- a/cla-backend-go/swagger/common/cla-group-document.yaml +++ b/cla-backend-go/swagger/common/cla-group-document.yaml @@ -46,3 +46,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 index d220a74c4..2dbcc99c8 100644 --- a/cla-backend-go/v2/main/main.go +++ b/cla-backend-go/v2/main/main.go @@ -3,13 +3,13 @@ package main import ( "fmt" - "github.com/communitybridge/easycla/cla-backend-go/v2/docusign_auth" + docusignauth "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 :=` + privateKey := ` -----BEGIN RSA PRIVATE KEY----- MIIEogIBAAKCAQEAh0M2mIGaJjP8S/FxZR7nRsatCR/KpCPBFBbxalZffykqtTID KNeDhJ5RvJKAVoJlLaLoUYSYloVaeSAwQdbn4F+Lsnll3mCGocwdl/W8998Lc/Ln @@ -44,4 +44,4 @@ func main() { } fmt.Println(token) -} \ No newline at end of file +} 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..dbd1190df 100644 --- a/cla-backend-go/v2/sign/docusign.go +++ b/cla-backend-go/v2/sign/docusign.go @@ -5,14 +5,132 @@ package sign import ( "context" - "log" + "encoding/json" + "io" + "net/http" + "strings" + + log "github.com/communitybridge/easycla/cla-backend-go/logging" + "github.com/communitybridge/easycla/cla-backend-go/utils" + "github.com/sirupsen/logrus" ) +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 + } - // Get the access token - jwtAssertion, jwterr := jwtToken() -} \ No newline at end of file + return tokenResponse.AccessToken, nil +} 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..f3058d1c7 --- /dev/null +++ b/cla-backend-go/v2/sign/models.go @@ -0,0 +1,18 @@ +// 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"` +} \ No newline at end of file diff --git a/cla-backend-go/v2/sign/service.go b/cla-backend-go/v2/sign/service.go index 99b282687..a2809ad39 100644 --- a/cla-backend-go/v2/sign/service.go +++ b/cla-backend-go/v2/sign/service.go @@ -12,10 +12,17 @@ import ( "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 +31,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 +45,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 +74,12 @@ type ProjectRepo interface { // Service interface defines the sign service methods type Service interface { + getAccessToken(ctx context.Context) (string, error) + voidDocument(ctx context.Context, documentID, message string) error + // GetDocusignEnvelope(ctx context.Context, envelopeID string) (*v1Models.ClaGroupDocument, 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 +90,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 +107,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, } } @@ -135,6 +163,47 @@ func validateCorporateSignatureInput(input *models.CorporateSignatureInput) erro return nil } +func getLatestVersion(documents []v1Models.ClaGroupDocument) *v1Models.ClaGroupDocument { + f := logrus.Fields{ + "functionName": "getLatestVersion", + } + var latest *v1Models.ClaGroupDocument + var latestMajor int64 + var latestMinor int64 + var latestDate string + for _, doc := range documents { + currentMajor, err := strconv.ParseInt(doc.DocumentMajorVersion, 10, 64) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to parse document major version: %s", doc.DocumentMajorVersion) + continue + } + currentMinor, err := strconv.ParseInt(doc.DocumentMinorVersion, 10, 64) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to parse document minor version: %s", doc.DocumentMinorVersion) + continue + } + + if currentMajor > latestMajor { + latest = &doc + latestMajor = currentMajor + latestMinor = currentMinor + continue + } + + if currentMajor == latestMajor && currentMinor > latestMinor { + latest = &doc + latestMinor = currentMinor + } + + if latestDate == "" || doc.DocumentCreationDate > latestDate { + latest = &doc + latestDate = doc.DocumentCreationDate + } + + } + return latest +} + func (s *service) RequestCorporateSignature(ctx context.Context, lfUsername string, authorizationHeader string, input *models.CorporateSignatureInput) (*models.CorporateSignatureOutput, error) { // nolint f := logrus.Fields{ "functionName": "sign.RequestCorporateSignature", @@ -311,25 +380,438 @@ 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 != 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 = "" } - log.WithFields(f).Debug("Get Access Token for DocuSign") - accessToken, err := docusignauth.GetAccessToken(integrationKey, userGUID, privateKey) + 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") + } + + // 6. get latest document + document := getLatestVersion(proj.ProjectIndividualDocuments) + if document == nil { + log.WithFields(f).Debugf("document not found for project: %s", projectID) + return nil, errors.New("document not found for project") + } + + aclUser := v1Models.User{} + + if returnURLType == "github" { + aclUser.GithubID = user.GithubID + } else if returnURLType == "gitlab" { + aclUser.GitlabID = user.GitlabID + } + + 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 IndividualValues, 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 + + 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 + + // 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 = getLatestVersion(claGroup.ProjectCorporateDocuments) + if document == nil { + log.WithFields(f).Debugf("company signature - document not found for project: %s", signature.ProjectID) + return errors.New("document not found for project") + } + 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 = getLatestVersion(claGroup.ProjectIndividualDocuments) + if document == nil { + log.WithFields(f).Debugf("user signature - document not found for project: %s", signature.ProjectID) + return errors.New("document not found for project") + } + 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 + } + } + + 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) IndividualValues { + individualValues := IndividualValues{} + if user == nil { + return individualValues + } + + if user.Username != "" { + individualValues.PublicName = user.Username + individualValues.FullName = 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 +}