Skip to content
Merged
36 changes: 18 additions & 18 deletions cla-backend-go/cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -440,8 +440,25 @@ func server(localMode bool) http.Handler {
gitlabOrganizationsService := gitlab_organizations.NewService(gitlabOrganizationRepo, v2RepositoriesService, v1ProjectClaGroupRepo, storeRepository, usersService, signaturesRepo, v1CompanyRepo)
v1SignaturesService := signatures.NewService(signaturesRepo, v1CompanyService, usersService, eventsService, githubOrgValidation, v1RepositoriesService, githubOrganizationsService, v1ProjectService, gitlabApp, configFile.ClaV1ApiURL, configFile.CLALandingPage, configFile.CLALogoURL)
v2SignatureService := v2Signatures.NewService(awsSession, configFile.SignatureFilesBucket, v1ProjectService, v1CompanyService, v1SignaturesService, v1ProjectClaGroupRepo, signaturesRepo, usersService, approvalsRepo)
// Initialize SSS (Sanctions Screening Service) client if configured.
// The sssRequired flag is controlled by the cla-sss-required-{stage} SSM parameter.
sssRequired := configFile.SSS.Required
sssEnabled := configFile.SSS.Enabled
var sssClient *sss.Client
sssClient, err = sss.NewClientFromPlatformCredentials(configFile.SSS.BaseURL, configFile.SSS.Audience, configFile.Auth0Platform.URL, configFile.Auth0Platform.ClientID, configFile.Auth0Platform.ClientSecret)
if err != nil {
if sssEnabled && sssRequired {
log.WithFields(f).WithError(err).Fatal("failed to initialize required SSS client")
}
log.WithFields(f).WithError(err).Warn("failed to initialize optional SSS client, screening will be unavailable")
sssClient = nil
}
if sssEnabled && sssRequired && sssClient == nil {
log.WithFields(f).Fatal("SSS is required but not configured")
}

v2ClaSearchService := v2ClaSearch.NewService(v2ClaSearch.NewRepository(awsSession, stage))
v2MyClasService := v2MyClas.NewService(v2MyClas.NewRepository(awsSession, stage), user_service.GetClient(), v1SignaturesService, v1CompanyRepo, v1ProjectClaGroupRepo, project_service.GetClient())
v2MyClasService := v2MyClas.NewService(v2MyClas.NewRepository(awsSession, stage), user_service.GetClient(), v1SignaturesService, v1CompanyRepo, v1ProjectClaGroupRepo, project_service.GetClient(), eventsService, v2MyClas.NewSanctionsScreener(sssClient, sssEnabled, sssRequired))
v2SelfServeSignService := v2SelfServeSign.NewService(v2MyClasService, usersService, v1ProjectService, v1ProjectClaGroupRepo, storeRepository, configFile.CLAContributorv2Base)
trustedCallerVerifier, err := auth.NewTrustedCallerVerifier(configFile.Auth0.Domain, configFile.Auth0.Algorithm, configFile.SelfServe.TrustedClientIDs)
if err != nil {
Expand All @@ -460,23 +477,6 @@ func server(localMode bool) http.Handler {

v2ClaGroupService := cla_groups.NewService(v1ProjectService, templateService, v1ProjectClaGroupRepo, v1ClaManagerService, v1SignaturesService, metricsRepo, gerritService, v1RepositoriesService, eventsService)

// Initialize SSS (Sanctions Screening Service) client if configured.
// The sssRequired flag is controlled by the cla-sss-required-{stage} SSM parameter.
sssRequired := configFile.SSS.Required
sssEnabled := configFile.SSS.Enabled
var sssClient *sss.Client
sssClient, err = sss.NewClientFromPlatformCredentials(configFile.SSS.BaseURL, configFile.SSS.Audience, configFile.Auth0Platform.URL, configFile.Auth0Platform.ClientID, configFile.Auth0Platform.ClientSecret)
if err != nil {
if sssEnabled && sssRequired {
log.WithFields(f).WithError(err).Fatal("failed to initialize required SSS client")
}
log.WithFields(f).WithError(err).Warn("failed to initialize optional SSS client, screening will be unavailable")
sssClient = nil
}
if sssEnabled && sssRequired && sssClient == nil {
log.WithFields(f).Fatal("SSS is required but not configured")
}

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, gitlabActivityService, gitlabApp, gerritService, sssClient, sssRequired, sssEnabled)

sessionStore, err := dynastore.New(dynastore.Path("/"), dynastore.HTTPOnly(), dynastore.TableName(configFile.SessionStoreTableName), dynastore.DynamoDB(dynamodb.New(awsSession)))
Expand Down
43 changes: 43 additions & 0 deletions cla-backend-go/emails/contact_cla_manager_templates.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Copyright The Linux Foundation and each contributor to CommunityBridge.
// SPDX-License-Identifier: MIT

package emails

import (
"github.com/linuxfoundation/easycla/cla-backend-go/utils"
)

// ContactClaManagerTemplateParams is email params for ContactClaManagerTemplate
type ContactClaManagerTemplateParams struct {
RequestAction string
ContributorName string
ContributorIdentity string
CompanyName string
ProjectName string
CLAGroupName string
OptionalMessage string
}

const (
// ContactClaManagerTemplateName is email template name for ContactClaManagerTemplate
ContactClaManagerTemplateName = "ContactClaManagerTemplate"
// ContactClaManagerTemplate is the email sent to the selected CLA managers when a
// contributor requests removal from or (re-)approval under the company CCLA
ContactClaManagerTemplate = `
<p>Hello CLA Manager,</p>
<p>This is a notification email from EasyCLA regarding the project {{.ProjectName}} and CLA Group {{.CLAGroupName}}.</p>
<p>{{.ContributorName}} ({{.ContributorIdentity}}) has requested {{.RequestAction}} for their employee acknowledgement
under the {{.CompanyName}} corporate CLA. You are receiving this message as a CLA Manager from {{.CompanyName}} for {{.ProjectName}}.</p>
{{if .OptionalMessage}}
<p>The contributor included the following message in the request:</p>
<p>{{.OptionalMessage}}</p>
{{end}}
<p>To act on this request, please log into the EasyCLA Corporate Console and update the Approved List for {{.CompanyName}} accordingly.
No change has been made automatically.</p>
`
)

// RenderContactClaManagerTemplate renders ContactClaManagerTemplate
func RenderContactClaManagerTemplate(params ContactClaManagerTemplateParams) (string, error) {
return RenderTemplate(utils.V2, ContactClaManagerTemplateName, ContactClaManagerTemplate, params)
}
43 changes: 43 additions & 0 deletions cla-backend-go/events/event_data.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package events

import (
"fmt"
"strings"

"github.com/linuxfoundation/easycla/cla-backend-go/gen/v1/models"
"github.com/linuxfoundation/easycla/cla-backend-go/utils"
Expand Down Expand Up @@ -226,6 +227,15 @@ type CCLAApprovalListRequestCreatedEventData struct {
RequestID string
}

// ContactCLAManagerRequestCreatedEventData data model
type ContactCLAManagerRequestCreatedEventData struct {
RequestID string
RequestType string
SignatureID string
Message string
Recipients []string
}

// CCLAApprovalListRequestApprovedEventData data model
type CCLAApprovalListRequestApprovedEventData struct {
RequestID string
Expand Down Expand Up @@ -1282,6 +1292,20 @@ func (ed *CCLAApprovalListRequestCreatedEventData) GetEventDetailsString(args *L
return data, true
}

// GetEventDetailsString returns the details string for this event
func (ed *ContactCLAManagerRequestCreatedEventData) GetEventDetailsString(args *LogEventArgs) (string, bool) {
data := fmt.Sprintf("A CLA manager %s request was created for the Project: %s, Company: %s, Signature: %s with Request ID: %s addressed to: %s",
ed.RequestType, args.ProjectName, args.CompanyName, ed.SignatureID, ed.RequestID, strings.Join(ed.Recipients, ","))
if args.UserName != "" {
data = data + fmt.Sprintf(" by the user %s", args.UserName)
}
if ed.Message != "" {
data = data + fmt.Sprintf(" with the message: %s", ed.Message)
Comment thread
lukaszgryglicki marked this conversation as resolved.
}
data = data + "."
return data, true
}

// GetEventDetailsString returns the details string for this event
func (ed *ApprovalListGitHubOrganizationAddedEventData) GetEventDetailsString(args *LogEventArgs) (string, bool) {
data := fmt.Sprintf("The GitHub Organization: %s was added to the approval list for the Company %s, Project: %s",
Expand Down Expand Up @@ -2371,6 +2395,25 @@ func (ed *CLAApprovalListRemoveGitLabGroupData) GetEventSummaryString(args *LogE
return data, true
}

// GetEventSummaryString returns the summary string for this event
func (ed *ContactCLAManagerRequestCreatedEventData) GetEventSummaryString(args *LogEventArgs) (string, bool) {
data := fmt.Sprintf("The user %s asked the CLA managers for %s", args.UserName, ed.RequestType)
if args.CLAGroupName != "" {
data = data + fmt.Sprintf(" for the CLA Group %s", args.CLAGroupName)
}
if args.ProjectName != "" {
data = data + fmt.Sprintf(" for the project %s", args.ProjectName)
}
if args.CompanyName != "" {
data = data + fmt.Sprintf(" for the company %s", args.CompanyName)
}
if ed.Message != "" {
data = data + " with a message"
}
data = data + "."
return data, true
}

// GetEventSummaryString returns the summary string for this event
func (ed *CCLAApprovalListRequestCreatedEventData) GetEventSummaryString(args *LogEventArgs) (string, bool) {
data := fmt.Sprintf("The user %s created a CCLA Approval Request", args.UserName)
Expand Down
21 changes: 21 additions & 0 deletions cla-backend-go/events/event_data_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,24 @@ func TestCLAGroupUpdatedEventData_GetEventDetailsString(t *testing.T) {
})
}
}

func TestContactCLAManagerRequestCreatedEventData(t *testing.T) {
eventData := &ContactCLAManagerRequestCreatedEventData{
RequestID: "request-1",
RequestType: "removal",
SignatureID: "sig-1",
Recipients: []string{"manager-one"},
}
args := &LogEventArgs{UserName: testUser, CompanyName: "Good Corp", ProjectName: "My Project"}

details, _ := eventData.GetEventDetailsString(args)
assert.NotContains(t, details, "with the message")
summary, _ := eventData.GetEventSummaryString(args)
assert.NotContains(t, summary, "with a message")

eventData.Message = "please remove me"
details, _ = eventData.GetEventDetailsString(args)
assert.Contains(t, details, "with the message: please remove me", "the receipt carries the contributor message")
summary, _ = eventData.GetEventSummaryString(args)
assert.Contains(t, summary, "with a message")
}
2 changes: 2 additions & 0 deletions cla-backend-go/events/event_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ const (
CompanyACLRequestApproved = "company_acl.request_approved"
CompanyACLRequestDenied = "company_acl.request_denied"

ContactCLAManagerRequestCreated = "contact_cla_manager_request.created"

CCLAApprovalListRequestCreated = "ccla_approval_list_request.created"
CCLAApprovalListRequestApproved = "ccla_approval_list_request.approved"
CCLAApprovalListRequestRejected = "ccla_approval_list_request.rejected"
Expand Down
16 changes: 16 additions & 0 deletions cla-backend-go/signatures/mocks/mock_service.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 20 additions & 9 deletions cla-backend-go/signatures/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ type SignatureService interface {
// handleGitHubStatusUpdate(ctx context.Context, employeeUserModel *models.User) error
ProcessEmployeeSignature(ctx context.Context, companyModel *models.Company, claGroupModel *models.ClaGroup, user *models.User) (*bool, error)
UserIsApproved(ctx context.Context, user *models.User, cclaSignature *models.Signature) (bool, error)
EvaluateUserApproval(ctx context.Context, user *models.User, cclaSignature *models.Signature) (approved bool, githubOrgLookupFailed bool, err error)
}

type service struct {
Expand Down Expand Up @@ -1605,16 +1606,25 @@ func (s service) ProcessEmployeeSignature(ctx context.Context, companyModel *mod
}

func (s service) UserIsApproved(ctx context.Context, user *models.User, cclaSignature *models.Signature) (bool, error) {
approved, _, err := s.EvaluateUserApproval(ctx, user, cclaSignature)
return approved, err
}

// EvaluateUserApproval is UserIsApproved plus whether the GitHub public-orgs lookup failed, so a
// false result was "could not tell" rather than "not approved". Access gating uses UserIsApproved.
func (s service) EvaluateUserApproval(ctx context.Context, user *models.User, cclaSignature *models.Signature) (bool, bool, error) {
// add lf email to emails
f := logrus.Fields{
"functionName": "v1.signatures.service.UserIsApproved",
"functionName": "v1.signatures.service.EvaluateUserApproval",
}
githubOrgLookupFailed := false

emails := user.Emails

if user.LfEmail != "" {
log.WithFields(f).Debugf("adding lf email: %s to emails", user.LfEmail)
emails = append(emails, string(user.LfEmail))
// copy first - the same user record may be evaluated concurrently
emails = append(append(make([]string, 0, len(emails)+1), emails...), string(user.LfEmail))
// remove duplicates
log.WithFields(f).Debug("removing duplicates")
emails = utils.RemoveDuplicates(emails)
Expand All @@ -1626,7 +1636,7 @@ func (s service) UserIsApproved(ctx context.Context, user *models.User, cclaSign
if len(gitHubUsernameApprovalList) > 0 {
for _, gitHubUsername := range gitHubUsernameApprovalList {
if strings.EqualFold(gitHubUsername, strings.TrimSpace(user.GithubUsername)) {
return true, nil
return true, githubOrgLookupFailed, nil
}
}
} else {
Expand All @@ -1638,7 +1648,7 @@ func (s service) UserIsApproved(ctx context.Context, user *models.User, cclaSign
if len(gitLabUsernameApprovalList) > 0 {
for _, gitLabUsername := range gitLabUsernameApprovalList {
if strings.EqualFold(gitLabUsername, strings.TrimSpace(user.GitlabUsername)) {
return true, nil
return true, githubOrgLookupFailed, nil
}
}
} else {
Expand All @@ -1654,7 +1664,7 @@ func (s service) UserIsApproved(ctx context.Context, user *models.User, cclaSign
// case insensitive search
for _, emailApproval := range emailApprovalList {
if strings.EqualFold(email, emailApproval) {
return true, nil
return true, githubOrgLookupFailed, nil
}
}
}
Expand All @@ -1667,10 +1677,10 @@ func (s service) UserIsApproved(ctx context.Context, user *models.User, cclaSign
if len(domainApprovalList) > 0 {
matched, err := s.processPattern(emails, domainApprovalList)
if err != nil {
return false, err
return false, githubOrgLookupFailed, err
}
if matched != nil && *matched {
return true, nil
return true, githubOrgLookupFailed, nil
}
}

Expand All @@ -1692,6 +1702,7 @@ func (s service) UserIsApproved(ctx context.Context, user *models.User, cclaSign
// /v3/sign route and a transient GitHub blip would block
// every org-approved contributor across the project.
log.WithFields(f).Warnf("could not list public orgs for github user %s; treating as no org-approval match: %v", login, err)
githubOrgLookupFailed = true
} else {
for _, approvedOrg := range githubOrgApprovalList {
approvedOrgTrim := strings.TrimSpace(approvedOrg)
Expand All @@ -1704,15 +1715,15 @@ func (s service) UserIsApproved(ctx context.Context, user *models.User, cclaSign
}
if matched {
log.WithFields(f).Debugf("found matching github organization: %s for user: %s", approvedOrg, login)
return true, nil
return true, githubOrgLookupFailed, nil
}
log.WithFields(f).Debugf("user: %s is not in the organization: %s", login, approvedOrg)
}
}
}
}

return false, nil
return false, githubOrgLookupFailed, nil
}

func (s service) processPattern(emails []string, patterns []string) (*bool, error) {
Expand Down
68 changes: 68 additions & 0 deletions cla-backend-go/signatures/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,74 @@ func TestUserIsApproved_GithubOrgApprovalList(t *testing.T) {
}
}

// TestEvaluateUserApproval covers the one thing the UserIsApproved boolean cannot express:
// a false result caused by a failed GitHub public-orgs lookup ("could not tell") rather than
// by a genuine approval-list miss. Callers that must not present a guess to the user - the
// My CLAs listing - key off this second return value.
func TestEvaluateUserApproval(t *testing.T) {
ctx := context.Background()

cases := []struct {
name string
user *v1Models.User
ccla *v1Models.Signature
userOrgs []string
listErr error
wantApproved bool
wantLookupFailed bool
}{
{
name: "org match",
user: &v1Models.User{GithubUsername: "alice"},
ccla: &v1Models.Signature{GithubOrgApprovalList: []string{"acme"}},
userOrgs: []string{"acme"},
wantApproved: true,
},
{
name: "no overlap is a genuine miss",
user: &v1Models.User{GithubUsername: "eve"},
ccla: &v1Models.Signature{GithubOrgApprovalList: []string{"acme"}},
userOrgs: []string{"contoso"},
},
{
name: "lookup failure is unevaluable, not a miss",
user: &v1Models.User{GithubUsername: "grace"},
ccla: &v1Models.Signature{GithubOrgApprovalList: []string{"acme"}},
listErr: errors.New("simulated 502 from github"),
wantLookupFailed: true,
},
{
name: "approved by email before github is consulted",
user: &v1Models.User{GithubUsername: "heidi", Emails: []string{"heidi@acme.org"}},
ccla: &v1Models.Signature{EmailApprovalList: []string{"heidi@acme.org"}, GithubOrgApprovalList: []string{"acme"}},
listErr: errors.New("would fail if reached"),
wantApproved: true,
},
{
name: "no approval list at all",
user: &v1Models.User{GithubUsername: "ivan"},
ccla: &v1Models.Signature{},
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
stubListUserPublicOrgs(t, tc.userOrgs, tc.listErr)
svc := NewService(nil, nil, nil, nil, false, nil, nil, nil, nil, "", "", "")

approved, lookupFailed, err := svc.EvaluateUserApproval(ctx, tc.user, tc.ccla)
assert.NoError(t, err)
assert.Equal(t, tc.wantApproved, approved)
assert.Equal(t, tc.wantLookupFailed, lookupFailed)

// UserIsApproved must stay byte-identical for the signing flow
legacyApproved, legacyErr := svc.UserIsApproved(ctx, tc.user, tc.ccla)
assert.NoError(t, legacyErr)
assert.Equal(t, approved, legacyApproved)
})
}
}

// TestListUserPublicOrgs_RejectsEmptyUser guards the public helper itself:
// go-github routes an empty user string to GET /user/orgs (the authenticated
// bot's own orgs), so an empty argument must never silently succeed.
Expand Down
Loading
Loading