diff --git a/cla-backend-go/cmd/server.go b/cla-backend-go/cmd/server.go index 8fd65f835..9759f8e0d 100644 --- a/cla-backend-go/cmd/server.go +++ b/cla-backend-go/cmd/server.go @@ -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 { @@ -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))) diff --git a/cla-backend-go/emails/contact_cla_manager_templates.go b/cla-backend-go/emails/contact_cla_manager_templates.go new file mode 100644 index 000000000..cee2f4598 --- /dev/null +++ b/cla-backend-go/emails/contact_cla_manager_templates.go @@ -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 = ` +

Hello CLA Manager,

+

This is a notification email from EasyCLA regarding the project {{.ProjectName}} and CLA Group {{.CLAGroupName}}.

+

{{.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}}.

+{{if .OptionalMessage}} +

The contributor included the following message in the request:

+

{{.OptionalMessage}}

+{{end}} +

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.

+` +) + +// RenderContactClaManagerTemplate renders ContactClaManagerTemplate +func RenderContactClaManagerTemplate(params ContactClaManagerTemplateParams) (string, error) { + return RenderTemplate(utils.V2, ContactClaManagerTemplateName, ContactClaManagerTemplate, params) +} diff --git a/cla-backend-go/events/event_data.go b/cla-backend-go/events/event_data.go index 4bae6c583..aee58b928 100644 --- a/cla-backend-go/events/event_data.go +++ b/cla-backend-go/events/event_data.go @@ -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" @@ -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 @@ -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) + } + 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", @@ -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) diff --git a/cla-backend-go/events/event_data_test.go b/cla-backend-go/events/event_data_test.go index 4f6bd1440..6aaf6877d 100644 --- a/cla-backend-go/events/event_data_test.go +++ b/cla-backend-go/events/event_data_test.go @@ -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") +} diff --git a/cla-backend-go/events/event_types.go b/cla-backend-go/events/event_types.go index 10d9c4508..fec253f4e 100644 --- a/cla-backend-go/events/event_types.go +++ b/cla-backend-go/events/event_types.go @@ -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" diff --git a/cla-backend-go/signatures/mocks/mock_service.go b/cla-backend-go/signatures/mocks/mock_service.go index 938bc0a4a..08bf9c177 100644 --- a/cla-backend-go/signatures/mocks/mock_service.go +++ b/cla-backend-go/signatures/mocks/mock_service.go @@ -130,6 +130,22 @@ func (mr *MockSignatureServiceMockRecorder) DeleteGithubOrganizationFromApproval return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteGithubOrganizationFromApprovalList", reflect.TypeOf((*MockSignatureService)(nil).DeleteGithubOrganizationFromApprovalList), ctx, signatureID, approvalListParams, githubAccessToken) } +// EvaluateUserApproval mocks base method. +func (m *MockSignatureService) EvaluateUserApproval(ctx context.Context, user *models.User, cclaSignature *models.Signature) (bool, bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "EvaluateUserApproval", ctx, user, cclaSignature) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(bool) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// EvaluateUserApproval indicates an expected call of EvaluateUserApproval. +func (mr *MockSignatureServiceMockRecorder) EvaluateUserApproval(ctx, user, cclaSignature interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EvaluateUserApproval", reflect.TypeOf((*MockSignatureService)(nil).EvaluateUserApproval), ctx, user, cclaSignature) +} + // GetCCLASignatures mocks base method. func (m *MockSignatureService) GetCCLASignatures(ctx context.Context, signed, approved *bool) ([]*signatures0.ItemSignature, error) { m.ctrl.T.Helper() diff --git a/cla-backend-go/signatures/service.go b/cla-backend-go/signatures/service.go index b1500aec7..4960acb3c 100644 --- a/cla-backend-go/signatures/service.go +++ b/cla-backend-go/signatures/service.go @@ -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 { @@ -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) @@ -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 { @@ -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 { @@ -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 } } } @@ -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 } } @@ -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) @@ -1704,7 +1715,7 @@ 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) } @@ -1712,7 +1723,7 @@ func (s service) UserIsApproved(ctx context.Context, user *models.User, cclaSign } } - return false, nil + return false, githubOrgLookupFailed, nil } func (s service) processPattern(emails []string, patterns []string) (*bool, error) { diff --git a/cla-backend-go/signatures/service_test.go b/cla-backend-go/signatures/service_test.go index fe4f87ca9..1c9530f08 100644 --- a/cla-backend-go/signatures/service_test.go +++ b/cla-backend-go/signatures/service_test.go @@ -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. diff --git a/cla-backend-go/swagger/cla.v2.yaml b/cla-backend-go/swagger/cla.v2.yaml index da2587229..95b46013a 100644 --- a/cla-backend-go/swagger/cla.v2.yaml +++ b/cla-backend-go/swagger/cla.v2.yaml @@ -2753,7 +2753,7 @@ paths: /my-clas: get: summary: Get My CLAs - description: Returns the signed ICLAs and ECLAs (employee acknowledgements) matching the provided identity - LF username, emails and GitHub/GitLab/Gerrit identities - aggregated across all matching EasyCLA user records and deduplicated, with validity evaluated against the current company CCLA approval lists. Unless the caller is an admin or a trusted LFX Self Serve client, each provided identity must belong to the authenticated user (per their EasyCLA user record or the identities connected to their LF account in the platform user-service) - identities that cannot be verified are not searched and are reported in skippedIdentities. A trusted caller is one whose Authorization bearer token is signature-verified against the Auth0 JWKS in-handler and whose azp claim is on the configured Self Serve client-ID allow-list; while that allow-list is configured every request must carry a verifiable bearer token, and a missing or unverifiable one is rejected with 401 + description: Returns the signed ICLAs and ECLAs (employee acknowledgements) matching the provided identity - LF username, emails, GitHub/GitLab/Gerrit identities - aggregated across all matching EasyCLA user records, deduplicated, with validity evaluated against the current company CCLA approval lists. Unless the caller is an admin or a trusted LFX Self Serve client, every provided identity must belong to the authenticated user (their EasyCLA user records, or the identities connected to their LF account in the platform user-service); unverifiable ones are not searched and are reported in skippedIdentities. A trusted caller has its Authorization bearer token signature-verified against the Auth0 JWKS in-handler and its azp claim on the configured Self Serve client-ID allow-list; while that allow-list is configured every request needs a verifiable bearer token and a missing or unverifiable one is 401 operationId: getMyClas parameters: - $ref: "#/parameters/x-request-id" @@ -2791,7 +2791,7 @@ paths: /my-clas/{signatureID}/pdf: get: summary: Get a signed ICLA PDF download link - description: Returns a time-limited download URL for the signed ICLA PDF when the signature belongs to the provided identity - unknown, not-owned and ECLA signature IDs return 404. The same identity-ownership enforcement and trusted-caller bearer token verification as GET /my-clas applies, so a caller that is neither an admin nor a trusted LFX Self Serve client can only download their own signed documents + description: Returns a time-limited download URL for a signed ICLA PDF owned by the provided identity - unknown, not-owned and ECLA signature IDs return 404. Identity-ownership enforcement and trusted-caller token verification are as in GET /my-clas, so a caller that is neither an admin nor a trusted LFX Self Serve client can only download its own documents operationId: getMyClaPdf parameters: - $ref: "#/parameters/x-request-id" @@ -2834,10 +2834,107 @@ paths: tags: - my_clas + /my-clas/{signatureID}/cla-managers: + get: + summary: Get the CLA managers for an ECLA + description: Returns the CLA managers of the company CCLA covering the given ECLA, so the contributor can pick whom to contact for a removal or approval request - unknown, not-owned and ICLA signature IDs return 404. Identity-ownership enforcement and trusted-caller token verification are as in GET /my-clas + operationId: getMyClaManagers + parameters: + - $ref: "#/parameters/x-request-id" + - $ref: "#/parameters/x-acl" + - $ref: "#/parameters/x-username" + - $ref: "#/parameters/x-email" + - name: signatureID + description: The signature ID (UUID string) + in: path + type: string + required: true + pattern: '^[a-fA-F0-9]{8}-?[a-fA-F0-9]{4}-?[a-fA-F0-9]{4}-?[a-fA-F0-9]{4}-?[a-fA-F0-9]{12}$' # this is any UUID, not only v4 + - $ref: "#/parameters/myClasLfUsername" + - $ref: "#/parameters/myClasEmail" + - $ref: "#/parameters/myClasSecondaryEmail" + - $ref: "#/parameters/myClasGithubId" + - $ref: "#/parameters/myClasGithubUsername" + - $ref: "#/parameters/myClasGitlabId" + - $ref: "#/parameters/myClasGitlabUsername" + - $ref: "#/parameters/myClasGerritUsername" + responses: + '200': + description: 'Success' + headers: + x-request-id: + type: string + description: The unique request ID value - assigned/set by the API Gateway based on the session + schema: + $ref: '#/definitions/my-cla-manager-list' + '400': + $ref: '#/responses/invalid-request' + '401': + $ref: '#/responses/unauthorized' + '403': + $ref: '#/responses/forbidden' + '404': + $ref: '#/responses/not-found' + '500': + $ref: '#/responses/internal-server-error' + tags: + - my_clas + + /my-clas/{signatureID}/cla-manager-requests: + post: + summary: Request removal or approval from the CLA managers of an ECLA + description: Records a contributor request against their own ECLA and emails it to the selected CLA managers of the covering company CCLA - removal (take me off the coverage) or approval (add me back to the approval list). recipients must be a non-empty subset of the managers from GET /my-clas/{signatureID}/cla-managers; empty only when none resolves, and the request is then recorded without email. No signature state changes. Unknown, not-owned and ICLA signature IDs return 404. An ECLA flagged for a sanctioned company is deliberately still accepted - a removal request is legitimate there. Identity-ownership enforcement and trusted-caller token verification are as in GET /my-clas + operationId: createMyClaManagerRequest + parameters: + - $ref: "#/parameters/x-request-id" + - $ref: "#/parameters/x-acl" + - $ref: "#/parameters/x-username" + - $ref: "#/parameters/x-email" + - name: signatureID + description: The signature ID (UUID string) + in: path + type: string + required: true + pattern: '^[a-fA-F0-9]{8}-?[a-fA-F0-9]{4}-?[a-fA-F0-9]{4}-?[a-fA-F0-9]{4}-?[a-fA-F0-9]{12}$' # this is any UUID, not only v4 + - $ref: "#/parameters/myClasLfUsername" + - $ref: "#/parameters/myClasEmail" + - $ref: "#/parameters/myClasSecondaryEmail" + - $ref: "#/parameters/myClasGithubId" + - $ref: "#/parameters/myClasGithubUsername" + - $ref: "#/parameters/myClasGitlabId" + - $ref: "#/parameters/myClasGitlabUsername" + - $ref: "#/parameters/myClasGerritUsername" + - name: body + in: body + required: true + schema: + $ref: '#/definitions/my-cla-manager-request' + responses: + '200': + description: 'Success' + headers: + x-request-id: + type: string + description: The unique request ID value - assigned/set by the API Gateway based on the session + schema: + $ref: '#/definitions/my-cla-manager-request-result' + '400': + $ref: '#/responses/invalid-request' + '401': + $ref: '#/responses/unauthorized' + '403': + $ref: '#/responses/forbidden' + '404': + $ref: '#/responses/not-found' + '500': + $ref: '#/responses/internal-server-error' + tags: + - my_clas + /my-clas/identities: get: summary: Get My Identities - description: Returns the deduplicated identities connected to the authenticated user, each formatted as ":" - the union of the identities on their EasyCLA user records and the identities connected to their LF account in the platform user-service, i.e. exactly the identity set the My CLAs API authorizes a non-admin, non-trusted caller to search. This endpoint always reports the authenticated principal's own identities; when the trusted Self Serve client-ID allow-list is configured the request must still carry a verifiable Authorization bearer token + description: Returns the deduplicated identities connected to the authenticated user, each formatted ":" - the union of the identities on their EasyCLA user records and those connected to their LF account in the platform user-service, i.e. exactly the set a non-admin, non-trusted caller is authorized to search. Always the authenticated principal's own identities; while the trusted Self Serve client-ID allow-list is configured the request still needs a verifiable Authorization bearer token operationId: getMyIdentities parameters: - $ref: "#/parameters/x-request-id" @@ -5061,7 +5158,7 @@ parameters: required: true myClasLfUsername: name: lfUsername - description: The LF username (LFID) of the user - when omitted, the username of the authenticated principal is used; unless the caller is an admin or a trusted LFX Self Serve client, a value different from the authenticated principal is not searched and is reported in skippedIdentities. Accepting a caller-supplied identity list is transitional - at M6, once EasyCLA runs on the K8s cluster, it should call lfx.auth-service.user_identity.list itself over NATS and drop both the caller-supplied list and the azp allow-list that authorizes it + description: The LF username (LFID) of the user - when omitted, the authenticated principal's username is used; unless the caller is an admin or a trusted LFX Self Serve client, a different value is not searched and is reported in skippedIdentities. The caller-supplied identity list is transitional - at M6, once EasyCLA runs on the K8s cluster, it should call lfx.auth-service.user_identity.list over NATS itself and drop both the list and the azp allow-list authorizing it in: query type: string required: false @@ -5077,7 +5174,7 @@ parameters: required: false myClasSecondaryEmail: name: secondaryEmail - description: Email addresses of the user - matched case-insensitively against the EasyCLA user records additional-emails set; this match is not index-backed (all provided values are matched in a single table scan) so it runs only when explicitly requested, use sparingly + description: Email addresses of the user - matched case-insensitively against the EasyCLA user records additional-emails set; not index-backed (all values are matched in one table scan), so it runs only on request - use sparingly in: query type: array maxItems: 20 @@ -5097,7 +5194,7 @@ parameters: required: false myClasGithubUsername: name: githubUsername - description: GitHub usernames of the user - note that GitHub usernames can be renamed/recycled, the numeric githubId is the authoritative key + description: GitHub usernames of the user - usernames can be renamed/recycled, the numeric githubId is the authoritative key in: query type: array maxItems: 100 @@ -5117,7 +5214,7 @@ parameters: required: false myClasGitlabUsername: name: gitlabUsername - description: GitLab usernames of the user - note that GitLab usernames can be renamed/recycled, the numeric gitlabId is the authoritative key + description: GitLab usernames of the user - usernames can be renamed/recycled, the numeric gitlabId is the authoritative key in: query type: array maxItems: 100 @@ -5127,7 +5224,7 @@ parameters: required: false myClasGerritUsername: name: gerritUsername - description: Gerrit usernames of the user - Gerrit uses LF SSO accounts, so these are (current or historical) LF usernames and are matched against the EasyCLA user records LF username + description: Gerrit usernames of the user - Gerrit uses LF SSO, so these are (current or historical) LF usernames, matched against the EasyCLA user records LF username in: query type: array maxItems: 100 @@ -5180,6 +5277,18 @@ definitions: my-cla-pdf: $ref: './common/my-cla-pdf.yaml' + my-cla-manager: + $ref: './common/my-cla-manager.yaml' + + my-cla-manager-list: + $ref: './common/my-cla-manager-list.yaml' + + my-cla-manager-request: + $ref: './common/my-cla-manager-request.yaml' + + my-cla-manager-request-result: + $ref: './common/my-cla-manager-request-result.yaml' + my-identity-list: $ref: './common/my-identity-list.yaml' diff --git a/cla-backend-go/swagger/common/my-cla-list.yaml b/cla-backend-go/swagger/common/my-cla-list.yaml index 6a557db0f..179838507 100644 --- a/cla-backend-go/swagger/common/my-cla-list.yaml +++ b/cla-backend-go/swagger/common/my-cla-list.yaml @@ -8,24 +8,33 @@ description: The signed ICLAs and ECLAs matching the provided user identity properties: lfUsername: type: string - description: The LF username (LFID) the list was resolved for, omitted when none was provided + description: Effective LF username (LFID) the list was resolved for - the authenticated principal when the query parameter is omitted, absent only when neither resolved userIds: type: array x-omitempty: false - description: The EasyCLA user record IDs (UUIDs) matched from the provided identity + description: EasyCLA user record IDs (UUIDs) matched from the provided identity items: type: string skippedIdentities: type: array x-omitempty: false - description: Identity parameters that were not searched because they could not be verified as belonging to the authenticated user, formatted as ":" + description: Identity parameters not searched because they could not be verified as belonging to the authenticated user, formatted ":" items: type: string + sssMode: + type: string + x-omitempty: false + enum: [required, optional, disabled] + description: > + Sanctions screening mode in effect. required - a live screen is mandatory, so any row with + flaggedCheck unavailable is unverified; optional - a live screen is best effort; disabled - + no screen was attempted and flagged is the persisted company flag. A screening failure + never fails this endpoint resultCount: type: integer format: int64 x-omitempty: false - description: The number of CLA records returned + description: Number of CLA records returned clas: type: array x-omitempty: false diff --git a/cla-backend-go/swagger/common/my-cla-manager-list.yaml b/cla-backend-go/swagger/common/my-cla-manager-list.yaml new file mode 100644 index 000000000..2f3672a40 --- /dev/null +++ b/cla-backend-go/swagger/common/my-cla-manager-list.yaml @@ -0,0 +1,41 @@ +# Copyright The Linux Foundation and each contributor to CommunityBridge. +# SPDX-License-Identifier: MIT + +type: object +x-nullable: false +title: My CLA Manager List +description: The CLA managers of the CCLA covering the given ECLA signature +properties: + signatureID: + type: string + description: ECLA signature ID (UUID) + claGroupID: + type: string + description: CLA Group ID (UUID) + claGroupName: + type: string + description: CLA Group name, omitted when unresolved + projectName: + type: string + description: Salesforce project display name, omitted when unresolved + companyID: + type: string + description: Employer company ID (UUID) + companyName: + type: string + description: Employer company name, omitted when unresolved + claManager: + type: boolean + x-omitempty: false + description: True when the resolved user is one of the CLA managers + managers: + type: array + x-omitempty: false + description: CLA managers from the CCLA signature ACL - empty when none is currently reachable + items: + $ref: '#/definitions/my-cla-manager' + resultCount: + type: integer + format: int64 + x-omitempty: false + description: Number of CLA managers returned diff --git a/cla-backend-go/swagger/common/my-cla-manager-request-result.yaml b/cla-backend-go/swagger/common/my-cla-manager-request-result.yaml new file mode 100644 index 000000000..a8b39796b --- /dev/null +++ b/cla-backend-go/swagger/common/my-cla-manager-request-result.yaml @@ -0,0 +1,28 @@ +# Copyright The Linux Foundation and each contributor to CommunityBridge. +# SPDX-License-Identifier: MIT + +type: object +x-nullable: false +title: My CLA Manager Request Result +description: Receipt of a contact-CLA-manager request - recorded as an EasyCLA audit event +properties: + requestID: + type: string + description: Request ID (UUID) carried by the audit event + signatureID: + type: string + description: ECLA signature ID (UUID) the request refers to + requestType: + type: string + enum: [removal, approval] + description: The request type + status: + type: string + enum: [sent, recorded] + description: sent - the email was dispatched to the selected CLA managers that have a resolvable email address (at least one had); recorded - the audit event was written but no email was sent, because the CCLA lists no CLA manager or none of the selected ones has a resolvable email + recipients: + type: array + x-omitempty: false + description: LF usernames of the selected CLA managers + items: + type: string diff --git a/cla-backend-go/swagger/common/my-cla-manager-request.yaml b/cla-backend-go/swagger/common/my-cla-manager-request.yaml new file mode 100644 index 000000000..fed062c72 --- /dev/null +++ b/cla-backend-go/swagger/common/my-cla-manager-request.yaml @@ -0,0 +1,23 @@ +# Copyright The Linux Foundation and each contributor to CommunityBridge. +# SPDX-License-Identifier: MIT + +type: object +x-nullable: false +title: My CLA Manager Request +description: A contributor removal or approval request to the CLA managers of the CCLA covering their ECLA, delivered by email - no signature state changes +required: + - requestType +properties: + requestType: + type: string + enum: [removal, approval] + description: removal - ask to be removed from the CCLA coverage; approval - ask to be (re-)added to the approval list + recipients: + type: array + description: LF usernames of the CLA managers to notify - a non-empty subset of the resolved managers; empty only when zero managers resolve, and the request is then recorded without email + items: + type: string + message: + type: string + maxLength: 4096 + description: Optional contributor message included in the notification email diff --git a/cla-backend-go/swagger/common/my-cla-manager.yaml b/cla-backend-go/swagger/common/my-cla-manager.yaml new file mode 100644 index 000000000..8314a4ae6 --- /dev/null +++ b/cla-backend-go/swagger/common/my-cla-manager.yaml @@ -0,0 +1,17 @@ +# Copyright The Linux Foundation and each contributor to CommunityBridge. +# SPDX-License-Identifier: MIT + +type: object +x-nullable: false +title: My CLA Manager +description: A CLA manager from the CCLA signature ACL of an ECLA's company and CLA Group +properties: + lfUsername: + type: string + description: CLA manager LF username - the recipient key for a contact request + name: + type: string + description: Display name, omitted when unknown + email: + type: string + description: Email address, omitted when the user record carries none diff --git a/cla-backend-go/swagger/common/my-cla.yaml b/cla-backend-go/swagger/common/my-cla.yaml index aee85d6be..d02be339c 100644 --- a/cla-backend-go/swagger/common/my-cla.yaml +++ b/cla-backend-go/swagger/common/my-cla.yaml @@ -4,67 +4,112 @@ type: object x-nullable: false title: My CLA -description: A single signed ICLA or ECLA (employee acknowledgement) belonging to the resolved user identity +description: A signed ICLA or ECLA (employee acknowledgement) of the resolved user identity properties: signatureID: type: string - description: The signature ID (UUID) + description: Signature ID (UUID) claType: type: string enum: [icla, ecla] - description: icla - individual CLA with a downloadable signed PDF, ecla - employee acknowledgement covered by the company CCLA, no PDF + description: icla - individual CLA, PDF downloadable; ecla - employee acknowledgement under the company CCLA, no PDF claGroupID: type: string - description: The CLA Group ID (UUID) the agreement was signed against + description: CLA Group ID (UUID) signed against claGroupName: type: string - description: The CLA Group name, omitted when the CLA Group record could not be resolved + description: CLA Group name, omitted when unresolved projectName: type: string - description: The Salesforce project display name the CLA Group belongs to (a foundation-level CLA Group resolves to its foundation), omitted when it could not be resolved + description: Salesforce project display name (the foundation, for a foundation-level CLA Group), omitted when unresolved projectLogo: type: string - description: The project (or foundation) logo URL, omitted when the project has no logo or could not be resolved + description: Project (or foundation) logo URL, omitted when absent or unresolved companyID: type: string - description: The company ID (UUID) of the employer, ECLA only + description: Employer company ID (UUID), ECLA only companyName: type: string - description: The company name of the employer, ECLA only + description: Employer company name, ECLA only signingEntityName: type: string - description: The company signing entity name of the employer, ECLA only + description: Employer signing entity name, ECLA only userID: type: string - description: The EasyCLA user record ID (UUID) owning this signature + description: EasyCLA user record ID (UUID) owning this signature signedOn: type: string - description: The date/time the agreement was signed or acknowledged + description: Date/time signed or acknowledged signed: type: boolean x-omitempty: false - description: The signature_signed flag - always true, unsigned records are not returned + description: signature_signed flag - always true, unsigned records are not returned approved: type: boolean x-omitempty: false - description: The signature_approved flag - false when the signature was invalidated + description: signature_approved flag - false when the signature was invalidated valid: type: boolean x-omitempty: false description: > - Computed validity. ICLA: signed and approved. ECLA: signed, approved, the employer is - not sanctioned, the employer still holds an approved+signed CCLA for the CLA Group, and - the user still matches the current CCLA approval lists (email, email domain, - GitHub/GitLab username, GitHub organization) + Computed validity. ICLA: signed and approved. ECLA: also requires an unsanctioned employer + holding a signed+approved CCLA for the CLA Group and the user still matching the current + CCLA approval lists (email, email domain, GitHub/GitLab username, GitHub organization) + status: + type: string + x-omitempty: false + enum: [valid, needs_attention, revoked, invalidated, unknown] + description: > + Contributor-facing standing, independent of approved and valid. revoked - employer flagged + by sanctions screening (system-set, no user action, see flagged/flaggedAt); invalidated - + the stored approval flag is false, which attributes nothing (an Approved List edit, an + invalidated ICLA and CLA Group deletion all produce it); needs_attention - a completed + approval-list check proved the user is no longer covered; unknown - ECLA coverage was not + evaluable; valid otherwise. ICLA is only valid or invalidated. New values may be added in + future revisions + statusReason: + type: string + enum: [not_on_approval_list, unknown] + description: > + Why the standing is not valid; omitted for other statuses and on every ICLA. Keyed on + status, not on valid - the two can disagree. not_on_approval_list - a completed Approved + List miss (what a Request approval action gates on); unknown - any other unevaluable ECLA + coverage outcome documentMajorVersion: type: integer x-omitempty: false - description: The major version of the CLA document that was signed + description: Major version of the signed CLA document documentMinorVersion: type: integer x-omitempty: false - description: The minor version of the CLA document that was signed + description: Minor version of the signed CLA document pdfAvailable: type: boolean x-omitempty: false - description: True when the record is a signed ICLA eligible for PDF retrieval - object availability is verified by the PDF endpoint on request + description: True for a signed ICLA eligible for PDF retrieval - object availability is verified by the PDF endpoint on request + signedVia: + type: string + enum: [github, gitlab, gerrit] + description: Platform signed via - gerrit also covers LF SSO signings identified by email + signedAs: + type: string + description: Account signed as - GitHub/GitLab username (numeric ID when no username was recorded) or email for gerrit/LF SSO, omitted when the signature carries no identity + claManager: + type: boolean + x-omitempty: false + description: True when the resolved user is a CLA manager of the employer's CCLA for this CLA Group - always present, always false on ICLA rows + flagged: + type: boolean + x-omitempty: false + description: True when the employer is currently flagged by sanctions screening - always present, always false on ICLA rows + flaggedAt: + type: string + description: When the flag was observed - currently the response time, as no sanction timestamp is stored yet; present only when flagged is true + flaggedCheck: + type: string + enum: [live, stored, unavailable] + description: > + How flagged was obtained, ECLA only. live - a screening call answered for this response; + stored - the persisted company flag, because screening is disabled or an administrator set + the block; unavailable - the call did not complete, so flagged is the persisted value and + may be stale - treat it as unknown, more so when sssMode is required diff --git a/cla-backend-go/v2/my_clas/cla_managers_test.go b/cla-backend-go/v2/my_clas/cla_managers_test.go new file mode 100644 index 000000000..41f51f4f7 --- /dev/null +++ b/cla-backend-go/v2/my_clas/cla_managers_test.go @@ -0,0 +1,470 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package my_clas + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/linuxfoundation/easycla/cla-backend-go/events" + v1Models "github.com/linuxfoundation/easycla/cla-backend-go/gen/v1/models" + "github.com/linuxfoundation/easycla/cla-backend-go/gen/v2/models" + "github.com/linuxfoundation/easycla/cla-backend-go/signatures" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const someoneEmail = "someone@example.org" + +type fakeEvents struct { + logged []*events.LogEventArgs +} + +func (f *fakeEvents) LogEventWithContext(_ context.Context, args *events.LogEventArgs) { + f.logged = append(f.logged, args) +} + +type sentEmail struct { + subject string + body string + recipients []string +} + +func managersFixture() (*fakeRepo, *fakeSignatures, *fakeCompanies) { + userA := &v1Models.User{UserID: "user-a", LfUsername: "someone", Username: "Some One"} + sig := ecla("sig-ecla", "company-1", "2024-01-01T00:00:00Z", true) + sig.UserEmail = someoneEmail + repo := &fakeRepo{ + byUserID: map[string][]*signatures.ItemSignature{ + "user-a": { + sig, + icla("sig-icla", "user-a", "cla-group-1", "2024-02-01T00:00:00Z", true), + }, + }, + byLFUsername: map[string][]*v1Models.User{"someone": {userA}}, + } + signaturesService := &fakeSignatures{ + cclas: map[string]*v1Models.Signature{ + "cla-group-1|company-1": {SignatureID: "ccla-1", SignatureACL: []v1Models.User{ + {LfUsername: "manager-one", Username: "Manager One", LfEmail: "manager-one@corp.example.org"}, + {LfUsername: "manager-two", Username: "Manager Two", Emails: []string{"manager-two@corp.example.org"}}, + {Username: "acl-no-lfid"}, + {}, + }}, + }, + approvedUserIDs: map[string]bool{"user-a": true}, + } + companies := &fakeCompanies{byID: map[string]*v1Models.Company{ + "company-1": {CompanyID: "company-1", CompanyName: "Good Corp"}, + }} + return repo, signaturesService, companies +} + +func TestGetMyClasSignedIdentity(t *testing.T) { + userA := &v1Models.User{UserID: "user-a", LfUsername: "someone"} + github := icla("sig-github", "user-a", "cla-group-1", "2024-01-01T00:00:00Z", true) + github.UserGithubUsername = "octocat" + github.UserGithubID = "999" + githubIDOnly := icla("sig-github-id", "user-a", "cla-group-1", "2024-02-01T00:00:00Z", true) + githubIDOnly.UserGithubID = "999" + gitlab := icla("sig-gitlab", "user-a", "cla-group-1", "2024-03-01T00:00:00Z", true) + gitlab.UserGitlabUsername = "octolab" + gerrit := icla("sig-gerrit", "user-a", "cla-group-1", "2024-04-01T00:00:00Z", true) + gerrit.UserEmail = someoneEmail + sso := icla("sig-sso", "user-a", "cla-group-1", "2024-05-01T00:00:00Z", true) + sso.UserLFUsername = "someone" + anonymous := icla("sig-anonymous", "user-a", "cla-group-1", "2024-06-01T00:00:00Z", true) + + repo := &fakeRepo{ + byUserID: map[string][]*signatures.ItemSignature{"user-a": {github, githubIDOnly, gitlab, gerrit, sso, anonymous}}, + byLFUsername: map[string][]*v1Models.User{"someone": {userA}}, + } + svc := newTestService(repo, &fakePlatform{}, &fakeSignatures{}, &fakeCompanies{}, &fakeClaGroups{}) + + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + require.NoError(t, err) + byID := map[string]models.MyCla{} + for _, row := range result.Clas { + byID[row.SignatureID] = row + } + + assert.Equal(t, "github", byID["sig-github"].SignedVia) + assert.Equal(t, "octocat", byID["sig-github"].SignedAs, "the username wins over the numeric ID") + assert.Equal(t, "github", byID["sig-github-id"].SignedVia) + assert.Equal(t, "999", byID["sig-github-id"].SignedAs, "the numeric ID is the fallback account") + assert.Equal(t, "gitlab", byID["sig-gitlab"].SignedVia) + assert.Equal(t, "octolab", byID["sig-gitlab"].SignedAs) + assert.Equal(t, "gerrit", byID["sig-gerrit"].SignedVia) + assert.Equal(t, someoneEmail, byID["sig-gerrit"].SignedAs) + assert.Equal(t, "gerrit", byID["sig-sso"].SignedVia, "LF SSO signings surface as gerrit") + assert.Equal(t, "someone", byID["sig-sso"].SignedAs) + require.Contains(t, byID, "sig-anonymous", "the identity-less record is still returned") + assert.Empty(t, byID["sig-anonymous"].SignedVia, "no identity on the record leaves both fields omitted") + assert.Empty(t, byID["sig-anonymous"].SignedAs) +} + +func TestGetMyClasFlaggedAndClaManager(t *testing.T) { + userA := &v1Models.User{UserID: "user-a", LfUsername: "someone"} + companies := &fakeCompanies{byID: map[string]*v1Models.Company{ + "company-1": {CompanyID: "company-1", CompanyName: "Good Corp"}, + "company-2": {CompanyID: "company-2", CompanyName: "Sanctioned Corp", IsSanctioned: true}, + }} + signaturesService := &fakeSignatures{ + cclas: map[string]*v1Models.Signature{ + "cla-group-1|company-1": {SignatureID: "ccla-1", SignatureACL: []v1Models.User{{LfUsername: "SomeOne"}}}, + }, + approvedUserIDs: map[string]bool{"user-a": true}, + } + repo := &fakeRepo{ + byUserID: map[string][]*signatures.ItemSignature{ + "user-a": { + ecla("sig-good", "company-1", "2024-01-01T00:00:00Z", true), + ecla("sig-sanctioned", "company-2", "2024-02-01T00:00:00Z", true), + icla("sig-icla", "user-a", "cla-group-1", "2024-03-01T00:00:00Z", true), + }, + }, + byLFUsername: map[string][]*v1Models.User{"someone": {userA}}, + } + svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{}) + + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + require.NoError(t, err) + byID := map[string]models.MyCla{} + for _, row := range result.Clas { + byID[row.SignatureID] = row + } + + good := byID["sig-good"] + assert.False(t, good.Flagged) + assert.Empty(t, good.FlaggedAt) + assert.True(t, good.ClaManager, "the ACL match is case-insensitive") + assert.True(t, good.Valid) + + sanctioned := byID["sig-sanctioned"] + assert.True(t, sanctioned.Flagged, "a sanctioned employer flags the ECLA") + assert.NotEmpty(t, sanctioned.FlaggedAt) + assert.Equal(t, models.MyClaStatusRevoked, sanctioned.Status, "the Revoked state is system-set from sanctions") + assert.False(t, sanctioned.Valid) + assert.False(t, sanctioned.ClaManager, "a sanctioned employer carries no CLA manager action") + + regular := byID["sig-icla"] + assert.False(t, regular.Flagged, "ICLAs are never flagged") + assert.False(t, regular.ClaManager) +} + +func TestGetMyClasNotAClaManager(t *testing.T) { + repo, signaturesService, companies := managersFixture() + svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{}) + + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + require.NoError(t, err) + for _, row := range result.Clas { + assert.False(t, row.ClaManager) + } +} + +func TestGetMyClaManagers(t *testing.T) { + repo, signaturesService, companies := managersFixture() + svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{names: map[string]string{"cla-group-1": "My CLA Group"}}) + caller := &Caller{Username: "someone"} + + result, err := svc.GetMyClaManagers(context.Background(), caller, &Identity{}, "sig-ecla") + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "sig-ecla", result.SignatureID) + assert.Equal(t, "cla-group-1", result.ClaGroupID) + assert.Equal(t, "My CLA Group", result.ClaGroupName) + assert.Equal(t, "company-1", result.CompanyID) + assert.Equal(t, "Good Corp", result.CompanyName) + assert.False(t, result.ClaManager) + assert.Equal(t, int64(3), result.ResultCount) + require.Len(t, result.Managers, 3) + assert.Equal(t, models.MyClaManager{LfUsername: "manager-one", Name: "Manager One", Email: "manager-one@corp.example.org"}, result.Managers[0]) + assert.Equal(t, models.MyClaManager{LfUsername: "manager-two", Name: "Manager Two", Email: "manager-two@corp.example.org"}, result.Managers[1], "the additional-emails list is the email fallback") + assert.Equal(t, models.MyClaManager{LfUsername: "acl-no-lfid", Name: "acl-no-lfid"}, result.Managers[2], "the plain username is the LF username fallback") + + result, err = svc.GetMyClaManagers(context.Background(), caller, &Identity{}, "sig-icla") + require.NoError(t, err) + assert.Nil(t, result, "ICLAs have no CLA managers") + + result, err = svc.GetMyClaManagers(context.Background(), caller, &Identity{}, "sig-of-somebody-else") + require.NoError(t, err) + assert.Nil(t, result, "signatures not owned by the resolved identity are not found") +} + +func TestGetMyClaManagersCallerIsManager(t *testing.T) { + repo, signaturesService, companies := managersFixture() + ccla := signaturesService.cclas["cla-group-1|company-1"] + ccla.SignatureACL = append(ccla.SignatureACL, v1Models.User{LfUsername: "SomeOne"}) + svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{}) + + result, err := svc.GetMyClaManagers(context.Background(), &Caller{Username: "someone"}, &Identity{}, "sig-ecla") + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.ClaManager, "the caller shows up as a CLA manager, case-insensitively") +} + +func TestGetMyClaManagersNoCcla(t *testing.T) { + repo, _, companies := managersFixture() + svc := newTestService(repo, &fakePlatform{}, &fakeSignatures{}, companies, &fakeClaGroups{}) + + result, err := svc.GetMyClaManagers(context.Background(), &Caller{Username: "someone"}, &Identity{}, "sig-ecla") + require.NoError(t, err) + require.NotNil(t, result) + assert.Empty(t, result.Managers, "no current CCLA yields an empty manager list") + assert.Equal(t, int64(0), result.ResultCount) + assert.Equal(t, "Good Corp", result.CompanyName) +} + +func TestGetMyClaManagersOwnershipEnforced(t *testing.T) { + repo, signaturesService, companies := managersFixture() + victim := &v1Models.User{UserID: "user-v", LfUsername: "victim"} + repo.byLFUsername["victim"] = []*v1Models.User{victim} + repo.byUserID["user-v"] = []*signatures.ItemSignature{ecla("sig-victim", "company-1", "2024-01-01T00:00:00Z", true)} + svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{}) + + result, err := svc.GetMyClaManagers(context.Background(), &Caller{Username: "someone"}, &Identity{LfUsername: "victim"}, "sig-victim") + require.NoError(t, err) + assert.Nil(t, result, "a non-admin cannot resolve somebody else's ECLA") + + result, err = svc.GetMyClaManagers(context.Background(), &Caller{Username: "staff-admin", Admin: true}, &Identity{LfUsername: "victim"}, "sig-victim") + require.NoError(t, err) + require.NotNil(t, result, "an admin can") +} + +func requestInput(requestType string, recipients []string, message string) *models.MyClaManagerRequest { + return &models.MyClaManagerRequest{RequestType: &requestType, Recipients: recipients, Message: message} +} + +func newRequestTestService(repo *fakeRepo, signaturesService SignaturesService, companies CompanyRepository) (*service, *fakeEvents, *[]sentEmail) { + svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{names: map[string]string{"cla-group-1": "My CLA Group"}}) + eventsService := &fakeEvents{} + svc.eventsService = eventsService + sent := &[]sentEmail{} + svc.sendEmail = func(subject, body string, recipients []string) error { + *sent = append(*sent, sentEmail{subject: subject, body: body, recipients: recipients}) + return nil + } + return svc, eventsService, sent +} + +func TestCreateMyClaManagerRequest(t *testing.T) { + repo, signaturesService, companies := managersFixture() + svc, eventsService, sent := newRequestTestService(repo, signaturesService, companies) + caller := &Caller{Username: "someone"} + + result, err := svc.CreateMyClaManagerRequest(context.Background(), caller, &Identity{}, "sig-ecla", + requestInput("removal", []string{"Manager-One", "manager-two"}, "please remove me")) + require.NoError(t, err) + require.NotNil(t, result) + assert.NotEmpty(t, result.RequestID) + assert.Equal(t, "sig-ecla", result.SignatureID) + assert.Equal(t, "removal", result.RequestType) + assert.Equal(t, "sent", result.Status) + assert.Equal(t, []string{"manager-one", "manager-two"}, result.Recipients, "recipients echo the canonical manager usernames") + + require.Len(t, *sent, 1) + email := (*sent)[0] + assert.Equal(t, []string{"manager-one@corp.example.org", "manager-two@corp.example.org"}, email.recipients) + assert.Contains(t, email.subject, "removal from the corporate CLA coverage") + assert.Contains(t, email.body, "Some One") + assert.Contains(t, email.body, someoneEmail) + assert.Contains(t, email.body, "Good Corp") + assert.Contains(t, email.body, "please remove me") + + require.Len(t, eventsService.logged, 1) + logged := eventsService.logged[0] + assert.Equal(t, events.ContactCLAManagerRequestCreated, logged.EventType) + assert.Equal(t, "user-a", logged.UserID) + assert.Equal(t, "company-1", logged.CompanyID) + eventData, ok := logged.EventData.(*events.ContactCLAManagerRequestCreatedEventData) + require.True(t, ok) + assert.Equal(t, result.RequestID, eventData.RequestID) + assert.Equal(t, "removal", eventData.RequestType) + assert.Equal(t, "please remove me", eventData.Message, "the audit event is the receipt, so it carries the message") + assert.Equal(t, []string{"manager-one", "manager-two"}, eventData.Recipients) +} + +func TestCreateMyClaManagerRequestApprovalWording(t *testing.T) { + repo, signaturesService, companies := managersFixture() + svc, _, sent := newRequestTestService(repo, signaturesService, companies) + + result, err := svc.CreateMyClaManagerRequest(context.Background(), &Caller{Username: "someone"}, &Identity{}, "sig-ecla", + requestInput("approval", []string{"manager-one"}, "")) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "approval", result.RequestType) + require.Len(t, *sent, 1) + assert.Contains(t, (*sent)[0].body, "approval under the corporate CLA") +} + +func TestCreateMyClaManagerRequestRecipientValidation(t *testing.T) { + repo, signaturesService, companies := managersFixture() + svc, eventsService, sent := newRequestTestService(repo, signaturesService, companies) + caller := &Caller{Username: "someone"} + + _, err := svc.CreateMyClaManagerRequest(context.Background(), caller, &Identity{}, "sig-ecla", + requestInput("removal", nil, "")) + assert.ErrorIs(t, err, ErrInvalidRecipients, "recipients are required while managers resolve") + + _, err = svc.CreateMyClaManagerRequest(context.Background(), caller, &Identity{}, "sig-ecla", + requestInput("removal", []string{"manager-one", "outsider"}, "")) + assert.ErrorIs(t, err, ErrInvalidRecipients, "a recipient outside the resolved managers is rejected") + + assert.Empty(t, *sent) + assert.Empty(t, eventsService.logged) +} + +func TestCreateMyClaManagerRequestRecipientDedupe(t *testing.T) { + repo, signaturesService, companies := managersFixture() + svc, _, sent := newRequestTestService(repo, signaturesService, companies) + caller := &Caller{Username: "someone"} + + result, err := svc.CreateMyClaManagerRequest(context.Background(), caller, &Identity{}, "sig-ecla", + requestInput("removal", []string{"Manager-One", "manager-one"}, "")) + require.NoError(t, err) + assert.Equal(t, []string{"manager-one"}, result.Recipients, "case-variant duplicates collapse to one recipient") + + require.Len(t, *sent, 1) + assert.Equal(t, []string{"manager-one@corp.example.org"}, (*sent)[0].recipients) +} + +func TestCreateMyClaManagerRequestZeroManagers(t *testing.T) { + repo, _, companies := managersFixture() + svc, eventsService, sent := newRequestTestService(repo, &fakeSignatures{}, companies) + caller := &Caller{Username: "someone"} + + _, err := svc.CreateMyClaManagerRequest(context.Background(), caller, &Identity{}, "sig-ecla", + requestInput("removal", []string{"manager-one"}, "")) + assert.ErrorIs(t, err, ErrInvalidRecipients, "recipients must be empty when no manager resolves") + + result, err := svc.CreateMyClaManagerRequest(context.Background(), caller, &Identity{}, "sig-ecla", + requestInput("removal", nil, "nobody to write to")) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "recorded", result.Status, "the request is recorded without sending email") + assert.Empty(t, result.Recipients) + assert.Empty(t, *sent) + require.Len(t, eventsService.logged, 1, "the audit event is still logged") +} + +func TestCreateMyClaManagerRequestNotFound(t *testing.T) { + repo, signaturesService, companies := managersFixture() + svc, _, _ := newRequestTestService(repo, signaturesService, companies) + caller := &Caller{Username: "someone"} + + result, err := svc.CreateMyClaManagerRequest(context.Background(), caller, &Identity{}, "sig-icla", + requestInput("removal", []string{"manager-one"}, "")) + require.NoError(t, err) + assert.Nil(t, result, "an ICLA has no CLA managers to contact") + + result, err = svc.CreateMyClaManagerRequest(context.Background(), caller, &Identity{}, "sig-unknown", + requestInput("removal", []string{"manager-one"}, "")) + require.NoError(t, err) + assert.Nil(t, result) +} + +func TestCreateMyClaManagerRequestSendFailure(t *testing.T) { + repo, signaturesService, companies := managersFixture() + svc, eventsService, _ := newRequestTestService(repo, signaturesService, companies) + svc.sendEmail = func(_, _ string, _ []string) error { return errors.New("SNS unavailable") } + + _, err := svc.CreateMyClaManagerRequest(context.Background(), &Caller{Username: "someone"}, &Identity{}, "sig-ecla", + requestInput("removal", []string{"manager-one"}, "")) + assert.Error(t, err, "a send failure is surfaced to the caller") + assert.Empty(t, eventsService.logged, "no audit event is logged when the email fails") +} + +func TestCreateMyClaManagerRequestManagerWithoutEmail(t *testing.T) { + repo, signaturesService, companies := managersFixture() + svc, _, sent := newRequestTestService(repo, signaturesService, companies) + + result, err := svc.CreateMyClaManagerRequest(context.Background(), &Caller{Username: "someone"}, &Identity{}, "sig-ecla", + requestInput("removal", []string{"acl-no-lfid"}, "")) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "recorded", result.Status, "a selected manager with no email leaves nothing to send") + assert.Equal(t, []string{"acl-no-lfid"}, result.Recipients) + assert.Empty(t, *sent) +} + +func TestCreateMyClaManagerRequestMixedRecipientEmails(t *testing.T) { + repo, signaturesService, companies := managersFixture() + svc, eventsService, sent := newRequestTestService(repo, signaturesService, companies) + + result, err := svc.CreateMyClaManagerRequest(context.Background(), &Caller{Username: "someone"}, &Identity{}, "sig-ecla", + requestInput("removal", []string{"manager-one", "acl-no-lfid"}, "")) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "sent", result.Status, "one reachable manager is enough for sent") + assert.Equal(t, []string{"manager-one", "acl-no-lfid"}, result.Recipients, "recipients are the whole selection, reachable or not") + + require.Len(t, *sent, 1) + assert.Equal(t, []string{"manager-one@corp.example.org"}, (*sent)[0].recipients, "only the reachable manager is emailed") + + require.Len(t, eventsService.logged, 1) + eventData, ok := eventsService.logged[0].EventData.(*events.ContactCLAManagerRequestCreatedEventData) + require.True(t, ok) + assert.Equal(t, []string{"manager-one", "acl-no-lfid"}, eventData.Recipients, "the receipt records the whole selection") +} + +func decodeJSON(t *testing.T, payload interface{}) map[string]interface{} { + t.Helper() + raw, err := json.Marshal(payload) + require.NoError(t, err) + decoded := map[string]interface{}{} + require.NoError(t, json.Unmarshal(raw, &decoded)) + return decoded +} + +// TestMyClasJSONContract pins the false and empty values the console keys on - they must appear +// in the payload rather than being dropped by omitempty +func TestMyClasJSONContract(t *testing.T) { + repo, _, companies := managersFixture() + svc, _, _ := newRequestTestService(repo, &fakeSignatures{}, companies) + caller := &Caller{Username: "someone"} + + list, err := svc.GetMyClas(context.Background(), caller, &Identity{}) + require.NoError(t, err) + byID := map[string]models.MyCla{} + for _, row := range list.Clas { + byID[row.SignatureID] = row + } + for _, signatureID := range []string{"sig-ecla", "sig-icla"} { + row := decodeJSON(t, byID[signatureID]) + for _, field := range []string{"flagged", "claManager"} { + require.Contains(t, row, field, "%s must always carry %s", signatureID, field) + assert.Equal(t, false, row[field]) + } + } + + managers, err := svc.GetMyClaManagers(context.Background(), caller, &Identity{}, "sig-ecla") + require.NoError(t, err) + decoded := decodeJSON(t, managers) + require.Contains(t, decoded, "managers") + assert.NotNil(t, decoded["managers"], "an empty manager list serializes as [], never null") + assert.Empty(t, decoded["managers"]) + + receipt, err := svc.CreateMyClaManagerRequest(context.Background(), caller, &Identity{}, "sig-ecla", + requestInput("removal", nil, "")) + require.NoError(t, err) + decoded = decodeJSON(t, receipt) + require.Contains(t, decoded, "recipients") + assert.NotNil(t, decoded["recipients"], "a recorded receipt serializes recipients as []") + assert.Empty(t, decoded["recipients"]) +} + +func TestSignedIdentityFallbacks(t *testing.T) { + gitlabIDOnly := &signatures.ItemSignature{UserGitlabID: "42"} + via, as := signedIdentity(gitlabIDOnly) + assert.Equal(t, "gitlab", via) + assert.Equal(t, "42", as) + + assert.False(t, isClaManager(nil, "someone")) + assert.False(t, isClaManager(&v1Models.Signature{SignatureACL: []v1Models.User{{LfUsername: "someone"}}}, "")) + assert.True(t, isClaManager(&v1Models.Signature{SignatureACL: []v1Models.User{{Username: "SomeOne"}}}, "someone"), + "the plain username matches too") +} diff --git a/cla-backend-go/v2/my_clas/handlers.go b/cla-backend-go/v2/my_clas/handlers.go index 6340c41df..f34f3afda 100644 --- a/cla-backend-go/v2/my_clas/handlers.go +++ b/cla-backend-go/v2/my_clas/handlers.go @@ -21,6 +21,7 @@ import ( const missingUsernameMsg = "the authenticated principal carries no username - unable to determine whose CLAs to look up" const missingIdentityMsg = "no identity provided - provide at least one of lfUsername, email, secondaryEmail, githubId, githubUsername, gitlabId, gitlabUsername, gerritUsername" const unverifiedCallerMsg = "unable to verify the caller's bearer token" +const notOwnedEclaMsg = "no signed ECLA with the given signature ID belongs to the provided identity" // CallerVerifier re-verifies the request bearer token in-handler - see auth.TrustedCallerVerifier type CallerVerifier interface { @@ -29,6 +30,8 @@ type CallerVerifier interface { } // Configure sets up the My CLAs API handlers +// +//nolint:gocyclo func Configure(api *operations.EasyclaAPI, service Service, callerVerifier CallerVerifier) { api.MyClasGetMyClasHandler = myClasOps.GetMyClasHandlerFunc( func(params myClasOps.GetMyClasParams, authUser *auth.User) middleware.Responder { @@ -120,6 +123,106 @@ func Configure(api *operations.EasyclaAPI, service Service, callerVerifier Calle return myClasOps.NewGetMyClaPdfOK().WithXRequestID(reqID).WithPayload(result) }) + api.MyClasGetMyClaManagersHandler = myClasOps.GetMyClaManagersHandlerFunc( + func(params myClasOps.GetMyClaManagersParams, authUser *auth.User) middleware.Responder { + reqID := utils.GetRequestID(params.XREQUESTID) + ctx := context.WithValue(params.HTTPRequest.Context(), utils.XREQUESTID, reqID) // nolint + utils.SetAuthUserProperties(authUser, params.XUSERNAME, params.XEMAIL) + f := logrus.Fields{ + "functionName": "v2.my_clas.handlers.GetMyClaManagers", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "authUserName": utils.StringValue(params.XUSERNAME), + "authUserEmail": utils.StringValue(params.XEMAIL), + "signatureID": params.SignatureID, + } + + trustedCaller, err := verifyCaller(callerVerifier, params.HTTPRequest, f) + if err != nil { + log.WithFields(f).WithError(err).Warn(unverifiedCallerMsg) + return myClasOps.NewGetMyClaManagersUnauthorized().WithXRequestID(reqID).WithPayload(utils.ErrorResponseUnauthorized(reqID, unverifiedCallerMsg)) + } + + currentUsername, admin := principal(authUser) + trusted := trustedCaller != nil && trustedCaller.Trusted + if !admin && !trusted && currentUsername == "" { + log.WithFields(f).Warn(missingUsernameMsg) + return myClasOps.NewGetMyClaManagersUnauthorized().WithXRequestID(reqID).WithPayload(utils.ErrorResponseUnauthorized(reqID, missingUsernameMsg)) + } + + requested := newIdentity(params.LfUsername, params.Email, params.SecondaryEmail, params.GithubID, params.GithubUsername, params.GitlabID, params.GitlabUsername, params.GerritUsername) + if (admin || trusted) && currentUsername == "" && requested.IsEmpty() { + log.WithFields(f).Warn(missingIdentityMsg) + return myClasOps.NewGetMyClaManagersBadRequest().WithXRequestID(reqID).WithPayload(utils.ErrorResponseBadRequest(reqID, missingIdentityMsg)) + } + logCallerIdentity(f, trustedCaller, requested) + + result, err := service.GetMyClaManagers(ctx, &Caller{Username: currentUsername, Admin: admin, Trusted: trusted}, requested, params.SignatureID) + if err != nil { + msg := "unable to lookup the CLA managers for the given signature" + log.WithFields(f).WithError(err).Warn(msg) + return myClasOps.NewGetMyClaManagersInternalServerError().WithXRequestID(reqID).WithPayload(utils.ErrorResponseInternalServerErrorWithError(reqID, msg, err)) + } + if result == nil { + msg := notOwnedEclaMsg + log.WithFields(f).Warn(msg) + return myClasOps.NewGetMyClaManagersNotFound().WithXRequestID(reqID).WithPayload(utils.ErrorResponseNotFound(reqID, msg)) + } + + return myClasOps.NewGetMyClaManagersOK().WithXRequestID(reqID).WithPayload(result) + }) + + api.MyClasCreateMyClaManagerRequestHandler = myClasOps.CreateMyClaManagerRequestHandlerFunc( + func(params myClasOps.CreateMyClaManagerRequestParams, authUser *auth.User) middleware.Responder { + reqID := utils.GetRequestID(params.XREQUESTID) + ctx := context.WithValue(params.HTTPRequest.Context(), utils.XREQUESTID, reqID) // nolint + utils.SetAuthUserProperties(authUser, params.XUSERNAME, params.XEMAIL) + f := logrus.Fields{ + "functionName": "v2.my_clas.handlers.CreateMyClaManagerRequest", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "authUserName": utils.StringValue(params.XUSERNAME), + "authUserEmail": utils.StringValue(params.XEMAIL), + "signatureID": params.SignatureID, + } + + trustedCaller, err := verifyCaller(callerVerifier, params.HTTPRequest, f) + if err != nil { + log.WithFields(f).WithError(err).Warn(unverifiedCallerMsg) + return myClasOps.NewCreateMyClaManagerRequestUnauthorized().WithXRequestID(reqID).WithPayload(utils.ErrorResponseUnauthorized(reqID, unverifiedCallerMsg)) + } + + currentUsername, admin := principal(authUser) + trusted := trustedCaller != nil && trustedCaller.Trusted + if !admin && !trusted && currentUsername == "" { + log.WithFields(f).Warn(missingUsernameMsg) + return myClasOps.NewCreateMyClaManagerRequestUnauthorized().WithXRequestID(reqID).WithPayload(utils.ErrorResponseUnauthorized(reqID, missingUsernameMsg)) + } + + requested := newIdentity(params.LfUsername, params.Email, params.SecondaryEmail, params.GithubID, params.GithubUsername, params.GitlabID, params.GitlabUsername, params.GerritUsername) + if (admin || trusted) && currentUsername == "" && requested.IsEmpty() { + log.WithFields(f).Warn(missingIdentityMsg) + return myClasOps.NewCreateMyClaManagerRequestBadRequest().WithXRequestID(reqID).WithPayload(utils.ErrorResponseBadRequest(reqID, missingIdentityMsg)) + } + logCallerIdentity(f, trustedCaller, requested) + + result, err := service.CreateMyClaManagerRequest(ctx, &Caller{Username: currentUsername, Admin: admin, Trusted: trusted}, requested, params.SignatureID, ¶ms.Body) + if err != nil { + if errors.Is(err, ErrInvalidRecipients) { + log.WithFields(f).WithError(err).Warn("invalid recipients") + return myClasOps.NewCreateMyClaManagerRequestBadRequest().WithXRequestID(reqID).WithPayload(utils.ErrorResponseBadRequest(reqID, err.Error())) + } + msg := "unable to create the CLA manager request" + log.WithFields(f).WithError(err).Warn(msg) + return myClasOps.NewCreateMyClaManagerRequestInternalServerError().WithXRequestID(reqID).WithPayload(utils.ErrorResponseInternalServerErrorWithError(reqID, msg, err)) + } + if result == nil { + msg := notOwnedEclaMsg + log.WithFields(f).Warn(msg) + return myClasOps.NewCreateMyClaManagerRequestNotFound().WithXRequestID(reqID).WithPayload(utils.ErrorResponseNotFound(reqID, msg)) + } + + return myClasOps.NewCreateMyClaManagerRequestOK().WithXRequestID(reqID).WithPayload(result) + }) + api.MyClasGetMyIdentitiesHandler = myClasOps.GetMyIdentitiesHandlerFunc( func(params myClasOps.GetMyIdentitiesParams, authUser *auth.User) middleware.Responder { reqID := utils.GetRequestID(params.XREQUESTID) @@ -156,8 +259,8 @@ func Configure(api *operations.EasyclaAPI, service Service, callerVerifier Calle // verifyCaller re-verifies the bearer token because /v4 otherwise trusts its invoke path // unconditionally: the gateway-injected X-ACL/X-USERNAME headers are decoded but never -// signature-checked, so anything able to invoke the lambda directly could forge them. -// Returns (nil, nil) while no allow-list is configured, when no bearer token is required. +// signature-checked, so anything able to invoke the lambda directly could forge them. Returns +// (nil, nil) while no allow-list is configured and no bearer token is required. func verifyCaller(callerVerifier CallerVerifier, r *http.Request, f logrus.Fields) (*claAuth.TrustedCaller, error) { if callerVerifier == nil || !callerVerifier.Enabled() { return nil, nil diff --git a/cla-backend-go/v2/my_clas/handlers_test.go b/cla-backend-go/v2/my_clas/handlers_test.go index 5ad9e73ba..2428b8a8a 100644 --- a/cla-backend-go/v2/my_clas/handlers_test.go +++ b/cla-backend-go/v2/my_clas/handlers_test.go @@ -21,9 +21,36 @@ import ( ) type fakeService struct { - callers []*Caller - err error - nilPdf bool + callers []*Caller + err error + nilPdf bool + nilManagers bool + invalidRecipients bool +} + +func (f *fakeService) GetMyClaManagers(_ context.Context, caller *Caller, _ *Identity, _ string) (*models.MyClaManagerList, error) { + f.callers = append(f.callers, caller) + if f.err != nil { + return nil, f.err + } + if f.nilManagers { + return nil, nil + } + return &models.MyClaManagerList{}, nil +} + +func (f *fakeService) CreateMyClaManagerRequest(_ context.Context, caller *Caller, _ *Identity, _ string, _ *models.MyClaManagerRequest) (*models.MyClaManagerRequestResult, error) { + f.callers = append(f.callers, caller) + if f.invalidRecipients { + return nil, ErrInvalidRecipients + } + if f.err != nil { + return nil, f.err + } + if f.nilManagers { + return nil, nil + } + return &models.MyClaManagerRequestResult{}, nil } func (f *fakeService) GetMyClas(_ context.Context, caller *Caller, _ *Identity) (*models.MyClaList, error) { @@ -126,9 +153,41 @@ func TestHandlersDenyUnverifiedCallers(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, statusOf(t, api.MyClasGetMyClasHandler.Handle(myClasOps.GetMyClasParams{HTTPRequest: req}, authUser))) assert.Equal(t, http.StatusUnauthorized, statusOf(t, api.MyClasGetMyClaPdfHandler.Handle(myClasOps.GetMyClaPdfParams{HTTPRequest: req, SignatureID: "sig-1"}, authUser))) assert.Equal(t, http.StatusUnauthorized, statusOf(t, api.MyClasGetMyIdentitiesHandler.Handle(myClasOps.GetMyIdentitiesParams{HTTPRequest: req}, authUser))) + assert.Equal(t, http.StatusUnauthorized, statusOf(t, api.MyClasGetMyClaManagersHandler.Handle(myClasOps.GetMyClaManagersParams{HTTPRequest: req, SignatureID: "sig-1"}, authUser))) + assert.Equal(t, http.StatusUnauthorized, statusOf(t, api.MyClasCreateMyClaManagerRequestHandler.Handle(myClasOps.CreateMyClaManagerRequestParams{HTTPRequest: req, SignatureID: "sig-1"}, authUser))) } assert.Empty(t, service.callers, "an unverified caller must never reach the service") - assert.Len(t, verifier.seen, 9, "every request must be verified") + assert.Len(t, verifier.seen, 15, "every request must be verified") +} + +func TestClaManagerHandlers(t *testing.T) { + api, service := configuredAPI(t, nil) + authUser := &auth.User{UserName: "someone"} + req := request(t, "") + requestType := "removal" + body := models.MyClaManagerRequest{RequestType: &requestType, Recipients: []string{"manager-one"}} + + assert.Equal(t, http.StatusOK, statusOf(t, api.MyClasGetMyClaManagersHandler.Handle(myClasOps.GetMyClaManagersParams{HTTPRequest: req, SignatureID: "sig-1"}, authUser))) + assert.Equal(t, http.StatusOK, statusOf(t, api.MyClasCreateMyClaManagerRequestHandler.Handle(myClasOps.CreateMyClaManagerRequestParams{HTTPRequest: req, SignatureID: "sig-1", Body: body}, authUser))) + require.Len(t, service.callers, 2) + assert.Equal(t, &Caller{Username: "someone"}, service.callers[0]) + + // an unauthenticated caller with no username is denied + assert.Equal(t, http.StatusUnauthorized, statusOf(t, api.MyClasGetMyClaManagersHandler.Handle(myClasOps.GetMyClaManagersParams{HTTPRequest: req, SignatureID: "sig-1"}, &auth.User{}))) + assert.Equal(t, http.StatusUnauthorized, statusOf(t, api.MyClasCreateMyClaManagerRequestHandler.Handle(myClasOps.CreateMyClaManagerRequestParams{HTTPRequest: req, SignatureID: "sig-1", Body: body}, &auth.User{}))) + + service.nilManagers = true + assert.Equal(t, http.StatusNotFound, statusOf(t, api.MyClasGetMyClaManagersHandler.Handle(myClasOps.GetMyClaManagersParams{HTTPRequest: req, SignatureID: "sig-1"}, authUser))) + assert.Equal(t, http.StatusNotFound, statusOf(t, api.MyClasCreateMyClaManagerRequestHandler.Handle(myClasOps.CreateMyClaManagerRequestParams{HTTPRequest: req, SignatureID: "sig-1", Body: body}, authUser))) + + service.nilManagers = false + service.invalidRecipients = true + assert.Equal(t, http.StatusBadRequest, statusOf(t, api.MyClasCreateMyClaManagerRequestHandler.Handle(myClasOps.CreateMyClaManagerRequestParams{HTTPRequest: req, SignatureID: "sig-1", Body: body}, authUser))) + + service.invalidRecipients = false + service.err = errors.New("boom") + assert.Equal(t, http.StatusInternalServerError, statusOf(t, api.MyClasGetMyClaManagersHandler.Handle(myClasOps.GetMyClaManagersParams{HTTPRequest: req, SignatureID: "sig-1"}, authUser))) + assert.Equal(t, http.StatusInternalServerError, statusOf(t, api.MyClasCreateMyClaManagerRequestHandler.Handle(myClasOps.CreateMyClaManagerRequestParams{HTTPRequest: req, SignatureID: "sig-1", Body: body}, authUser))) } func TestHandlersTrustAllowListedCallers(t *testing.T) { diff --git a/cla-backend-go/v2/my_clas/prefetch.go b/cla-backend-go/v2/my_clas/prefetch.go new file mode 100644 index 000000000..1248e0aed --- /dev/null +++ b/cla-backend-go/v2/my_clas/prefetch.go @@ -0,0 +1,308 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package my_clas + +import ( + "context" + + v1Models "github.com/linuxfoundation/easycla/cla-backend-go/gen/v1/models" + "github.com/linuxfoundation/easycla/cla-backend-go/gen/v2/models" + log "github.com/linuxfoundation/easycla/cla-backend-go/logging" + "github.com/linuxfoundation/easycla/cla-backend-go/signatures" + "github.com/linuxfoundation/easycla/cla-backend-go/utils" + "github.com/sirupsen/logrus" + "golang.org/x/sync/errgroup" +) + +// fetchConcurrency caps the external calls one listing keeps in flight per stage +const fetchConcurrency = 8 + +// claRef is one returned row before resolution: the user record it belongs to and its signature +type claRef struct { + user *v1Models.User + sig *signatures.ItemSignature +} + +// eclaRef is one distinct (CLA Group, employer) pair and the user records holding an ECLA under it +type eclaRef struct { + users []*v1Models.User + claGroupID string + companyID string +} + +// claData is everything the rows need from DynamoDB, the projects and organizations services, the +// sanctions screen and GitHub - resolved once per distinct key, concurrently +type claData struct { + claGroupNames map[string]string + projectInfos map[string]projectInfo + companies map[string]*v1Models.Company + sanctions map[string]sanctionState + cclas map[string]*v1Models.Signature + approvals map[string]eclaCoverage +} + +func cclaKey(claGroupID, companyID string) string { + return claGroupID + "|" + companyID +} + +func approvalKey(claGroupID, companyID, userID string) string { + return cclaKey(claGroupID, companyID) + "|" + userID +} + +// userSignatures loads every user record's signatures concurrently, keeping userModels order so the +// listing stays deterministic +func (s *service) userSignatures(ctx context.Context, userModels []*v1Models.User) ([][]*signatures.ItemSignature, error) { + perUser := make([][]*signatures.ItemSignature, len(userModels)) + group, groupCtx := errgroup.WithContext(ctx) + group.SetLimit(fetchConcurrency) + for i, userModel := range userModels { + group.Go(func() error { + userSigs, err := s.repo.GetUserCLASignatures(groupCtx, userModel.UserID) + if err != nil { + return err + } + perUser[i] = userSigs + return nil + }) + } + return perUser, group.Wait() +} + +// claRefs flattens the loaded signatures into the rows to return, dropping unsigned and duplicates +func claRefs(userModels []*v1Models.User, perUser [][]*signatures.ItemSignature) []claRef { + seen := make(map[string]bool) + refs := make([]claRef, 0) + for i, userModel := range userModels { + for _, sig := range perUser[i] { + if !sig.SignatureSigned || seen[sig.SignatureID] { + continue + } + seen[sig.SignatureID] = true + refs = append(refs, claRef{user: userModel, sig: sig}) + } + } + return refs +} + +// prefetch resolves every external dependency of the rows up front, running the three independent +// chains - CLA Group and project details, employer and its sanctions screen, corporate signature +// and its approval-list evaluation - concurrently. Only the CLA Group chain can fail the listing: +// an employer, CCLA or approval-list failure degrades its own rows. +func (s *service) prefetch(ctx context.Context, refs []claRef) (*claData, error) { + data := &claData{ + claGroupNames: make(map[string]string), + projectInfos: make(map[string]projectInfo), + companies: make(map[string]*v1Models.Company), + sanctions: make(map[string]sanctionState), + cclas: make(map[string]*v1Models.Signature), + approvals: make(map[string]eclaCoverage), + } + claGroupIDs, companyIDs, eclaRefs := distinctRefs(refs) + + group, groupCtx := errgroup.WithContext(ctx) + group.Go(func() error { return s.loadProjects(groupCtx, data, claGroupIDs) }) + group.Go(func() error { return s.loadEmployers(groupCtx, data, companyIDs) }) + group.Go(func() error { return s.loadCoverage(groupCtx, data, eclaRefs) }) + if err := group.Wait(); err != nil { + return nil, err + } + return data, nil +} + +// distinctRefs collects the keys to resolve: one per CLA Group, one per employer and one per (CLA +// Group, employer) pair, carrying the user records that pair must be evaluated for +func distinctRefs(refs []claRef) ([]string, []string, []eclaRef) { + var claGroupIDs, companyIDs []string + var eclaRefs []eclaRef + seenClaGroup := make(map[string]bool) + seenCompany := make(map[string]bool) + seenEcla := make(map[string]int) + seenEclaUser := make(map[string]bool) + for _, ref := range refs { + claGroupID := ref.sig.SignatureProjectID + if claGroupID != "" && !seenClaGroup[claGroupID] { + seenClaGroup[claGroupID] = true + claGroupIDs = append(claGroupIDs, claGroupID) + } + companyID := ref.sig.SignatureUserCompanyID + if companyID == "" { + continue + } + if !seenCompany[companyID] { + seenCompany[companyID] = true + companyIDs = append(companyIDs, companyID) + } + key := cclaKey(claGroupID, companyID) + index, ok := seenEcla[key] + if !ok { + index = len(eclaRefs) + seenEcla[key] = index + eclaRefs = append(eclaRefs, eclaRef{claGroupID: claGroupID, companyID: companyID}) + } + if userKey := approvalKey(claGroupID, companyID, ref.user.UserID); !seenEclaUser[userKey] { + seenEclaUser[userKey] = true + eclaRefs[index].users = append(eclaRefs[index].users, ref.user) + } + } + return claGroupIDs, companyIDs, eclaRefs +} + +// loadProjects resolves each distinct CLA Group's name and its project name and logo. These +// failures affect every row alike, so they fail the listing rather than degrade it. +func (s *service) loadProjects(ctx context.Context, data *claData, claGroupIDs []string) error { + names := make([]string, len(claGroupIDs)) + infos := make([]projectInfo, len(claGroupIDs)) + group, groupCtx := errgroup.WithContext(ctx) + group.SetLimit(fetchConcurrency) + for i, claGroupID := range claGroupIDs { + group.Go(func() error { + name, err := s.claGroupName(groupCtx, claGroupID) + if err != nil { + return err + } + names[i] = name + return nil + }) + group.Go(func() error { + info, err := s.projectInfo(groupCtx, claGroupID) + if err != nil { + return err + } + infos[i] = info + return nil + }) + } + if err := group.Wait(); err != nil { + return err + } + for i, claGroupID := range claGroupIDs { + data.claGroupNames[claGroupID] = names[i] + data.projectInfos[claGroupID] = infos[i] + } + return nil +} + +// loadEmployers looks up each distinct employer and screens it for sanctions in the same chain, +// so a slow screen never delays another employer. A failed lookup degrades that employer's rows. +func (s *service) loadEmployers(ctx context.Context, data *claData, companyIDs []string) error { + f := logrus.Fields{ + "functionName": "v2.my_clas.prefetch.loadEmployers", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + } + companyModels := make([]*v1Models.Company, len(companyIDs)) + states := make([]sanctionState, len(companyIDs)) + group, groupCtx := errgroup.WithContext(ctx) + group.SetLimit(fetchConcurrency) + for i, companyID := range companyIDs { + group.Go(func() error { + companyModel, err := s.company(groupCtx, companyID) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to lookup employer %s - degrading its rows", companyID) + states[i] = sanctionState{check: models.MyClaFlaggedCheckUnavailable} + return nil + } + companyModels[i] = companyModel + states[i] = s.companySanctions(groupCtx, companyModel) + return nil + }) + } + if err := group.Wait(); err != nil { + return err + } + for i, companyID := range companyIDs { + data.companies[companyID] = companyModels[i] + data.sanctions[companyID] = states[i] + } + return nil +} + +// loadCoverage resolves each distinct (CLA Group, employer) pair's corporate signature and then +// evaluates its approval lists for every user record holding an ECLA under it. Both stages run +// concurrently within themselves; a failure degrades the affected rows only. +func (s *service) loadCoverage(ctx context.Context, data *claData, eclaRefs []eclaRef) error { + f := logrus.Fields{ + "functionName": "v2.my_clas.prefetch.loadCoverage", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + } + cclas := make([]*v1Models.Signature, len(eclaRefs)) + group, groupCtx := errgroup.WithContext(ctx) + group.SetLimit(fetchConcurrency) + for i, ref := range eclaRefs { + group.Go(func() error { + approved, signed := true, true + ccla, err := s.signaturesService.GetCorporateSignature(groupCtx, ref.claGroupID, ref.companyID, &approved, &signed) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to lookup the corporate signature of %s for CLA Group %s - degrading its rows", ref.companyID, ref.claGroupID) + return nil + } + cclas[i] = ccla + return nil + }) + } + if err := group.Wait(); err != nil { + return err + } + + type approvalRef struct { + ccla *v1Models.Signature + user *v1Models.User + key string + } + var approvalRefs []approvalRef + for i, ref := range eclaRefs { + data.cclas[cclaKey(ref.claGroupID, ref.companyID)] = cclas[i] + if cclas[i] == nil { + continue + } + for _, userModel := range ref.users { + approvalRefs = append(approvalRefs, approvalRef{ + key: approvalKey(ref.claGroupID, ref.companyID, userModel.UserID), + user: userModel, + ccla: cclas[i], + }) + } + } + + coverages := make([]eclaCoverage, len(approvalRefs)) + approvalGroup, approvalCtx := errgroup.WithContext(ctx) + approvalGroup.SetLimit(fetchConcurrency) + for i, ref := range approvalRefs { + approvalGroup.Go(func() error { + coverages[i] = s.evaluateApproval(approvalCtx, ref.user, ref.ccla) + return nil + }) + } + if err := approvalGroup.Wait(); err != nil { + return err + } + for i, ref := range approvalRefs { + data.approvals[ref.key] = coverages[i] + } + return nil +} + +// coverage is one ECLA row's approval-list outcome. An unreadable or sanctioned employer, a missing +// or unreadable corporate signature and a failed approval-list check are all unevaluable, so a +// false covered never means "no longer approved". +func (d *claData) coverage(sig *signatures.ItemSignature, userModel *v1Models.User, flagged bool) eclaCoverage { + if flagged || d.companies[sig.SignatureUserCompanyID] == nil { + return eclaCoverage{unevaluable: true} + } + if d.cclas[cclaKey(sig.SignatureProjectID, sig.SignatureUserCompanyID)] == nil { + return eclaCoverage{unevaluable: true} + } + if resolved, ok := d.approvals[approvalKey(sig.SignatureProjectID, sig.SignatureUserCompanyID, userModel.UserID)]; ok { + return resolved + } + return eclaCoverage{unevaluable: true} +} + +// claManager reports the caller as a CLA manager only on rows whose coverage was evaluated: a +// revoked or unreadable-employer row carries no action +func (d *claData) claManager(sig *signatures.ItemSignature, lfUsername string, flagged bool) bool { + if flagged || d.companies[sig.SignatureUserCompanyID] == nil { + return false + } + return isClaManager(d.cclas[cclaKey(sig.SignatureProjectID, sig.SignatureUserCompanyID)], lfUsername) +} diff --git a/cla-backend-go/v2/my_clas/sanctions.go b/cla-backend-go/v2/my_clas/sanctions.go new file mode 100644 index 000000000..29293bcea --- /dev/null +++ b/cla-backend-go/v2/my_clas/sanctions.go @@ -0,0 +1,150 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package my_clas + +import ( + "context" + "errors" + "net/url" + "strings" + + v1Models "github.com/linuxfoundation/easycla/cla-backend-go/gen/v1/models" + "github.com/linuxfoundation/easycla/cla-backend-go/gen/v2/models" + log "github.com/linuxfoundation/easycla/cla-backend-go/logging" + "github.com/linuxfoundation/easycla/cla-backend-go/sss" + "github.com/linuxfoundation/easycla/cla-backend-go/utils" + organizationService "github.com/linuxfoundation/easycla/cla-backend-go/v2/organization-service" + orgModels "github.com/linuxfoundation/easycla/cla-backend-go/v2/organization-service/models" + "github.com/sirupsen/logrus" +) + +const sanctionOriginSSS = "sss" + +// SanctionsScreener answers the sanctions question for an employer. It never errors: a listing +// must not fail because screening is down, so an unusable screen degrades to the persisted company +// flag and reports check=unavailable. +type SanctionsScreener interface { + Mode() string + ScreenCompany(ctx context.Context, company *v1Models.Company) (flagged bool, check string) +} + +type sssStatusClient interface { + GetOrganizationStatus(ctx context.Context, req sss.OrganizationStatusRequest) (*sss.ScreeningResult, error) +} + +type sssScreener struct { + client sssStatusClient + enabled bool + required bool + getOrganization func(ctx context.Context, orgID string) (*orgModels.Organization, error) +} + +// NewSanctionsScreener returns a read-only screener: unlike the signing flow it never persists what +// it observes, so the listing stays a pure read +func NewSanctionsScreener(client *sss.Client, enabled, required bool) SanctionsScreener { + screener := &sssScreener{ + enabled: enabled, + required: required, + getOrganization: lookupOrganization, + } + if client != nil { + screener.client = client + } + return screener +} + +func lookupOrganization(ctx context.Context, orgID string) (*orgModels.Organization, error) { + client := organizationService.GetClient() + if client == nil { + return nil, errors.New("organization service client is not configured") + } + return client.GetOrganization(ctx, orgID) +} + +func (s *sssScreener) Mode() string { + if !s.enabled || s.client == nil { + return models.MyClaListSssModeDisabled + } + if s.required { + return models.MyClaListSssModeRequired + } + return models.MyClaListSssModeOptional +} + +func (s *sssScreener) ScreenCompany(ctx context.Context, company *v1Models.Company) (bool, string) { + if company == nil { + return false, "" + } + + f := logrus.Fields{ + "functionName": "v2.my_clas.sanctions.ScreenCompany", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "companyID": company.CompanyID, + "mode": s.Mode(), + } + + // An administrator-set block is authoritative and needs no live screen (as in v2/sign + // checkCompanyCompliance) + if company.IsSanctioned && company.SanctionOrigin != sanctionOriginSSS { + return true, models.MyClaFlaggedCheckStored + } + if !s.enabled || s.client == nil { + return company.IsSanctioned, models.MyClaFlaggedCheckStored + } + + unavailable := func(reason string) (bool, string) { + log.WithFields(f).Warnf("live sanctions screening unavailable, honoring the persisted flag: %s", reason) + return company.IsSanctioned, models.MyClaFlaggedCheckUnavailable + } + + externalID := strings.TrimSpace(company.CompanyExternalID) + if externalID == "" { + return unavailable("company has no external ID for domain resolution") + } + org, err := s.getOrganization(ctx, externalID) + if err != nil { + return unavailable("organization lookup failed: " + err.Error()) + } + if org == nil { + return unavailable("organization record is nil for " + externalID) + } + domain := resolveOrgDomain(org) + if domain == "" { + return unavailable("unable to resolve a domain for organization " + externalID) + } + + req := sss.OrganizationStatusRequest{Domain: domain, OrgName: company.CompanyName} + if strings.HasPrefix(externalID, "001") { + req.SFDCID = externalID + } + result, err := s.client.GetOrganizationStatus(ctx, req) + if err != nil { + return unavailable("SSS call failed: " + err.Error()) + } + if result == nil || (result.Status != sss.StatusClean && result.Status != sss.StatusFlagged) { + return unavailable("unexpected SSS status") + } + return result.Status == sss.StatusFlagged, models.MyClaFlaggedCheckLive +} + +// resolveOrgDomain prefers the Domains field and falls back to the host of Link +func resolveOrgDomain(org *orgModels.Organization) string { + for _, domain := range strings.Split(org.Domains, ",") { + if domain = strings.TrimPrefix(strings.TrimSpace(domain), "www."); domain != "" { + return domain + } + } + link := strings.TrimSpace(org.Link) + if link == "" { + return "" + } + if !strings.Contains(link, "://") { + link = "https://" + link + } + parsed, err := url.Parse(link) + if err != nil { + return "" + } + return strings.TrimPrefix(parsed.Hostname(), "www.") +} diff --git a/cla-backend-go/v2/my_clas/sanctions_test.go b/cla-backend-go/v2/my_clas/sanctions_test.go new file mode 100644 index 000000000..cbf96f439 --- /dev/null +++ b/cla-backend-go/v2/my_clas/sanctions_test.go @@ -0,0 +1,173 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package my_clas + +import ( + "context" + "fmt" + "testing" + + v1Models "github.com/linuxfoundation/easycla/cla-backend-go/gen/v1/models" + "github.com/linuxfoundation/easycla/cla-backend-go/gen/v2/models" + "github.com/linuxfoundation/easycla/cla-backend-go/sss" + orgModels "github.com/linuxfoundation/easycla/cla-backend-go/v2/organization-service/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeSSSClient struct { + result *sss.ScreeningResult + err error + request sss.OrganizationStatusRequest + calls int +} + +func (f *fakeSSSClient) GetOrganizationStatus(_ context.Context, req sss.OrganizationStatusRequest) (*sss.ScreeningResult, error) { + f.calls++ + f.request = req + return f.result, f.err +} + +func testScreener(client sssStatusClient, required bool, org *orgModels.Organization, orgErr error) *sssScreener { + return &sssScreener{ + client: client, + enabled: true, + required: required, + getOrganization: func(_ context.Context, _ string) (*orgModels.Organization, error) { + return org, orgErr + }, + } +} + +func company(sanctioned bool, origin string) *v1Models.Company { + return &v1Models.Company{ + CompanyID: "company-1", + CompanyName: "Acme Corp", + CompanyExternalID: "0014100000Te0lqAAB", + IsSanctioned: sanctioned, + SanctionOrigin: origin, + } +} + +func TestScreenerMode(t *testing.T) { + assert.Equal(t, models.MyClaListSssModeDisabled, NewSanctionsScreener(nil, true, false).Mode(), "an unconfigured client cannot screen") + assert.Equal(t, models.MyClaListSssModeDisabled, (&sssScreener{client: &fakeSSSClient{}}).Mode(), "the kill switch wins") + assert.Equal(t, models.MyClaListSssModeOptional, (&sssScreener{client: &fakeSSSClient{}, enabled: true}).Mode()) + assert.Equal(t, models.MyClaListSssModeRequired, (&sssScreener{client: &fakeSSSClient{}, enabled: true, required: true}).Mode()) +} + +func TestScreenCompanyLiveResult(t *testing.T) { + org := &orgModels.Organization{Domains: " , www.acme.org "} + + t.Run("flagged", func(t *testing.T) { + client := &fakeSSSClient{result: &sss.ScreeningResult{Status: sss.StatusFlagged}} + flagged, check := testScreener(client, false, org, nil).ScreenCompany(context.Background(), company(false, "")) + assert.True(t, flagged) + assert.Equal(t, models.MyClaFlaggedCheckLive, check) + assert.Equal(t, "acme.org", client.request.Domain) + assert.Equal(t, "Acme Corp", client.request.OrgName) + assert.Equal(t, "0014100000Te0lqAAB", client.request.SFDCID, "only 001-prefixed external IDs are Salesforce accounts") + }) + + t.Run("clean clears a stored sss sanction", func(t *testing.T) { + client := &fakeSSSClient{result: &sss.ScreeningResult{Status: sss.StatusClean}} + flagged, check := testScreener(client, true, org, nil).ScreenCompany(context.Background(), company(true, sanctionOriginSSS)) + assert.False(t, flagged) + assert.Equal(t, models.MyClaFlaggedCheckLive, check) + }) +} + +func TestScreenCompanyStoredWithoutLiveCall(t *testing.T) { + t.Run("administrator block", func(t *testing.T) { + client := &fakeSSSClient{result: &sss.ScreeningResult{Status: sss.StatusClean}} + flagged, check := testScreener(client, true, nil, nil).ScreenCompany(context.Background(), company(true, "manual")) + assert.True(t, flagged, "a non-SSS block is authoritative") + assert.Equal(t, models.MyClaFlaggedCheckStored, check) + assert.Zero(t, client.calls) + }) + + t.Run("screening disabled", func(t *testing.T) { + client := &fakeSSSClient{result: &sss.ScreeningResult{Status: sss.StatusFlagged}} + screener := testScreener(client, false, nil, nil) + screener.enabled = false + flagged, check := screener.ScreenCompany(context.Background(), company(true, sanctionOriginSSS)) + assert.True(t, flagged, "the persisted flag stands in when screening is off") + assert.Equal(t, models.MyClaFlaggedCheckStored, check) + assert.Zero(t, client.calls) + }) + + t.Run("client not configured", func(t *testing.T) { + flagged, check := (&sssScreener{enabled: true}).ScreenCompany(context.Background(), company(false, "")) + assert.False(t, flagged) + assert.Equal(t, models.MyClaFlaggedCheckStored, check) + }) +} + +// The listing must survive every screening failure: the answer degrades to the persisted flag +// and says so, in both required and optional mode. +func TestScreenCompanyUnavailable(t *testing.T) { + org := &orgModels.Organization{Domains: "acme.org"} + + cases := []struct { + name string + client sssStatusClient + org *orgModels.Organization + orgErr error + noID bool + }{ + {name: "no external id", client: &fakeSSSClient{result: &sss.ScreeningResult{Status: sss.StatusClean}}, org: org, noID: true}, + {name: "organization lookup failed", client: &fakeSSSClient{}, orgErr: fmt.Errorf("org-service down")}, + {name: "organization missing", client: &fakeSSSClient{}}, + {name: "no resolvable domain", client: &fakeSSSClient{}, org: &orgModels.Organization{}}, + {name: "sss call failed", client: &fakeSSSClient{err: fmt.Errorf("502 from sss")}, org: org}, + {name: "unexpected status", client: &fakeSSSClient{result: &sss.ScreeningResult{Status: "pending"}}, org: org}, + {name: "nil result", client: &fakeSSSClient{}, org: org}, + } + + for _, tc := range cases { + for _, required := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/required=%v", tc.name, required), func(t *testing.T) { + for _, stored := range []bool{false, true} { + companyModel := company(stored, sanctionOriginSSS) + if tc.noID { + companyModel.CompanyExternalID = "" + } + flagged, check := testScreener(tc.client, required, tc.org, tc.orgErr).ScreenCompany(context.Background(), companyModel) + assert.Equal(t, stored, flagged, "an unusable screen falls back to the persisted flag") + assert.Equal(t, models.MyClaFlaggedCheckUnavailable, check) + } + }) + } + } +} + +func TestScreenCompanyNilCompany(t *testing.T) { + flagged, check := testScreener(&fakeSSSClient{}, true, nil, nil).ScreenCompany(context.Background(), nil) + assert.False(t, flagged) + assert.Empty(t, check, "an unresolved employer has no sanctions answer at all") +} + +func TestResolveOrgDomain(t *testing.T) { + cases := []struct { + org *orgModels.Organization + want string + }{ + {org: &orgModels.Organization{Domains: "acme.org,acme.com"}, want: "acme.org"}, + {org: &orgModels.Organization{Domains: " , www.acme.org"}, want: "acme.org"}, + {org: &orgModels.Organization{Link: "https://www.acme.org/about"}, want: "acme.org"}, + {org: &orgModels.Organization{Link: "acme.org"}, want: "acme.org"}, + {org: &orgModels.Organization{}, want: ""}, + } + for _, tc := range cases { + assert.Equal(t, tc.want, resolveOrgDomain(tc.org)) + } +} + +func TestNewSanctionsScreenerNilClient(t *testing.T) { + screener := NewSanctionsScreener(nil, true, false) + require.NotNil(t, screener) + flagged, check := screener.ScreenCompany(context.Background(), company(true, sanctionOriginSSS)) + assert.True(t, flagged, "a typed nil client must not be treated as usable") + assert.Equal(t, models.MyClaFlaggedCheckStored, check) +} diff --git a/cla-backend-go/v2/my_clas/service.go b/cla-backend-go/v2/my_clas/service.go index 0ea3357c4..4934a9b60 100644 --- a/cla-backend-go/v2/my_clas/service.go +++ b/cla-backend-go/v2/my_clas/service.go @@ -11,6 +11,9 @@ import ( "strconv" "strings" + "github.com/gofrs/uuid" + "github.com/linuxfoundation/easycla/cla-backend-go/emails" + "github.com/linuxfoundation/easycla/cla-backend-go/events" v1Models "github.com/linuxfoundation/easycla/cla-backend-go/gen/v1/models" "github.com/linuxfoundation/easycla/cla-backend-go/gen/v2/models" log "github.com/linuxfoundation/easycla/cla-backend-go/logging" @@ -20,6 +23,7 @@ import ( v2ProjectServiceModels "github.com/linuxfoundation/easycla/cla-backend-go/v2/project-service/models" platformModels "github.com/linuxfoundation/easycla/cla-backend-go/v2/user-service/models" "github.com/sirupsen/logrus" + "golang.org/x/sync/errgroup" ) const ( @@ -31,10 +35,8 @@ const ( ) // Identity holds the caller-provided identity keys used to resolve EasyCLA user records. -// -// Taking the identity list from the caller is a transitional mechanism (P3/P9 of the trust-SS -// decision): at M6 EasyCLA should call lfx.auth-service.user_identity.list itself over NATS and -// drop both these parameters and the azp allow-list that authorizes them. +// Caller-supplied keys are transitional (P3/P9 of the trust-SS decision): at M6 EasyCLA should +// call lfx.auth-service.user_identity.list over NATS itself and drop them with the azp allow-list. type Identity struct { LfUsername string Emails []string @@ -53,7 +55,7 @@ func (i *Identity) IsEmpty() bool { !hasValue(i.GitlabUsernames) && !hasValue(i.GerritUsernames) } -// Summary renders the identity keys as a compact, length-bounded string for the caller audit log +// Summary renders the identity keys, length-bounded, for the caller audit log func (i *Identity) Summary() string { var parts []string addStrings := func(param string, values []string) { @@ -105,43 +107,53 @@ func hasValue(values []string) bool { return false } -// PlatformUsersService is the subset of the platform user-service client used to verify -// that identities are connected to the authenticated user's LF account +// PlatformUsersService is the user-service subset used to verify that identities are connected +// to the authenticated user's LF account type PlatformUsersService interface { GetUserByUsernameContext(ctx context.Context, lfUsername string) (*platformModels.User, error) ListUserIdentities(ctx context.Context, userSFID string) ([]*platformModels.UserIdentity, error) } -// SignaturesService is the subset of the v1 signatures service used to evaluate ECLA validity +// SignaturesService is the v1 signatures subset used to evaluate ECLA validity type SignaturesService interface { GetCorporateSignature(ctx context.Context, claGroupID, companyID string, approved, signed *bool) (*v1Models.Signature, error) - UserIsApproved(ctx context.Context, user *v1Models.User, cclaSignature *v1Models.Signature) (bool, error) + EvaluateUserApproval(ctx context.Context, user *v1Models.User, cclaSignature *v1Models.Signature) (approved bool, githubOrgLookupFailed bool, err error) } -// CompanyRepository is the subset of the company repository used to resolve employers +// CompanyRepository is the company repository subset used to resolve employers type CompanyRepository interface { GetCompany(ctx context.Context, companyID string) (*v1Models.Company, error) } -// ProjectsCLAGroupsRepository is the subset of the projects-cla-groups repository used to resolve -// CLA Group names and the Salesforce project(s) a CLA Group is mapped to +// ProjectsCLAGroupsRepository is the projects-cla-groups subset used to resolve CLA Group names +// and their Salesforce project mappings type ProjectsCLAGroupsRepository interface { GetCLAGroupNameByID(ctx context.Context, claGroupID string) (string, error) GetProjectsIdsForClaGroup(ctx context.Context, claGroupID string) ([]*projects_cla_groups.ProjectClaGroup, error) } -// ProjectService is the subset of the project-service client used to resolve a project's -// display name and logo from its Salesforce ID +// ProjectService is the project-service subset used to resolve a project's display name and logo type ProjectService interface { GetProject(projectSFID string) (*v2ProjectServiceModels.ProjectOutputDetailed, error) } +// EventsService is the v1 events subset used to audit contact-CLA-manager requests +type EventsService interface { + LogEventWithContext(ctx context.Context, args *events.LogEventArgs) +} + +// ErrInvalidRecipients is returned when recipients is not a non-empty subset of the resolved CLA +// managers - empty is valid only when none resolves +var ErrInvalidRecipients = errors.New("recipients must be a non-empty subset of the CLA managers returned by the cla-managers endpoint - empty only when no CLA manager resolves") + // Service interface defines the My CLAs service methods type Service interface { GetMyClas(ctx context.Context, caller *Caller, requested *Identity) (*models.MyClaList, error) GetMyClaPdfURL(ctx context.Context, caller *Caller, requested *Identity, signatureID string) (*models.MyClaPdf, error) GetMyIdentities(ctx context.Context, currentUsername string) (*models.MyIdentityList, error) AuthorizeIdentity(ctx context.Context, currentUsername string, admin bool, requested *Identity) (*Identity, []string, error) + GetMyClaManagers(ctx context.Context, caller *Caller, requested *Identity, signatureID string) (*models.MyClaManagerList, error) + CreateMyClaManagerRequest(ctx context.Context, caller *Caller, requested *Identity, signatureID string, input *models.MyClaManagerRequest) (*models.MyClaManagerRequestResult, error) } type service struct { @@ -151,12 +163,15 @@ type service struct { companyRepo CompanyRepository projectsClaGroupsRepo ProjectsCLAGroupsRepository projectService ProjectService + eventsService EventsService + sanctions SanctionsScreener presign func(filename string) (string, error) documentExists func(filename string) (bool, error) + sendEmail func(subject string, body string, recipients []string) error } // NewService creates a new instance of the My CLAs service -func NewService(repo Repository, platformUsersService PlatformUsersService, signaturesService SignaturesService, companyRepo CompanyRepository, projectsClaGroupsRepo ProjectsCLAGroupsRepository, projectService ProjectService) Service { +func NewService(repo Repository, platformUsersService PlatformUsersService, signaturesService SignaturesService, companyRepo CompanyRepository, projectsClaGroupsRepo ProjectsCLAGroupsRepository, projectService ProjectService, eventsService EventsService, sanctions SanctionsScreener) Service { return &service{ repo: repo, platformUsersService: platformUsersService, @@ -164,19 +179,22 @@ func NewService(repo Repository, platformUsersService PlatformUsersService, sign companyRepo: companyRepo, projectsClaGroupsRepo: projectsClaGroupsRepo, projectService: projectService, + eventsService: eventsService, + sanctions: sanctions, presign: utils.GetDownloadLink, documentExists: utils.DocumentExists, + sendEmail: utils.SendEmail, } } -// projectInfo holds the resolved Salesforce project display name and logo for a CLA Group +// projectInfo is the resolved Salesforce project display name and logo of a CLA Group type projectInfo struct { name string logo string } -// GetMyClas returns all signed ICLAs and ECLAs of the EasyCLA user records matching the -// given identity, with validity evaluated against the current CCLA approval lists +// GetMyClas returns the signed ICLAs and ECLAs of every EasyCLA user record matching the identity, +// with validity evaluated against the current CCLA approval lists func (s *service) GetMyClas(ctx context.Context, caller *Caller, requested *Identity) (*models.MyClaList, error) { f := logrus.Fields{ "functionName": "v2.my_clas.service.GetMyClas", @@ -196,80 +214,70 @@ func (s *service) GetMyClas(ctx context.Context, caller *Caller, requested *Iden return nil, err } + perUser, err := s.userSignatures(ctx, userModels) + if err != nil { + return nil, err + } + refs := claRefs(userModels, perUser) + data, err := s.prefetch(ctx, refs) + if err != nil { + return nil, err + } + result := &models.MyClaList{ LfUsername: identity.LfUsername, UserIds: make([]string, 0, len(userModels)), SkippedIdentities: skipped, - Clas: []models.MyCla{}, + Clas: make([]models.MyCla, 0, len(refs)), + SssMode: s.sanctionsMode(), } - - seen := make(map[string]bool) - claGroupNames := make(map[string]string) - projectInfos := make(map[string]projectInfo) - companies := make(map[string]*v1Models.Company) - cclas := make(map[string]*v1Models.Signature) - approvals := make(map[string]bool) - for _, userModel := range userModels { result.UserIds = append(result.UserIds, userModel.UserID) + } - userSignatures, sigErr := s.repo.GetUserCLASignatures(ctx, userModel.UserID) - if sigErr != nil { - return nil, sigErr + for _, ref := range refs { + sig := ref.sig + project := data.projectInfos[sig.SignatureProjectID] + row := models.MyCla{ + SignatureID: sig.SignatureID, + ClaGroupID: sig.SignatureProjectID, + ClaGroupName: data.claGroupNames[sig.SignatureProjectID], + ProjectName: project.name, + ProjectLogo: project.logo, + UserID: sig.SignatureReferenceID, + SignedOn: signedOn(sig), + Signed: sig.SignatureSigned, + Approved: sig.SignatureApproved, + DocumentMajorVersion: int64(sig.SignatureDocumentMajorVersion), + DocumentMinorVersion: int64(sig.SignatureDocumentMinorVersion), } + row.SignedVia, row.SignedAs = signedIdentity(sig) - for _, sig := range userSignatures { - if !sig.SignatureSigned || seen[sig.SignatureID] { - continue - } - seen[sig.SignatureID] = true - - claGroupName, nameErr := s.claGroupName(ctx, claGroupNames, sig.SignatureProjectID) - if nameErr != nil { - return nil, nameErr - } - project, projectErr := s.projectInfo(ctx, projectInfos, sig.SignatureProjectID) - if projectErr != nil { - return nil, projectErr - } - row := models.MyCla{ - SignatureID: sig.SignatureID, - ClaGroupID: sig.SignatureProjectID, - ClaGroupName: claGroupName, - ProjectName: project.name, - ProjectLogo: project.logo, - UserID: sig.SignatureReferenceID, - SignedOn: signedOn(sig), - Signed: sig.SignatureSigned, - Approved: sig.SignatureApproved, - DocumentMajorVersion: int64(sig.SignatureDocumentMajorVersion), - DocumentMinorVersion: int64(sig.SignatureDocumentMinorVersion), + if sig.SignatureUserCompanyID == "" { + row.ClaType = utils.ClaTypeICLA + row.Valid = sig.SignatureApproved + row.PdfAvailable = true + assignMyClaStatus(&row, eclaCoverage{}) + } else { + row.ClaType = utils.ClaTypeECLA + row.CompanyID = sig.SignatureUserCompanyID + if companyModel := data.companies[sig.SignatureUserCompanyID]; companyModel != nil { + row.CompanyName = companyModel.CompanyName + row.SigningEntityName = companyModel.SigningEntityName } - - if sig.SignatureUserCompanyID == "" { - row.ClaType = utils.ClaTypeICLA - row.Valid = sig.SignatureApproved - row.PdfAvailable = true - } else { - row.ClaType = utils.ClaTypeECLA - row.CompanyID = sig.SignatureUserCompanyID - companyModel, companyErr := s.company(ctx, companies, sig.SignatureUserCompanyID) - if companyErr != nil { - return nil, companyErr - } - if companyModel != nil { - row.CompanyName = companyModel.CompanyName - row.SigningEntityName = companyModel.SigningEntityName - } - covered, coveredErr := s.eclaCoveredByCurrentApprovalList(ctx, cclas, approvals, userModel, companyModel, sig) - if coveredErr != nil { - return nil, coveredErr - } - row.Valid = sig.SignatureApproved && covered + sanction := data.sanctions[sig.SignatureUserCompanyID] + row.Flagged = sanction.flagged + row.FlaggedCheck = sanction.check + if sanction.flagged { + _, row.FlaggedAt = utils.CurrentTime() } - - result.Clas = append(result.Clas, row) + coverage := data.coverage(sig, ref.user, sanction.flagged) + row.Valid = sig.SignatureApproved && coverage.covered + row.ClaManager = data.claManager(sig, identity.LfUsername, sanction.flagged) + assignMyClaStatus(&row, coverage) } + + result.Clas = append(result.Clas, row) } sort.SliceStable(result.Clas, func(i, j int) bool { @@ -281,9 +289,8 @@ func (s *service) GetMyClas(ctx context.Context, caller *Caller, requested *Iden return result, nil } -// GetMyClaPdfURL returns a time-limited download URL for the signed ICLA PDF when the -// signature belongs to one of the EasyCLA user records matching the given identity - -// a nil result means unknown, not-owned, unsigned or ECLA signature ID +// GetMyClaPdfURL returns a time-limited download URL for a signed ICLA PDF owned by the identity - +// nil means unknown, not-owned, unsigned or ECLA signature ID func (s *service) GetMyClaPdfURL(ctx context.Context, caller *Caller, requested *Identity, signatureID string) (*models.MyClaPdf, error) { f := logrus.Fields{ "functionName": "v2.my_clas.service.GetMyClaPdfURL", @@ -346,9 +353,311 @@ func (s *service) GetMyClaPdfURL(ctx context.Context, caller *Caller, requested return nil, nil } +// GetMyClaManagers returns the CLA managers of the CCLA covering the given ECLA - nil means +// unknown, not-owned, unsigned or ICLA signature ID +func (s *service) GetMyClaManagers(ctx context.Context, caller *Caller, requested *Identity, signatureID string) (*models.MyClaManagerList, error) { + identity, sig, _, err := s.findOwnedEcla(ctx, caller, requested, signatureID) + if err != nil || sig == nil { + return nil, err + } + + details, err := s.eclaManagerDetails(ctx, identity, sig) + if err != nil { + return nil, err + } + + return &models.MyClaManagerList{ + SignatureID: sig.SignatureID, + ClaGroupID: sig.SignatureProjectID, + ClaGroupName: details.claGroupName, + ProjectName: details.projectName, + CompanyID: sig.SignatureUserCompanyID, + CompanyName: details.companyName, + ClaManager: details.callerIsManager, + Managers: details.managers, + ResultCount: int64(len(details.managers)), + }, nil +} + +// CreateMyClaManagerRequest emails a removal/approval request against the caller's own ECLA to the +// selected CLA managers and logs the audit event that is its receipt - nil means unknown, +// not-owned, unsigned or ICLA signature ID, ErrInvalidRecipients an invalid recipients list +func (s *service) CreateMyClaManagerRequest(ctx context.Context, caller *Caller, requested *Identity, signatureID string, input *models.MyClaManagerRequest) (*models.MyClaManagerRequestResult, error) { + f := logrus.Fields{ + "functionName": "v2.my_clas.service.CreateMyClaManagerRequest", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "currentUsername": callerUsername(caller), + "signatureID": signatureID, + } + + identity, sig, userModel, err := s.findOwnedEcla(ctx, caller, requested, signatureID) + if err != nil || sig == nil { + return nil, err + } + + details, err := s.eclaManagerDetails(ctx, identity, sig) + if err != nil { + return nil, err + } + + byUsername := make(map[string]models.MyClaManager, len(details.managers)) + for _, manager := range details.managers { + byUsername[strings.ToLower(manager.LfUsername)] = manager + } + recipients := trimAll(input.Recipients) + if len(details.managers) > 0 && len(recipients) == 0 { + return nil, ErrInvalidRecipients + } + selectedUsernames := make([]string, 0, len(recipients)) + recipientEmails := make([]string, 0, len(recipients)) + selected := make(map[string]bool, len(recipients)) + for _, recipient := range recipients { + key := strings.ToLower(recipient) + manager, ok := byUsername[key] + if !ok { + return nil, ErrInvalidRecipients + } + if selected[key] { + continue + } + selected[key] = true + selectedUsernames = append(selectedUsernames, manager.LfUsername) + if manager.Email != "" { + recipientEmails = append(recipientEmails, manager.Email) + } + } + + requestType := utils.StringValue(input.RequestType) + message := strings.TrimSpace(input.Message) + contributorName := userModel.Username + if contributorName == "" { + contributorName = identity.LfUsername + } + _, contributorIdentity := signedIdentity(sig) + if contributorIdentity == "" { + contributorIdentity = identity.LfUsername + } + + status := models.MyClaManagerRequestResultStatusRecorded + body := "" + if len(recipientEmails) > 0 { + body, err = emails.RenderContactClaManagerTemplate(emails.ContactClaManagerTemplateParams{ + RequestAction: requestAction(requestType), + ContributorName: contributorName, + ContributorIdentity: contributorIdentity, + CompanyName: details.companyName, + ProjectName: details.projectName, + CLAGroupName: details.claGroupName, + OptionalMessage: message, + }) + if err != nil { + log.WithFields(f).WithError(err).Warn("unable to render the contact CLA manager email") + return nil, err + } + status = models.MyClaManagerRequestResultStatusSent + } + + requestUUID, err := uuid.NewV4() + if err != nil { + log.WithFields(f).WithError(err).Warn("unable to generate a request ID") + return nil, err + } + requestID := requestUUID.String() + + if len(recipientEmails) > 0 { + subject := fmt.Sprintf("EasyCLA: %s request from %s for %s", requestAction(requestType), contributorName, details.companyName) + if sendErr := s.sendEmail(subject, body, recipientEmails); sendErr != nil { + log.WithFields(f).WithError(sendErr).Warn("unable to send the contact CLA manager email") + return nil, sendErr + } + } + + if s.eventsService != nil { + s.eventsService.LogEventWithContext(ctx, &events.LogEventArgs{ + EventType: events.ContactCLAManagerRequestCreated, + UserID: userModel.UserID, + LfUsername: identity.LfUsername, + UserName: contributorName, + CLAGroupID: sig.SignatureProjectID, + CLAGroupName: details.claGroupName, + ProjectID: sig.SignatureProjectID, + ProjectName: details.projectName, + CompanyID: sig.SignatureUserCompanyID, + CompanyName: details.companyName, + EventData: &events.ContactCLAManagerRequestCreatedEventData{ + RequestID: requestID, + RequestType: requestType, + SignatureID: sig.SignatureID, + Message: message, + Recipients: selectedUsernames, + }, + }) + } + + return &models.MyClaManagerRequestResult{ + RequestID: requestID, + SignatureID: sig.SignatureID, + RequestType: requestType, + Status: status, + Recipients: selectedUsernames, + }, nil +} + +// findOwnedEcla locates the signed ECLA with this ID among the identity's EasyCLA user records - +// the ownership boundary GetMyClaPdfURL enforces; nil means unknown, not-owned, unsigned or ICLA +func (s *service) findOwnedEcla(ctx context.Context, caller *Caller, requested *Identity, signatureID string) (*Identity, *signatures.ItemSignature, *v1Models.User, error) { + identity, _, err := s.effectiveIdentity(ctx, caller, requested) + if err != nil { + return nil, nil, nil, err + } + + userModels, err := s.resolveUsers(ctx, identity) + if err != nil { + return nil, nil, nil, err + } + + for _, userModel := range userModels { + userSignatures, sigErr := s.repo.GetUserCLASignatures(ctx, userModel.UserID) + if sigErr != nil { + return nil, nil, nil, sigErr + } + for _, sig := range userSignatures { + if sig.SignatureID != signatureID { + continue + } + if !sig.SignatureSigned || sig.SignatureUserCompanyID == "" { + return identity, nil, nil, nil + } + return identity, sig, userModel, nil + } + } + + return identity, nil, nil, nil +} + +type managerDetails struct { + claGroupName string + projectName string + companyName string + managers []models.MyClaManager + callerIsManager bool +} + +// eclaManagerDetails resolves an ECLA's CLA Group/project/company context and the CLA managers +// from the covering CCLA's ACL - no CCLA yields an empty manager list +func (s *service) eclaManagerDetails(ctx context.Context, identity *Identity, sig *signatures.ItemSignature) (*managerDetails, error) { + var ( + claGroupName string + project projectInfo + companyModel *v1Models.Company + ccla *v1Models.Signature + ) + group, groupCtx := errgroup.WithContext(ctx) + group.Go(func() error { + var err error + claGroupName, err = s.claGroupName(groupCtx, sig.SignatureProjectID) + return err + }) + group.Go(func() error { + var err error + project, err = s.projectInfo(groupCtx, sig.SignatureProjectID) + return err + }) + group.Go(func() error { + var err error + companyModel, err = s.company(groupCtx, sig.SignatureUserCompanyID) + return err + }) + group.Go(func() error { + approved, signed := true, true + var err error + ccla, err = s.signaturesService.GetCorporateSignature(groupCtx, sig.SignatureProjectID, sig.SignatureUserCompanyID, &approved, &signed) + return err + }) + if err := group.Wait(); err != nil { + return nil, err + } + + details := &managerDetails{ + claGroupName: claGroupName, + projectName: project.name, + managers: []models.MyClaManager{}, + } + if companyModel != nil { + details.companyName = companyModel.CompanyName + } + if ccla == nil { + return details, nil + } + + for _, aclUser := range ccla.SignatureACL { + lfUsername := aclUser.LfUsername + if lfUsername == "" { + lfUsername = aclUser.Username + } + if lfUsername == "" { + continue + } + email := string(aclUser.LfEmail) + if email == "" && len(aclUser.Emails) > 0 { + email = aclUser.Emails[0] + } + details.managers = append(details.managers, models.MyClaManager{ + LfUsername: lfUsername, + Name: aclUser.Username, + Email: email, + }) + } + details.callerIsManager = isClaManager(ccla, identity.LfUsername) + + return details, nil +} + +func requestAction(requestType string) string { + if requestType == models.MyClaManagerRequestRequestTypeRemoval { + return "removal from the corporate CLA coverage" + } + return "approval under the corporate CLA" +} + +func isClaManager(ccla *v1Models.Signature, lfUsername string) bool { + if ccla == nil || lfUsername == "" { + return false + } + for _, aclUser := range ccla.SignatureACL { + if strings.EqualFold(aclUser.LfUsername, lfUsername) || strings.EqualFold(aclUser.Username, lfUsername) { + return true + } + } + return false +} + +// signedIdentity derives the platform and account signed via/as from the signature's identity +// attributes +func signedIdentity(sig *signatures.ItemSignature) (string, string) { + switch { + case sig.UserGithubUsername != "" || sig.UserGithubID != "": + if sig.UserGithubUsername != "" { + return models.MyClaSignedViaGithub, sig.UserGithubUsername + } + return models.MyClaSignedViaGithub, sig.UserGithubID + case sig.UserGitlabUsername != "" || sig.UserGitlabID != "": + if sig.UserGitlabUsername != "" { + return models.MyClaSignedViaGitlab, sig.UserGitlabUsername + } + return models.MyClaSignedViaGitlab, sig.UserGitlabID + case sig.UserEmail != "" || sig.UserLFUsername != "": + if sig.UserEmail != "" { + return models.MyClaSignedViaGerrit, sig.UserEmail + } + return models.MyClaSignedViaGerrit, sig.UserLFUsername + } + return "", "" +} + // GetMyIdentities returns the deduplicated ":" identities the authenticated user -// owns - the union of their EasyCLA user records and their platform user-service account, the -// same two sources authorizeIdentity uses to authorize a non-admin caller's identity keys +// owns - the union of their EasyCLA records and platform account, the two sources +// authorizeIdentity checks func (s *service) GetMyIdentities(ctx context.Context, currentUsername string) (*models.MyIdentityList, error) { if currentUsername == "" { return nil, errors.New("no username on the authenticated principal") @@ -407,17 +716,17 @@ func (s *service) GetMyIdentities(ctx context.Context, currentUsername string) ( }, nil } -// AuthorizeIdentity narrows the requested identity keys to the ones that belong to the -// authenticated user, reporting the dropped keys - the same boundary GET /my-clas enforces +// AuthorizeIdentity narrows the requested identity keys to those belonging to the authenticated +// user and reports the dropped ones - the boundary GET /my-clas enforces func (s *service) AuthorizeIdentity(ctx context.Context, currentUsername string, admin bool, requested *Identity) (*Identity, []string, error) { return s.effectiveIdentity(ctx, &Caller{Username: currentUsername, Admin: admin}, requested) } -// effectiveIdentity resolves which identity keys the lookup may search. An admin or a trusted -// LFX Self Serve caller supplies them directly; anyone else has each key verified against their -// own records first. A trusted caller's list is Auth0-derived and cannot be re-derived here: the -// historical GitHub-only signers this endpoint serves have no lf_username on their EasyCLA -// records, so verifying against those records would deny exactly the CLAs they may see. +// effectiveIdentity resolves which identity keys may be searched. An admin or trusted LFX Self +// Serve caller supplies them directly: a trusted list is Auth0-derived and not re-derivable here, +// as the historical GitHub-only signers this endpoint serves carry no lf_username on their EasyCLA +// records, so verifying against them would deny exactly the CLAs the caller may see. Anyone else +// has every key verified against their own records first. func (s *service) effectiveIdentity(ctx context.Context, caller *Caller, requested *Identity) (*Identity, []string, error) { if caller == nil { return nil, nil, errors.New("no authenticated principal") @@ -447,11 +756,10 @@ type platformIdentitySet struct { usernames map[string]map[string][]string } -// authorizeIdentity verifies each requested identity key against all of the -// authenticated user's own EasyCLA records and, when not covered there, against the -// identities connected to their LF account in the platform user-service - unverified -// keys are dropped from the search and reported back; verified usernames are replaced -// by their canonical spellings for the exact-match index lookups +// authorizeIdentity verifies each requested key against all of the authenticated user's own +// EasyCLA records and, when not covered there, the identities connected to their LF account - +// unverified keys are dropped and reported; verified usernames become their canonical spellings +// for the exact-match index lookups func (s *service) authorizeIdentity(ctx context.Context, currentUsername string, requested *Identity) (*Identity, []string, error) { f := logrus.Fields{ "functionName": "v2.my_clas.service.authorizeIdentity", @@ -594,9 +902,8 @@ func appendAllowedUsernames(values []string, param string, canon func(string) [] } } -// loadPlatformIdentities collects the emails and per-source canonical usernames -// connected to the LF account - lookup failures yield an empty set, so the affected -// keys are skipped, never allowed +// loadPlatformIdentities collects the emails and per-source canonical usernames connected to the +// LF account - a lookup failure yields an empty set, so affected keys are skipped, never allowed func (s *service) loadPlatformIdentities(ctx context.Context, lfUsername string) *platformIdentitySet { f := logrus.Fields{ "functionName": "v2.my_clas.service.loadPlatformIdentities", @@ -669,153 +976,175 @@ func (s *service) resolveUsers(ctx context.Context, identity *Identity) ([]*v1Mo utils.XREQUESTID: ctx.Value(utils.XREQUESTID), } - var userModels []*v1Models.User - seen := make(map[string]bool) - add := func(matches ...*v1Models.User) { - for _, userModel := range matches { - if userModel == nil || userModel.UserID == "" || seen[userModel.UserID] { - continue - } - seen[userModel.UserID] = true - userModels = append(userModels, userModel) - } - } - addByLookup := func(values []string, what string, lookup func(context.Context, string) ([]*v1Models.User, error)) error { + var lookups []userLookup + byValue := func(values []string, what string, lookup func(context.Context, string) ([]*v1Models.User, error)) { for _, value := range values { - matches, err := lookup(ctx, value) - if err != nil { - log.WithFields(f).WithError(err).Warnf("unable to lookup users by %s: %s", what, value) - return err - } - add(matches...) + lookups = append(lookups, userLookup{what: what, key: value, run: func(lookupCtx context.Context) ([]*v1Models.User, error) { + return lookup(lookupCtx, value) + }}) } - return nil } - addByIDLookup := func(ids []int64, what string, lookup func(context.Context, int64) ([]*v1Models.User, error)) error { + byID := func(ids []int64, what string, lookup func(context.Context, int64) ([]*v1Models.User, error)) { for _, id := range dedupeIDs(ids) { - matches, err := lookup(ctx, id) - if err != nil { - log.WithFields(f).WithError(err).Warnf("unable to lookup users by %s: %d", what, id) - return err - } - add(matches...) + lookups = append(lookups, userLookup{what: what, key: strconv.FormatInt(id, 10), run: func(lookupCtx context.Context) ([]*v1Models.User, error) { + return lookup(lookupCtx, id) + }}) } - return nil } - lfUsernames := trimAll(append([]string{identity.LfUsername}, identity.GerritUsernames...)) - if err := addByLookup(lfUsernames, "LF username", s.repo.GetUsersByLFUsername); err != nil { - return nil, err - } - if err := addByLookup(normalizeEmails(identity.Emails), "email", s.repo.GetUsersByPrimaryEmail); err != nil { - return nil, err - } + byValue(trimAll(append([]string{identity.LfUsername}, identity.GerritUsernames...)), "LF username", s.repo.GetUsersByLFUsername) + byValue(normalizeEmails(identity.Emails), "email", s.repo.GetUsersByPrimaryEmail) if secondaryEmails := normalizeEmails(identity.SecondaryEmails); len(secondaryEmails) > 0 { - matches, err := s.repo.GetUsersBySecondaryEmails(ctx, secondaryEmails) - if err != nil { - log.WithFields(f).WithError(err).Warn("unable to lookup users by secondary emails") - return nil, err - } - add(matches...) - } - if err := addByIDLookup(identity.GithubIDs, "GitHub ID", s.repo.GetUsersByGithubID); err != nil { - return nil, err - } - if err := addByLookup(trimAll(identity.GithubUsernames), "GitHub username", s.repo.GetUsersByGithubUsername); err != nil { - return nil, err - } - if err := addByIDLookup(identity.GitlabIDs, "GitLab ID", s.repo.GetUsersByGitlabID); err != nil { + lookups = append(lookups, userLookup{what: "secondary email", key: strings.Join(secondaryEmails, ","), run: func(lookupCtx context.Context) ([]*v1Models.User, error) { + return s.repo.GetUsersBySecondaryEmails(lookupCtx, secondaryEmails) + }}) + } + byID(identity.GithubIDs, "GitHub ID", s.repo.GetUsersByGithubID) + byValue(trimAll(identity.GithubUsernames), "GitHub username", s.repo.GetUsersByGithubUsername) + byID(identity.GitlabIDs, "GitLab ID", s.repo.GetUsersByGitlabID) + byValue(trimAll(identity.GitlabUsernames), "GitLab username", s.repo.GetUsersByGitlabUsername) + + matches := make([][]*v1Models.User, len(lookups)) + errs := make([]error, len(lookups)) + group, groupCtx := errgroup.WithContext(ctx) + group.SetLimit(fetchConcurrency) + for i, lookup := range lookups { + group.Go(func() error { + matches[i], errs[i] = lookup.run(groupCtx) + return nil + }) + } + if err := group.Wait(); err != nil { return nil, err } - if err := addByLookup(trimAll(identity.GitlabUsernames), "GitLab username", s.repo.GetUsersByGitlabUsername); err != nil { - return nil, err + + var userModels []*v1Models.User + seen := make(map[string]bool) + for i, lookup := range lookups { + if errs[i] != nil { + log.WithFields(f).WithError(errs[i]).Warnf("unable to lookup users by %s: %s", lookup.what, lookup.key) + return nil, errs[i] + } + for _, userModel := range matches[i] { + if userModel == nil || userModel.UserID == "" || seen[userModel.UserID] { + continue + } + seen[userModel.UserID] = true + userModels = append(userModels, userModel) + } } return userModels, nil } -// eclaCoveredByCurrentApprovalList mirrors the PR gating logic (signatures service -// ProcessEmployeeSignature/UserIsApproved): the company must not be sanctioned, must -// hold an approved+signed CCLA for the CLA Group, and the user must match its current -// approval lists -func (s *service) eclaCoveredByCurrentApprovalList(ctx context.Context, cclas map[string]*v1Models.Signature, approvals map[string]bool, userModel *v1Models.User, companyModel *v1Models.Company, sig *signatures.ItemSignature) (bool, error) { - f := logrus.Fields{ - "functionName": "v2.my_clas.service.eclaCoveredByCurrentApprovalList", - utils.XREQUESTID: ctx.Value(utils.XREQUESTID), - "signatureID": sig.SignatureID, - "claGroupID": sig.SignatureProjectID, - "companyID": sig.SignatureUserCompanyID, - } +// userLookup is one pending user-record lookup. All run concurrently and merge in declaration +// order, so the resolved set and the reported error match a serial walk. +type userLookup struct { + run func(context.Context) ([]*v1Models.User, error) + what string + key string +} - if companyModel == nil || companyModel.IsSanctioned { - return false, nil - } +// eclaCoverage is one ECLA's coverage outcome. covered drives valid; unevaluable means the +// approval-list check never completed, so a false covered proves nothing. +type eclaCoverage struct { + covered bool + unevaluable bool +} - cclaKey := sig.SignatureProjectID + "|" + sig.SignatureUserCompanyID - ccla, ok := cclas[cclaKey] - if !ok { - approved, signed := true, true - cclaModel, cclaErr := s.signaturesService.GetCorporateSignature(ctx, sig.SignatureProjectID, sig.SignatureUserCompanyID, &approved, &signed) - if cclaErr != nil { - log.WithFields(f).WithError(cclaErr).Warn("unable to lookup the corporate signature for the employee acknowledgement") - return false, cclaErr - } - ccla = cclaModel - cclas[cclaKey] = ccla - } - if ccla == nil { - return false, nil +// sanctionState is the sanctions answer for one employer: the flag plus how it was obtained +type sanctionState struct { + flagged bool + check string +} + +func (s *service) sanctionsMode() string { + if s.sanctions == nil { + return models.MyClaListSssModeDisabled } + return s.sanctions.Mode() +} - approvalKey := cclaKey + "|" + userModel.UserID - if covered, ok := approvals[approvalKey]; ok { - return covered, nil +// companySanctions screens one employer, live where possible. An unreadable employer is +// unavailable, never an absent answer. +func (s *service) companySanctions(ctx context.Context, companyModel *v1Models.Company) sanctionState { + if companyModel == nil { + return sanctionState{check: models.MyClaFlaggedCheckUnavailable} } - covered, approvedErr := s.signaturesService.UserIsApproved(ctx, userModel, ccla) - if approvedErr != nil { - log.WithFields(f).WithError(approvedErr).Warn("unable to evaluate the approval list for the employee acknowledgement") - covered = false + state := sanctionState{flagged: companyModel.IsSanctioned, check: models.MyClaFlaggedCheckStored} + if s.sanctions != nil { + state.flagged, state.check = s.sanctions.ScreenCompany(ctx, companyModel) } - // UserIsApproved cannot evaluate GitLab group membership (it needs per-group OAuth - // tokens); defer to the signature_approved flag, which the approval-list - // invalidation flow maintains (see docs/MY_CLAS_API.md). - if !covered && approvedErr == nil && len(ccla.GitlabOrgApprovalList) > 0 { - covered = true + return state +} + +// assignMyClaStatus sets the contributor-facing status independently of approved/valid. A +// sanctioned employer wins over everything else and carries no user action. +func assignMyClaStatus(row *models.MyCla, coverage eclaCoverage) { + switch { + case row.Flagged: + row.Status = models.MyClaStatusRevoked + case !row.Approved: + row.Status = models.MyClaStatusInvalidated + case row.ClaType == utils.ClaTypeICLA: + row.Status = models.MyClaStatusValid + case coverage.unevaluable: + row.Status = models.MyClaStatusUnknown + row.StatusReason = models.MyClaStatusReasonUnknown + case coverage.covered: + row.Status = models.MyClaStatusValid + default: + row.Status = models.MyClaStatusNeedsAttention + row.StatusReason = models.MyClaStatusReasonNotOnApprovalList } - approvals[approvalKey] = covered - return covered, nil } -func (s *service) claGroupName(ctx context.Context, cache map[string]string, claGroupID string) (string, error) { +// evaluateApproval mirrors the PR gating logic (signatures EvaluateUserApproval): the user must +// match the current approval lists of the employer's approved+signed CCLA. A check that could not +// complete is unevaluable, so a false covered never means "no longer approved". +func (s *service) evaluateApproval(ctx context.Context, userModel *v1Models.User, ccla *v1Models.Signature) eclaCoverage { + covered, githubOrgLookupFailed, err := s.signaturesService.EvaluateUserApproval(ctx, userModel, ccla) + if err != nil { + log.WithFields(logrus.Fields{ + "functionName": "v2.my_clas.service.evaluateApproval", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "claGroupID": ccla.ProjectID, + "companyID": ccla.SignatureReferenceID, + "userID": userModel.UserID, + }).WithError(err).Warn("unable to evaluate the approval list for the employee acknowledgement") + return eclaCoverage{unevaluable: true} + } + // EvaluateUserApproval cannot evaluate GitLab group membership (it needs per-group OAuth + // tokens); defer to signature_approved, which the invalidation flow maintains. Membership was + // never checked, so the row stays unevaluable. + if !covered && len(ccla.GitlabOrgApprovalList) > 0 { + return eclaCoverage{covered: true, unevaluable: true} + } + return eclaCoverage{covered: covered, unevaluable: githubOrgLookupFailed} +} + +func (s *service) claGroupName(ctx context.Context, claGroupID string) (string, error) { if claGroupID == "" { return "", nil } - if name, ok := cache[claGroupID]; ok { - return name, nil - } name, err := s.projectsClaGroupsRepo.GetCLAGroupNameByID(ctx, claGroupID) if err != nil { if !errors.Is(err, projects_cla_groups.ErrCLAGroupDoesNotExist) { return "", err } - name = "" + return "", nil } - cache[claGroupID] = name return name, nil } -// projectInfo resolves the Salesforce project display name and logo the CLA Group belongs to, -// cached per request. The name comes from the projects-cla-groups mapping table; the logo lives -// only in the project-service and is fetched by project SFID (a foundation-level CLA Group -// resolves to its foundation). A project-service lookup miss degrades to an empty logo rather -// than failing the whole listing. -func (s *service) projectInfo(ctx context.Context, cache map[string]projectInfo, claGroupID string) (projectInfo, error) { +// projectInfo resolves the Salesforce project display name and logo of a CLA Group. The name comes +// from the projects-cla-groups mapping, the logo only from the project-service, fetched by project +// SFID (a foundation-level CLA Group resolves to its foundation). A lookup miss degrades to an +// empty logo rather than failing the listing. +func (s *service) projectInfo(ctx context.Context, claGroupID string) (projectInfo, error) { if claGroupID == "" { return projectInfo{}, nil } - if info, ok := cache[claGroupID]; ok { - return info, nil - } mappings, err := s.projectsClaGroupsRepo.GetProjectsIdsForClaGroup(ctx, claGroupID) if err != nil { @@ -824,12 +1153,11 @@ func (s *service) projectInfo(ctx context.Context, cache map[string]projectInfo, var info projectInfo var projectSFID string - // Foundation-level CLA Groups are identified by a mapping whose ProjectSFID == FoundationSFID - // (the projects_cla_groups convention used by SignedAtFoundation), NOT by the number of - // mappings: such a group resolves to its foundation, and a single project-level mapping - // resolves to that project. Multiple project-level mappings with no foundation marker are - // left unresolved (empty name/logo, so the consumer falls back to claGroupName) rather than - // inventing an association with an arbitrary one of the mapped projects. + // A foundation-level CLA Group is marked by a mapping with ProjectSFID == FoundationSFID (the + // projects_cla_groups convention used by SignedAtFoundation), not by the mapping count, and + // resolves to its foundation; a single project-level mapping resolves to that project. Several + // project-level mappings with no foundation marker stay unresolved (empty name/logo, so the + // consumer falls back to claGroupName) rather than picking an arbitrary one. switch fm := foundationMapping(mappings); { case fm != nil: projectSFID = fm.FoundationSFID @@ -857,13 +1185,11 @@ func (s *service) projectInfo(ctx context.Context, cache map[string]projectInfo, } } - cache[claGroupID] = info return info, nil } -// foundationMapping returns the mapping row that marks a foundation-level CLA Group -// (ProjectSFID == FoundationSFID, the projects_cla_groups convention used by -// SignedAtFoundation), or nil when the CLA Group is not foundation-level. +// foundationMapping returns the mapping marking a foundation-level CLA Group (ProjectSFID == +// FoundationSFID, the SignedAtFoundation convention), or nil when it is not foundation-level. func foundationMapping(mappings []*projects_cla_groups.ProjectClaGroup) *projects_cla_groups.ProjectClaGroup { for _, m := range mappings { if m.FoundationSFID != "" && m.FoundationSFID == m.ProjectSFID { @@ -873,19 +1199,15 @@ func foundationMapping(mappings []*projects_cla_groups.ProjectClaGroup) *project return nil } -func (s *service) company(ctx context.Context, cache map[string]*v1Models.Company, companyID string) (*v1Models.Company, error) { - if companyModel, ok := cache[companyID]; ok { - return companyModel, nil - } +func (s *service) company(ctx context.Context, companyID string) (*v1Models.Company, error) { companyModel, err := s.companyRepo.GetCompany(ctx, companyID) if err != nil { var companyNotFound *utils.CompanyNotFound if !errors.As(err, &companyNotFound) { return nil, err } - companyModel = nil + return nil, nil } - cache[companyID] = companyModel return companyModel, nil } diff --git a/cla-backend-go/v2/my_clas/service_test.go b/cla-backend-go/v2/my_clas/service_test.go index 83b654b4a..a99369151 100644 --- a/cla-backend-go/v2/my_clas/service_test.go +++ b/cla-backend-go/v2/my_clas/service_test.go @@ -8,7 +8,9 @@ import ( "errors" "fmt" "strings" + "sync" "testing" + "time" v1Models "github.com/linuxfoundation/easycla/cla-backend-go/gen/v1/models" "github.com/linuxfoundation/easycla/cla-backend-go/gen/v2/models" @@ -92,30 +94,76 @@ type fakeSignatures struct { cclas map[string]*v1Models.Signature approvedUserIDs map[string]bool userIsApprovedErr error + cclaErr error + mu sync.Mutex + orgLookupFailed bool + cclaCalls int } func (f *fakeSignatures) GetCorporateSignature(_ context.Context, claGroupID, companyID string, _, _ *bool) (*v1Models.Signature, error) { + f.mu.Lock() + f.cclaCalls++ + f.mu.Unlock() + if f.cclaErr != nil { + return nil, f.cclaErr + } return f.cclas[claGroupID+"|"+companyID], nil } -func (f *fakeSignatures) UserIsApproved(_ context.Context, user *v1Models.User, _ *v1Models.Signature) (bool, error) { +func (f *fakeSignatures) EvaluateUserApproval(_ context.Context, user *v1Models.User, _ *v1Models.Signature) (bool, bool, error) { if f.userIsApprovedErr != nil { - return false, f.userIsApprovedErr + return false, false, f.userIsApprovedErr } - return f.approvedUserIDs[user.UserID], nil + return f.approvedUserIDs[user.UserID], f.orgLookupFailed, nil } type fakeCompanies struct { - byID map[string]*v1Models.Company + byID map[string]*v1Models.Company + failIDs map[string]bool + mu sync.Mutex + calls int } func (f *fakeCompanies) GetCompany(_ context.Context, companyID string) (*v1Models.Company, error) { + f.mu.Lock() + f.calls++ + f.mu.Unlock() + if f.failIDs[companyID] { + return nil, fmt.Errorf("dynamodb unavailable for company %s", companyID) + } if companyModel, ok := f.byID[companyID]; ok { return companyModel, nil } return nil, &utils.CompanyNotFound{CompanyID: companyID} } +// fakeScreener stands in for the live SSS screen and records how often each employer was screened +type fakeScreener struct { + mode string + flagged map[string]bool + checks map[string]string + calls map[string]int + mu sync.Mutex +} + +func (f *fakeScreener) Mode() string { + return f.mode +} + +func (f *fakeScreener) ScreenCompany(_ context.Context, company *v1Models.Company) (bool, string) { + f.mu.Lock() + defer f.mu.Unlock() + if f.calls == nil { + f.calls = map[string]int{} + } + f.calls[company.CompanyID]++ + check, ok := f.checks[company.CompanyID] + if !ok { + check = models.MyClaFlaggedCheckLive + } + return f.flagged[company.CompanyID], check +} + type fakeClaGroups struct { names map[string]string mappings map[string][]*projects_cla_groups.ProjectClaGroup @@ -136,9 +184,12 @@ type fakeProjectService struct { byID map[string]*v2ProjectServiceModels.ProjectOutputDetailed err error calls map[string]int + mu sync.Mutex } func (f *fakeProjectService) GetProject(projectSFID string) (*v2ProjectServiceModels.ProjectOutputDetailed, error) { + f.mu.Lock() + defer f.mu.Unlock() if f.calls != nil { f.calls[projectSFID]++ } @@ -763,9 +814,13 @@ func TestGetMyClasEclaValidity(t *testing.T) { assert.Equal(t, "company-1", covered.CompanyID) assert.False(t, byID["sig-2"].Valid, "sanctioned company invalidates the ECLA") + assert.Equal(t, models.MyClaStatusRevoked, byID["sig-2"].Status) assert.False(t, byID["sig-3"].Valid, "missing current CCLA invalidates the ECLA") + assert.Equal(t, models.MyClaStatusUnknown, byID["sig-3"].Status) assert.False(t, byID["sig-4"].Valid, "signature_approved=false invalidates the ECLA") + assert.Equal(t, models.MyClaStatusInvalidated, byID["sig-4"].Status) assert.False(t, byID["sig-5"].Valid, "unknown company invalidates the ECLA") + assert.Equal(t, models.MyClaStatusUnknown, byID["sig-5"].Status) assert.Empty(t, byID["sig-5"].CompanyName) } @@ -793,6 +848,7 @@ func TestGetMyClasEclaNotOnCurrentApprovalList(t *testing.T) { require.Len(t, result.Clas, 1) assert.True(t, result.Clas[0].Approved) assert.False(t, result.Clas[0].Valid, "ECLA no longer matching the current approval list is invalid") + assert.Equal(t, models.MyClaStatusNeedsAttention, result.Clas[0].Status) } func TestGetMyClasEclaGitlabGroupFallback(t *testing.T) { @@ -818,6 +874,7 @@ func TestGetMyClasEclaGitlabGroupFallback(t *testing.T) { require.NoError(t, err) require.Len(t, result.Clas, 1) assert.True(t, result.Clas[0].Valid, "GitLab-group-approved ECLAs defer to the signature_approved flag") + assert.Equal(t, models.MyClaStatusUnknown, result.Clas[0].Status, "group membership was never evaluated") } func TestGetMyClasEclaApprovalEvaluationError(t *testing.T) { @@ -843,6 +900,325 @@ func TestGetMyClasEclaApprovalEvaluationError(t *testing.T) { require.NoError(t, err, "approval-list evaluation problems must not fail the listing") require.Len(t, result.Clas, 1) assert.False(t, result.Clas[0].Valid, "evaluation errors leave the ECLA not covered - no GitLab fallback") + assert.Equal(t, models.MyClaStatusUnknown, result.Clas[0].Status, "an evaluation error proves nothing about the approval list") + assert.Equal(t, models.MyClaStatusReasonUnknown, result.Clas[0].StatusReason) +} + +func TestGetMyClasStatus(t *testing.T) { + userA := &v1Models.User{UserID: "user-a", LfUsername: "someone"} + companies := &fakeCompanies{byID: map[string]*v1Models.Company{ + "company-1": {CompanyID: "company-1", CompanyName: "Good Corp"}, + }} + signaturesService := &fakeSignatures{ + cclas: map[string]*v1Models.Signature{"cla-group-1|company-1": {SignatureID: "ccla-1"}}, + approvedUserIDs: map[string]bool{"user-a": true}, + } + repo := &fakeRepo{ + byUserID: map[string][]*signatures.ItemSignature{ + "user-a": { + icla("sig-1", "user-a", "cla-group-1", "2024-01-01T00:00:00Z", true), + icla("sig-2", "user-a", "cla-group-1", "2024-02-01T00:00:00Z", false), + ecla("sig-3", "company-1", "2024-03-01T00:00:00Z", true), + ecla("sig-4", "company-1", "2024-04-01T00:00:00Z", false), + }, + }, + byLFUsername: map[string][]*v1Models.User{"someone": {userA}}, + } + svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{}) + + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + require.NoError(t, err) + require.Len(t, result.Clas, 4) + assert.Equal(t, models.MyClaListSssModeDisabled, result.SssMode, "no screener configured reports disabled") + + byID := map[string]models.MyCla{} + for _, row := range result.Clas { + byID[row.SignatureID] = row + } + assert.Equal(t, models.MyClaStatusValid, byID["sig-1"].Status) + assert.Empty(t, byID["sig-1"].StatusReason, "valid rows carry no reason") + assert.Equal(t, models.MyClaStatusInvalidated, byID["sig-2"].Status) + assert.Empty(t, byID["sig-2"].StatusReason, "invalidated attributes nothing") + assert.Equal(t, models.MyClaStatusValid, byID["sig-3"].Status) + assert.Equal(t, models.MyClaStatusInvalidated, byID["sig-4"].Status) + assert.Equal(t, models.MyClaFlaggedCheckStored, byID["sig-3"].FlaggedCheck) + assert.Empty(t, byID["sig-1"].FlaggedCheck, "sanctions are an ECLA concept") +} + +func TestGetMyClasStatusNeedsAttentionAndUnknown(t *testing.T) { + userA := &v1Models.User{UserID: "user-a", LfUsername: "someone"} + companies := &fakeCompanies{byID: map[string]*v1Models.Company{ + "company-1": {CompanyID: "company-1", CompanyName: "Good Corp"}, + }} + repo := &fakeRepo{ + byUserID: map[string][]*signatures.ItemSignature{ + "user-a": {ecla("sig-1", "company-1", "2024-01-01T00:00:00Z", true)}, + }, + byLFUsername: map[string][]*v1Models.User{"someone": {userA}}, + } + ccla := map[string]*v1Models.Signature{"cla-group-1|company-1": {SignatureID: "ccla-1"}} + + t.Run("completed approval-list miss", func(t *testing.T) { + svc := newTestService(repo, &fakePlatform{}, &fakeSignatures{cclas: ccla}, companies, &fakeClaGroups{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + require.NoError(t, err) + require.Len(t, result.Clas, 1) + assert.Equal(t, models.MyClaStatusNeedsAttention, result.Clas[0].Status) + assert.Equal(t, models.MyClaStatusReasonNotOnApprovalList, result.Clas[0].StatusReason, "the only reason a Request approval action may gate on") + }) + + t.Run("github organization lookup failed", func(t *testing.T) { + svc := newTestService(repo, &fakePlatform{}, &fakeSignatures{cclas: ccla, orgLookupFailed: true}, companies, &fakeClaGroups{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + require.NoError(t, err) + require.Len(t, result.Clas, 1) + assert.Equal(t, models.MyClaStatusUnknown, result.Clas[0].Status, "a failed org lookup must not read as an approval-list miss") + assert.Equal(t, models.MyClaStatusReasonUnknown, result.Clas[0].StatusReason) + }) +} + +func TestGetMyClasDegradesFailedLookups(t *testing.T) { + userA := &v1Models.User{UserID: "user-a", LfUsername: "someone"} + + t.Run("company lookup failure degrades the row", func(t *testing.T) { + companies := &fakeCompanies{ + byID: map[string]*v1Models.Company{"company-1": {CompanyID: "company-1", CompanyName: "Good Corp"}}, + failIDs: map[string]bool{"company-2": true}, + } + signaturesService := &fakeSignatures{ + cclas: map[string]*v1Models.Signature{"cla-group-1|company-1": {SignatureID: "ccla-1"}}, + approvedUserIDs: map[string]bool{"user-a": true}, + } + repo := &fakeRepo{ + byUserID: map[string][]*signatures.ItemSignature{ + "user-a": { + ecla("sig-1", "company-1", "2024-01-01T00:00:00Z", true), + ecla("sig-2", "company-2", "2024-02-01T00:00:00Z", true), + ecla("sig-3", "company-2", "2024-03-01T00:00:00Z", true), + }, + }, + byLFUsername: map[string][]*v1Models.User{"someone": {userA}}, + } + svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{}) + + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + require.NoError(t, err, "one unresolvable employer must not fail the whole list") + require.Len(t, result.Clas, 3) + + byID := map[string]models.MyCla{} + for _, row := range result.Clas { + byID[row.SignatureID] = row + } + assert.Equal(t, models.MyClaStatusValid, byID["sig-1"].Status, "healthy rows keep their status") + assert.Equal(t, models.MyClaStatusUnknown, byID["sig-2"].Status) + assert.Equal(t, models.MyClaStatusUnknown, byID["sig-3"].Status) + assert.Empty(t, byID["sig-2"].CompanyName) + assert.False(t, byID["sig-2"].Flagged) + assert.Equal(t, models.MyClaFlaggedCheckUnavailable, byID["sig-2"].FlaggedCheck, "an unreadable employer cannot be screened, so it is never an absent answer") + assert.Equal(t, models.MyClaFlaggedCheckStored, byID["sig-1"].FlaggedCheck) + assert.Equal(t, 2, companies.calls, "a failed employer lookup is cached, not retried per row") + }) + + t.Run("ccla lookup failure degrades the row", func(t *testing.T) { + companies := &fakeCompanies{byID: map[string]*v1Models.Company{ + "company-1": {CompanyID: "company-1", CompanyName: "Good Corp"}, + }} + signaturesService := &fakeSignatures{cclaErr: fmt.Errorf("dynamodb unavailable")} + repo := &fakeRepo{ + byUserID: map[string][]*signatures.ItemSignature{ + "user-a": { + ecla("sig-1", "company-1", "2024-01-01T00:00:00Z", true), + ecla("sig-2", "company-1", "2024-02-01T00:00:00Z", true), + }, + }, + byLFUsername: map[string][]*v1Models.User{"someone": {userA}}, + } + svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{}) + + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + require.NoError(t, err, "an unresolvable CCLA must not fail the whole list") + require.Len(t, result.Clas, 2) + for _, row := range result.Clas { + assert.Equal(t, models.MyClaStatusUnknown, row.Status) + assert.False(t, row.Valid) + } + assert.Equal(t, 1, signaturesService.cclaCalls, "a failed CCLA lookup is cached, not retried per row") + }) +} + +func TestGetMyClasLiveSanctionsScreening(t *testing.T) { + userA := &v1Models.User{UserID: "user-a", LfUsername: "someone"} + companies := &fakeCompanies{byID: map[string]*v1Models.Company{ + "company-1": {CompanyID: "company-1", CompanyName: "Live Flagged Corp"}, + "company-2": {CompanyID: "company-2", CompanyName: "Cleared Corp", IsSanctioned: true, SanctionOrigin: sanctionOriginSSS}, + "company-3": {CompanyID: "company-3", CompanyName: "Unscreenable Corp", IsSanctioned: true}, + }} + signaturesService := &fakeSignatures{ + cclas: map[string]*v1Models.Signature{ + "cla-group-1|company-1": {SignatureID: "ccla-1"}, + "cla-group-1|company-2": {SignatureID: "ccla-2"}, + "cla-group-1|company-3": {SignatureID: "ccla-3"}, + }, + approvedUserIDs: map[string]bool{"user-a": true}, + } + repo := &fakeRepo{ + byUserID: map[string][]*signatures.ItemSignature{ + "user-a": { + ecla("sig-1", "company-1", "2024-01-01T00:00:00Z", true), + ecla("sig-2", "company-2", "2024-02-01T00:00:00Z", true), + ecla("sig-3", "company-3", "2024-03-01T00:00:00Z", true), + ecla("sig-4", "company-1", "2024-04-01T00:00:00Z", true), + }, + }, + byLFUsername: map[string][]*v1Models.User{"someone": {userA}}, + } + screener := &fakeScreener{ + mode: models.MyClaListSssModeRequired, + flagged: map[string]bool{"company-1": true, "company-3": true}, + checks: map[string]string{"company-3": models.MyClaFlaggedCheckUnavailable}, + } + svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{}) + svc.sanctions = screener + + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + require.NoError(t, err, "a screening failure must never fail the listing") + require.Len(t, result.Clas, 4) + assert.Equal(t, models.MyClaListSssModeRequired, result.SssMode) + + byID := map[string]models.MyCla{} + for _, row := range result.Clas { + byID[row.SignatureID] = row + } + + liveFlagged := byID["sig-1"] + assert.True(t, liveFlagged.Flagged, "a live flagged result overrides the stored clean flag") + assert.Equal(t, models.MyClaFlaggedCheckLive, liveFlagged.FlaggedCheck) + assert.NotEmpty(t, liveFlagged.FlaggedAt) + assert.Equal(t, models.MyClaStatusRevoked, liveFlagged.Status) + assert.Empty(t, liveFlagged.StatusReason) + assert.False(t, liveFlagged.Valid) + + liveClean := byID["sig-2"] + assert.False(t, liveClean.Flagged, "a live clean result overrides the stored sanction") + assert.Equal(t, models.MyClaFlaggedCheckLive, liveClean.FlaggedCheck) + assert.Empty(t, liveClean.FlaggedAt) + assert.Equal(t, models.MyClaStatusValid, liveClean.Status) + + unavailable := byID["sig-3"] + assert.True(t, unavailable.Flagged, "an unusable screen honors the stored flag") + assert.Equal(t, models.MyClaFlaggedCheckUnavailable, unavailable.FlaggedCheck) + assert.Equal(t, models.MyClaStatusRevoked, unavailable.Status) + + assert.Equal(t, 1, screener.calls["company-1"], "each distinct employer is screened once per response") +} + +// countingScreener records how many screens run at once and holds the first want of them open, +// so the listing can only complete if that many employers are screened concurrently +type countingScreener struct { + calls map[string]int + gate chan struct{} + mu sync.Mutex + want int + arrived int + inFlight int + maxSeen int + released bool +} + +func (c *countingScreener) Mode() string { + return models.MyClaListSssModeOptional +} + +func (c *countingScreener) peakInFlight() int { + c.mu.Lock() + defer c.mu.Unlock() + return c.maxSeen +} + +func (c *countingScreener) release() { + c.mu.Lock() + defer c.mu.Unlock() + if !c.released { + c.released = true + close(c.gate) + } +} + +func (c *countingScreener) ScreenCompany(_ context.Context, company *v1Models.Company) (bool, string) { + c.mu.Lock() + c.calls[company.CompanyID]++ + c.arrived++ + c.inFlight++ + if c.inFlight > c.maxSeen { + c.maxSeen = c.inFlight + } + hold := c.arrived <= c.want + full := c.arrived >= c.want + c.mu.Unlock() + + if full { + c.release() + } + if hold { + <-c.gate + } + + c.mu.Lock() + c.inFlight-- + c.mu.Unlock() + return false, models.MyClaFlaggedCheckLive +} + +// TestGetMyClasScreensDistinctEmployersInParallel is the 100-ECLAs-over-20-employers case: one +// screen per distinct employer, wantInFlight of them running at once, results merged. It pins +// fetchConcurrency deliberately - the in-flight count is a documented guarantee of the endpoint. +func TestGetMyClasScreensDistinctEmployersInParallel(t *testing.T) { + const employers, perEmployer, wantInFlight = 20, 5, 8 + userA := &v1Models.User{UserID: "user-a", LfUsername: "someone"} + companies := &fakeCompanies{byID: map[string]*v1Models.Company{}} + cclas := map[string]*v1Models.Signature{} + var userSigs []*signatures.ItemSignature + for i := 1; i <= employers; i++ { + companyID := fmt.Sprintf("company-%d", i) + companies.byID[companyID] = &v1Models.Company{CompanyID: companyID, CompanyName: companyID} + cclas["cla-group-1|"+companyID] = &v1Models.Signature{SignatureID: "ccla-" + companyID} + for j := 1; j <= perEmployer; j++ { + userSigs = append(userSigs, ecla(fmt.Sprintf("sig-%d-%d", i, j), companyID, "2024-01-01T00:00:00Z", true)) + } + } + repo := &fakeRepo{ + byUserID: map[string][]*signatures.ItemSignature{"user-a": userSigs}, + byLFUsername: map[string][]*v1Models.User{"someone": {userA}}, + } + signaturesService := &fakeSignatures{cclas: cclas, approvedUserIDs: map[string]bool{"user-a": true}} + svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{}) + screener := &countingScreener{calls: map[string]int{}, gate: make(chan struct{}), want: wantInFlight} + svc.sanctions = screener + + type outcome struct { + list *models.MyClaList + err error + } + done := make(chan outcome, 1) + go func() { + list, listErr := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + done <- outcome{list: list, err: listErr} + }() + + select { + case result := <-done: + require.NoError(t, result.err) + require.Len(t, result.list.Clas, employers*perEmployer) + case <-time.After(10 * time.Second): + screener.release() + t.Fatalf("the listing stalled with only %d of %d employers screened concurrently", screener.peakInFlight(), wantInFlight) + } + + assert.Len(t, screener.calls, employers, "every distinct employer is screened") + for companyID, calls := range screener.calls { + assert.Equal(t, 1, calls, "employer %s is screened exactly once for all %d of its rows", companyID, perEmployer) + } + assert.Equal(t, wantInFlight, screener.peakInFlight(), "the screens run fetchConcurrency at a time") } func TestGetMyClaPdfURL(t *testing.T) { diff --git a/cla-backend-go/v2/project-service/client.go b/cla-backend-go/v2/project-service/client.go index b222cda0a..60ab67912 100644 --- a/cla-backend-go/v2/project-service/client.go +++ b/cla-backend-go/v2/project-service/client.go @@ -83,13 +83,14 @@ func (pmm *Client) GetProject(projectSFID string) (*models.ProjectOutputDetailed // Lookup in cache first mutex.Lock() // exclusive lock to the shared project service model map existingModel, exists := projectServiceModels[projectSFID] + cacheSize := len(projectServiceModels) mutex.Unlock() if exists { - //log.WithFields(f).Debugf("cache hit - cache size: %d", len(projectServiceModels)) + //log.WithFields(f).Debugf("cache hit - cache size: %d", cacheSize) return existingModel, nil } - log.WithFields(f).Debugf("cache miss - cache size: %d", len(projectServiceModels)) + log.WithFields(f).Debugf("cache miss - cache size: %d", cacheSize) tok, err := token.GetToken() if err != nil { diff --git a/docs/MY_CLAS_API.md b/docs/MY_CLAS_API.md index 028b9e23e..9540fdefb 100644 --- a/docs/MY_CLAS_API.md +++ b/docs/MY_CLAS_API.md @@ -1,96 +1,102 @@ -# My CLAs API — EasyCLA backend for LFX Self Serve M1 +# My CLAs API — EasyCLA backend for LFX Self Serve M1/M2 Copyright The Linux Foundation and each contributor to CommunityBridge. SPDX-License-Identifier: CC-BY-4.0 -Backend implementation of the **M1 — Read-only "My CLAs" (Me lens)** milestone of the -EasyCLA → LFX Self Serve integration program -([epic linuxfoundation/lfx-self-serve#1157](https://github.com/linuxfoundation/lfx-self-serve/issues/1157), +Backend for the **M1 "My CLAs" (Me lens)** and **M2 "My CLAs actions"** milestones of the +EasyCLA → LFX Self Serve program +([M1 epic linuxfoundation/lfx-self-serve#1157](https://github.com/linuxfoundation/lfx-self-serve/issues/1157), specs in [`specs/001-easycla-ss-integration-fable/m1-my-cla/`](https://github.com/linuxfoundation/easycla/tree/001-easycla-ss-integration/specs/001-easycla-ss-integration-fable/m1-my-cla) on the `001-easycla-ss-integration` branch). -Three new **read-only** EasyCLA v2 endpoints (served under `/v4`, i.e. -`/cla-service/v4/...` through lfx-gateway) let the authenticated user retrieve **all -their current and historical ICLAs and ECLAs** — matched across their LF username, -emails and GitHub/GitLab/Gerrit identities, with per-record validity evaluated against -the *current* company CCLA approval lists — download their signed ICLA PDFs via -time-limited links, and list the deduplicated identity set they own (the same set the -list endpoint authorizes them to search). For non-admin, untrusted callers every provided identity -key is **verified to belong to the authenticated user** before it is searched (see "Identity -ownership enforcement" for the admin and trusted-caller exceptions), so the endpoints cannot be -used to freely enumerate other people's CLA history. -"Belongs to" means the identity is *currently* attached to the caller's LF account -(their EasyCLA user record or their platform user-service profile/identities) — the -accepted product bar for this read-only surface; the recycled-alias trade-off this -implies is documented under "Known limitations": +Five EasyCLA v2 endpoints — four read-only plus the M2 contact-request POST — under `/v4` +(`/cla-service/v4/...` through lfx-gateway). They let the authenticated user list **all +their current and historical ICLAs and ECLAs** (matched across LF username, emails and +GitHub/GitLab/Gerrit identities, validity evaluated against the *current* company CCLA +approval lists), download signed ICLA PDFs via time-limited links, and list the +deduplicated identity set they own (the set the list endpoint authorizes them to search). +For non-admin, untrusted callers every identity key is **verified to belong to the +authenticated user** before it is searched (admin/trusted exceptions below), so the +endpoints cannot enumerate other people's CLA history. "Belongs to" means *currently* +attached to the caller's LF account (their EasyCLA user records or their platform +user-service profile/identities) — the accepted bar for this read-only surface; the +recycled-alias trade-off is under "Known limitations". | Method | Path | Purpose | |---|---|---| | `GET` | `/v4/my-clas` | List all signed ICLAs/ECLAs matching the provided identity, with computed validity | | `GET` | `/v4/my-clas/{signatureID}/pdf` | Time-limited (15 min) presigned S3 URL for a signed ICLA PDF owned by the provided identity | | `GET` | `/v4/my-clas/identities` | List the deduplicated `:` identities the authenticated user owns (no query params) | +| `GET` | `/v4/my-clas/{signatureID}/cla-managers` | List the CLA managers of the CCLA covering an ECLA owned by the provided identity | +| `POST` | `/v4/my-clas/{signatureID}/cla-manager-requests` | Email a removal/approval request for an owned ECLA to selected CLA managers (M2; swagger-documented) | ## Changed repositories and branches | Repository | Branch | Change | |---|---|---| | `easycla` | `unicron-easycla-my-clas-api` | New swagger paths/models, new `cla-backend-go/v2/my_clas` module, wiring in `cmd/server.go`, unit tests, this document | -| `acs-cli` | `unicron-easycla-my-clas-api` | ACS resource/policy/role registration for the three new paths (`services/11-cla-service.yaml`) — must be `acs-cli sync`'d per environment before the endpoints are reachable through the gateway | +| `acs-cli` | `unicron-easycla-my-clas-api` | ACS resource/policy/role registration for the five paths (`services/11-cla-service.yaml`; PR #1137 covers the two M2 additions) — must be `acs-cli sync`'d per environment before the endpoints are reachable through the gateway | Files changed in `easycla`: -- `cla-backend-go/swagger/cla.v2.yaml` — three new paths, eight new shared query parameters, four new definitions -- `cla-backend-go/swagger/common/my-cla-list.yaml`, `my-cla.yaml`, `my-cla-pdf.yaml`, `my-identity-list.yaml` — new response models +- `cla-backend-go/swagger/cla.v2.yaml` — five paths, eight shared query parameters, eight definitions +- `cla-backend-go/swagger/common/my-cla-list.yaml`, `my-cla.yaml`, `my-cla-pdf.yaml`, `my-identity-list.yaml`, `my-cla-manager.yaml`, `my-cla-manager-list.yaml`, `my-cla-manager-request.yaml`, `my-cla-manager-request-result.yaml` — request/response models - `cla-backend-go/v2/my_clas/handlers.go` — swagger operation wiring (`Configure`) -- `cla-backend-go/v2/my_clas/service.go` — identity ownership enforcement, resolution, aggregation, validity evaluation -- `cla-backend-go/v2/my_clas/repository.go` — plural, paginated GSI queries for identity resolution, the user's ICLA/ECLA records query, and the single-scan secondary-email lookup -- `cla-backend-go/v2/my_clas/service_test.go` — unit tests -- `cla-backend-go/v2/user-service/client.go` — two new (additive) context-aware read helpers, `GetUserByUsernameContext` and `ListUserIdentities` (paginated), mirroring the existing `ListUsersByUsername` pattern with bounded HTTP clients; no existing method changed +- `cla-backend-go/v2/my_clas/service.go` — ownership enforcement, identity resolution, aggregation, validity evaluation +- `cla-backend-go/v2/my_clas/prefetch.go` — per-request concurrent prefetch of every distinct external lookup +- `cla-backend-go/v2/my_clas/sanctions.go` — read-only sanctions screener (live SSS lookup, never persists what it observes) +- `cla-backend-go/v2/my_clas/repository.go` — plural, paginated GSI queries for identity resolution and the user's ICLA/ECLA records, plus the single-scan secondary-email lookup +- `cla-backend-go/emails/contact_cla_manager_templates.go`, `cla-backend-go/events/event_data.go`, `event_types.go` — the contact-request email and its audit event +- `cla-backend-go/v2/my_clas/*_test.go` — unit tests +- `cla-backend-go/v2/user-service/client.go` — two additive context-aware read helpers, `GetUserByUsernameContext` and `ListUserIdentities` (paginated), mirroring `ListUsersByUsername` with bounded HTTP clients; no existing method changed - `cla-backend-go/cmd/server.go` — module registration (three lines) - `cla-backend-go/gen/**` is generated by `make swagger` and not committed -The module follows the repo's three-layer v2 module pattern (`handlers.go` / -`service.go` / `repository.go`) and is **additive and isolated**: no existing -endpoint, service method, or repository method changes behavior — the shared-code -touch points are the three registration lines in `cmd/server.go` and the two new -helper methods added to `v2/user-service/client.go`. +The module follows the repo's three-layer v2 pattern and is **additive**: no existing +endpoint, service or repository method changes behavior. Shared-code touch points: the +three registration lines in `cmd/server.go`, the two new helpers in +`v2/user-service/client.go`, one new method (`EvaluateUserApproval`) on the v1 signatures +service with `UserIsApproved` reduced to a call through it (signing flow unchanged), and +`v2/project-service/client.go`, where a `len()` in a debug message moved under the mutex +that already guards the cache — logging only. ## Authentication -All three endpoints use the standard v4 `lf-auth` security (base64 `X-ACL` header injected -by lfx-gateway), exactly like every other secured v4 endpoint: +All five endpoints use the standard v4 `lf-auth` security (base64 `X-ACL` header injected +by lfx-gateway), like every other secured v4 endpoint: 1. The caller (LFX Self Serve server) sends `GET /cla-service/v4/my-clas` with a user - bearer token minted for the api-gw audience (the token model verified in + bearer token minted for the api-gw audience (token model verified in [`docs/easycla-ss-migration/role-mapping-feasibility.md`](https://github.com/linuxfoundation/easycla/blob/001-easycla-ss-integration/docs/easycla-ss-migration/role-mapping-feasibility.md)). -2. lfx-gateway's `secured` chain validates the JWT (signature + issuer) and asks the - ACS warden whether the username may access this path/method. With the `acs-cli` - change, all three paths are registered `anyRole: true` and attached to the `user` role - (`ViewMyClas` policy — resources `my_clas`, `my_clas_pdf`, `my_clas_identities`) — - mirroring `user_from_token` / `active_signature` — so **any authenticated LF user** is - authorized. `/v4/my-clas/identities` is registered as its own resource because the - `/v4/my-clas` path is matched exactly (the PDF route likewise needs its own - `/v4/my-clas/*/pdf` entry); an unregistered sub-path would be denied by the warden. - Without a valid token the gateway returns 401/403 and the request never reaches EasyCLA. +2. lfx-gateway's `secured` chain validates the JWT (signature + issuer) and asks the ACS + warden whether the username may access this path/method. With the `acs-cli` change all + five paths are registered `anyRole: true` and attached to the `user` role — the + `ViewMyClas` policy (resources `my_clas`, `my_clas_pdf`, `my_clas_identities`) and the + `ContactMyClaManagers` policy (resources `my_clas_managers`, + `my_clas_manager_requests`) — mirroring `user_from_token` / `active_signature`, so + **any authenticated LF user** is authorized. `/v4/my-clas/identities` is its own + resource because `/v4/my-clas` is matched exactly (the PDF route likewise needs its own + `/v4/my-clas/*/pdf` entry); an unregistered sub-path is denied by the warden. Without a + valid token the gateway returns 401/403 and the request never reaches EasyCLA. 3. The gateway injects `X-ACL`/`X-USERNAME`/`X-EMAIL`; the Lambda decodes them into the handler's `authUser` principal. -Note the ACS warden caches authorize responses for ~30 minutes; that only affects the -first rollout (after `acs-cli sync`), not steady-state behavior. +The ACS warden caches authorize responses for ~30 minutes; that affects only the first +rollout (after `acs-cli sync`), not steady state. ### Trusted Self Serve caller (in-handler JWT verification + `azp` allow-list) The gateway-injected `X-ACL`/`X-USERNAME` headers are **decoded but never signature checked**, so anything able to invoke the Lambda directly could forge them. To make the -identity-list bypass below safe, the handlers re-verify the request bearer token themselves -(`cla-backend-go/auth/trusted_caller.go`): the signing algorithm is pinned to the configured -Auth0 algorithm, the signature is verified against the tenant JWKS by `kid` -(`https://{cla-auth0-domain}/.well-known/jwks.json`, cached 15 min; a cache miss reloads it -at most once a minute; a JWKS outage keeps serving the cached key for at most 24 h), -`exp` must be present and unexpired, and the caller is **trusted** when the token's -`azp` is listed in the SSM parameter `cla-ss-trusted-client-ids-{stage}` (comma-separated -Auth0 client IDs). +identity-list bypass below safe, the handlers re-verify the request bearer token +themselves (`cla-backend-go/auth/trusted_caller.go`): the signing algorithm is pinned to +the configured Auth0 algorithm, the signature is verified against the tenant JWKS by `kid` +(`https://{cla-auth0-domain}/.well-known/jwks.json`, cached 15 min; a cache miss reloads +at most once a minute; a JWKS outage keeps serving the cached key for at most 24 h), `exp` +must be present and unexpired, and the caller is **trusted** when the token's `azp` is +listed in the SSM parameter `cla-ss-trusted-client-ids-{stage}` (comma-separated Auth0 +client IDs). | Request, once the allow-list is configured | Result | |---|---| @@ -98,21 +104,22 @@ Auth0 client IDs). | Verified token, `azp` **on** the allow-list | trusted: the caller-supplied identity list is searched as given, no per-identity verification | | Verified token, `azp` **not** on the allow-list (or absent) | untrusted: unchanged behavior — admin bypass or per-identity ownership enforcement | -An absent header is denied exactly like an invalid one: the traefik `aws-lambda` middleware -drops duplicated headers, so a duplicated `Authorization` header arrives as an absent one. -`iss` and `aud` are deliberately not re-checked in-handler: the JWKS already binds the token to -this one tenant, and which audience an SS token carries is still open in the -[trust-SS decision](https://github.com/linuxfoundation/lfx-self-serve/issues/1216) (session access -token vs. the P3 api-gw-audience token), so pinning one would 401 the other. A token this tenant -minted for another API therefore also verifies — accepted because `azp` stays the client signal. - -While the SSM parameter is unset the verifier is **disabled** — no bearer token is required -and nothing is trusted, so the endpoints behave exactly as before. A local `./bin/cla` run -loads the stage's SSM, so once the parameter is set local requests need a real token too -(`utils/my_clas.sh` sends one; its token-less `PRINCIPAL=...` mode does not). A non-empty -allow-list without `cla-auth0-domain` panics at startup rather than trusting blindly. Each -request is logged with `callerClientID` (`azp`), `callerSubject` (`sub`), `trustedCaller` -and the requested identity list (length-bounded) for anomaly detection. +An absent header is denied exactly like an invalid one: the traefik `aws-lambda` +middleware drops duplicated headers, so a duplicated `Authorization` header arrives as an +absent one. `iss` and `aud` are deliberately not re-checked in-handler: the JWKS already +binds the token to this one tenant, and which audience an SS token carries is still open in +the [trust-SS decision](https://github.com/linuxfoundation/lfx-self-serve/issues/1216) +(session access token vs. the P3 api-gw-audience token), so pinning one would 401 the +other. A token this tenant minted for another API therefore also verifies — accepted +because `azp` stays the client signal. + +While the SSM parameter is unset the verifier is **disabled** — no bearer token is +required and nothing is trusted, so the endpoints behave exactly as before. A local +`./bin/cla` run loads the stage's SSM, so once the parameter is set local requests need a +real token too (`utils/my_clas.sh` sends one; its token-less `PRINCIPAL=...` mode does +not). A non-empty allow-list without `cla-auth0-domain` panics at startup rather than +trusting blindly. Each request logs `callerClientID` (`azp`), `callerSubject` (`sub`), +`trustedCaller` and the requested identity list (length-bounded) for anomaly detection. ## `GET /v4/my-clas` @@ -120,18 +127,19 @@ and the requested identity list (length-bounded) for anomaly detection. | Parameter | Type | Repeatable | Meaning | |---|---|---|---| -| `lfUsername` | string | no | LF username (LFID). **When omitted, defaults to the username of the authenticated principal** (from the token via `X-USERNAME`), so a plain `GET /v4/my-clas` returns the caller's own CLAs. For non-admin callers a value different from the token username is never searched (reported in `skippedIdentities`). | -| `email` | string | yes | Email addresses of the user. Lowercased/trimmed and matched against the EasyCLA user records' primary email (`lf_email` GSI — index-backed). All repeatable parameters are capped (`maxItems: 100`, `secondaryEmail: 20`) and deduplicated server-side. | -| `secondaryEmail` | string | yes | Email addresses additionally matched against the EasyCLA user records' additional-emails set (`user_emails`). **This match is not index-backed** (the attribute is a DynamoDB string set, which cannot carry a GSI): all provided values (max 20, normalized + deduplicated) are matched in **one single table scan** — never one scan per value, and never for plain `email` values. Use sparingly. | +| `lfUsername` | string | no | LF username (LFID). **When omitted, defaults to the authenticated principal's username** (from the token via `X-USERNAME`), so a plain `GET /v4/my-clas` returns the caller's own CLAs. For non-admin, untrusted callers a value different from the token username is never searched (reported in `skippedIdentities`). | +| `email` | string | yes | Lowercased/trimmed and matched against the EasyCLA user records' primary email (`lf_email` GSI — index-backed). All repeatable parameters are capped (`maxItems: 100`, `secondaryEmail: 20`) and deduplicated server-side. | +| `secondaryEmail` | string | yes | Matched against the records' additional-emails set (`user_emails`). **Not index-backed** (a DynamoDB string set cannot carry a GSI): all provided values (max 20, normalized + deduplicated) are matched in **one single table scan** — never one scan per value, and never for plain `email` values. Use sparingly. | | `githubId` | integer | yes | GitHub numeric user IDs linked to the LF identity (from Auth0 identities). The highest-precision key for pre-LF-login history. | | `githubUsername` | string | yes | GitHub usernames (hint only — usernames can be renamed/recycled; prefer `githubId`). | | `gitlabId` | integer | yes | GitLab numeric user IDs linked to the LF identity. | | `gitlabUsername` | string | yes | GitLab usernames (hint only). | -| `gerritUsername` | string | yes | Gerrit usernames. Gerrit authenticates via LF SSO, so these are (current or historical) **LF usernames** — matched against the EasyCLA records' `lf_username`. Useful for historical gerrit-era records tied to an older LDAP/LF username connected to the account. | +| `gerritUsername` | string | yes | Gerrit usernames. Gerrit authenticates via LF SSO, so these are (current or historical) **LF usernames** — matched against the records' `lf_username`. Useful for gerrit-era records tied to an older LDAP/LF username on the account. | -If the token carries no username (and the caller is not an admin), the endpoint -returns `401`. There is deliberately **no pagination**: a person's CLA set is small -(typically well under 50 records) and the upstream queries paginate internally. +If the token carries no username and the caller is neither an admin nor a trusted Self +Serve client, the endpoint returns `401`. There is deliberately **no pagination**: a +person's CLA set is small (typically well under 50 records) and the upstream queries +paginate internally. Example (through the gateway): @@ -142,46 +150,44 @@ curl -H "Authorization: Bearer $TOKEN" \ ### Identity ownership enforcement (step 0) -Without enforcement, an endpoint of this shape would let any authenticated user list -anybody's CLA history by guessing an email or GitHub ID. Therefore, for **non-admin** -callers every provided identity key must be verified to belong to the authenticated -user (the token's username claim) before it is searched: +Unenforced, an endpoint of this shape would let any authenticated user list anybody's CLA +history by guessing an email or GitHub ID. So for **non-admin** callers every identity key +is verified against the authenticated user (the token's username claim) before it is +searched: 1. **All** EasyCLA user records matching the token username are fetched - (`lf-username-index`, plural + paginated — one person may hold several LFID rows) - and their identity fields are merged. A key is allowed when it matches any of - them: emails against `lf_email` + `user_emails` (case-insensitive), `githubId` - against `user_github_id`, `githubUsername` against `user_github_username` + (`lf-username-index`, plural + paginated — one person may hold several LFID rows) and + their identity fields merged. A key is allowed when it matches any of them: emails + against `lf_email` + `user_emails` (case-insensitive), `githubId` against + `user_github_id`, `githubUsername` against `user_github_username` (case-insensitive), `gitlabId`/`gitlabUsername` likewise, and `gerritUsername`/`lfUsername` against the token username. -2. Username keys and keys not covered there are checked against the **LF-wide - identities** connected to the account in the platform user-service (loaded - lazily, at most once per request): `GET /user-service/v1/users?username={lfid}` - for the profile (SFID + all non-deleted profile emails) and - `GET /user-service/v1/users/{sfid}/identities` for connected identities - (paginated — all pages are fetched, so accounts with more than 100 identities are - fully covered; identities with a `DataSource` other than `platform` are ignored). - Usernames are accepted per source (`github`, `gitlab`, `gerrit` — a Slack +2. Username keys and keys not covered there are checked against the **LF-wide identities** + connected to the account in the platform user-service (loaded lazily, at most once per + request): `GET /user-service/v1/users?username={lfid}` for the profile (SFID + all + non-deleted profile emails) and `GET /user-service/v1/users/{sfid}/identities` for + connected identities (paginated — all pages fetched, so accounts with more than 100 + identities are fully covered; identities whose `DataSource` is not `platform` are + ignored). Usernames are accepted per source (`github`, `gitlab`, `gerrit` — a Slack identity never authorizes a GitHub search); identity emails are accepted from any platform-sourced identity. Numeric GitHub/GitLab IDs cannot be validated through user-service (identities carry usernames, not provider IDs), so they validate only - against the EasyCLA LFID records. - Because the users-table username indexes are exact-match while verification is - case-insensitive, allowed usernames are expanded to their **canonical spellings** - (from the EasyCLA records and user-service, plus the requested spelling) before - the index lookups — `githubUsername=octocat` finds a record stored as `Octocat`. -3. Keys verified by neither source are **not searched** and are reported back in the - response's `skippedIdentities` array (formatted `":"`, e.g. - `"email:bob@corp.com"`). They do not fail the request; the caller can surface or - log them (they are also the natural telemetry signal for identity-mapping gaps). - A user-service outage degrades the same way: unverifiable keys are skipped and - reported, never allowed. - -Callers whose gateway-injected `X-ACL` carries the **admin** flag -(`utils.IsUserAdmin`) bypass enforcement entirely — preserving a support/parity- -sampling path (e.g. the SC-001 comparison script) without weakening the contributor -case. The token username is always searched for non-admin callers, so a bare -`GET /v4/my-clas` works even when every extra key is skipped. + against the EasyCLA LFID records. Because the users-table username indexes are + exact-match while verification is case-insensitive, allowed usernames are expanded to + their **canonical spellings** (from the EasyCLA records and user-service, plus the + requested spelling) before the index lookups — `githubUsername=octocat` finds a record + stored as `Octocat`. +3. Keys verified by neither source are **not searched** and are reported in the response's + `skippedIdentities` array (formatted `":"`, e.g. + `"email:bob@corp.com"`). They do not fail the request; the caller can surface or log + them (they are the natural telemetry signal for identity-mapping gaps). A user-service + outage degrades the same way: unverifiable keys are skipped and reported, never allowed. + +Callers whose gateway-injected `X-ACL` carries the **admin** flag (`utils.IsUserAdmin`) +bypass enforcement entirely — preserving a support/parity-sampling path (e.g. the SC-001 +comparison script) without weakening the contributor case. The token username is always +searched for non-admin callers, so a bare `GET /v4/my-clas` works even when every extra key +is skipped. **A trusted Self Serve caller** (see [Trusted Self Serve caller](#trusted-self-serve-caller-in-handler-jwt-verification--azp-allow-list)) also bypasses enforcement — deliberately, because here it is the *wrong* check: SS derives @@ -194,18 +200,17 @@ supplying neither a username nor any identity key is a `400`, not an "everyone" ### Identity resolution (step 1) The allowed keys are resolved to EasyCLA user records — **union of all matches, -deduplicated by `user_id`, in parameter order**. One person may hold several EasyCLA -user records (e.g. a pre-LF-login GitHub-derived record without `lf_username` plus a -console-created record); this is why aggregation happens across all matches -(spec FR-005). Every lookup is a DynamoDB GSI query on the `cla-{stage}-users` table -except the explicitly opt-in `secondaryEmail` scan (the existing `/v3/users/search` -full-scan-per-request pattern is exactly what this avoids, per -[lfx-self-serve#1161](https://github.com/linuxfoundation/lfx-self-serve/issues/1161)): - -All lookups are **plural and paginated** module-repository queries returning every -matching record — the legacy singular `users` service helpers were deliberately not -reused because they return only the first row when a GSI holds several matches for -the same key (one person with multiple records), which would silently drop history: +deduplicated by `user_id`, in parameter order**. One person may hold several EasyCLA user +records (e.g. a pre-LF-login GitHub-derived record without `lf_username` plus a +console-created one); hence aggregation across all matches (spec FR-005). Every lookup is +a DynamoDB GSI query on `cla-{stage}-users` except the opt-in `secondaryEmail` scan (the +existing `/v3/users/search` full-scan-per-request pattern is exactly what this avoids, per +[lfx-self-serve#1161](https://github.com/linuxfoundation/lfx-self-serve/issues/1161)). + +All lookups are **plural and paginated** module-repository queries returning every matching +record — the legacy singular `users` service helpers were deliberately not reused because +they return only the first row when a GSI holds several matches for the same key (one +person with multiple records), which would silently drop history: | Identity key | Users-table access | Module repository method | |---|---|---| @@ -218,126 +223,182 @@ the same key (one person with multiple records), which would silently drop histo | `gitlabId` | `gitlab-id-index` GSI (N-typed key) | `GetUsersByGitlabID` | | `gitlabUsername` | `gitlab-username-index` GSI | `GetUsersByGitlabUsername` | -Empty results are simply empty; any EasyCLA repository lookup error fails the request (`500`) — user-service failures instead skip and report the affected keys rather -than silently returning a partial history — an incomplete list would erode user -trust (spec: "an incomplete list here erodes trust in every later milestone"). The -same rule applies to the display lookups: a *missing* CLA group or company record -degrades gracefully (name omitted / ECLA marked invalid), while any other data-layer -error fails the request. - -On `user_emails` (why `secondaryEmail` is separate): the attribute is a DynamoDB -string set and **cannot be GSI-indexed**, so matching it requires a table scan. The -default `email` parameter therefore stays strictly index-backed (`lf_email`), and a -single scan (matching every provided value at once) runs only when the caller -explicitly passes `secondaryEmail` values. In practice GitHub/GitLab-derived records -(the ones whose `user_emails` would matter most) are matched precisely by the numeric -`githubId`/`gitlabId` keys instead. +Empty results are simply empty. An EasyCLA repository error during identity resolution or +signature retrieval fails the request (`500`) rather than returning a partial history — +user-service failures instead skip and report the affected keys — because "an incomplete +list here erodes trust in every later milestone" (spec). A *missing* CLA group or company +record degrades gracefully (name omitted / ECLA marked invalid), and a **failed company or +CCLA lookup degrades that single row** — name omitted, `status` becomes `unknown` — instead +of failing the whole list, so one unreachable company cannot blank out a user's entire CLA +history (the failure is cached per company, so sibling rows do not retry it). Any other +data-layer error, including a CLA-group/project mapping failure that would affect every row +alike, still fails the request. No row is ever silently dropped. + +On `user_emails` (why `secondaryEmail` is separate): the attribute is a DynamoDB string set +and **cannot be GSI-indexed**, so matching it requires a table scan. The default `email` +parameter therefore stays strictly index-backed (`lf_email`), and a single scan (matching +every provided value at once) runs only when the caller explicitly passes `secondaryEmail` +values. In practice GitHub/GitLab-derived records — the ones whose `user_emails` would +matter most — are matched precisely by the numeric `githubId`/`gitlabId` keys instead. ### Gerrit support -Gerrit has no separate identity space in EasyCLA: Gerrit instances are LF-hosted and -contributors authenticate with their **LF SSO account**, and the gerrit user-creation -path (`POST /v1/user/gerrit`, `cla-backend-legacy/internal/api/handlers.go` +Gerrit has no separate identity space in EasyCLA: instances are LF-hosted, contributors +authenticate with their **LF SSO account**, and the gerrit user-creation path +(`POST /v1/user/gerrit`, `cla-backend-legacy/internal/api/handlers.go` `PostOrGetUserGerritV1` → `getOrCreateUser` from the auth token) keys the EasyCLA user record on `lf_username`/`lf_email`. Consequently: -- CLAs signed via Gerrit are found automatically through the token username and email - keys — nothing extra to pass for the common case. -- The `gerritUsername` parameter covers the historical case where the account is - connected (per user-service `Source=gerrit` identities) to an **older/different LF - username** than the current one — those values resolve through the same - `lf-username-index`. -- Gerrit's internal numeric account IDs exist only inside Gerrit itself; neither - EasyCLA nor the platform user-service stores them, so there is nothing to look up by - — usernames are the supported Gerrit key. +- CLAs signed via Gerrit are found automatically through the token username and email keys + — nothing extra to pass for the common case. +- `gerritUsername` covers the historical case where the account is connected (per + user-service `Source=gerrit` identities) to an **older/different LF username** — those + values resolve through the same `lf-username-index`. +- Gerrit's internal numeric account IDs exist only inside Gerrit; neither EasyCLA nor the + platform user-service stores them, so usernames are the supported Gerrit key. ### Signature retrieval (step 2) -For each matched user record, the module queries the `cla-{stage}-signatures` table on -the `reference-signature-index` GSI (`signature_reference_id = user_id`), filtered to -`signature_reference_type = user` and `signature_type IN (cla, ecla)`, and paginates -until exhausted. - -Two facts discovered during implementation (both verified against the dev DynamoDB -data) shaped this query — a new module-private query was required because **no existing -endpoint returns a user's ECLAs**: - -- The existing `GET /v4/signatures/user/{userID}` (v1 `signatures` repository - `GetUserSignatures`) explicitly filters **out** every record with - `signature_user_ccla_company_id` set — i.e. it returns ICLAs only, contrary to what - the M1 research doc assumed. -- ECLA records exist with **two spellings of `signature_type`**: DocuSign-era ECLAs - carry `signature_type=cla`, while ECLAs auto-created from approval-list changes +For each matched user record the module queries `cla-{stage}-signatures` on the +`reference-signature-index` GSI (`signature_reference_id = user_id`), filtered to +`signature_reference_type = user` and `signature_type IN (cla, ecla)`, paginating until +exhausted. + +Two facts found during implementation (both verified against dev DynamoDB data) shaped this +query — a module-private query was required because **no existing endpoint returns a user's +ECLAs**: + +- `GET /v4/signatures/user/{userID}` (v1 `signatures` repository `GetUserSignatures`) + explicitly filters **out** every record with `signature_user_ccla_company_id` set, i.e. + it returns ICLAs only, contrary to what the M1 research doc assumed. +- ECLA records exist with **two spellings of `signature_type`**: DocuSign-era ECLAs carry + `signature_type=cla`, ECLAs auto-created from approval-list changes (`signatures/repository.go` `CreateOrUpdateEmployeeSignature`) carry - `signature_type=ecla`. Dev sample: 177 user-referenced `cla`-typed records with a - company ID vs 77 `ecla`-typed; every `ecla`-typed record has a company ID. + `signature_type=ecla`. Dev sample: 177 user-referenced `cla`-typed records with a company + ID vs. 77 `ecla`-typed; every `ecla`-typed record has a company ID. ### Classification, filtering, deduplication (step 3) -- Records with `signature_signed = false` (abandoned/incomplete signing ceremonies) are +- Records with `signature_signed = false` (abandoned/incomplete ceremonies) are **excluded** — the milestone lists signed agreements only. -- **ICLA vs ECLA** is decided by `signature_user_ccla_company_id` presence (absent ⇒ - ICLA, set ⇒ ECLA) — the same invariant the v1→v2 signature converters and the - dynamo-events lambda use, and the one that holds for both `signature_type` spellings. - CCLA records (`signature_reference_type=company`) never match the query; corporate - data is out of M1 scope. +- **ICLA vs ECLA** is decided by `signature_user_ccla_company_id` presence (absent ⇒ ICLA, + set ⇒ ECLA) — the same invariant the v1→v2 signature converters and the dynamo-events + lambda use, and the one that holds for both `signature_type` spellings. CCLA records + (`signature_reference_type=company`) never match the query; corporate data is out of M1 + scope. - Results are **deduplicated by `signatureID`** across the matched user records; each - distinct signature is a distinct legal record and is shown even when several exist - for the same CLA group (per the M1 data-model decision). -- Rows are sorted by `signedOn` **descending**. `signedOn` mirrors the v1 converter - behavior: `signed_on` attribute, falling back to `date_created` for older records. + distinct signature is a distinct legal record and is shown even when several exist for the + same CLA group (per the M1 data-model decision). +- Rows are sorted by `signedOn` **descending**. `signedOn` mirrors the v1 converter: + `signed_on`, falling back to `date_created` for older records. ### Validity evaluation (step 4) -Each returned row carries the raw flags (`signed`, `approved`) plus a computed `valid` -boolean: +Each row carries the raw flags (`signed`, `approved`) plus a computed `valid`: - **ICLA**: `valid = signed && approved`. `approved=false` means the signature was - invalidated (PM invalidation or approval-criteria removal set - `signature_approved=false`; the stored `note` records why). + invalidated (PM invalidation or approval-criteria removal set `signature_approved=false`; + the stored `note` records why). - **ECLA** (employee acknowledgement): `valid` requires **all** of: 1. `signature_signed && signature_approved`; - 2. the employer (company record) exists and is **not sanctioned** - (`is_sanctioned` — the same persisted gate `ProcessEmployeeSignature` enforces - for PR checks); - 3. the employer **currently holds an approved + signed CCLA** for the CLA group - (v1 `signatures.Service.GetCorporateSignature`); - 4. the user **still matches that CCLA's current approval lists** — evaluated by - reusing v1 `signatures.Service.UserIsApproved`, the *exact* function PR gating - uses (issue [#1164](https://github.com/linuxfoundation/lfx-self-serve/issues/1164) - demands the real gating logic, not an approximation): - - GitHub username approval list — case-insensitive exact match; - - GitLab username approval list — case-insensitive exact match; - - email approval list — case-insensitive exact match over the user record's - emails (`user_emails` + `lf_email`); - - email-domain approval list — regex patterns (`*.corp.com`, `*corp.com`, - `.corp.com`, `corp.com` forms); - - GitHub org approval list — live lookup of the user's **public** GitHub org - memberships (transient GitHub API failures are treated as "no match", never as - an error — same as gating); - - GitLab group approval list — `UserIsApproved` cannot evaluate group membership - live (that needs per-group OAuth tokens held by the MR-gating service), so when - the CCLA carries GitLab group approvals and nothing else matched, the check - **defers to the `signature_approved` flag** (which the approval-list - invalidation flow maintains) instead of wrongly reporting the ECLA invalid. - - This is checked **at request time inside the API**, because approval lists change - after acknowledgements are recorded. Approval-list edits do synchronously invalidate - affected ECLAs (`signature_approved=false` + note), so `approved` usually already - reflects removals — the live re-check is the belt-and-braces guarantee the task - demands, and it also catches drift (e.g. a user who left the company's GitHub org, - or edits where the invalidation pass missed a record). + 2. the employer (company record) exists and is **not flagged** by sanctions screening — a + live screen when screening is enabled, otherwise the persisted `is_sanctioned` gate + `ProcessEmployeeSignature` enforces for PR checks (step 5); + 3. the employer **currently holds an approved + signed CCLA** for the CLA group (v1 + `signatures.Service.GetCorporateSignature`); + 4. the user **still matches that CCLA's current approval lists** — evaluated by reusing v1 + `signatures.Service.EvaluateUserApproval`, the *exact* function PR gating uses (issue + [#1164](https://github.com/linuxfoundation/lfx-self-serve/issues/1164) demands the real + gating logic, not an approximation): + - GitHub username list — case-insensitive exact match; + - GitLab username list — case-insensitive exact match; + - email list — case-insensitive exact match over the record's emails (`user_emails` + + `lf_email`); + - email-domain list — regex patterns (`*.corp.com`, `*corp.com`, `.corp.com`, + `corp.com` forms); + - GitHub org list — live lookup of the user's **public** GitHub org memberships + (transient GitHub API failures count as "no match", never as an error — same as + gating); + - GitLab group list — `EvaluateUserApproval` cannot evaluate group membership live + (that needs per-group OAuth tokens held by the MR-gating service), so when the CCLA + carries GitLab group approvals and nothing else matched, the check **defers to the + `signature_approved` flag** (which the invalidation flow maintains) instead of + wrongly reporting the ECLA invalid. + + This runs **at request time inside the API** because approval lists change after + acknowledgements are recorded. Edits do synchronously invalidate affected ECLAs + (`signature_approved=false` + note), so `approved` usually already reflects removals; the + live re-check is the guarantee the task demands and catches drift (a user who left the + company's GitHub org, or edits the invalidation pass missed). The user record used for the approval-list check is the record that **owns** the ECLA -(`signature_reference_id`), matching how gating evaluates that user — not the union of -all provided identities. +(`signature_reference_id`), matching how gating evaluates that user — not the union of all +provided identities. + +The endpoint deliberately returns **invalid rows too** (`valid=false`): story +[#1158](https://github.com/linuxfoundation/lfx-self-serve/issues/1158) wants ICLAs in *all* +statuses, and faithful data keeps the API useful for parity sampling (SC-001) and support. + +FR-002's "display ECLAs only when valid" predates the computed `status`, so **do not filter +on `valid=false`**: `needs_attention` rows are `valid=false` by construction and are exactly +the rows carrying the "Request approval" action +([#1372](https://github.com/linuxfoundation/lfx-self-serve/issues/1372)); an `unknown` row +whose coverage could not be determined is `valid=false` too, yet should render as covered. +Filter, if at all, on `status` (e.g. hide only `invalidated`). -The endpoint intentionally returns **invalid rows too** (flagged `valid=false`) instead -of hiding them: story [#1158](https://github.com/linuxfoundation/lfx-self-serve/issues/1158) -wants ICLAs in *all* statuses, while ECLAs are only *displayed* when valid (FR-002) — -that final display filter is one line in the consumer (`clas.filter(c => c.claType !== -'ecla' || c.valid)`), and keeping the data faithful makes the API useful for parity -sampling (SC-001) and support. +### Contributor-facing status and sanctions screening (step 5) + +`valid` answers "does this agreement attribute contributions right now"; the console needs +the *reason*, so each row also carries a computed `status` (plus `statusReason` when it is +not `valid`), evaluated in this precedence: + +| `status` | When | `statusReason` | +|---|---|---| +| `revoked` | The employer is flagged by sanctions screening — system-set, no user action | — | +| `invalidated` | The stored `approved` flag is `false` (an Approved List edit, an invalidated ICLA, a deleted CLA Group all produce it) | — | +| `unknown` | ECLA coverage could not be evaluated (company/CCLA record unreadable, the GitHub public-orgs lookup failed, or the GitLab-group fallback applied) | `unknown` | +| `valid` | ICLA that is signed + approved, or an ECLA whose employer's CCLA still covers the user | — | +| `needs_attention` | A *completed* approval-list check proved the user is no longer covered | `not_on_approval_list` | + +ICLAs are only ever `valid` or `invalidated`. New `status` values may be added in a future +spec revision (a generated client then needs a spec refresh, since go-swagger validates the +enum strictly). `not_on_approval_list` is the one reason a "Request approval" action can act +on; anything else is informational. + +`status` and `valid` can legitimately disagree, and the pair carries more than either alone: +the GitLab-group deferral (step 4) returns `valid: true` with `status: unknown` — displayed +as covered, but the coverage was never independently verified. A consumer that wants #1256's +three-value column can safely render `unknown && valid` as Valid; the reverse (recovering +"unverified" from `valid` alone) is impossible. + +The full #1256 pill derivation: Valid = `valid` (and `unknown`, per above), Needs attention += `needs_attention`, Revoked = `revoked` **∪** `invalidated` — #1256 defines Revoked as +"`approved = false` / invalidated by the system", which is exactly our `invalidated`, while +`revoked` is the sanctions case it names; the API keeps the two apart so the *cause* stays +distinguishable until #1370's durable revocation metadata exists. #1256 also calls a +needs-attention row "still a valid signature": that means still signed and not revoked, not +that `valid` is `true` — `valid` answers current attribution, so those rows carry +`valid: false` (see the filtering note in step 4). + +`flagged` is not read from the stored company flag alone. When sanctions screening is +enabled the listing screens **each distinct employer once per response** against the +Sanctions Screening Service for a live answer, and reports how the answer was obtained in +`flaggedCheck`: + +| Situation | `flagged` | `flaggedCheck` | +|---|---|---| +| Employer blocked by an administrator (`sanction_origin != sss`) | `true` (authoritative, no call made) | `stored` | +| Screening disabled or unconfigured | the persisted `is_sanctioned` flag | `stored` | +| Live screen answered | the live verdict (it overrides a stale persisted flag either way) | `live` | +| Live screen could not be completed (no company external ID, org lookup failure, unresolvable domain, SSS error/unexpected status) | the persisted `is_sanctioned` flag — possibly stale | `unavailable` | +| Employer record itself could not be read | `false` (no persisted flag to fall back to) | `unavailable` | + +The response-level `sssMode` (`required` / `optional` / `disabled`) tells the consumer how +much weight `unavailable` carries: in `required` mode an unverified row is a real gap, in +`optional` mode best-effort. **A screening failure never fails this endpoint** in either +mode — unlike the signing flow, which may fail closed. The screener is also strictly +read-only: `v2/sign`'s `checkCompanyCompliance` *persists* what it observes, which a GET must +not do, so the lookup/domain/status logic is duplicated rather than shared. ### Response — `200 my-cla-list` @@ -346,7 +407,8 @@ sampling (SC-001) and support. "lfUsername": "jdoe", "userIds": ["6e29e1a9-...", "a3b1c2d3-..."], "skippedIdentities": [], - "resultCount": 3, + "sssMode": "optional", + "resultCount": 2, "clas": [ { "signatureID": "3c1e5d7a-...", @@ -360,12 +422,13 @@ sampling (SC-001) and support. "signed": true, "approved": true, "valid": true, + "status": "valid", "documentMajorVersion": 2, "documentMinorVersion": 0, "pdfAvailable": true }, { - "signatureID": "9ab2f4c1-...", + "signatureID": "207b003b-...", "claType": "ecla", "claGroupID": "88bc3d21-...", "claGroupName": "LF Energy", @@ -373,26 +436,14 @@ sampling (SC-001) and support. "companyName": "Example Corp", "signingEntityName": "Example Corp LLC", "userID": "a3b1c2d3-...", - "signedOn": "2026-01-18T08:00:00Z", - "signed": true, - "approved": true, - "valid": true, - "documentMajorVersion": 2, - "documentMinorVersion": 0, - "pdfAvailable": false - }, - { - "signatureID": "207b003b-...", - "claType": "ecla", - "claGroupID": "01af041c-...", - "claGroupName": "CNCF - Kubernetes", - "companyID": "f7c7ac9c-...", - "companyName": "Example Corp", - "userID": "6e29e1a9-...", "signedOn": "2024-05-05T09:16:19Z", "signed": true, "approved": false, "valid": false, + "status": "invalidated", + "claManager": false, + "flagged": false, + "flaggedCheck": "live", "documentMajorVersion": 2, "documentMinorVersion": 0, "pdfAvailable": false @@ -408,36 +459,46 @@ Field reference (`my-cla` rows): | `signatureID` | string | Signature UUID; input to the PDF endpoint | | `claType` | `icla` \| `ecla` | See classification above; the UI renders `ICLA` / `ECLA · ` pills | | `claGroupID` | string | CLA Group UUID (`signature_project_id`) | -| `claGroupName` | string | Resolved via `projects_cla_groups` repo (single `GetItem`, cached per request); **omitted from the JSON** (string fields marshal with `omitempty`) if the CLA group record is gone — solves the "payload carries no project display name" gap noted in M1 research R6. No v1 user-service/org-service IDs are exposed (architecture-proposal P9) | -| `projectName` | string | The Salesforce project display name the CLA Group belongs to (a foundation-level CLA Group — identified by a `projects_cla_groups` mapping whose `project_sfid == foundation_sfid` — resolves to its foundation). Name comes from the `projects_cla_groups` mapping table and is upgraded to the project-service `Name` when available; both cached per request. Rendered as the bold top line of the UI's Project cell (with `claGroupName` as the subtext). Omitted when it could not be resolved | -| `projectLogo` | string | The project (or foundation) logo URL, fetched from the project-service by project SFID (cached per request). Rendered as the Project cell's logo tile (the consumer supplies a default-icon fallback). A project-service miss degrades to an empty logo without failing the listing; omitted when empty | -| `companyID` / `companyName` / `signingEntityName` | string | ECLA only; resolved from the companies table (cached per request) | -| `userID` | string | The owning EasyCLA user record — lets the consumer correlate rows with `userIds` and with other per-user endpoints | +| `claGroupName` | string | From the `projects_cla_groups` repo (single `GetItem`, cached per request); **omitted from the JSON** (string fields marshal with `omitempty`) when the CLA group record is gone — closes the "payload carries no project display name" gap from M1 research R6. No v1 user-service/org-service IDs are exposed (architecture-proposal P9) | +| `projectName` | string | Salesforce project display name of the CLA Group (a foundation-level CLA Group — a `projects_cla_groups` mapping whose `project_sfid == foundation_sfid` — resolves to its foundation). From the mapping table, upgraded to the project-service `Name` when available; both cached per request. Bold top line of the UI's Project cell, with `claGroupName` as subtext. Omitted when unresolved | +| `projectLogo` | string | Project (or foundation) logo URL from the project-service by project SFID (cached per request). The Project cell's logo tile (the consumer supplies the default-icon fallback). A miss degrades to an empty logo without failing the listing; omitted when empty | +| `companyID` / `companyName` / `signingEntityName` | string | ECLA only; from the companies table (cached per request) | +| `userID` | string | The owning EasyCLA user record — correlates rows with `userIds` and with other per-user endpoints | | `signedOn` | string | Signing/acknowledgement date (fallback: record creation date) | | `signed` / `approved` | bool | Raw signature flags | | `valid` | bool | Computed as defined above | -| `documentMajorVersion` / `documentMinorVersion` | int | CLA document version that was signed (display/superseded detection is the consumer's choice) | -| `pdfAvailable` | bool | `true` when the record is a signed ICLA eligible for PDF retrieval (ECLAs have no signed document — FR-002); invalidated ICLAs stay eligible (it is the user's own signed legal record); actual S3 object availability is verified by the PDF endpoint on request | +| `status` | `valid` \| `needs_attention` \| `revoked` \| `invalidated` \| `unknown` | Contributor-facing standing, computed independently of `approved`/`valid` (see step 5). New values may be added in a future spec revision (generated clients validate the enum strictly) | +| `statusReason` | `not_on_approval_list` \| `unknown` | Why the standing is not `valid`; omitted for every other status and on every ICLA | +| `flagged` / `flaggedAt` | bool / string | ECLA only: the employer is currently flagged by sanctions screening, and when that was observed (response time — no sanction timestamp is stored yet) | +| `flaggedCheck` | `live` \| `stored` \| `unavailable` | ECLA only: how `flagged` was obtained (see step 5). `unavailable` means the value is the persisted flag and may be stale | +| `signedVia` / `signedAs` | string | The platform signed via (`github`, `gitlab`, `gerrit` — the last also covers LF SSO signings identified by email) and the account signed as; omitted when the record carries no such identity | +| `claManager` | bool | ECLA only: the owning user is a CLA manager of the employer's CCLA for this CLA Group | +| `documentMajorVersion` / `documentMinorVersion` | int | Signed CLA document version (display/superseded detection is the consumer's choice) | +| `pdfAvailable` | bool | `true` for a signed ICLA eligible for PDF retrieval (ECLAs have no signed document — FR-002); invalidated ICLAs stay eligible (the user's own signed legal record); actual S3 object availability is verified by the PDF endpoint on request | List-level fields: `lfUsername` (the effective username the list was resolved for), `userIds` (matched EasyCLA user record IDs), `skippedIdentities` (identity parameters dropped by the ownership enforcement, `":"` strings, always present — -`[]` when nothing was skipped), `resultCount`. +`[]` when nothing was skipped), `sssMode` (the sanctions screening mode in effect, always +present), `resultCount`. Errors: `401` (token carries no username — also returned by the gateway for a -missing/invalid token before the request reaches EasyCLA), `400` (admin caller with no -username and no identity keys at all), `403` (ACS deny at the gateway), `500` -(upstream data-layer failure — no partial results are returned). +missing/invalid token before the request reaches EasyCLA), `400` (an admin or trusted +caller with no username and no identity keys at all), `403` (ACS deny at the gateway), +`500` (a data-layer failure affecting the whole list — no partial results). Per-row failures are +not errors: a company/CCLA lookup or a sanctions screen that fails degrades that row +(`status: unknown`, or `flaggedCheck: unavailable`) and still returns `200`, as described +under "Identity resolution (step 1)". -An identity that resolves to zero user records returns `200` with empty -`userIds`/`clas` (`resultCount: 0`) — that is the Self Serve "unmatched" empty state, -not an error. +An identity that resolves to zero user records returns `200` with empty `userIds`/`clas` +(`resultCount: 0`) — the Self Serve "unmatched" empty state, not an error. ## `GET /v4/my-clas/{signatureID}/pdf` -Issues the signed-PDF download link for an ICLA **owned by the authenticated user**. -Takes the **same identity query parameters** as `/v4/my-clas` (same defaulting from -the token) plus the path parameter: +Issues the signed-PDF download link for an ICLA **owned by the resolved identity** — the +authenticated user's own, unless the caller is an admin or trusted (step 0). Takes the +**same identity query parameters** as `/v4/my-clas` (same defaulting from the token) plus +the path parameter: ```bash curl -H "Authorization: Bearer $TOKEN" \ @@ -446,15 +507,15 @@ curl -H "Authorization: Bearer $TOKEN" \ Behavior: -1. Applies the **same identity-ownership enforcement** as the list endpoint (step 0) - and resolves the allowed identity to user records — so for a non-admin caller the - resolvable set can only ever contain their own records, and a signature ID - belonging to somebody else is a guaranteed 404 even if the caller passes that - person's email/GitHub keys explicitly (covered by a dedicated unit test). -2. Verifies the `signatureID` belongs to one of those records **and** is a signed - ICLA, then verifies the PDF object actually exists in S3 (`HeadObject`) — a missing - document returns 404 instead of a dead presigned URL. -3. On success, returns a presigned S3 GET URL for +1. Applies the **same identity-ownership enforcement** as the list endpoint (step 0) and + resolves the allowed identity to user records — so for a non-admin, untrusted caller the + resolvable set can only contain their own records, and a signature ID belonging to + somebody else is a guaranteed 404 even if the caller passes that person's email/GitHub + keys explicitly (covered by a dedicated unit test). +2. Verifies the `signatureID` belongs to one of those records **and** is a signed ICLA, then + verifies the PDF object exists in S3 (`HeadObject`) — a missing document returns 404 + instead of a dead presigned URL. +3. On success returns a presigned S3 GET URL for `contract-group/{claGroupID}/icla/{userID}/{signatureID}.pdf` in the `cla-signature-files-{stage}` bucket, valid for **15 minutes**: @@ -466,54 +527,50 @@ Behavior: } ``` -4. Unknown, not-owned, unsigned, or ECLA signature IDs all return **`404`** (never - `403`), so the endpoint is not an existence oracle — matching the M1 SS contract +4. Unknown, not-owned, unsigned and ECLA signature IDs all return **`404`** (never `403`), + so the endpoint is not an existence oracle — matching the M1 SS contract (`ss-me-clas-api.md`). -Because the URL TTL is 15 minutes, the consumer must fetch it **on click**, never on -page load, and hand it straight to the browser (issues +Because the URL TTL is 15 minutes, the consumer must fetch it **on click**, never on page +load, and hand it straight to the browser (issues [#1166](https://github.com/linuxfoundation/lfx-self-serve/issues/1166), -[#1167](https://github.com/linuxfoundation/lfx-self-serve/issues/1167) — SS never -stores documents). +[#1167](https://github.com/linuxfoundation/lfx-self-serve/issues/1167) — SS never stores +documents). ### Why a new PDF endpoint instead of the existing one? -`GET /v4/signatures/{signatureID}/signed-document` already exists, but its access check +`GET /v4/signatures/{signatureID}/signed-document` exists, but its access check (`v2/signatures/handlers.go` `isUserHaveAccessOfSignedSignaturePDF`) requires -**project-scoped ACL authority** (project manager / project-org scopes). A plain -contributor holds no such scopes, so the Me-lens flow could not use it. The new -endpoint is modeled on that implementation — it reuses the very same S3 key layout and -presign helper (`utils.SignedCLAFilename` + `utils.GetDownloadLink`, 15-minute TTL) and -the same ECLA exclusion (`v2/signatures/service.go` `GetSignedDocument` rejects -employee signatures for the same reason: no document exists) — but replaces the -role-based check with the **token-anchored identity-ownership check** described above, -which is the right authorization model for "download *my own* signed document". +**project-scoped ACL authority** (project manager / project-org scopes), which a contributor +does not hold. The new endpoint reuses that implementation's S3 key layout and presign helper +(`utils.SignedCLAFilename` + `utils.GetDownloadLink`, 15-minute TTL) and its ECLA exclusion +(`v2/signatures/service.go` `GetSignedDocument` rejects employee signatures — no document +exists), replacing the role-based check with the **token-anchored identity-ownership check** +above: the right model for "download *my own* signed document". ## `GET /v4/my-clas/identities` -Returns the deduplicated identities the **authenticated user** owns — no query -parameters, always scoped to the token holder (an admin token returns the admin's own -identities, not anyone else's). This is the identity-resolution counterpart to -`lfx-self-serve` issue [#1161](https://github.com/linuxfoundation/lfx-self-serve/issues/1161): -instead of the Sanctions-Screening/SS side scanning `cla-*-users` client-side to map an -identity back to an EasyCLA user, it can read the exact identity set EasyCLA already -associates with the caller. +Returns the deduplicated identities the **authenticated user** owns — no query parameters, +always scoped to the token holder (an admin token returns the admin's own identities). This +is the identity-resolution counterpart to +[lfx-self-serve#1161](https://github.com/linuxfoundation/lfx-self-serve/issues/1161): +instead of the SS side scanning `cla-*-users` client-side to map an identity back to an +EasyCLA user, it can read the exact identity set EasyCLA already associates with the caller. ```bash curl -H "Authorization: Bearer $TOKEN" "$GW/cla-service/v4/my-clas/identities" ``` -The set is the **union of the two sources the list endpoint's ownership enforcement -(step 0) trusts** — the identities on the caller's EasyCLA user records -(`GetUsersByLFUsername`) and the identities connected to their LF account in the platform -user-service (`loadPlatformIdentities`: profile emails + non-deleted connected identities). -The service method `GetMyIdentities` reuses those same two calls, so this endpoint can -never surface an identity that `/v4/my-clas` would refuse to search for that caller, and -the My CLAs / PDF endpoints are left unchanged. +The set is the **union of the two sources the ownership enforcement (step 0) trusts** — the +identities on the caller's EasyCLA user records (`GetUsersByLFUsername`) and those connected +to their LF account in the platform user-service (`loadPlatformIdentities`: profile emails + +non-deleted connected identities). `GetMyIdentities` reuses those same two calls, so this +endpoint can never surface an identity that `/v4/my-clas` would refuse to search for that +caller, and the My CLAs / PDF endpoints are unchanged. -Each entry is `":"`, deduplicated and sorted; types are `lf-username`, -`email`, `github-id`, `github-username`, `gitlab-id`, `gitlab-username`, -`gerrit-username`. Response `200 my-identity-list`: +Each entry is `":"`, deduplicated and sorted; types are `lf-username`, `email`, +`github-id`, `github-username`, `gitlab-id`, `gitlab-username`, `gerrit-username`. Response +`200 my-identity-list`: ```json { @@ -527,195 +584,196 @@ Each entry is `":"`, deduplicated and sorted; types are `lf-usernam } ``` -A token carrying no username returns `401` (same as the list endpoint). +A token carrying no username returns `401` — unconditionally here, with no admin or +trusted-caller exemption, since the endpoint is scoped to the token holder. ## How LFX Self Serve consumes this (M1 mapping) -The SS server slice already on `lfx-self-serve` branch `feat/easycla-my-clas-server` -resolves identity from the session (LF username via `getEffectiveUsername`, verified -emails, GitHub numeric IDs from Auth0 identities) and currently unions per-user calls -with a TODO for the missing lookup endpoint. With this API it collapses to: - -- `GET /api/me/clas` → one upstream call - `GET /cla-service/v4/my-clas?email=…&secondaryEmail=…&githubId=…&githubUsername=…&gitlabId=…&gitlabUsername=…&gerritUsername=…` - — SS MUST forward **all** available session-derived identity keys: each verified - email as both `email` and `secondaryEmail` (they search different attributes), and - provider usernames alongside numeric IDs (an ID present only on a detached - pre-LFID record cannot be authorized by itself; the verified username recovers - it). The same full set goes on the PDF call (server-derived values from the session - — EasyCLA **re-verifies** every key against the LF account server-side until SS is - allow-listed, after which SS's Auth0-derived list is authoritative). Mapping to +The SS server slice on `lfx-self-serve` branch `feat/easycla-my-clas-server` resolves +identity from the session (LF username via `getEffectiveUsername`, verified emails, GitHub +numeric IDs from Auth0 identities) and currently unions per-user calls with a TODO for the +missing lookup endpoint. With this API it collapses to: + +- `GET /api/me/clas` → one upstream + `GET /cla-service/v4/my-clas?email=…&secondaryEmail=…&githubId=…&githubUsername=…&gitlabId=…&gitlabUsername=…&gerritUsername=…`. + SS MUST forward **all** session-derived identity keys: each verified email as both `email` + and `secondaryEmail` (they search different attributes), and provider usernames alongside + numeric IDs (an ID present only on a detached pre-LFID record cannot authorize itself; the + verified username recovers it). The same set goes on the PDF call. All values are + server-derived from the session; EasyCLA **re-verifies** every key against the LF account + until SS is allow-listed, after which SS's Auth0-derived list is authoritative. Mapping to `MyClaAgreement`: `kind = claType`, `projectName = projectName` (bold top line of the - Project cell) with `claGroupName` as its subtext and `projectLogo` as the logo tile - (falling back to `claGroupName` / a default icon when a field is absent — the endpoint - now supplies the distinct project name + logo, so the old `projectName = claGroupName` - UUID fallback is retired), `status` from `valid`, drop ECLAs with `valid=false` - (FR-002), `pdfAvailable` as-is; identity telemetry from `userIds` and - `skippedIdentities` (`matchedUserIds = userIds.length`, `unmatched = resultCount === - 0 && userIds.length === 0` — skipped keys are the direct signal for issue - [#1165](https://github.com/linuxfoundation/lfx-self-serve/issues/1165)'s + Project cell) with `claGroupName` as subtext and `projectLogo` as the logo tile (falling + back to `claGroupName` / a default icon when absent — the old + `projectName = claGroupName` UUID fallback is retired), the status pill from + `status`/`statusReason` (`valid` stays the boolean shortcut but is **not** the display + filter — see step 4), `pdfAvailable` as-is; identity telemetry from `userIds` and + `skippedIdentities` (`matchedUserIds = userIds.length`, and + `unmatched = resultCount === 0 && userIds.length === 0`; skipped keys are the direct signal + for [#1165](https://github.com/linuxfoundation/lfx-self-serve/issues/1165)'s identity-mapping-gap telemetry). -- `GET /api/me/clas/:signatureId/pdf-url` → `GET /cla-service/v4/my-clas/{id}/pdf` - with the same identity params; upstream 404 maps to SS 404 (never 403). +- `GET /api/me/clas/:signatureId/pdf-url` → `GET /cla-service/v4/my-clas/{id}/pdf` with the + same identity params; upstream 404 maps to SS 404 (never 403). This also reshapes the originally-contingent `GET /v4/users/by-identity` endpoint -(issue [#1161](https://github.com/linuxfoundation/lfx-self-serve/issues/1161)): the -GSI-backed identity→user resolution now runs *inside* EasyCLA where the approval-list -validity check — impossible to perform from SS, which cannot see approval lists — also -lives, so SS never needs to scan `cla-*-users` itself. What #1161 actually needs is the -inverse — the identity set already attached to the caller — and that is served directly -by `GET /v4/my-clas/identities` (above). If a bare arbitrary identity→user lookup is -ever still wanted, the `resolveUsers` service function is the ready-made core of it. +([#1161](https://github.com/linuxfoundation/lfx-self-serve/issues/1161)): the GSI-backed +identity→user resolution now runs *inside* EasyCLA, where the approval-list validity check — +impossible from SS, which cannot see approval lists — also lives, so SS never scans +`cla-*-users` itself. What #1161 actually needs is the inverse, the identity set already +attached to the caller, served by `GET /v4/my-clas/identities`. For a bare identity→user +lookup, the `resolveUsers` service function is its ready-made core. ## Performance notes -Per request, with all caches request-scoped: +Per request, with every distinct key resolved exactly once: - one GSI query for the caller's own EasyCLA records (enforcement) + zero, one or two - platform user-service HTTP calls (profile + paginated identities — loaded lazily, - at most once, whenever a username key is present or another key is not covered by - the EasyCLA records); + platform user-service HTTP calls (profile + paginated identities — loaded lazily, at most + once, whenever a username key is present or another key is not covered by the EasyCLA + records); - one GSI query per allowed identity key (typically 2–4); - one paginated GSI query per matched user record (typically 1–2); - one `GetItem` per distinct CLA group (name), one per distinct company (ECLAs only); -- one `projects_cla_groups` GSI query per distinct CLA group (project name/logo resolution) - plus, when it resolves to a project/foundation SFID, up to one project-service HTTP call per - distinct SFID (both cached per request; a lookup miss degrades to an empty logo); -- one CCLA query per distinct (CLA group, company) pair and one approval-list - evaluation per (pair, user) — ECLAs only; the GitHub-org check may add one GitHub - API call per evaluation when the CCLA actually uses org-based approval. - -No table scans except the explicitly opt-in `secondaryEmail` match (a single scan -covering all provided values — callers should still treat it as a slow path). Each -logical query above may issue multiple paginated calls; the latency-envelope -assumption is to be confirmed on dev. +- one `projects_cla_groups` GSI query per distinct CLA group (project name/logo) plus, when + it resolves to a project/foundation SFID, up to one project-service HTTP call per distinct + SFID (both cached per request; a miss degrades to an empty logo); +- one CCLA query per distinct (CLA group, company) pair and one approval-list evaluation per + (pair, user) — ECLAs only; the GitHub-org check may add one GitHub API call per evaluation + when the CCLA actually uses org-based approval; +- when screening is enabled, one organization-service lookup (for the domain) plus one SSS + call per **distinct employer** — not per row, and none at all for administrator-blocked + employers or when screening is off. + +Those calls are issued **concurrently**, at most 8 in flight per stage (three parallel +chains, so ≤ ~24 total): the matched records' signatures load together, then the +CLA-group/project, employer/sanctions and CCLA/approval-list chains run in parallel, and the +rows are assembled from the results. Latency is ~4 dependent stages deep rather than +proportional to the row count, so a slow SSS screen overlaps the rest of the work instead of +adding to it. One consequence: a revoked or unreadable employer may still incur its CCLA and +approval-list reads, whose results are then discarded. + +No table scans except the opt-in `secondaryEmail` match (a single scan covering all provided +values — still a slow path). Each logical query above may issue multiple paginated calls; +the latency envelope is to be confirmed on dev. ## Deployment / rollout 1. Merge the `easycla` branch; the endpoints deploy with the normal v4 Lambda pipeline (`gen/` is rebuilt by `make swagger` during the build). 2. Run `acs-cli sync` with the updated `services/11-cla-service.yaml` against each - environment (dev → staging → prod) so the ACS warden allows the paths; until then - the gateway returns 403 for them (fail-closed — nothing else can regress). + environment (dev → staging → prod) so the ACS warden allows the paths; until then the + gateway returns 403 for them (fail-closed — nothing else can regress). 3. No lfx-gateway deploy is needed. -4. To switch on the trusted Self Serve caller path, provision the SSM parameter with the ID of a - Self Serve client whose tokens are **never returned to a user** — the only infrastructure change - the trust-SS hardening needs (the key matches the existing `cla-*` `ssm:GetParameter` grant). - The client SS calls with today does not qualify, so this step is on hold; see "Security notes": +4. To switch on the trusted Self Serve caller path, provision the SSM parameter with the ID + of a Self Serve client whose tokens are **never returned to a user** — the only + infrastructure change the trust-SS hardening needs (the key matches the existing `cla-*` + `ssm:GetParameter` grant). The client SS uses today does not qualify, so this is on hold + (see "Known limitations"): ```bash aws --profile lfproduct-dev ssm put-parameter --name cla-ss-trusted-client-ids-dev \ --type String --value '[,]' --overwrite ``` - Both partial states are safe, so deploy order does not matter, but the allow-list is read at - cold start only: after `put-parameter`, force a Lambda restart/redeploy, otherwise warm + Both partial states are safe, so deploy order does not matter, but the allow-list is read + at cold start only: after `put-parameter`, force a Lambda restart/redeploy, otherwise warm containers keep the path disabled while fresh ones enable it. Rollback is `ssm delete-parameter` plus the same restart. A read failure other than a missing parameter - is logged as a warning and leaves the path disabled, i.e. non-admin callers keep having every - identity verified per request — it never aborts the other lambdas that load this config. -5. Read-only rollback: revert the ACS sync (or simply never flip the SS feature flag); - the endpoints write nothing. + is logged as a warning and leaves the path disabled — non-admin callers keep having every + identity verified per request — and never aborts the other lambdas that load this config. +5. Read-only rollback: revert the ACS sync (or never flip the SS feature flag); the endpoints + write nothing. ## Verification performed -- `make swagger` (v2 spec compiled + validated), `make build-linux`, full `make test` - (all packages pass), `make lint` (golangci-lint + license-header check) — all clean. -- Unit tests (`cla-backend-go/v2/my_clas/service_test.go`): identity union + dedupe + - email normalization; no-match empty result; **ownership enforcement** (every foreign - identity key skipped + reported while the caller's own record still resolves; - validation via the EasyCLA record incl. `user_emails`/GitLab keys; validation via - platform user-service identities incl. gerrit usernames and source-scoping — a - Slack identity never authorizes a GitHub search; user-service loaded lazily and at - most once; admin bypass); `secondaryEmail` scan runs only for explicitly provided - values; ICLA validity (approved/invalidated, unsigned exclusion); ECLA validity - matrix (covered, sanctioned company, missing CCLA, `approved=false`, - not-on-current-approval-list) across both `signature_type` spellings; PDF - ownership/eligibility (owned ICLA, ECLA, unsigned, not-owned, cross-user attempts - with explicit foreign keys → 404, admin path) and S3 key shape; `Identity.IsEmpty`; - not-found error classification; `GetMyIdentities` union/dedupe/sort of the - `:` set across EasyCLA records + platform identities (deleted platform - emails and non-code sources excluded, empty-username error). -- Unit tests for the trusted-caller path (`cla-backend-go/auth/trusted_caller_test.go`, - `cla-backend-go/config/ssm_test.go`, `cla-backend-go/v2/my_clas/handlers_test.go`, - `service_test.go`): allow-list parsing and on/off (incl. a configured allow-list without an - Auth0 domain failing startup); missing/blank/`Basic`/`Bearer`-only headers denied without a - JWKS lookup; trusted vs. verified-but-not-allow-listed/`azp`-less tokens; rejection of - expired, `exp`-less, `nbf`/`iat`-future, `kid`-less, unknown-`kid`, wrong-key and - `HS256`/`alg: none` tokens (algorithm pinning); JWKS caching, refresh cooldown, TTL reload, - cached-key fallback and its 24 h bound, concurrent use, fetch/decode failure modes and both - the `n`+`e` and `x5c` key forms; per-handler 401 on an unverifiable caller (service never - reached), trusted caller reaching the service with `Trusted=true` and no username, `400` - when it supplies no identity at all, service-error/404 mapping, unchanged behavior with the - verifier disabled or absent; service-level trusted bypass (a GitHub-only record with no - `lf_username` resolves, its PDF downloads, the platform user-service is never consulted and - the requested identity is not mutated) and the bounded identity-summary log line. -- Read-only checks against the shared **dev** AWS environment: confirmed - `reference-signature-index` on `cla-dev-signatures` and all six identity GSIs on - `cla-dev-users`; sampled real ICLA/ECLA records to verify the +- `make swagger` (v2 spec compiled + validated), `make build-linux`, `make test`, `make lint` + (golangci-lint + license headers) — all clean. +- Unit tests (`v2/my_clas/service_test.go`): identity union/dedupe/email normalization; + no-match empty result; **ownership enforcement** (foreign keys skipped + reported while the + caller's own record still resolves; validation via the EasyCLA record incl. + `user_emails`/GitLab keys; validation via platform identities incl. gerrit usernames and + source scoping — a Slack identity never authorizes a GitHub search; user-service loaded + lazily, at most once; admin bypass); `secondaryEmail` scan only when values are passed; + ICLA validity (approved/invalidated, unsigned exclusion); ECLA validity matrix (covered, + sanctioned company, missing CCLA, `approved=false`, not-on-current-approval-list) across + both `signature_type` spellings; PDF ownership/eligibility (owned ICLA, ECLA, unsigned, + not-owned, cross-user attempt with explicit foreign keys → 404, admin path) and S3 key + shape; `Identity.IsEmpty`; not-found classification; `GetMyIdentities` union/dedupe/sort of + the `:` set (deleted platform emails and non-platform sources excluded, + empty-username error). +- Trusted-caller tests (`auth/trusted_caller_test.go`, `config/ssm_test.go`, + `v2/my_clas/handlers_test.go`, `service_test.go`): allow-list parsing and on/off (a + configured allow-list without an Auth0 domain fails startup); missing/blank/`Basic`/ + `Bearer`-only headers denied without a JWKS lookup; trusted vs. verified-but-not-listed and + `azp`-less tokens; rejection of expired, `exp`-less, `nbf`/`iat`-future, `kid`-less, + unknown-`kid`, wrong-key, `HS256` and `alg: none` tokens (algorithm pinning); JWKS caching, + refresh cooldown, TTL reload, cached-key fallback and its 24 h bound, concurrent use, + fetch/decode failures, both the `n`+`e` and `x5c` key forms; per-handler 401 on an + unverifiable caller (service never reached), trusted caller reaching the service with + `Trusted=true` and no username, `400` when it supplies no identity, service-error/404 + mapping, unchanged behavior with the verifier disabled or absent; trusted bypass (a + GitHub-only record with no `lf_username` resolves, its PDF downloads, user-service never + consulted, requested identity not mutated) and the bounded identity-summary log line. +- Read-only **dev** checks: `reference-signature-index` on `cla-dev-signatures` and all six + identity GSIs on `cla-dev-users` exist; sampled records confirm the `signature_type=cla|ecla` split, that every `ecla`-typed record carries - `signature_user_ccla_company_id`, and the `signed_on`/`date_created`/invalidation - `note` field semantics. -- Read-only checks against **prod**: zero signature records carrying a - `signature_reference_id` lack `signature_type` or `signature_reference_type`, so the - repository's filter cannot drop legacy records (the only attribute-less rows, 9 on - dev, are empty stubs with no reference ID and never appear in the queried GSI). + `signature_user_ccla_company_id`, and the `signed_on`/`date_created`/invalidation `note` + semantics. +- Read-only **prod** check: no signature record carrying a `signature_reference_id` lacks + `signature_type` or `signature_reference_type`, so the repository's filter cannot drop + legacy records (the only attribute-less rows, 9 on dev, are stubs with no reference ID, + absent from the queried GSI). - Not verified here (needs a deployed dev build + ACS sync): end-to-end curl through - lfx-gateway. Suggested smoke test afterwards: - `curl -H "Authorization: Bearer $TOK" "$GW/cla-service/v4/my-clas"` for a dev user - with known ICLA/ECLA fixtures, then the returned `signatureID` through - `/my-clas/{id}/pdf` and download the URL (SC-001's ≥99% download check), and - `curl -H "Authorization: Bearer $TOK" "$GW/cla-service/v4/my-clas/identities"` to - confirm the identity set (`IDENTITIES=1 ./utils/my_clas.sh`). + lfx-gateway. Smoke test: `curl -H "Authorization: Bearer $TOK" "$GW/cla-service/v4/my-clas"` + for a dev user with known ICLA/ECLA fixtures, the returned `signatureID` through + `/my-clas/{id}/pdf` plus a download of that URL (SC-001's ≥99% check), and + `/v4/my-clas/identities` (`IDENTITIES=1 ./utils/my_clas.sh`). ## Known limitations / follow-ups -- **Current possession of an identity grants access to its historical records — - accepted product decision.** A verified-as-currently-owned email or SCM username is - the bar for surfacing (and downloading) historical CLAs recorded under that - identity; a recycled/reassigned alias (e.g. a corporate email handed to a new - employee, a renamed GitHub handle re-registered by someone else, both also linked to - the new holder's LF account) can therefore surface the previous holder's records. - This matches how EasyCLA itself keys pre-LF-login history, keeps the M1 read-only - surface simple, and was explicitly chosen over a stable-ID corroboration scheme; - numeric `githubId`/`gitlabId` keys are immune (immutable) and remain the preferred - high-precision parameters. -- **GitLab group approval lists** (`gitlab_org_approval_list`) are not re-evaluated - live: group membership requires the GitLab group's OAuth token (the MR-gating - service holds per-group credentials). When a CCLA uses GitLab group approvals and no - other criterion matched, validity defers to the `signature_approved` flag (see - validity evaluation above) — so a member removed from the group whose ECLA was not - yet invalidated shows `valid=true` until the invalidation flow catches up. A - follow-up could add the live group check via the gitlab-activity service. -- **Secondary emails** (`user_emails`) are matchable only via the opt-in - `secondaryEmail` parameter, which costs one table scan for all values (set - attribute, not indexable) — the default `email` parameter stays index-backed; - numeric GitHub/GitLab IDs remain the preferred high-precision keys. -- **Numeric GitHub/GitLab IDs can only be verified against the caller's EasyCLA - LFID records** — the platform user-service identities carry usernames, not provider - IDs. An ID not present on any of those records is skipped even if the matching - username would have been allowed; callers should pass the username alongside the ID. -- **Superseded-document detection** is not computed server-side; the signed document - version is exposed (`documentMajorVersion/MinorVersion`) so a consumer can compare - against the CLA group's current template version if the product wants a - "superseded" badge later (the final mockup dropped the status column). +- **Current possession of an identity grants access to its historical records — accepted + product decision.** A verified-as-currently-owned email or SCM username is the bar for + surfacing (and downloading) historical CLAs recorded under it, so a recycled/reassigned + alias (a corporate email handed to a new employee, a renamed GitHub handle re-registered by + someone else, both linked to the new holder's LF account) can surface the previous holder's + records. This matches how EasyCLA itself keys pre-LF-login history and was chosen over a + stable-ID corroboration scheme; the immutable `githubId`/`gitlabId` keys are immune and stay + the preferred high-precision parameters. +- **GitLab group approval lists** (`gitlab_org_approval_list`) are not re-evaluated live: + membership needs the group's OAuth token, held per group by the MR-gating service. When a + CCLA uses group approvals and nothing else matched, validity defers to `signature_approved` + (step 4) — a member removed from the group whose ECLA is not yet invalidated shows + `valid=true` with `status: unknown` until the invalidation flow catches up. A follow-up + could add the live check via the gitlab-activity service. +- **Secondary emails** (`user_emails`) are matchable only via the opt-in `secondaryEmail` + parameter, one table scan for all values (set attribute, not indexable); the default `email` + parameter stays index-backed. +- **Numeric GitHub/GitLab IDs can only be verified against the caller's EasyCLA LFID + records** — platform identities carry usernames, not provider IDs. An ID on no such record + is skipped even when the matching username would have been allowed, so pass the username + alongside the ID. +- **Superseded-document detection** is not computed server-side; the signed document version + is exposed (`documentMajorVersion/MinorVersion`) so a consumer can compare it against the + CLA group's current template version if a "superseded" badge is wanted later (the final + mockup dropped the status column). - **Unmatched-identity telemetry** (an M1 exit criterion) is a Self Serve concern; the - response deliberately exposes `userIds`/`resultCount` so SS can emit it. -- Unlike the existing `GET /v4/signatures/user/{userID}` (which performs no ownership - check at all — the upstream-hardening observation from M1 research R3), these - endpoints **enforce identity ownership server-side** for non-admin callers. The - residual trust decisions are: the token's username claim is the identity anchor - (that is the platform-wide model — the gateway/ACS chain keys on the same claim), - and admin-flagged principals bypass enforcement (needed for support/parity - sampling; remove the `utils.IsUserAdmin` branch in `handlers.go` to revoke it). -- **The `azp` allow-list is only as sound as no user being able to hold a token that carries an - allow-listed `azp`.** Server-side minting with a client secret is not sufficient: the token SS - sends here (`req.apiGatewayToken`, a refresh-token exchange on `PCC_AUTH0_CLIENT_ID`) is minted - that way and then returned to every logged-in user as `v1Token` by SS's - `GET /api/profile/developer` ([lfx-self-serve#1045](https://github.com/linuxfoundation/lfx-self-serve/pull/1045)), - and the v2 session token shares that `azp`. Allow-listing that client would therefore let any - logged-in user pass any identity — including to the PDF endpoint, whose presigned URL exposes a - signed ICLA. So allow-list only a client whose tokens are never surfaced to a user; SS needs a + response exposes `userIds`/`resultCount` so SS can emit it. +- Unlike `GET /v4/signatures/user/{userID}`, which performs no ownership check at all (the + upstream-hardening observation from M1 research R3), these endpoints **enforce identity + ownership server-side** for non-admin callers. Residual trust decisions: the token's + username claim is the identity anchor (the platform-wide model — the gateway/ACS chain keys + on the same claim), and admin-flagged principals bypass enforcement (needed for + support/parity sampling; remove the `utils.IsUserAdmin` branch in `handlers.go` to revoke + it). +- **The `azp` allow-list is only as sound as no user being able to hold a token carrying an + allow-listed `azp`.** Server-side minting with a client secret is not sufficient: the token + SS sends here (`req.apiGatewayToken`, a refresh-token exchange on `PCC_AUTH0_CLIENT_ID`) is + minted that way and then returned to every logged-in user as `v1Token` by SS's + `GET /api/profile/developer` + ([lfx-self-serve#1045](https://github.com/linuxfoundation/lfx-self-serve/pull/1045)), and the + v2 session token shares that `azp`. Allow-listing that client would let any logged-in user + pass any identity — including to the PDF endpoint, whose presigned URL exposes a signed + ICLA. So allow-list only a client whose tokens are never surfaced to a user; SS needs a dedicated client for this hop first. The same caveat is recorded in code at the `azp` check. - **Both the caller-supplied identity list and the `azp` allow-list are transitional (P3/P9 of the trust-SS decision).** At M6, once EasyCLA runs on K8s, it should call @@ -723,10 +781,10 @@ assumption is to be confirmed on dev. point the `githubId`/`githubUsername`/… parameters, the allow-list and the in-handler JWT verification all go away together. `swagger/cla.v2.yaml` carries the same note. - **The in-handler verification covers the bearer token, not the gateway's `X-ACL` headers.** - Those are still consumed unverified (`lfx-kit/auth.SwaggerAuth`), so what is closed, once the - allow-list is configured, is the trust decision that matters here: nothing reaches the handlers + Those are still consumed unverified (`lfx-kit/auth.SwaggerAuth`), so what the configured + allow-list closes is the trust decision that matters here: nothing reaches the handlers without a JWKS-verified tenant token, and nothing can claim to be Self Serve without an - allow-listed `azp`. A forged `X-ACL` can still assert a username, or the admin flag that bypasses - ownership checks — both pre-existing, both now additionally requiring a valid token. Binding the - principal to a token claim belongs to the v4 invoke-path trust work (spike 4); the M6 move removes - the header trust entirely. + allow-listed `azp`. A forged `X-ACL` can still assert a username, or the admin flag that + bypasses ownership checks — both pre-existing, both now additionally requiring a valid + token. Binding the principal to a token claim belongs to the v4 invoke-path trust work + (spike 4); the M6 move removes the header trust entirely. diff --git a/utils/my_cla_manager_request.sh b/utils/my_cla_manager_request.sh new file mode 100755 index 000000000..c7e91f690 --- /dev/null +++ b/utils/my_cla_manager_request.sh @@ -0,0 +1,120 @@ +#!/bin/bash +# Copyright The Linux Foundation and each contributor to CommunityBridge. +# SPDX-License-Identifier: MIT + +# Calls POST /v4/my-clas/{signatureID}/cla-manager-requests through lfx-gateway and reports the HTTP status and total time. +# SIGNATURE_ID (or 1st arg): the ECLA signature ID - required. +# REQUEST_TYPE (or 2nd arg): removal (default) | approval. +# RECIPIENTS (or 3rd arg): comma-separated CLA manager LF usernames (must come from ./utils/my_cla_managers.sh output; empty only when no manager resolves). +# MESSAGE: optional message included in the notification email. +# TOKEN: bearer access token (env, or ./my_clas.token.secret / ./auth0.token.secret). Get one with ~/get_oauth_token.sh (dev) or ~/get_oauth_token_prod.sh (prod). +# STAGE: dev (default) | test | staging | prod - selects the api-gw host. +# Identity params (each comma-separated, repeated in the query): LF_USERNAME EMAIL SECONDARY_EMAIL GITHUB_ID GITHUB_USERNAME GITLAB_ID GITLAB_USERNAME GERRIT_USERNAME +# Local mode (against a standalone backend, bypassing the gateway): set PRINCIPAL to the token username and ADMIN=true|false (or pass a raw base64 X_ACL). Defaults API_URL to http://localhost:8080. +# Examples: +# SIGNATURE_ID=3c1e5d7a-... RECIPIENTS=manager1,manager2 MESSAGE='please remove me' ./utils/my_cla_manager_request.sh +# ./utils/my_cla_manager_request.sh 3c1e5d7a-... approval manager1 + +[ -z "$SIGNATURE_ID" ] && SIGNATURE_ID="$1" +[ -z "$REQUEST_TYPE" ] && REQUEST_TYPE="${2:-removal}" +[ -z "$RECIPIENTS" ] && RECIPIENTS="$3" +if [ -z "$SIGNATURE_ID" ] +then + echo "$0: SIGNATURE_ID (or 1st arg) is required" + exit 3 +fi +if ! command -v jq >/dev/null 2>&1 +then + echo "$0: jq is required to build the request body" + exit 4 +fi + +if [ -n "$PRINCIPAL" ] && [ -z "$X_ACL" ] +then + admin=false + [ "$ADMIN" = "true" ] && admin=true + X_ACL="$(printf '{"user_name":"%s","email":"%s","isAdmin":%s,"allowed":true}' "$PRINCIPAL" "${PRINCIPAL_EMAIL:-$PRINCIPAL}" "$admin" | base64 | tr -d '\n')" +fi + +if [ -n "$X_ACL" ] +then + auth=(-H "X-ACL: ${X_ACL}") + [ -z "$API_URL" ] && API_URL="http://localhost:${PORT:-8080}" +else + if [ -z "$TOKEN" ] + then + [ -f ./my_clas.token.secret ] && TOKEN="$(cat ./my_clas.token.secret)" + fi + if [ -z "$TOKEN" ] + then + [ -f ./auth0.token.secret ] && TOKEN="$(cat ./auth0.token.secret)" + fi + if [ -z "$TOKEN" ] + then + echo "$0: TOKEN not set - run ~/get_oauth_token.sh (dev) or ~/get_oauth_token_prod.sh (prod) and export TOKEN (or use PRINCIPAL=... for local mode)" + exit 1 + fi + auth=(-H "Authorization: Bearer ${TOKEN}") + if [ -z "$STAGE" ] + then + STAGE=dev + fi + case "$STAGE" in + prod) GW="https://api-gw.platform.linuxfoundation.org" ;; + staging) GW="https://api-gw.staging.platform.linuxfoundation.org" ;; + test) GW="https://api-gw.test.platform.linuxfoundation.org" ;; + dev) GW="https://api-gw.dev.platform.linuxfoundation.org" ;; + *) echo "$0: unknown STAGE '$STAGE'"; exit 2 ;; + esac + [ -z "$API_URL" ] && API_URL="${GW}/cla-service" +fi + +query="" +add_param() { + local name="$1" values="$2" + [ -z "$values" ] && return + local IFS=, + for v in $values + do + [ -n "$v" ] && query="${query}${query:+&}${name}=$(jq -rn --arg v "$v" '$v|@uri')" + done +} +add_param lfUsername "$LF_USERNAME" +add_param email "$EMAIL" +add_param secondaryEmail "$SECONDARY_EMAIL" +add_param githubId "$GITHUB_ID" +add_param githubUsername "$GITHUB_USERNAME" +add_param gitlabId "$GITLAB_ID" +add_param gitlabUsername "$GITLAB_USERNAME" +add_param gerritUsername "$GERRIT_USERNAME" + +URL="${API_URL}/v4/my-clas/${SIGNATURE_ID}/cla-manager-requests${query:+?}${query}" + +recipients_json="[]" +if [ -n "$RECIPIENTS" ] +then + recipients_json="$(printf '%s' "$RECIPIENTS" | jq -Rc 'split(",")|map(select(length>0))')" +fi +if [ -n "$MESSAGE" ] +then + payload="$(jq -nc --arg requestType "$REQUEST_TYPE" --argjson recipients "$recipients_json" --arg message "$MESSAGE" '{requestType:$requestType,recipients:$recipients,message:$message}')" +else + payload="$(jq -nc --arg requestType "$REQUEST_TYPE" --argjson recipients "$recipients_json" '{requestType:$requestType,recipients:$recipients}')" +fi + +if [ -n "$DEBUG" ] +then + echo "curl -sS -XPOST ${auth[0]} '${auth[1]%%:*}: ' -H 'Content-Type: application/json' -d '${payload}' '${URL}'" +fi + +body="$(mktemp)" +timing="$(curl -sS -XPOST "${auth[@]}" -H "Content-Type: application/json" -d "$payload" -w '%{http_code} %{time_total}' -o "$body" "$URL")" +if command -v jq >/dev/null 2>&1 +then + jq -r '.' < "$body" 2>/dev/null || cat "$body" +else + cat "$body" +fi +echo +echo "HTTP ${timing% *} in ${timing#* }s" +rm -f "$body" diff --git a/utils/my_cla_managers.sh b/utils/my_cla_managers.sh new file mode 100755 index 000000000..79ed1fdcd --- /dev/null +++ b/utils/my_cla_managers.sh @@ -0,0 +1,98 @@ +#!/bin/bash +# Copyright The Linux Foundation and each contributor to CommunityBridge. +# SPDX-License-Identifier: MIT + +# Calls GET /v4/my-clas/{signatureID}/cla-managers through lfx-gateway and reports the HTTP status and total time. +# SIGNATURE_ID (or 1st arg): the ECLA signature ID - required. +# TOKEN: bearer access token (env, or ./my_clas.token.secret / ./auth0.token.secret). Get one with ~/get_oauth_token.sh (dev) or ~/get_oauth_token_prod.sh (prod). +# STAGE: dev (default) | test | staging | prod - selects the api-gw host. +# Identity params (each comma-separated, repeated in the query): LF_USERNAME EMAIL SECONDARY_EMAIL GITHUB_ID GITHUB_USERNAME GITLAB_ID GITLAB_USERNAME GERRIT_USERNAME +# Local mode (against a standalone backend, bypassing the gateway): set PRINCIPAL to the token username and ADMIN=true|false (or pass a raw base64 X_ACL). Defaults API_URL to http://localhost:8080. +# Examples: +# SIGNATURE_ID=3c1e5d7a-... ./utils/my_cla_managers.sh +# PRINCIPAL=lgryglicki ADMIN=false ./utils/my_cla_managers.sh 3c1e5d7a-... + +[ -z "$SIGNATURE_ID" ] && SIGNATURE_ID="$1" +if [ -z "$SIGNATURE_ID" ] +then + echo "$0: SIGNATURE_ID (or 1st arg) is required" + exit 3 +fi + +if [ -n "$PRINCIPAL" ] && [ -z "$X_ACL" ] +then + admin=false + [ "$ADMIN" = "true" ] && admin=true + X_ACL="$(printf '{"user_name":"%s","email":"%s","isAdmin":%s,"allowed":true}' "$PRINCIPAL" "${PRINCIPAL_EMAIL:-$PRINCIPAL}" "$admin" | base64 | tr -d '\n')" +fi + +if [ -n "$X_ACL" ] +then + auth=(-H "X-ACL: ${X_ACL}") + [ -z "$API_URL" ] && API_URL="http://localhost:${PORT:-8080}" +else + if [ -z "$TOKEN" ] + then + [ -f ./my_clas.token.secret ] && TOKEN="$(cat ./my_clas.token.secret)" + fi + if [ -z "$TOKEN" ] + then + [ -f ./auth0.token.secret ] && TOKEN="$(cat ./auth0.token.secret)" + fi + if [ -z "$TOKEN" ] + then + echo "$0: TOKEN not set - run ~/get_oauth_token.sh (dev) or ~/get_oauth_token_prod.sh (prod) and export TOKEN (or use PRINCIPAL=... for local mode)" + exit 1 + fi + auth=(-H "Authorization: Bearer ${TOKEN}") + if [ -z "$STAGE" ] + then + STAGE=dev + fi + case "$STAGE" in + prod) GW="https://api-gw.platform.linuxfoundation.org" ;; + staging) GW="https://api-gw.staging.platform.linuxfoundation.org" ;; + test) GW="https://api-gw.test.platform.linuxfoundation.org" ;; + dev) GW="https://api-gw.dev.platform.linuxfoundation.org" ;; + *) echo "$0: unknown STAGE '$STAGE'"; exit 2 ;; + esac + [ -z "$API_URL" ] && API_URL="${GW}/cla-service" +fi + +URL="${API_URL}/v4/my-clas/${SIGNATURE_ID}/cla-managers" + +args=() +add_param() { + local name="$1" values="$2" + [ -z "$values" ] && return + local IFS=, + for v in $values + do + [ -n "$v" ] && args+=(--data-urlencode "${name}=${v}") + done +} +add_param lfUsername "$LF_USERNAME" +add_param email "$EMAIL" +add_param secondaryEmail "$SECONDARY_EMAIL" +add_param githubId "$GITHUB_ID" +add_param githubUsername "$GITHUB_USERNAME" +add_param gitlabId "$GITLAB_ID" +add_param gitlabUsername "$GITLAB_USERNAME" +add_param gerritUsername "$GERRIT_USERNAME" + +if [ -n "$DEBUG" ] +then + echo "curl -sS -G -XGET ${auth[0]} '${auth[1]%%:*}: ' -H 'Content-Type: application/json' ${args[*]} '${URL}'" +fi + +body="$(mktemp)" +timing="$(curl -sS -G -XGET "${auth[@]}" -H "Content-Type: application/json" "${args[@]}" -w '%{http_code} %{time_total}' -o "$body" "$URL")" +if command -v jq >/dev/null 2>&1 +then + jq -r '.' < "$body" 2>/dev/null || cat "$body" +else + cat "$body" +fi +echo +echo "HTTP ${timing% *} in ${timing#* }s" +rm -f "$body"