diff --git a/.gitignore b/.gitignore index 35523f6f5..fbebf19c8 100755 --- a/.gitignore +++ b/.gitignore @@ -288,3 +288,4 @@ spans*.json *.out CLAUDE.md .claude/* +/*user_emails.json diff --git a/cla-backend-go/approval_list/repository.go b/cla-backend-go/approval_list/repository.go index 49eee1920..03b1af15c 100644 --- a/cla-backend-go/approval_list/repository.go +++ b/cla-backend-go/approval_list/repository.go @@ -6,6 +6,7 @@ package approval_list import ( "errors" "fmt" + "strings" models2 "github.com/linuxfoundation/easycla/cla-backend-go/project/models" @@ -86,7 +87,7 @@ func (repo repository) AddCclaApprovalRequest(company *models.Company, project * addStringAttribute(input.Item, "project_id", project.ProjectID) addStringAttribute(input.Item, "project_name", project.ProjectName) addStringAttribute(input.Item, "user_id", user.UserID) - addStringSliceAttribute(input.Item, "user_emails", []string{requesterEmail}) + addStringSliceAttribute(input.Item, "user_emails", []string{strings.ToLower(strings.TrimSpace(requesterEmail))}) addStringAttribute(input.Item, "user_name", requesterName) addStringAttribute(input.Item, "user_github_id", user.GithubID) addStringAttribute(input.Item, "user_github_username", user.GithubUsername) diff --git a/cla-backend-go/signatures/repository.go b/cla-backend-go/signatures/repository.go index 52e50316f..d71b52745 100644 --- a/cla-backend-go/signatures/repository.go +++ b/cla-backend-go/signatures/repository.go @@ -3342,6 +3342,7 @@ func (repo repository) UpdateApprovalList(ctx context.Context, claManager *model for _, email := range params.RemoveEmailApprovalList { go func(email string) { defer wg.Done() + email = strings.ToLower(strings.TrimSpace(email)) var iclas []*models.IclaSignature var eclas []*models.Signature log.WithFields(f).Debugf("getting cla user record for email: %s ", email) diff --git a/cla-backend-go/users/repository.go b/cla-backend-go/users/repository.go index b3c70991d..d60038981 100644 --- a/cla-backend-go/users/repository.go +++ b/cla-backend-go/users/repository.go @@ -136,6 +136,7 @@ func (repo repository) CreateUser(user *models.User) (*models.User, error) { } } + user.Emails = normalizeEmails(user.Emails) if len(user.Emails) > 0 { attributes["user_emails"] = &dynamodb.AttributeValue{ SS: utils.ArrayStringPointer(user.Emails), @@ -386,9 +387,10 @@ func (repo repository) Save(user *models.UserUpdate) (*models.User, error) { } if user.Emails != nil { - log.WithFields(f).Debugf("building query - adding user_emails: %v", user.Emails) + normalized := normalizeEmails(user.Emails) + log.WithFields(f).Debugf("building query - adding user_emails: %v", normalized) expressionAttributeNames["#UES"] = aws.String("user_emails") - expressionAttributeValues[":ues"] = &dynamodb.AttributeValue{SS: aws.StringSlice(user.Emails)} + expressionAttributeValues[":ues"] = &dynamodb.AttributeValue{SS: aws.StringSlice(normalized)} updateExpression = updateExpression + " #UES = :ues, " } @@ -808,6 +810,8 @@ func (repo repository) GetUsersByEmail(userEmail string) ([]*models.User, error) "userEmail": userEmail, } + userEmail = strings.ToLower(strings.TrimSpace(userEmail)) + // This is the filter we want to match filter := expression.Name("user_emails").Contains(userEmail) @@ -817,7 +821,7 @@ func (repo repository) GetUsersByEmail(userEmail string) ([]*models.User, error) // Use the nice builder to create the expression expr, err := expression.NewBuilder().WithFilter(filter).WithProjection(projection).Build() if err != nil { - log.WithFields(f).Warnf("error building expression for lf_email : %s, error: %v", userEmail, err) + log.WithFields(f).Warnf("error building expression for user_emails : %s, error: %v", userEmail, err) return nil, err } @@ -883,6 +887,27 @@ func (repo repository) GetUsersByEmail(userEmail string) ([]*models.User, error) return users, nil } +// normalizeEmails lower-cases, trims and de-duplicates emails (DynamoDB string sets reject duplicates). +func normalizeEmails(emails []string) []string { + if emails == nil { + return nil + } + seen := make(map[string]struct{}, len(emails)) + out := make([]string, 0, len(emails)) + for _, email := range emails { + email = strings.ToLower(strings.TrimSpace(email)) + if email == "" { + continue + } + if _, ok := seen[email]; ok { + continue + } + seen[email] = struct{}{} + out = append(out, email) + } + return out +} + // GetUsersByLFEmail fetches the user record by email func (repo repository) GetUsersByLFEmail(userEmail string) ([]*models.User, error) { f := logrus.Fields{ diff --git a/cla-backend-legacy/internal/api/handlers.go b/cla-backend-legacy/internal/api/handlers.go index 6e1851b0f..a0c38e1fd 100644 --- a/cla-backend-legacy/internal/api/handlers.go +++ b/cla-backend-legacy/internal/api/handlers.go @@ -2338,7 +2338,7 @@ func (h *Handlers) InviteCompanyAdminV2(w http.ResponseWriter, r *http.Request) "project_name": &types.AttributeValueMemberS{Value: projectName}, "user_github_id": &types.AttributeValueMemberS{Value: contributorID}, "user_github_username": &types.AttributeValueMemberS{Value: contributorName}, - "user_emails": &types.AttributeValueMemberSS{Value: []string{contributorEmail}}, + "user_emails": &types.AttributeValueMemberSS{Value: []string{strings.ToLower(strings.TrimSpace(contributorEmail))}}, "request_status": &types.AttributeValueMemberS{Value: "pending"}, "date_created": &types.AttributeValueMemberS{Value: now}, "date_modified": &types.AttributeValueMemberS{Value: now}, @@ -2525,7 +2525,7 @@ func (h *Handlers) RequestCompanyCclaV2(w http.ResponseWriter, r *http.Request) "request_id": &types.AttributeValueMemberS{Value: reqID}, "company_name": &types.AttributeValueMemberS{Value: companyName}, "project_name": &types.AttributeValueMemberS{Value: projectName}, - "user_emails": &types.AttributeValueMemberSS{Value: []string{userEmail}}, + "user_emails": &types.AttributeValueMemberSS{Value: []string{strings.ToLower(strings.TrimSpace(userEmail))}}, "request_status": &types.AttributeValueMemberS{Value: "pending"}, "date_created": &types.AttributeValueMemberS{Value: now}, "date_modified": &types.AttributeValueMemberS{Value: now}, diff --git a/tests/functional/cypress/e2e/v4/cla-manager.cy.ts b/tests/functional/cypress/e2e/v4/cla-manager.cy.ts index fafef74f5..4d68dac39 100644 --- a/tests/functional/cypress/e2e/v4/cla-manager.cy.ts +++ b/tests/functional/cypress/e2e/v4/cla-manager.cy.ts @@ -707,7 +707,7 @@ https://api-gw.dev.platform.linuxfoundation.org/acs/v1/api-docs#tag/Role/operati method: 'POST', url: url, timeout: timeout, - failOnStatusCode: allowFail, + failOnStatusCode: false, headers: getXACLHeader(), auth: { bearer: bearerToken, diff --git a/utils/downcase_emails.sh b/utils/downcase_emails.sh new file mode 100644 index 000000000..d430dfbc0 --- /dev/null +++ b/utils/downcase_emails.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +STAGE=${STAGE:-dev} +PROFILE="lfproduct-${STAGE}" +REGION=us-east-1 +TABLE="cla-${STAGE}-users" +APPLY="${APPLY:-0}" + +aws dynamodb scan --profile "$PROFILE" --region "$REGION" --table-name "$TABLE" --projection-expression 'user_id, user_emails' --filter-expression 'attribute_exists(user_emails)' --output json > "${STAGE}_user_emails.json" +cat "${STAGE}_user_emails.json" | jq -c '.Items[] | select(.user_emails.SS != null) | ([.user_emails.SS[] | ascii_downcase | gsub("^\\s+|\\s+$";"") | select(length > 0)] | unique) as $n | select(($n | length > 0) and ($n != (.user_emails.SS | sort)))' \ +| while IFS= read -r item; do + uid=$(jq -r '.user_id.S' <<<"$item") + newss=$(jq -c '[.user_emails.SS[] | ascii_downcase | gsub("^\\s+|\\s+$";"") | select(length > 0)] | unique' <<<"$item") # lower + trim + drop-empty + dedupe + if [ "$newss" = "[]" ] + then + echo "skip $uid (no valid emails after normalize)" >&2 + continue + fi + echo "user $uid -> $newss" + if [ "$APPLY" = "1" ] + then + aws dynamodb update-item --profile "$PROFILE" --region "$REGION" --table-name "$TABLE" \ + --key "{\"user_id\":{\"S\":\"$uid\"}}" \ + --update-expression 'SET user_emails = :e' \ + --expression-attribute-values "{\":e\":{\"SS\":$newss}}" && echo "ok" + fi +done