From c6ca13bfe34fef3a79d941bd66dba066544e0242 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Gryglicki?= Date: Thu, 10 Sep 2026 11:57:41 +0200 Subject: [PATCH 1/3] Add ioptional paging in M1-M3 BE APIs (where applicable), add missing endpoint and add get token local util script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Łukasz Gryglicki Assisted by [OpenAI](https://platform.openai.com/) Assisted by [GitHub Copilot](https://github.com/features/copilot) Assisted by [Claude](https://claude.ai) --- cla-backend-go/swagger/cla.v2.yaml | 52 +++++ .../common/cla-manager-request-list.yaml | 4 + .../swagger/common/company-cla-groups.yaml | 5 + .../swagger/common/my-cla-list.yaml | 5 + .../swagger/common/my-cla-manager-list.yaml | 5 + .../swagger/common/my-identity-list.yaml | 5 + cla-backend-go/utils/paging.go | 23 ++ cla-backend-go/utils/paging_test.go | 41 ++++ cla-backend-go/v2/cla_manager/handlers.go | 2 +- .../v2/cla_manager/handlers_test.go | 2 +- cla-backend-go/v2/cla_manager/requests.go | 13 +- .../v2/cla_manager/requests_test.go | 43 +++- cla-backend-go/v2/cla_manager/service.go | 2 +- cla-backend-go/v2/company/handlers.go | 2 +- cla-backend-go/v2/company/handlers_test.go | 2 +- cla-backend-go/v2/company/service.go | 7 +- cla-backend-go/v2/company/service_test.go | 60 ++++- .../v2/my_clas/cla_managers_test.go | 72 +++++- cla-backend-go/v2/my_clas/handlers.go | 6 +- cla-backend-go/v2/my_clas/handlers_test.go | 6 +- cla-backend-go/v2/my_clas/service.go | 46 +++- cla-backend-go/v2/my_clas/service_test.go | 151 ++++++++++--- .../company_corporate_contributors_test.go | 181 +++++++++++++++ cla-backend-go/v2/signatures/handlers.go | 83 +++++++ docs/M3_ORG_LENS_API.md | 44 +++- docs/MY_CLAS_API.md | 10 +- utils/auth0.secret.example | 13 ++ utils/get_auth0_token.sh | 212 ++++++++++++++++++ 28 files changed, 1005 insertions(+), 92 deletions(-) create mode 100644 cla-backend-go/utils/paging.go create mode 100644 cla-backend-go/utils/paging_test.go create mode 100644 cla-backend-go/v2/signatures/company_corporate_contributors_test.go create mode 100644 utils/auth0.secret.example create mode 100755 utils/get_auth0_token.sh diff --git a/cla-backend-go/swagger/cla.v2.yaml b/cla-backend-go/swagger/cla.v2.yaml index 3c15f7ed4..6ef9fcacc 100644 --- a/cla-backend-go/swagger/cla.v2.yaml +++ b/cla-backend-go/swagger/cla.v2.yaml @@ -2768,6 +2768,8 @@ paths: - $ref: "#/parameters/myClasGitlabId" - $ref: "#/parameters/myClasGitlabUsername" - $ref: "#/parameters/myClasGerritUsername" + - $ref: "#/parameters/pageSize" + - $ref: "#/parameters/offset" responses: '200': description: 'Success' @@ -2858,6 +2860,8 @@ paths: - $ref: "#/parameters/myClasGitlabId" - $ref: "#/parameters/myClasGitlabUsername" - $ref: "#/parameters/myClasGerritUsername" + - $ref: "#/parameters/pageSize" + - $ref: "#/parameters/offset" responses: '200': description: 'Success' @@ -2941,6 +2945,8 @@ paths: - $ref: "#/parameters/x-acl" - $ref: "#/parameters/x-username" - $ref: "#/parameters/x-email" + - $ref: "#/parameters/pageSize" + - $ref: "#/parameters/offset" responses: '200': description: 'Success' @@ -3154,6 +3160,8 @@ paths: - $ref: "#/parameters/x-email" - $ref: "#/parameters/path-companyID" - $ref: "#/parameters/path-projectSFID" + - $ref: "#/parameters/pageSize" + - $ref: "#/parameters/offset" responses: '200': description: 'Success' @@ -4130,6 +4138,8 @@ paths: - $ref: "#/parameters/x-username" - $ref: "#/parameters/x-email" - $ref: '#/parameters/path-companySFID' + - $ref: '#/parameters/pageSize' + - $ref: '#/parameters/offset' produces: - application/json responses: @@ -4150,6 +4160,41 @@ paths: tags: - company + /company/external/{companySFID}/cla-group/{claGroupID}/corporate-contributors: + get: + summary: List corporate contributors by company SFID + description: Returns the corporate contributors (employee acknowledgements) of the CLA Group for the company identified by its SFID - the same data as /cla-group/{claGroupID}/corporate-contributors on an organization-scoped path, so organization-scoped (CLA manager) tokens are authorized at the API gateway + operationId: listCompanyClaGroupCorporateContributors + parameters: + - $ref: "#/parameters/x-request-id" + - $ref: "#/parameters/x-acl" + - $ref: "#/parameters/x-username" + - $ref: "#/parameters/x-email" + - $ref: '#/parameters/path-companySFID' + - $ref: "#/parameters/path-claGroupID" + - $ref: '#/parameters/searchTerm' + - $ref: '#/parameters/pageSize' + - $ref: '#/parameters/nextKey' + 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/corporate-contributor-list' + '400': + $ref: '#/responses/invalid-request' + '403': + $ref: '#/responses/forbidden' + '404': + $ref: '#/responses/not-found' + '500': + $ref: '#/responses/internal-server-error' + tags: + - signatures + /company/name/{companyName}: get: summary: Gets the company by name @@ -5157,6 +5202,13 @@ parameters: required: false minimum: 0 exclusiveMinimum: true + offset: + name: offset + description: The optional number of leading results to skip before assembling the page - when omitted no results are skipped + in: query + type: integer + required: false + minimum: 0 returnAllEvents: name: returnAllEvents description: The optional parameter which would avoid pagination and it will return all data diff --git a/cla-backend-go/swagger/common/cla-manager-request-list.yaml b/cla-backend-go/swagger/common/cla-manager-request-list.yaml index 9173d4499..83525bd3d 100644 --- a/cla-backend-go/swagger/common/cla-manager-request-list.yaml +++ b/cla-backend-go/swagger/common/cla-manager-request-list.yaml @@ -12,3 +12,7 @@ properties: x-omitempty: false items: $ref: '#/definitions/cla-manager-request' + totalCount: + type: integer + format: int64 + description: Total number of requests before optional pageSize/offset paging was applied - only set by the v2 API diff --git a/cla-backend-go/swagger/common/company-cla-groups.yaml b/cla-backend-go/swagger/common/company-cla-groups.yaml index f6666b91c..26088a43e 100644 --- a/cla-backend-go/swagger/common/company-cla-groups.yaml +++ b/cla-backend-go/swagger/common/company-cla-groups.yaml @@ -15,6 +15,11 @@ properties: format: int64 x-omitempty: false description: Number of entries returned + totalCount: + type: integer + format: int64 + x-omitempty: false + description: Total number of entries before optional pageSize/offset paging was applied list: type: array x-omitempty: false diff --git a/cla-backend-go/swagger/common/my-cla-list.yaml b/cla-backend-go/swagger/common/my-cla-list.yaml index 179838507..dc63b92e4 100644 --- a/cla-backend-go/swagger/common/my-cla-list.yaml +++ b/cla-backend-go/swagger/common/my-cla-list.yaml @@ -35,6 +35,11 @@ properties: format: int64 x-omitempty: false description: Number of CLA records returned + totalCount: + type: integer + format: int64 + x-omitempty: false + description: Total number of CLA records before optional pageSize/offset paging was applied 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 index 2f3672a40..7aa21de1d 100644 --- a/cla-backend-go/swagger/common/my-cla-manager-list.yaml +++ b/cla-backend-go/swagger/common/my-cla-manager-list.yaml @@ -39,3 +39,8 @@ properties: format: int64 x-omitempty: false description: Number of CLA managers returned + totalCount: + type: integer + format: int64 + x-omitempty: false + description: Total number of CLA managers before optional pageSize/offset paging was applied diff --git a/cla-backend-go/swagger/common/my-identity-list.yaml b/cla-backend-go/swagger/common/my-identity-list.yaml index cbf4e3720..4e0e9b522 100644 --- a/cla-backend-go/swagger/common/my-identity-list.yaml +++ b/cla-backend-go/swagger/common/my-identity-list.yaml @@ -14,6 +14,11 @@ properties: format: int64 x-omitempty: false description: The number of identities returned + totalCount: + type: integer + format: int64 + x-omitempty: false + description: Total number of identities before optional pageSize/offset paging was applied identities: type: array x-omitempty: false diff --git a/cla-backend-go/utils/paging.go b/cla-backend-go/utils/paging.go new file mode 100644 index 000000000..52cb6613a --- /dev/null +++ b/cla-backend-go/utils/paging.go @@ -0,0 +1,23 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package utils + +// PageBounds converts the optional pageSize/offset query parameters into +// [start, end) slice bounds for an in-memory list of totalCount entries. +// When both parameters are nil the full range is returned, keeping the +// non-paged behavior unchanged. +func PageBounds(totalCount int, pageSize, offset *int64) (int, int) { + start, end := 0, totalCount + if offset != nil && *offset > 0 { + if *offset >= int64(totalCount) { + start = totalCount + } else { + start = int(*offset) + } + } + if pageSize != nil && *pageSize > 0 && int64(end-start) > *pageSize { + end = start + int(*pageSize) + } + return start, end +} diff --git a/cla-backend-go/utils/paging_test.go b/cla-backend-go/utils/paging_test.go new file mode 100644 index 000000000..40f1fa73c --- /dev/null +++ b/cla-backend-go/utils/paging_test.go @@ -0,0 +1,41 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package utils + +import ( + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/stretchr/testify/assert" +) + +func TestPageBounds(t *testing.T) { + cases := []struct { + name string + total int + pageSize *int64 + offset *int64 + wantStart int + wantEnd int + }{ + {"no paging", 10, nil, nil, 0, 10}, + {"page size only", 10, aws.Int64(3), nil, 0, 3}, + {"offset only", 10, nil, aws.Int64(4), 4, 10}, + {"page size and offset", 10, aws.Int64(3), aws.Int64(4), 4, 7}, + {"last partial page", 10, aws.Int64(3), aws.Int64(9), 9, 10}, + {"offset at end", 10, aws.Int64(3), aws.Int64(10), 10, 10}, + {"offset beyond end", 10, aws.Int64(3), aws.Int64(42), 10, 10}, + {"page size beyond end", 10, aws.Int64(100), nil, 0, 10}, + {"zero page size ignored", 10, aws.Int64(0), nil, 0, 10}, + {"zero offset ignored", 10, nil, aws.Int64(0), 0, 10}, + {"empty list", 0, aws.Int64(5), aws.Int64(2), 0, 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + start, end := PageBounds(tc.total, tc.pageSize, tc.offset) + assert.Equal(t, tc.wantStart, start, "start") + assert.Equal(t, tc.wantEnd, end, "end") + }) + } +} diff --git a/cla-backend-go/v2/cla_manager/handlers.go b/cla-backend-go/v2/cla_manager/handlers.go index a17d06861..b8ba88888 100644 --- a/cla-backend-go/v2/cla_manager/handlers.go +++ b/cla-backend-go/v2/cla_manager/handlers.go @@ -473,7 +473,7 @@ func Configure(api *operations.EasyclaAPI, service Service, v1CompanyService v1C return cla_manager.NewGetCLAManagerRequestsBadRequest().WithXRequestID(reqID).WithPayload(utils.ErrorResponseBadRequestWithError(reqID, msg, err)) } - requestList, err := service.GetCLAManagerRequests(ctx, v1CompanyModel, cginfo.ClaGroupID) + requestList, err := service.GetCLAManagerRequests(ctx, v1CompanyModel, cginfo.ClaGroupID, params.PageSize, params.Offset) if err != nil { msg := fmt.Sprintf("unable to lookup CLA Manager requests for Company ID: %s, Project ID: %s", params.CompanyID, params.ProjectSFID) log.WithFields(f).WithError(err).Warn(msg) diff --git a/cla-backend-go/v2/cla_manager/handlers_test.go b/cla-backend-go/v2/cla_manager/handlers_test.go index b7b5a8720..982568dcf 100644 --- a/cla-backend-go/v2/cla_manager/handlers_test.go +++ b/cla-backend-go/v2/cla_manager/handlers_test.go @@ -48,7 +48,7 @@ func (f *fakeRequestsService) record(companyModel *v1Models.Company, claGroupID f.claGroups = append(f.claGroups, claGroupID) } -func (f *fakeRequestsService) GetCLAManagerRequests(_ context.Context, companyModel *v1Models.Company, claGroupID string) (*models.ClaManagerRequestList, error) { +func (f *fakeRequestsService) GetCLAManagerRequests(_ context.Context, companyModel *v1Models.Company, claGroupID string, _, _ *int64) (*models.ClaManagerRequestList, error) { f.record(companyModel, claGroupID) if f.list != nil || f.listErr != nil { return f.list, f.listErr diff --git a/cla-backend-go/v2/cla_manager/requests.go b/cla-backend-go/v2/cla_manager/requests.go index a62fd9373..a6cb17f39 100644 --- a/cla-backend-go/v2/cla_manager/requests.go +++ b/cla-backend-go/v2/cla_manager/requests.go @@ -6,6 +6,7 @@ package cla_manager import ( "context" "fmt" + "sort" "github.com/LF-Engineering/lfx-kit/auth" "github.com/aws/aws-sdk-go/aws" @@ -20,7 +21,7 @@ import ( ) // GetCLAManagerRequests returns the list of CLA manager requests for the given company and CLA group -func (s *service) GetCLAManagerRequests(ctx context.Context, companyModel *v1Models.Company, claGroupID string) (*models.ClaManagerRequestList, error) { +func (s *service) GetCLAManagerRequests(ctx context.Context, companyModel *v1Models.Company, claGroupID string, pageSize, offset *int64) (*models.ClaManagerRequestList, error) { requestList, err := s.managerService.GetRequests(companyModel.CompanyID, claGroupID) if err != nil { return nil, err @@ -30,6 +31,16 @@ func (s *service) GetCLAManagerRequests(ctx context.Context, companyModel *v1Mod for i := range result.Requests { result.Requests[i].CompanyExternalID = companyModel.CompanyExternalID } + // deterministic order so the optional pageSize/offset windows are stable + sort.Slice(result.Requests, func(i, j int) bool { + if result.Requests[i].Created != result.Requests[j].Created { + return result.Requests[i].Created < result.Requests[j].Created + } + return result.Requests[i].RequestID < result.Requests[j].RequestID + }) + result.TotalCount = int64(len(result.Requests)) + start, end := utils.PageBounds(len(result.Requests), pageSize, offset) + result.Requests = result.Requests[start:end] return result, nil } diff --git a/cla-backend-go/v2/cla_manager/requests_test.go b/cla-backend-go/v2/cla_manager/requests_test.go index 0855db5ab..435a715f8 100644 --- a/cla-backend-go/v2/cla_manager/requests_test.go +++ b/cla-backend-go/v2/cla_manager/requests_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/LF-Engineering/lfx-kit/auth" + "github.com/aws/aws-sdk-go/aws" "github.com/go-openapi/strfmt" v1ClaManager "github.com/linuxfoundation/easycla/cla-backend-go/cla_manager" "github.com/linuxfoundation/easycla/cla-backend-go/emails" @@ -182,7 +183,7 @@ func TestGetCLAManagerRequests(t *testing.T) { mgr := &fakeManagerService{requestList: &v1Models.ClaManagerRequestList{Requests: []v1Models.ClaManagerRequest{*pendingRequest()}}} s := &service{managerService: mgr} - result, err := s.GetCLAManagerRequests(context.Background(), acmeCompany(), "cla-group-1") + result, err := s.GetCLAManagerRequests(context.Background(), acmeCompany(), "cla-group-1", nil, nil) assert.Nil(t, err) assert.Equal(t, [][]string{{"company-1", "cla-group-1"}}, mgr.listCalls) if assert.Len(t, result.Requests, 1) { @@ -208,7 +209,7 @@ func TestGetCLAManagerRequests(t *testing.T) { mgr := &fakeManagerService{requestList: &v1Models.ClaManagerRequestList{}} s := &service{managerService: mgr} - result, err := s.GetCLAManagerRequests(context.Background(), acmeCompany(), "cla-group-1") + result, err := s.GetCLAManagerRequests(context.Background(), acmeCompany(), "cla-group-1", nil, nil) assert.Nil(t, err) body, marshalErr := json.Marshal(result) assert.Nil(t, marshalErr) @@ -219,10 +220,46 @@ func TestGetCLAManagerRequests(t *testing.T) { mgr := &fakeManagerService{listErr: errors.New("dynamo down")} s := &service{managerService: mgr} - result, err := s.GetCLAManagerRequests(context.Background(), acmeCompany(), "cla-group-1") + result, err := s.GetCLAManagerRequests(context.Background(), acmeCompany(), "cla-group-1", nil, nil) assert.Nil(t, result) assert.EqualError(t, err, "dynamo down") }) + + t.Run("optional paging returns a stable window and the total count", func(t *testing.T) { + var reqs []v1Models.ClaManagerRequest + for _, rec := range []struct{ id, created string }{ + {"req-c", "2026-01-03T00:00:00Z"}, + {"req-a", "2026-01-01T00:00:00Z"}, + {"req-b", "2026-01-02T00:00:00Z"}, + } { + r := *pendingRequest() + r.RequestID = rec.id + r.Created = rec.created + reqs = append(reqs, r) + } + mgr := &fakeManagerService{requestList: &v1Models.ClaManagerRequestList{Requests: reqs}} + s := &service{managerService: mgr} + + full, err := s.GetCLAManagerRequests(context.Background(), acmeCompany(), "cla-group-1", nil, nil) + assert.Nil(t, err) + assert.Equal(t, int64(3), full.TotalCount) + if assert.Len(t, full.Requests, 3) { + assert.Equal(t, []string{"req-a", "req-b", "req-c"}, + []string{full.Requests[0].RequestID, full.Requests[1].RequestID, full.Requests[2].RequestID}, "sorted by created date") + } + + page, err := s.GetCLAManagerRequests(context.Background(), acmeCompany(), "cla-group-1", aws.Int64(1), aws.Int64(1)) + assert.Nil(t, err) + assert.Equal(t, int64(3), page.TotalCount) + if assert.Len(t, page.Requests, 1) { + assert.Equal(t, "req-b", page.Requests[0].RequestID) + } + + beyond, err := s.GetCLAManagerRequests(context.Background(), acmeCompany(), "cla-group-1", aws.Int64(5), aws.Int64(10)) + assert.Nil(t, err) + assert.Equal(t, int64(3), beyond.TotalCount) + assert.Len(t, beyond.Requests, 0) + }) } func TestGetCLAManagerRequest(t *testing.T) { diff --git a/cla-backend-go/v2/cla_manager/service.go b/cla-backend-go/v2/cla_manager/service.go index ebb4575a7..2e76f19c9 100644 --- a/cla-backend-go/v2/cla_manager/service.go +++ b/cla-backend-go/v2/cla_manager/service.go @@ -97,7 +97,7 @@ type Service interface { CreateCLAManagerDesigneeByGroup(ctx context.Context, params cla_manager.CreateCLAManagerDesigneeByGroupParams, projectCLAGroups []*projects_cla_groups.ProjectClaGroup) ([]*models.ClaManagerDesignee, string, error) ProjectCompanySignedOrNot(ctx context.Context, signedAtFoundation bool, projectCLAGroups []*projects_cla_groups.ProjectClaGroup, companyModel *v1Models.Company) error IsCLAManagerDesignee(ctx context.Context, companySFID, claGroupID, userLFID string) (*models.UserRoleStatus, error) - GetCLAManagerRequests(ctx context.Context, companyModel *v1Models.Company, claGroupID string) (*models.ClaManagerRequestList, error) + GetCLAManagerRequests(ctx context.Context, companyModel *v1Models.Company, claGroupID string, pageSize, offset *int64) (*models.ClaManagerRequestList, error) GetCLAManagerRequest(ctx context.Context, companyModel *v1Models.Company, claGroupID, requestID string) (*models.ClaManagerRequest, error) ApproveCLAManagerRequest(ctx context.Context, authUser *auth.User, companyModel *v1Models.Company, claGroupID, requestID string) (*models.ClaManagerRequest, error) DenyCLAManagerRequest(ctx context.Context, authUser *auth.User, companyModel *v1Models.Company, claGroupID, requestID string) (*models.ClaManagerRequest, error) diff --git a/cla-backend-go/v2/company/handlers.go b/cla-backend-go/v2/company/handlers.go index 756421347..7efea91f1 100644 --- a/cla-backend-go/v2/company/handlers.go +++ b/cla-backend-go/v2/company/handlers.go @@ -138,7 +138,7 @@ func Configure(api *operations.EasyclaAPI, service Service, projectClaGroupRepo return company.NewGetCompanyClaGroupsForbidden().WithXRequestID(reqID).WithPayload(utils.ErrorResponseForbidden(reqID, msg)) } - result, err := service.GetCompanyClaGroups(ctx, params.CompanySFID) + result, err := service.GetCompanyClaGroups(ctx, params.CompanySFID, params.PageSize, params.Offset) if err != nil { msg := fmt.Sprintf("unable to load CLA groups for company SFID: %s", params.CompanySFID) log.WithFields(f).WithError(err).Warn(msg) diff --git a/cla-backend-go/v2/company/handlers_test.go b/cla-backend-go/v2/company/handlers_test.go index cd7e58f48..b050c8846 100644 --- a/cla-backend-go/v2/company/handlers_test.go +++ b/cla-backend-go/v2/company/handlers_test.go @@ -27,7 +27,7 @@ type fakeCompanyService struct { calls int } -func (f *fakeCompanyService) GetCompanyClaGroups(_ context.Context, companySFID string) (*models.CompanyClaGroups, error) { +func (f *fakeCompanyService) GetCompanyClaGroups(_ context.Context, companySFID string, _, _ *int64) (*models.CompanyClaGroups, error) { f.calls++ if f.err != nil { return nil, f.err diff --git a/cla-backend-go/v2/company/service.go b/cla-backend-go/v2/company/service.go index f24bff6ca..e7b50132f 100644 --- a/cla-backend-go/v2/company/service.go +++ b/cla-backend-go/v2/company/service.go @@ -90,7 +90,7 @@ type Service interface { GetCompanyProjectActiveCLAs(ctx context.Context, companyID string, projectSFID string) (*models.ActiveClaList, error) GetCompanyProjectContributors(ctx context.Context, params *v2Ops.GetCompanyProjectContributorsParams) (*models.CorporateContributorList, error) GetCompanyProjectCLA(ctx context.Context, authUser *auth.User, companySFID, projectSFID string, companyID *string) (*models.CompanyProjectClaList, error) - GetCompanyClaGroups(ctx context.Context, companySFID string) (*models.CompanyClaGroups, error) + GetCompanyClaGroups(ctx context.Context, companySFID string, pageSize, offset *int64) (*models.CompanyClaGroups, error) CreateCompany(ctx context.Context, params *v2Ops.CreateCompanyParams) (*models.CompanyOutput, error) CreateCompanyFromSFModel(ctx context.Context, orgModel *orgModels.Organization, authUser *auth.User) (*models.CompanyOutput, error) GetCompanyByName(ctx context.Context, companyName string) (*models.Company, error) @@ -1311,7 +1311,7 @@ func (s *service) getCLAGroupsUnderProjectOrFoundation(ctx context.Context, proj return result, nil } -func (s *service) GetCompanyClaGroups(ctx context.Context, companySFID string) (*models.CompanyClaGroups, error) { +func (s *service) GetCompanyClaGroups(ctx context.Context, companySFID string, pageSize, offset *int64) (*models.CompanyClaGroups, error) { f := logrus.Fields{ "functionName": "v2.company.service.GetCompanyClaGroups", utils.XREQUESTID: ctx.Value(utils.XREQUESTID), @@ -1431,6 +1431,9 @@ func (s *service) GetCompanyClaGroups(ctx context.Context, companySFID string) ( } return result.List[i].ClaGroupID < result.List[j].ClaGroupID }) + result.TotalCount = int64(len(result.List)) + start, end := utils.PageBounds(len(result.List), pageSize, offset) + result.List = result.List[start:end] result.ResultCount = int64(len(result.List)) return result, nil } diff --git a/cla-backend-go/v2/company/service_test.go b/cla-backend-go/v2/company/service_test.go index 2280cc538..9f777f598 100644 --- a/cla-backend-go/v2/company/service_test.go +++ b/cla-backend-go/v2/company/service_test.go @@ -291,11 +291,12 @@ func TestGetCompanyClaGroups(t *testing.T) { }, nil) service := NewService(nil, mockSignatureRepo, mock_project_repo.NewMockProjectRepository(ctrl), mock_user_repo.NewMockUserRepository(ctrl), mockCompanyRepo, mockProjectClaGroupRepo, nil) - result, err := service.GetCompanyClaGroups(ctx, companySFID) + result, err := service.GetCompanyClaGroups(ctx, companySFID, nil, nil) assert.Nil(t, err) assert.Equal(t, companySFID, result.CompanySFID) assert.Equal(t, int64(2), result.ResultCount) + assert.Equal(t, int64(2), result.TotalCount) assert.Len(t, result.List, 2) first := result.List[0] @@ -340,6 +341,49 @@ func TestGetCompanyClaGroups(t *testing.T) { assert.False(t, second.AutoCreateECLA) } +func TestGetCompanyClaGroupsPaging(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + companySFID := "0014100000Te0000AAH" + mockCompanyRepo := mock_company_repo.NewMockIRepository(ctrl) + mockCompanyRepo.EXPECT().GetCompaniesByExternalID(ctx, companySFID, true).Return([]*v1Models.Company{ + {CompanyID: "company-id-1", CompanyExternalID: companySFID, CompanyName: "Acme"}, + {CompanyID: "company-id-2", CompanyExternalID: companySFID, CompanyName: "Acme", SigningEntityName: "Acme Sub"}, + }, nil) + + mockSignatureRepo := mock_signature_repo.NewMockSignatureRepository(ctrl) + mockSignatureRepo.EXPECT().GetCompanySignatures(ctx, cclaSignaturesParams("company-id-1", nil), HugePageSize, signatures.LoadACLDetails).Return(&v1Models.Signatures{ + Signatures: []*v1Models.Signature{ + {SignatureID: "signature-id-1", ProjectID: "cla-group-id", SignatureSigned: true}, + }, + }, nil) + mockSignatureRepo.EXPECT().GetCompanySignatures(ctx, cclaSignaturesParams("company-id-2", nil), HugePageSize, signatures.LoadACLDetails).Return(&v1Models.Signatures{ + Signatures: []*v1Models.Signature{ + {SignatureID: "signature-id-2", ProjectID: "cla-group-id", SignatureSigned: true}, + }, + }, nil) + mockSignatureRepo.EXPECT().GetClaGroupCorporateContributors(ctx, "cla-group-id", aws.String("company-id-1"), aws.Int64(1), nil, nil).Return(&v1Models.CorporateContributorList{}, nil) + mockSignatureRepo.EXPECT().GetClaGroupCorporateContributors(ctx, "cla-group-id", aws.String("company-id-2"), aws.Int64(1), nil, nil).Return(&v1Models.CorporateContributorList{}, nil) + + mockProjectClaGroupRepo := mock_pcg_repo.NewMockRepository(ctrl) + mockProjectClaGroupRepo.EXPECT().GetProjectsIdsForClaGroup(ctx, "cla-group-id").Times(1).Return([]*projects_cla_groups.ProjectClaGroup{ + {ClaGroupID: "cla-group-id", ClaGroupName: "Test CLA Group", FoundationSFID: "foundation-sfid", FoundationName: "Test Foundation", ProjectSFID: "foundation-sfid", ProjectName: "Test Foundation"}, + }, nil) + + service := NewService(nil, mockSignatureRepo, mock_project_repo.NewMockProjectRepository(ctrl), mock_user_repo.NewMockUserRepository(ctrl), mockCompanyRepo, mockProjectClaGroupRepo, nil) + result, err := service.GetCompanyClaGroups(ctx, companySFID, aws.Int64(1), aws.Int64(1)) + + assert.Nil(t, err) + assert.Equal(t, int64(2), result.TotalCount, "total before paging") + assert.Equal(t, int64(1), result.ResultCount, "returned page size") + if assert.Len(t, result.List, 1) { + assert.Equal(t, "company-id-2", result.List[0].CompanyID, "second row by signing entity sort order") + assert.Equal(t, "Acme Sub", result.List[0].SigningEntityName) + } +} + func TestGetCompanyClaGroupsSignedByOmitsBlankWithManagers(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) @@ -385,7 +429,7 @@ func TestGetCompanyClaGroupsSignedByOmitsBlankWithManagers(t *testing.T) { }, nil) service := NewService(nil, mockSignatureRepo, mock_project_repo.NewMockProjectRepository(ctrl), mock_user_repo.NewMockUserRepository(ctrl), mockCompanyRepo, mockProjectClaGroupRepo, nil) - result, err := service.GetCompanyClaGroups(ctx, companySFID) + result, err := service.GetCompanyClaGroups(ctx, companySFID, nil, nil) assert.Nil(t, err) assert.Len(t, result.List, 1) @@ -441,7 +485,7 @@ func TestGetCompanyClaGroupsCompanyNotFound(t *testing.T) { mockCompanyRepo.EXPECT().GetCompaniesByExternalID(ctx, "0014100000Te0000AAB", true).Return(nil, &utils.CompanyNotFound{CompanySFID: "0014100000Te0000AAB"}) service := NewService(nil, mock_signature_repo.NewMockSignatureRepository(ctrl), mock_project_repo.NewMockProjectRepository(ctrl), mock_user_repo.NewMockUserRepository(ctrl), mockCompanyRepo, mock_pcg_repo.NewMockRepository(ctrl), nil) - result, err := service.GetCompanyClaGroups(ctx, "0014100000Te0000AAB") + result, err := service.GetCompanyClaGroups(ctx, "0014100000Te0000AAB", nil, nil) assert.Nil(t, err) assert.Equal(t, "0014100000Te0000AAB", result.CompanySFID) @@ -484,7 +528,7 @@ func TestGetCompanyClaGroupsOrphanClaGroup(t *testing.T) { mockProjectRepo.EXPECT().GetCLAGroupByID(ctx, "orphan-cla-group-id", DontLoadRepoDetails).Return(&v1Models.ClaGroup{ProjectName: "Orphan Group", FoundationSFID: "orphan-foundation-sfid"}, nil) service := NewService(nil, mockSignatureRepo, mockProjectRepo, mock_user_repo.NewMockUserRepository(ctrl), mockCompanyRepo, mockProjectClaGroupRepo, nil) - result, err := service.GetCompanyClaGroups(ctx, companySFID) + result, err := service.GetCompanyClaGroups(ctx, companySFID, nil, nil) assert.Nil(t, err) assert.Len(t, result.List, 1) @@ -540,7 +584,7 @@ func TestGetCompanyClaGroupsOrphanClaGroupErrors(t *testing.T) { mockProjectRepo.EXPECT().GetCLAGroupByID(ctx, "orphan-cla-group-id", DontLoadRepoDetails).Return(nil, tc.cgErr) mockSignatureRepo.EXPECT().GetClaGroupCorporateContributors(ctx, "orphan-cla-group-id", aws.String("company-id-1"), aws.Int64(1), nil, nil).Return(&v1Models.CorporateContributorList{TotalCount: 0}, nil) - result, err := service.GetCompanyClaGroups(ctx, companySFID) + result, err := service.GetCompanyClaGroups(ctx, companySFID, nil, nil) assert.Nil(t, err) assert.Len(t, result.List, 1) @@ -558,7 +602,7 @@ func TestGetCompanyClaGroupsOrphanClaGroupErrors(t *testing.T) { repoErr := errors.New("dynamodb failure") mockProjectRepo.EXPECT().GetCLAGroupByID(ctx, "orphan-cla-group-id", DontLoadRepoDetails).Return(nil, repoErr) - result, err := service.GetCompanyClaGroups(ctx, companySFID) + result, err := service.GetCompanyClaGroups(ctx, companySFID, nil, nil) assert.Nil(t, result) assert.Equal(t, repoErr, err) @@ -618,7 +662,7 @@ func TestGetCompanyClaGroupsSignaturePagination(t *testing.T) { }, nil) service := NewService(nil, mockSignatureRepo, mock_project_repo.NewMockProjectRepository(ctrl), mock_user_repo.NewMockUserRepository(ctrl), mockCompanyRepo, mockProjectClaGroupRepo, nil) - result, err := service.GetCompanyClaGroups(ctx, companySFID) + result, err := service.GetCompanyClaGroups(ctx, companySFID, nil, nil) assert.Nil(t, err) assert.Len(t, result.List, 2) @@ -658,7 +702,7 @@ func TestCompanyClaGroupsJSONContract(t *testing.T) { lb, err := json.Marshal(models.CompanyClaGroups{List: make([]models.CompanyClaGroup, 0)}) assert.Nil(t, err) - for _, key := range []string{"companySFID", "resultCount", "list"} { + for _, key := range []string{"companySFID", "resultCount", "totalCount", "list"} { assert.Contains(t, string(lb), fmt.Sprintf("%q:", key)) } assert.Contains(t, string(lb), `"list":[]`) diff --git a/cla-backend-go/v2/my_clas/cla_managers_test.go b/cla-backend-go/v2/my_clas/cla_managers_test.go index 42ea469f3..b4fd11fed 100644 --- a/cla-backend-go/v2/my_clas/cla_managers_test.go +++ b/cla-backend-go/v2/my_clas/cla_managers_test.go @@ -125,7 +125,7 @@ func TestGetMyClasSignedIdentity(t *testing.T) { } svc := newTestService(repo, &fakePlatform{}, &fakeSignatures{}, &fakeCompanies{}, &fakeClaGroups{}) - result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}, nil, nil) require.NoError(t, err) byID := map[string]models.MyCla{} for _, row := range result.Clas { @@ -181,7 +181,7 @@ func TestGetMyClasFlaggedAndClaManager(t *testing.T) { } svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{}) - result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}, nil, nil) require.NoError(t, err) byID := map[string]models.MyCla{} for _, row := range result.Clas { @@ -210,7 +210,7 @@ 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{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}, nil, nil) require.NoError(t, err) for _, row := range result.Clas { assert.False(t, row.ClaManager) @@ -222,7 +222,7 @@ func TestGetMyClaManagers(t *testing.T) { 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") + result, err := svc.GetMyClaManagers(context.Background(), caller, &Identity{}, "sig-ecla", nil, nil) require.NoError(t, err) require.NotNil(t, result) assert.Equal(t, "sig-ecla", result.SignatureID) @@ -237,11 +237,11 @@ func TestGetMyClaManagers(t *testing.T) { 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") + result, err = svc.GetMyClaManagers(context.Background(), caller, &Identity{}, "sig-icla", nil, nil) 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") + result, err = svc.GetMyClaManagers(context.Background(), caller, &Identity{}, "sig-of-somebody-else", nil, nil) require.NoError(t, err) assert.Nil(t, result, "signatures not owned by the resolved identity are not found") } @@ -252,7 +252,7 @@ func TestGetMyClaManagersCallerIsManager(t *testing.T) { 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") + result, err := svc.GetMyClaManagers(context.Background(), &Caller{Username: "someone"}, &Identity{}, "sig-ecla", nil, nil) require.NoError(t, err) require.NotNil(t, result) assert.True(t, result.ClaManager, "the caller shows up as a CLA manager, case-insensitively") @@ -262,7 +262,7 @@ 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") + result, err := svc.GetMyClaManagers(context.Background(), &Caller{Username: "someone"}, &Identity{}, "sig-ecla", nil, nil) require.NoError(t, err) require.NotNil(t, result) assert.Empty(t, result.Managers, "no current CCLA yields an empty manager list") @@ -277,11 +277,11 @@ func TestGetMyClaManagersOwnershipEnforced(t *testing.T) { 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") + result, err := svc.GetMyClaManagers(context.Background(), &Caller{Username: "someone"}, &Identity{LfUsername: "victim"}, "sig-victim", nil, nil) 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") + result, err = svc.GetMyClaManagers(context.Background(), &Caller{Username: "staff-admin", Admin: true}, &Identity{LfUsername: "victim"}, "sig-victim", nil, nil) require.NoError(t, err) require.NotNil(t, result, "an admin can") } @@ -493,7 +493,7 @@ func TestMyClasJSONContract(t *testing.T) { svc, _, _ := newRequestTestService(repo, &fakeSignatures{}, companies) caller := &Caller{Username: "someone"} - list, err := svc.GetMyClas(context.Background(), caller, &Identity{}) + list, err := svc.GetMyClas(context.Background(), caller, &Identity{}, nil, nil) require.NoError(t, err) byID := map[string]models.MyCla{} for _, row := range list.Clas { @@ -507,7 +507,7 @@ func TestMyClasJSONContract(t *testing.T) { } } - managers, err := svc.GetMyClaManagers(context.Background(), caller, &Identity{}, "sig-ecla") + managers, err := svc.GetMyClaManagers(context.Background(), caller, &Identity{}, "sig-ecla", nil, nil) require.NoError(t, err) decoded := decodeJSON(t, managers) require.Contains(t, decoded, "managers") @@ -593,3 +593,51 @@ func TestCreateMyClaManagerRequestSubjectStaysSingleLine(t *testing.T) { assert.NotContains(t, subject, "\r") assert.Contains(t, subject, "request from EvilBcc: victim@example.org for Good Corp") } + +func TestGetMyClaManagersPaging(t *testing.T) { + repo, signaturesService, companies := managersFixture() + svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{}) + caller := &Caller{Username: "someone"} + + // no paging params - the stored ACL order, with totalCount mirroring resultCount + all, err := svc.GetMyClaManagers(context.Background(), caller, &Identity{}, "sig-ecla", nil, nil) + require.NoError(t, err) + require.NotNil(t, all) + assert.Equal(t, int64(3), all.ResultCount) + assert.Equal(t, int64(3), all.TotalCount) + assert.Equal(t, "manager-one", all.Managers[0].LfUsername, "the unpaged order is the stored ACL order") + + // paged windows are sorted by LF username: acl-no-lfid, manager-one, manager-two + pageSize := int64(2) + offset := int64(0) + page, err := svc.GetMyClaManagers(context.Background(), caller, &Identity{}, "sig-ecla", &pageSize, &offset) + require.NoError(t, err) + require.NotNil(t, page) + assert.Equal(t, int64(3), page.TotalCount) + assert.Equal(t, int64(2), page.ResultCount) + require.Len(t, page.Managers, 2) + assert.Equal(t, "acl-no-lfid", page.Managers[0].LfUsername) + assert.Equal(t, "manager-one", page.Managers[1].LfUsername) + + offset = 2 + page, err = svc.GetMyClaManagers(context.Background(), caller, &Identity{}, "sig-ecla", &pageSize, &offset) + require.NoError(t, err) + require.NotNil(t, page) + require.Len(t, page.Managers, 1) + assert.Equal(t, "manager-two", page.Managers[0].LfUsername) + assert.Equal(t, int64(1), page.ResultCount) + assert.Equal(t, int64(3), page.TotalCount) + + offset = 30 + page, err = svc.GetMyClaManagers(context.Background(), caller, &Identity{}, "sig-ecla", &pageSize, &offset) + require.NoError(t, err) + require.NotNil(t, page) + assert.Empty(t, page.Managers) + assert.Equal(t, int64(0), page.ResultCount) + assert.Equal(t, int64(3), page.TotalCount) + + // a paged call must not disturb a later unpaged call + all, err = svc.GetMyClaManagers(context.Background(), caller, &Identity{}, "sig-ecla", nil, nil) + require.NoError(t, err) + assert.Equal(t, "manager-one", all.Managers[0].LfUsername) +} diff --git a/cla-backend-go/v2/my_clas/handlers.go b/cla-backend-go/v2/my_clas/handlers.go index 291fe1bdf..132d818e8 100644 --- a/cla-backend-go/v2/my_clas/handlers.go +++ b/cla-backend-go/v2/my_clas/handlers.go @@ -65,7 +65,7 @@ func Configure(api *operations.EasyclaAPI, service Service, callerVerifier Calle } logCallerIdentity(f, trustedCaller, requested) - result, err := service.GetMyClas(ctx, &Caller{Username: currentUsername, Admin: admin, Trusted: trusted}, requested) + result, err := service.GetMyClas(ctx, &Caller{Username: currentUsername, Admin: admin, Trusted: trusted}, requested, params.PageSize, params.Offset) if err != nil { msg := "unable to lookup the CLAs for the provided identity" log.WithFields(f).WithError(err).Warn(msg) @@ -156,7 +156,7 @@ func Configure(api *operations.EasyclaAPI, service Service, callerVerifier Calle } logCallerIdentity(f, trustedCaller, requested) - result, err := service.GetMyClaManagers(ctx, &Caller{Username: currentUsername, Admin: admin, Trusted: trusted}, requested, params.SignatureID) + result, err := service.GetMyClaManagers(ctx, &Caller{Username: currentUsername, Admin: admin, Trusted: trusted}, requested, params.SignatureID, params.PageSize, params.Offset) if err != nil { msg := "unable to lookup the CLA managers for the given signature" log.WithFields(f).WithError(err).Warn(msg) @@ -246,7 +246,7 @@ func Configure(api *operations.EasyclaAPI, service Service, callerVerifier Calle return myClasOps.NewGetMyIdentitiesUnauthorized().WithXRequestID(reqID).WithPayload(utils.ErrorResponseUnauthorized(reqID, missingUsernameMsg)) } - result, err := service.GetMyIdentities(ctx, currentUsername) + result, err := service.GetMyIdentities(ctx, currentUsername, params.PageSize, params.Offset) if err != nil { msg := "unable to lookup the identities for the authenticated user" log.WithFields(f).WithError(err).Warn(msg) diff --git a/cla-backend-go/v2/my_clas/handlers_test.go b/cla-backend-go/v2/my_clas/handlers_test.go index 2428b8a8a..ecb7ab959 100644 --- a/cla-backend-go/v2/my_clas/handlers_test.go +++ b/cla-backend-go/v2/my_clas/handlers_test.go @@ -28,7 +28,7 @@ type fakeService struct { invalidRecipients bool } -func (f *fakeService) GetMyClaManagers(_ context.Context, caller *Caller, _ *Identity, _ string) (*models.MyClaManagerList, error) { +func (f *fakeService) GetMyClaManagers(_ context.Context, caller *Caller, _ *Identity, _ string, _, _ *int64) (*models.MyClaManagerList, error) { f.callers = append(f.callers, caller) if f.err != nil { return nil, f.err @@ -53,7 +53,7 @@ func (f *fakeService) CreateMyClaManagerRequest(_ context.Context, caller *Calle return &models.MyClaManagerRequestResult{}, nil } -func (f *fakeService) GetMyClas(_ context.Context, caller *Caller, _ *Identity) (*models.MyClaList, error) { +func (f *fakeService) GetMyClas(_ context.Context, caller *Caller, _ *Identity, _, _ *int64) (*models.MyClaList, error) { f.callers = append(f.callers, caller) if f.err != nil { return nil, f.err @@ -72,7 +72,7 @@ func (f *fakeService) GetMyClaPdfURL(_ context.Context, caller *Caller, _ *Ident return &models.MyClaPdf{}, nil } -func (f *fakeService) GetMyIdentities(_ context.Context, currentUsername string) (*models.MyIdentityList, error) { +func (f *fakeService) GetMyIdentities(_ context.Context, currentUsername string, _, _ *int64) (*models.MyIdentityList, error) { f.callers = append(f.callers, &Caller{Username: currentUsername}) if f.err != nil { return nil, f.err diff --git a/cla-backend-go/v2/my_clas/service.go b/cla-backend-go/v2/my_clas/service.go index 96ced2bf4..8aa0f4ed4 100644 --- a/cla-backend-go/v2/my_clas/service.go +++ b/cla-backend-go/v2/my_clas/service.go @@ -152,11 +152,11 @@ var ErrMissingMessage = errors.New("message is required for a contact request an // Service interface defines the My CLAs service methods type Service interface { - GetMyClas(ctx context.Context, caller *Caller, requested *Identity) (*models.MyClaList, error) + GetMyClas(ctx context.Context, caller *Caller, requested *Identity, pageSize, offset *int64) (*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) + GetMyIdentities(ctx context.Context, currentUsername string, pageSize, offset *int64) (*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) + GetMyClaManagers(ctx context.Context, caller *Caller, requested *Identity, signatureID string, pageSize, offset *int64) (*models.MyClaManagerList, error) CreateMyClaManagerRequest(ctx context.Context, caller *Caller, requested *Identity, signatureID string, input *models.MyClaManagerRequest) (*models.MyClaManagerRequestResult, error) } @@ -203,7 +203,7 @@ type projectInfo struct { // 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) { +func (s *service) GetMyClas(ctx context.Context, caller *Caller, requested *Identity, pageSize, offset *int64) (*models.MyClaList, error) { f := logrus.Fields{ "functionName": "v2.my_clas.service.GetMyClas", utils.XREQUESTID: ctx.Value(utils.XREQUESTID), @@ -296,6 +296,18 @@ func (s *service) GetMyClas(ctx context.Context, caller *Caller, requested *Iden sort.SliceStable(result.Clas, func(i, j int) bool { return result.Clas[i].SignedOn > result.Clas[j].SignedOn }) + result.TotalCount = int64(len(result.Clas)) + if pageSize != nil || offset != nil { + // deterministic tiebreak so paged windows never overlap or skip rows across calls + sort.SliceStable(result.Clas, func(i, j int) bool { + if result.Clas[i].SignedOn != result.Clas[j].SignedOn { + return result.Clas[i].SignedOn > result.Clas[j].SignedOn + } + return result.Clas[i].SignatureID < result.Clas[j].SignatureID + }) + start, end := utils.PageBounds(len(result.Clas), pageSize, offset) + result.Clas = result.Clas[start:end] + } result.ResultCount = int64(len(result.Clas)) log.WithFields(f).Debugf("resolved %d user records with %d CLA records (%d identity keys skipped)", len(result.UserIds), result.ResultCount, len(skipped)) @@ -368,7 +380,7 @@ func (s *service) GetMyClaPdfURL(ctx context.Context, caller *Caller, requested // 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) { +func (s *service) GetMyClaManagers(ctx context.Context, caller *Caller, requested *Identity, signatureID string, pageSize, offset *int64) (*models.MyClaManagerList, error) { identity, sig, _, err := s.findOwnedEcla(ctx, caller, requested, signatureID) if err != nil || sig == nil { return nil, err @@ -379,6 +391,17 @@ func (s *service) GetMyClaManagers(ctx context.Context, caller *Caller, requeste return nil, err } + managers := details.managers + if pageSize != nil || offset != nil { + // deterministic order for paged windows - the unpaged response keeps the stored ACL order + managers = append([]models.MyClaManager{}, managers...) + sort.SliceStable(managers, func(i, j int) bool { + return managers[i].LfUsername < managers[j].LfUsername + }) + start, end := utils.PageBounds(len(managers), pageSize, offset) + managers = managers[start:end] + } + return &models.MyClaManagerList{ SignatureID: sig.SignatureID, ClaGroupID: sig.SignatureProjectID, @@ -387,8 +410,9 @@ func (s *service) GetMyClaManagers(ctx context.Context, caller *Caller, requeste CompanyID: sig.SignatureUserCompanyID, CompanyName: details.companyName, ClaManager: details.callerIsManager, - Managers: details.managers, - ResultCount: int64(len(details.managers)), + Managers: managers, + ResultCount: int64(len(managers)), + TotalCount: int64(len(details.managers)), }, nil } @@ -788,7 +812,7 @@ func precedenceIdentity(githubAs, gitlabAs, gerritAs string) (string, string) { // GetMyIdentities returns the deduplicated ":" identities the authenticated user // 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) { +func (s *service) GetMyIdentities(ctx context.Context, currentUsername string, pageSize, offset *int64) (*models.MyIdentityList, error) { if currentUsername == "" { return nil, errors.New("no username on the authenticated principal") } @@ -844,10 +868,16 @@ func (s *service) GetMyIdentities(ctx context.Context, currentUsername string) ( } sort.Strings(identities) + totalCount := int64(len(identities)) + if pageSize != nil || offset != nil { + start, end := utils.PageBounds(len(identities), pageSize, offset) + identities = identities[start:end] + } return &models.MyIdentityList{ LfUsername: currentUsername, Identities: identities, ResultCount: int64(len(identities)), + TotalCount: totalCount, }, nil } diff --git a/cla-backend-go/v2/my_clas/service_test.go b/cla-backend-go/v2/my_clas/service_test.go index 08686525d..597a67d5c 100644 --- a/cla-backend-go/v2/my_clas/service_test.go +++ b/cla-backend-go/v2/my_clas/service_test.go @@ -306,7 +306,7 @@ func TestGetMyClasUnionAndDedupe(t *testing.T) { result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{ Emails: []string{"Someone@Example.org ", "someone@example.org"}, GithubIDs: []int64{12345, 12345}, - }) + }, nil, nil) require.NoError(t, err) assert.Equal(t, []string{"user-a", "user-b"}, result.UserIds) assert.Empty(t, result.SkippedIdentities) @@ -407,7 +407,7 @@ func TestGetMyClasProjectNameAndLogo(t *testing.T) { "found-sfid": {ProjectOutput: v2ProjectServiceModels.ProjectOutput{ProjectCommon: v2ProjectServiceModels.ProjectCommon{Name: "Cloud Native Computing Foundation", ProjectLogo: "https://logos.example.org/cncf.png"}}}, }} - result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}, nil, nil) require.NoError(t, err) require.Len(t, result.Clas, 2) @@ -449,7 +449,7 @@ func TestGetMyClasProjectLookupDegradesGracefully(t *testing.T) { svc := newTestService(repo, &fakePlatform{}, &fakeSignatures{}, &fakeCompanies{}, claGroups) svc.projectService = &fakeProjectService{byID: map[string]*v2ProjectServiceModels.ProjectOutputDetailed{}} - result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}, nil, nil) require.NoError(t, err, "a project-service miss must not fail the listing") require.Len(t, result.Clas, 1) assert.Equal(t, "Kubernetes", result.Clas[0].ProjectName, "the mapping-table name is kept when the project-service has no record") @@ -484,7 +484,7 @@ func TestGetMyClasMultiProjectNonFoundation(t *testing.T) { "proj-alpha": {ProjectOutput: v2ProjectServiceModels.ProjectOutput{ProjectCommon: v2ProjectServiceModels.ProjectCommon{Name: "Alpha", ProjectLogo: "https://logos.example.org/alpha.png"}}}, }} - result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}, nil, nil) require.NoError(t, err) require.Len(t, result.Clas, 1) assert.Empty(t, result.Clas[0].ProjectName, "an ambiguous multi-project non-foundation group invents no project name") @@ -520,7 +520,7 @@ func TestGetMyClasProjectCacheHitPerRequest(t *testing.T) { } svc.projectService = projectSvc - result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}, nil, nil) require.NoError(t, err) require.Len(t, result.Clas, 2) assert.Equal(t, 1, projectSvc.calls["proj-sfid-1"], "the project-service is queried once per distinct CLA group within a request") @@ -551,7 +551,7 @@ func TestGetMyClasProjectServiceErrorAndNilClient(t *testing.T) { svc := newTestService(newRepo(), &fakePlatform{}, &fakeSignatures{}, &fakeCompanies{}, newClaGroups()) svc.projectService = &fakeProjectService{err: errors.New("project-service unavailable")} - result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}, nil, nil) require.NoError(t, err, "a project-service error must not fail the listing") require.Len(t, result.Clas, 1) assert.Equal(t, "Kubernetes", result.Clas[0].ProjectName, "the mapping-table name is kept on a project-service error") @@ -562,7 +562,7 @@ func TestGetMyClasProjectServiceErrorAndNilClient(t *testing.T) { svc := newTestService(newRepo(), &fakePlatform{}, &fakeSignatures{}, &fakeCompanies{}, newClaGroups()) svc.projectService = nil - result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}, nil, nil) require.NoError(t, err, "a nil project-service client must not fail the listing") require.Len(t, result.Clas, 1) assert.Equal(t, "Kubernetes", result.Clas[0].ProjectName, "the mapping-table name is kept with no project-service client") @@ -586,7 +586,7 @@ func TestGetMyClasMultipleRecordsSameLFID(t *testing.T) { } svc := newTestService(repo, &fakePlatform{}, &fakeSignatures{}, &fakeCompanies{}, &fakeClaGroups{}) - result, err := svc.GetMyClas(context.Background(), &Caller{Username: "alice"}, &Identity{GithubIDs: []int64{12345}}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "alice"}, &Identity{GithubIDs: []int64{12345}}, nil, nil) require.NoError(t, err) assert.Empty(t, result.SkippedIdentities, "a numeric ID stored on any of the caller's LFID records is authorized") assert.ElementsMatch(t, []string{"user-a1", "user-a2", "user-a3"}, result.UserIds, "all records per key are unioned") @@ -600,7 +600,7 @@ func TestGetMyClasMultipleRecordsSameLFID(t *testing.T) { func TestGetMyClasNoMatches(t *testing.T) { svc := newTestService(&fakeRepo{}, &fakePlatform{}, &fakeSignatures{}, &fakeCompanies{}, &fakeClaGroups{}) - result, err := svc.GetMyClas(context.Background(), &Caller{Username: "missing"}, &Identity{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "missing"}, &Identity{}, nil, nil) require.NoError(t, err) assert.Empty(t, result.UserIds) assert.Empty(t, result.Clas) @@ -634,7 +634,7 @@ func TestGetMyClasOwnershipRejectsForeignIdentities(t *testing.T) { GitlabIDs: []int64{7}, GitlabUsernames: []string{"victim-gl"}, GerritUsernames: []string{"victim"}, - }) + }, nil, nil) require.NoError(t, err) assert.Equal(t, []string{"user-a"}, result.UserIds, "only the caller's own record is searched") require.Len(t, result.Clas, 1) @@ -676,7 +676,7 @@ func TestGetMyClasOwnershipViaEasyCLARecord(t *testing.T) { result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{ SecondaryEmails: []string{"Alt@Example.org", "alt2@example.org", "alt@example.org"}, GitlabIDs: []int64{777}, - }) + }, nil, nil) require.NoError(t, err) assert.Empty(t, result.SkippedIdentities) assert.ElementsMatch(t, []string{"user-a", "user-b", "user-c"}, result.UserIds) @@ -711,7 +711,7 @@ func TestGetMyClasOwnershipViaPlatformIdentities(t *testing.T) { result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{ GithubUsernames: []string{"octocat"}, GerritUsernames: []string{"old-ldap-id"}, - }) + }, nil, nil) require.NoError(t, err) assert.Empty(t, result.SkippedIdentities) assert.ElementsMatch(t, []string{"user-a", "user-gh", "user-gerrit"}, result.UserIds, @@ -720,14 +720,14 @@ func TestGetMyClasOwnershipViaPlatformIdentities(t *testing.T) { result, err = svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{ GithubUsernames: []string{"not-a-code-identity"}, - }) + }, nil, nil) require.NoError(t, err) assert.Equal(t, []string{"githubUsername:not-a-code-identity"}, result.SkippedIdentities, "a slack username must not authorize a github search") result, err = svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{ GithubUsernames: []string{"lakecat"}, - }) + }, nil, nil) require.NoError(t, err) assert.Equal(t, []string{"githubUsername:lakecat"}, result.SkippedIdentities, "a non-platform (datalake) identity must not authorize a search") @@ -743,7 +743,7 @@ func TestGetMyClasAdminBypass(t *testing.T) { } svc := newTestService(repo, &fakePlatform{}, &fakeSignatures{}, &fakeCompanies{}, &fakeClaGroups{}) - result, err := svc.GetMyClas(context.Background(), &Caller{Username: "staff-admin", Admin: true}, &Identity{LfUsername: "victim"}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "staff-admin", Admin: true}, &Identity{LfUsername: "victim"}, nil, nil) require.NoError(t, err) assert.Empty(t, result.SkippedIdentities) assert.Equal(t, "victim", result.LfUsername) @@ -768,7 +768,7 @@ func TestGetMyClasTrustedCallerBypass(t *testing.T) { result, err := svc.GetMyClas(context.Background(), &Caller{Trusted: true}, &Identity{ GithubIDs: []int64{999}, GithubUsernames: []string{"octocat"}, - }) + }, nil, nil) require.NoError(t, err) assert.Empty(t, result.SkippedIdentities) assert.Equal(t, []string{"user-gh"}, result.UserIds) @@ -785,10 +785,10 @@ func TestGetMyClasTrustedCallerBypass(t *testing.T) { func TestEffectiveIdentityRequiresACaller(t *testing.T) { svc := newTestService(&fakeRepo{}, &fakePlatform{}, &fakeSignatures{}, &fakeCompanies{}, &fakeClaGroups{}) - _, err := svc.GetMyClas(context.Background(), nil, &Identity{GithubIDs: []int64{999}}) + _, err := svc.GetMyClas(context.Background(), nil, &Identity{GithubIDs: []int64{999}}, nil, nil) assert.Error(t, err, "a nil caller must never be treated as authorized") - _, err = svc.GetMyClas(context.Background(), &Caller{}, &Identity{GithubIDs: []int64{999}}) + _, err = svc.GetMyClas(context.Background(), &Caller{}, &Identity{GithubIDs: []int64{999}}, nil, nil) assert.Error(t, err, "an untrusted caller without a username must never be treated as authorized") _, err = svc.GetMyClaPdfURL(context.Background(), &Caller{}, &Identity{GithubIDs: []int64{999}}, "sig-1") @@ -864,7 +864,7 @@ func TestGetMyClasIclaValidity(t *testing.T) { } svc := newTestService(repo, &fakePlatform{}, &fakeSignatures{}, &fakeCompanies{}, &fakeClaGroups{}) - result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}, nil, nil) require.NoError(t, err) require.Len(t, result.Clas, 2, "unsigned records must be excluded") @@ -914,7 +914,7 @@ func TestGetMyClasEclaValidity(t *testing.T) { } svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{}) - result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}, nil, nil) require.NoError(t, err) require.Len(t, result.Clas, 5) @@ -961,7 +961,7 @@ func TestGetMyClasEclaNotOnCurrentApprovalList(t *testing.T) { } svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{}) - result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}, nil, nil) require.NoError(t, err) require.Len(t, result.Clas, 1) assert.True(t, result.Clas[0].Approved) @@ -988,7 +988,7 @@ func TestGetMyClasEclaGitlabGroupFallback(t *testing.T) { } svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{}) - result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}, nil, nil) 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") @@ -1014,7 +1014,7 @@ func TestGetMyClasEclaApprovalEvaluationError(t *testing.T) { } svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{}) - result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}, nil, nil) 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") @@ -1044,7 +1044,7 @@ func TestGetMyClasStatus(t *testing.T) { } svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{}) - result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}, nil, nil) require.NoError(t, err) require.Len(t, result.Clas, 4) assert.Equal(t, models.MyClaListSssModeDisabled, result.SssMode, "no screener configured reports disabled") @@ -1078,7 +1078,7 @@ func TestGetMyClasStatusNeedsAttentionAndUnknown(t *testing.T) { 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{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}, nil, nil) require.NoError(t, err) require.Len(t, result.Clas, 1) assert.Equal(t, models.MyClaStatusNeedsAttention, result.Clas[0].Status) @@ -1087,7 +1087,7 @@ func TestGetMyClasStatusNeedsAttentionAndUnknown(t *testing.T) { 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{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}, nil, nil) 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") @@ -1119,7 +1119,7 @@ func TestGetMyClasDegradesFailedLookups(t *testing.T) { } svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{}) - result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}, nil, nil) require.NoError(t, err, "one unresolvable employer must not fail the whole list") require.Len(t, result.Clas, 3) @@ -1153,7 +1153,7 @@ func TestGetMyClasDegradesFailedLookups(t *testing.T) { } svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{}) - result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}, nil, nil) require.NoError(t, err, "an unresolvable CCLA must not fail the whole list") require.Len(t, result.Clas, 2) for _, row := range result.Clas { @@ -1198,7 +1198,7 @@ func TestGetMyClasLiveSanctionsScreening(t *testing.T) { svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{}) svc.sanctions = screener - result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}, nil, nil) 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) @@ -1286,7 +1286,7 @@ func TestGetMyClasPersistsFirstLiveSanction(t *testing.T) { svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{}) svc.sanctions = &fakeScreener{mode: models.MyClaListSssModeRequired, flagged: map[string]bool{"company-1": true}} - result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}, nil, nil) require.NoError(t, err, "persisting must never fail the listing") require.Len(t, result.Clas, 1) row := result.Clas[0] @@ -1399,7 +1399,7 @@ func TestGetMyClasScreensDistinctEmployersInParallel(t *testing.T) { } done := make(chan outcome, 1) go func() { - list, listErr := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + list, listErr := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}, nil, nil) done <- outcome{list: list, err: listErr} }() @@ -1505,7 +1505,7 @@ func TestGetMyIdentities(t *testing.T) { {Provider: "github", UserID: "9218699"}, }} - result, err := svc.GetMyIdentities(context.Background(), "someone") + result, err := svc.GetMyIdentities(context.Background(), "someone", nil, nil) require.NoError(t, err) assert.Equal(t, "someone", result.LfUsername) assert.Equal(t, []string{ @@ -1522,7 +1522,7 @@ func TestGetMyIdentities(t *testing.T) { }, result.Identities) assert.Equal(t, int64(len(result.Identities)), result.ResultCount) - _, err = svc.GetMyIdentities(context.Background(), "") + _, err = svc.GetMyIdentities(context.Background(), "", nil, nil) assert.Error(t, err) } @@ -1595,7 +1595,7 @@ func TestGetMyClasEmitsCompanySanctionedEvent(t *testing.T) { eventsLog := &fakeEvents{} svc.eventsService = eventsLog - _, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + _, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}, nil, nil) require.NoError(t, err) require.Len(t, eventsLog.logged, tc.wantEvents) @@ -1625,7 +1625,7 @@ func TestGetMyClasInvalidatedAt(t *testing.T) { } svc := newTestService(repo, &fakePlatform{}, &fakeSignatures{}, &fakeCompanies{}, &fakeClaGroups{}) - result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}, nil, nil) require.NoError(t, err) byID := map[string]models.MyCla{} for _, row := range result.Clas { @@ -1735,7 +1735,7 @@ func TestGetMyClasSignedAsFallsBackToUserRecord(t *testing.T) { } svc := newTestService(repo, &fakePlatform{}, &fakeSignatures{}, &fakeCompanies{}, &fakeClaGroups{}) - result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}, nil, nil) require.NoError(t, err) byID := map[string]models.MyCla{} for _, row := range result.Clas { @@ -1749,3 +1749,82 @@ func TestGetMyClasSignedAsFallsBackToUserRecord(t *testing.T) { assert.Equal(t, octocatGithub, byID["sig-bare"].SignedAs) assert.Equal(t, models.MyClaSignedViaGithub, byID["sig-bare"].SignedVia) } + +func TestGetMyClasPaging(t *testing.T) { + userA := &v1Models.User{UserID: "user-a", LfUsername: "someone"} + repo := &fakeRepo{ + byUserID: map[string][]*signatures.ItemSignature{ + "user-a": { + icla("sig-c", "user-a", "cla-group-1", "2024-03-01T00:00:00Z", true), + icla("sig-e", "user-a", "cla-group-1", "2024-01-01T00:00:00Z", true), + icla("sig-b", "user-a", "cla-group-1", "2024-04-01T00:00:00Z", true), + // SignedOn tie with sig-c - the paged order falls back to the signature ID + icla("sig-d", "user-a", "cla-group-1", "2024-03-01T00:00:00Z", true), + icla("sig-a", "user-a", "cla-group-1", "2024-05-01T00:00:00Z", true), + }, + }, + byLFUsername: map[string][]*v1Models.User{"someone": {userA}}, + } + svc := newTestService(repo, &fakePlatform{}, &fakeSignatures{}, &fakeCompanies{}, &fakeClaGroups{}) + + // no paging params - the pre-paging response shape: every row, resultCount == totalCount + all, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}, nil, nil) + require.NoError(t, err) + require.Len(t, all.Clas, 5) + assert.Equal(t, int64(5), all.ResultCount) + assert.Equal(t, int64(5), all.TotalCount) + + wantOrder := []string{"sig-a", "sig-b", "sig-c", "sig-d", "sig-e"} // SignedOn desc, signature ID asc on the tie + pageSize := int64(2) + var got []string + for offset := int64(0); ; offset += pageSize { + off := offset + page, pageErr := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}, &pageSize, &off) + require.NoError(t, pageErr) + assert.Equal(t, int64(5), page.TotalCount, "totalCount is the pre-paging row count") + assert.Equal(t, int64(len(page.Clas)), page.ResultCount) + assert.LessOrEqual(t, len(page.Clas), 2) + if len(page.Clas) == 0 { + break + } + for _, row := range page.Clas { + got = append(got, row.SignatureID) + } + } + assert.Equal(t, wantOrder, got, "paged windows join back to the full deterministic order") + + off := int64(50) + empty, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}, nil, &off) + require.NoError(t, err) + assert.Empty(t, empty.Clas) + assert.Equal(t, int64(0), empty.ResultCount) + assert.Equal(t, int64(5), empty.TotalCount) +} + +func TestGetMyIdentitiesPaging(t *testing.T) { + userA := &v1Models.User{UserID: "user-a", LfUsername: "someone", LfEmail: "someone@example.org", Emails: []string{"alt@example.org"}, GithubUsername: "octocat"} + repo := &fakeRepo{byLFUsername: map[string][]*v1Models.User{"someone": {userA}}} + svc := newTestService(repo, &fakePlatform{}, &fakeSignatures{}, &fakeCompanies{}, &fakeClaGroups{}) + + all, err := svc.GetMyIdentities(context.Background(), "someone", nil, nil) + require.NoError(t, err) + require.NotEmpty(t, all.Identities) + total := len(all.Identities) + assert.Equal(t, int64(total), all.ResultCount) + assert.Equal(t, int64(total), all.TotalCount) + + pageSize := int64(3) + var got []string + for offset := int64(0); ; offset += pageSize { + off := offset + page, pageErr := svc.GetMyIdentities(context.Background(), "someone", &pageSize, &off) + require.NoError(t, pageErr) + assert.Equal(t, int64(total), page.TotalCount) + assert.Equal(t, int64(len(page.Identities)), page.ResultCount) + if len(page.Identities) == 0 { + break + } + got = append(got, page.Identities...) + } + assert.Equal(t, all.Identities, got, "paged windows join back to the unpaged sorted list") +} diff --git a/cla-backend-go/v2/signatures/company_corporate_contributors_test.go b/cla-backend-go/v2/signatures/company_corporate_contributors_test.go new file mode 100644 index 000000000..ea41dce80 --- /dev/null +++ b/cla-backend-go/v2/signatures/company_corporate_contributors_test.go @@ -0,0 +1,181 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package signatures + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/LF-Engineering/lfx-kit/auth" + "github.com/go-openapi/runtime" + "github.com/golang/mock/gomock" + mock_company "github.com/linuxfoundation/easycla/cla-backend-go/company/mocks" + 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/gen/v2/restapi/operations" + sigOps "github.com/linuxfoundation/easycla/cla-backend-go/gen/v2/restapi/operations/signatures" + mock_project_repo "github.com/linuxfoundation/easycla/cla-backend-go/project/mocks" + "github.com/linuxfoundation/easycla/cla-backend-go/projects_cla_groups" + mock_projects_cla_groups "github.com/linuxfoundation/easycla/cla-backend-go/projects_cla_groups/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeCorporateContributorsService struct { + ServiceInterface + calls int + gotParams sigOps.ListClaGroupCorporateContributorsParams + result *models.CorporateContributorList + err error +} + +func (f *fakeCorporateContributorsService) GetClaGroupCorporateContributors(_ context.Context, params sigOps.ListClaGroupCorporateContributorsParams) (*models.CorporateContributorList, error) { + f.calls++ + f.gotParams = params + if f.err != nil { + return nil, f.err + } + return f.result, nil +} + +func TestListCompanyClaGroupCorporateContributors(t *testing.T) { + t.Setenv("DISABLE_LOCAL_PERMISSION_CHECKS", "false") + + const ( + claGroupID = "cla-group-cc-1" + companySFID = "0014100000CCAliasAAA" + companyID = "company-cc-1" + foundation = "found-cc-sfid" + ) + + orgScopedUser := func() *auth.User { + return &auth.User{ + UserName: "cla-manager-user", + Email: "cla-manager@example.com", + ACL: auth.ACL{Allowed: true, Scopes: []auth.Scope{{Type: auth.Organization, ID: companySFID}}}, + } + } + + serve := func(t *testing.T, api *operations.EasyclaAPI, authUser *auth.User, params sigOps.ListCompanyClaGroupCorporateContributorsParams) *httptest.ResponseRecorder { + t.Helper() + require.NotNil(t, api.SignaturesListCompanyClaGroupCorporateContributorsHandler) + username, email, reqID := authUser.UserName, authUser.Email, testReqID + params.HTTPRequest = httptest.NewRequest(http.MethodGet, "/v4/company/external/"+companySFID+"/cla-group/"+claGroupID+"/corporate-contributors", nil) + params.XUSERNAME, params.XEMAIL, params.XREQUESTID = &username, &email, &reqID + recorder := httptest.NewRecorder() + api.SignaturesListCompanyClaGroupCorporateContributorsHandler.Handle(params, authUser).WriteResponse(recorder, runtime.JSONProducer()) + return recorder + } + + t.Run("org-scoped caller gets the delegated list", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockProjectRepo := mock_project_repo.NewMockProjectRepository(ctrl) + mockProjectRepo.EXPECT().GetCLAGroupByID(gomock.Any(), claGroupID, false).Return(&v1Models.ClaGroup{ProjectID: claGroupID, ProjectCCLAEnabled: true}, nil) + + mockCompanyService := mock_company.NewMockIService(ctrl) + mockCompanyService.EXPECT().GetCompanyByExternalID(gomock.Any(), companySFID).Return(&v1Models.Company{CompanyID: companyID, CompanyExternalID: companySFID}, nil) + + mockPcgRepo := mock_projects_cla_groups.NewMockRepository(ctrl) + mockPcgRepo.EXPECT().GetProjectsIdsForClaGroup(gomock.Any(), claGroupID).Return([]*projects_cla_groups.ProjectClaGroup{{ClaGroupID: claGroupID, FoundationSFID: foundation}}, nil) + + searchTerm, pageSize, nextKey := "ali", int64(7), "next-key-1" + v2Service := &fakeCorporateContributorsService{result: &models.CorporateContributorList{List: []*models.CorporateContributor{{LinuxFoundationID: "alice-lfid"}}}} + + api := operations.NewEasyclaAPI(nil) + Configure(api, nil, mockProjectRepo, mockCompanyService, nil, nil, nil, v2Service, mockPcgRepo) + + recorder := serve(t, api, orgScopedUser(), sigOps.ListCompanyClaGroupCorporateContributorsParams{ + ClaGroupID: claGroupID, CompanySFID: companySFID, + SearchTerm: &searchTerm, PageSize: &pageSize, NextKey: &nextKey, + }) + + assert.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + require.Equal(t, 1, v2Service.calls) + require.NotNil(t, v2Service.gotParams.CompanyID) + assert.Equal(t, companyID, *v2Service.gotParams.CompanyID, "the SFID is resolved to the internal company ID before delegation") + assert.Equal(t, claGroupID, v2Service.gotParams.ClaGroupID) + require.NotNil(t, v2Service.gotParams.SearchTerm) + assert.Equal(t, searchTerm, *v2Service.gotParams.SearchTerm) + require.NotNil(t, v2Service.gotParams.PageSize) + assert.Equal(t, pageSize, *v2Service.gotParams.PageSize) + require.NotNil(t, v2Service.gotParams.NextKey) + assert.Equal(t, nextKey, *v2Service.gotParams.NextKey) + assert.Contains(t, recorder.Body.String(), "alice-lfid") + }) + + t.Run("caller scoped to another organization is forbidden", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockProjectRepo := mock_project_repo.NewMockProjectRepository(ctrl) + mockProjectRepo.EXPECT().GetCLAGroupByID(gomock.Any(), claGroupID, false).Return(&v1Models.ClaGroup{ProjectID: claGroupID, ProjectCCLAEnabled: true}, nil) + + mockCompanyService := mock_company.NewMockIService(ctrl) + mockCompanyService.EXPECT().GetCompanyByExternalID(gomock.Any(), companySFID).Return(&v1Models.Company{CompanyID: companyID, CompanyExternalID: companySFID}, nil) + + mockPcgRepo := mock_projects_cla_groups.NewMockRepository(ctrl) + mockPcgRepo.EXPECT().GetProjectsIdsForClaGroup(gomock.Any(), claGroupID).Return([]*projects_cla_groups.ProjectClaGroup{{ClaGroupID: claGroupID, FoundationSFID: foundation}}, nil) + // the scope fallback walks the foundation -> CLA group mapping before giving up + mockPcgRepo.EXPECT().GetClaGroupIDForProject(gomock.Any(), foundation).Return(nil, errors.New("no mapping")) + + v2Service := &fakeCorporateContributorsService{} + api := operations.NewEasyclaAPI(nil) + Configure(api, nil, mockProjectRepo, mockCompanyService, nil, nil, nil, v2Service, mockPcgRepo) + + otherOrgUser := &auth.User{ + UserName: "other-manager", + Email: "other@example.com", + ACL: auth.ACL{Allowed: true, Scopes: []auth.Scope{{Type: auth.Organization, ID: "some-other-org-sfid"}}}, + } + recorder := serve(t, api, otherOrgUser, sigOps.ListCompanyClaGroupCorporateContributorsParams{ClaGroupID: claGroupID, CompanySFID: companySFID}) + + assert.Equal(t, http.StatusForbidden, recorder.Code, recorder.Body.String()) + assert.Equal(t, 0, v2Service.calls, "an unauthorized caller must never reach the service") + }) + + t.Run("unknown company SFID is not found", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockProjectRepo := mock_project_repo.NewMockProjectRepository(ctrl) + mockProjectRepo.EXPECT().GetCLAGroupByID(gomock.Any(), claGroupID, false).Return(&v1Models.ClaGroup{ProjectID: claGroupID, ProjectCCLAEnabled: true}, nil) + + mockCompanyService := mock_company.NewMockIService(ctrl) + mockCompanyService.EXPECT().GetCompanyByExternalID(gomock.Any(), companySFID).Return(nil, errors.New("company not found")) + + v2Service := &fakeCorporateContributorsService{} + api := operations.NewEasyclaAPI(nil) + Configure(api, nil, mockProjectRepo, mockCompanyService, nil, nil, nil, v2Service, mock_projects_cla_groups.NewMockRepository(ctrl)) + + recorder := serve(t, api, orgScopedUser(), sigOps.ListCompanyClaGroupCorporateContributorsParams{ClaGroupID: claGroupID, CompanySFID: companySFID}) + + assert.Equal(t, http.StatusNotFound, recorder.Code, recorder.Body.String()) + assert.Equal(t, 0, v2Service.calls) + }) + + t.Run("CLA group without CCLA support is a bad request", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockProjectRepo := mock_project_repo.NewMockProjectRepository(ctrl) + mockProjectRepo.EXPECT().GetCLAGroupByID(gomock.Any(), claGroupID, false).Return(&v1Models.ClaGroup{ProjectID: claGroupID, ProjectCCLAEnabled: false}, nil) + + mockCompanyService := mock_company.NewMockIService(ctrl) + mockCompanyService.EXPECT().GetCompanyByExternalID(gomock.Any(), companySFID).Return(&v1Models.Company{CompanyID: companyID, CompanyExternalID: companySFID}, nil) + + v2Service := &fakeCorporateContributorsService{} + api := operations.NewEasyclaAPI(nil) + Configure(api, nil, mockProjectRepo, mockCompanyService, nil, nil, nil, v2Service, mock_projects_cla_groups.NewMockRepository(ctrl)) + + recorder := serve(t, api, orgScopedUser(), sigOps.ListCompanyClaGroupCorporateContributorsParams{ClaGroupID: claGroupID, CompanySFID: companySFID}) + + assert.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String()) + assert.Equal(t, 0, v2Service.calls) + }) +} diff --git a/cla-backend-go/v2/signatures/handlers.go b/cla-backend-go/v2/signatures/handlers.go index 5a29cb419..d89a852b9 100644 --- a/cla-backend-go/v2/signatures/handlers.go +++ b/cla-backend-go/v2/signatures/handlers.go @@ -968,6 +968,89 @@ func Configure(api *operations.EasyclaAPI, claGroupService service.Service, proj return signatures.NewListClaGroupCorporateContributorsOK().WithXRequestID(reqID).WithPayload(result) }) + // Organization-scoped alias of listClaGroupCorporateContributors - the companySFID path segment + // lets the API gateway authorize organization-scoped (CLA manager) tokens, which the + // /cla-group/{claGroupID}/corporate-contributors path cannot + api.SignaturesListCompanyClaGroupCorporateContributorsHandler = signatures.ListCompanyClaGroupCorporateContributorsHandlerFunc(func(params signatures.ListCompanyClaGroupCorporateContributorsParams, authUser *auth.User) middleware.Responder { + reqID := utils.GetRequestID(params.XREQUESTID) + ctx := context.WithValue(context.Background(), utils.XREQUESTID, reqID) // nolint + utils.SetAuthUserProperties(authUser, params.XUSERNAME, params.XEMAIL) + f := logrus.Fields{ + "functionName": "v2.signatures.handlers.SignaturesListCompanyClaGroupCorporateContributorsHandler", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "claGroupID": params.ClaGroupID, + "companySFID": params.CompanySFID, + } + + claGroupModel, err := projectRepo.GetCLAGroupByID(ctx, params.ClaGroupID, repository.DontLoadRepoDetails) + if err != nil { + log.WithFields(f).WithError(err).Warn(problemLoadingCLAGroupByID) + if err == repository.ErrProjectDoesNotExist { + return signatures.NewListCompanyClaGroupCorporateContributorsNotFound().WithXRequestID(reqID).WithPayload( + utils.ErrorResponseNotFoundWithError(reqID, problemLoadingCLAGroupByID, err)) + } + return signatures.NewListCompanyClaGroupCorporateContributorsBadRequest().WithXRequestID(reqID).WithPayload( + utils.ErrorResponseBadRequest(reqID, problemLoadingCLAGroupByID)) + } + + companyModel, err := companyService.GetCompanyByExternalID(ctx, params.CompanySFID) + if err != nil || companyModel == nil { + msg := fmt.Sprintf("company lookup by SFID: %s failed", params.CompanySFID) + log.WithFields(f).WithError(err).Warn(msg) + return signatures.NewListCompanyClaGroupCorporateContributorsNotFound().WithXRequestID(reqID).WithPayload( + utils.ErrorResponseNotFoundWithError(reqID, msg, err)) + } + + if !claGroupModel.ProjectCCLAEnabled { + msg := fmt.Sprintf("CLA Group with ID '%s' does not support corporate contribution", params.ClaGroupID) + log.WithFields(f).Warn(msg) + return signatures.NewListCompanyClaGroupCorporateContributorsBadRequest().WithXRequestID(reqID).WithPayload(utils.ErrorResponseBadRequestWithError(reqID, msg, errors.New(msg))) + } + + projectCLAGroupEntries, projectCLAGroupErr := projectClaGroupsRepo.GetProjectsIdsForClaGroup(ctx, params.ClaGroupID) + if projectCLAGroupErr != nil || len(projectCLAGroupEntries) == 0 { + msg := fmt.Sprintf("unable to load project CLA Group mappings for CLA Group: %s - has this project been migrated to v2?", params.ClaGroupID) + log.WithFields(f).Warn(msg) + return signatures.NewListCompanyClaGroupCorporateContributorsBadRequest().WithXRequestID(reqID).WithPayload(utils.ErrorResponseBadRequest(reqID, msg)) + } + f["foundationSFID"] = projectCLAGroupEntries[0].FoundationSFID + + log.WithFields(f).Debug("checking access control permissions for user...") + if !isUserHaveAccessToCLAProjectOrganization(ctx, authUser, projectCLAGroupEntries[0].FoundationSFID, companyModel.CompanyExternalID, projectClaGroupsRepo) { + msg := fmt.Sprintf("user '%s' is not authorized to view project CCLA signatures project scope or project|organization scope for company ID: %s", + authUser.UserName, companyModel.CompanyID) + log.Warn(msg) + return signatures.NewListCompanyClaGroupCorporateContributorsForbidden().WithXRequestID(reqID).WithPayload(utils.ErrorResponseForbidden(reqID, msg)) + } + log.WithFields(f).Debug("user has access for this query") + + result, err := v2SignatureService.GetClaGroupCorporateContributors(ctx, signatures.ListClaGroupCorporateContributorsParams{ + HTTPRequest: params.HTTPRequest, + XREQUESTID: params.XREQUESTID, + XACL: params.XACL, + XEMAIL: params.XEMAIL, + XUSERNAME: params.XUSERNAME, + ClaGroupID: params.ClaGroupID, + CompanyID: &companyModel.CompanyID, + SearchTerm: params.SearchTerm, + PageSize: params.PageSize, + NextKey: params.NextKey, + }) + if err != nil { + msg := fmt.Sprintf("problem getting corporate contributors for CLA Group: %s with company: %s", params.ClaGroupID, companyModel.CompanyID) + if _, ok := err.(*organizations.GetOrgNotFound); ok { + formatErr := errors.New("error retrieving company using companySFID") + return signatures.NewListCompanyClaGroupCorporateContributorsNotFound().WithXRequestID(reqID).WithPayload( + utils.ErrorResponseNotFoundWithError(reqID, msg, formatErr)) + } + return signatures.NewListCompanyClaGroupCorporateContributorsInternalServerError().WithXRequestID(reqID).WithPayload( + utils.ErrorResponseInternalServerErrorWithError(reqID, "unexpected error when searching for corporate contributors", err)) + } + + log.WithFields(f).Debugf("returning %d Corporate contributors to caller...", len(result.List)) + return signatures.NewListCompanyClaGroupCorporateContributorsOK().WithXRequestID(reqID).WithPayload(result) + }) + api.SignaturesGetSignatureSignedDocumentHandler = signatures.GetSignatureSignedDocumentHandlerFunc(func(params signatures.GetSignatureSignedDocumentParams, authUser *auth.User) middleware.Responder { reqID := utils.GetRequestID(params.XREQUESTID) ctx := context.WithValue(context.Background(), utils.XREQUESTID, reqID) // nolint diff --git a/docs/M3_ORG_LENS_API.md b/docs/M3_ORG_LENS_API.md index e2a9de33a..3d19e2a55 100644 --- a/docs/M3_ORG_LENS_API.md +++ b/docs/M3_ORG_LENS_API.md @@ -39,11 +39,28 @@ when that name is empty; there is no CLA-manager fallback. The Org Lens overview then render `Signed by {name} on {date}`. An unknown company or a company with no CCLAs returns HTTP 200 with an empty `list` — -the endpoint never auto-creates the company record. Auth: LF admin, `organization` +the endpoint never auto-creates the company record. Optional `pageSize`/`offset` query +parameters page the sorted list (`totalCount` = size before paging, `resultCount` = rows +returned); when omitted the full list is returned. Auth: LF admin, `organization` scope for the `companySFID`, or any `project|organization` scope whose organization half matches (ACS resource `company_cla_groups`, action `view_all`). Probe: `utils/company_cla_groups.sh`. +## `GET /v4/company/external/{companySFID}/cla-group/{claGroupID}/corporate-contributors` ([lfx-self-serve#1978](https://github.com/linuxfoundation/lfx-self-serve/issues/1978)) + +Organization-scoped alias of the pre-existing `GET /v4/cla-group/{claGroupID}/corporate-contributors` +(whose path has no SFID segment, so `organization`/`project|organization` scopes can never +match at the gateway — effectively admin-only; note the EasyCLA `azp` trusted-caller +allow-list is my-clas-only and not enabled). Resolves the company by SFID, enforces the +same in-handler project/organization access check as the original, then delegates to the +same service; `searchTerm`, `pageSize` and `nextKey` pass through unchanged. The new path +needs the same ACS resource registration (organization object on `{companySFID}`) as the +other org-lens paths before gateway scope-matching works. Done on dev: resource +`company_cla_group_corporate_contributors` (type-2 + type-1 twin, `view_all`) bound into +`ViewCompanyClaGroups`, plus the OPA bundle-data refresh described below — verified: a +non-admin cla-manager token now passes the gateway (pre-deploy proof = lambda 404 instead +of gateway 403). Declared in acs-cli `services/11-cla-service.yaml` for prod. + ## `POST /v4/self-serve/request-corporate-signature` ([lfx-self-serve#2150](https://github.com/linuxfoundation/lfx-self-serve/issues/2150)) Self Serve front door for starting a CCLA signing session. Input = the corporate-console @@ -68,7 +85,9 @@ DocuSign envelope**; attestation/auth probes (400/403) are side-effect free. CLA-manager request lifecycle under `/v4/company/{companyID}/project/{projectSFID}/cla-manager/requests`: `GET` (list — HTTP -200 with an empty `requests` array when none), `GET .../{requestID}`, +200 with an empty `requests` array when none; optional `pageSize`/`offset` query +parameters page the created-date-sorted list with `totalCount` set to the pre-paging +size), `GET .../{requestID}`, `PUT .../{requestID}/approve` and `PUT .../{requestID}/deny`. The v4 surface wraps the v1 request service verbatim: approve flips the request to `approved`, adds the requester to the CCLA signature ACL and emails the CLA managers + requester; deny flips it to `denied` @@ -133,12 +152,21 @@ that matter again at **prod rollout**: `company_cla_groups` → type 2 (organization) plus a type-1 twin resource wired into `ViewCompanyClaGroups` (`view_all`) so `project|organization` pair holders pass too, and an extra `CLAManagerRequestAdmin` statement binding the type-1 - `cla_manager_request` row. After edits, flush: `POST /warden/invalidate/cache - {"type":"resource"}` + `POST /cache/flush`. **Repeat the same surgery on prod ACS - before M3 goes live**, then verify with warden v1 - `GET /acs/v1/api/warden/subjects/authorize?resource=&actions=` (returns - computed scopes per resource row). Quirk: statements POST works only **without** the - trailing slash (`/policies/{id}/statements`). + `cla_manager_request` row. **DB edits alone do not propagate**: the live gateway + authorizer is OPA reading a bundle from S3 (`s3://lf-opa-bundle-{stage}`), not the + warden DB — `POST /warden/invalidate/cache` + `/cache/flush` are NOT sufficient. To + propagate: refresh `bundle-data/resources.json` (= `GET /opa/resources`) and + `bundle-data/role_permissions.json` (= `GET /opa/role/permission?limit=100&offset=0`) + in that bucket — either via an acs-cli deploy run (`make opa-bundle`) or by fetching + both with an admin M2M token, diffing against the current S3 objects (expect only your + additions), and uploading. A bundler lambda re-tars `bundle.tar.gz` within seconds of + upload; OPA picks it up in ~1–2 min. **Repeat the surgery + bundle refresh on prod ACS + before M3 goes live.** Verification that works pre-deploy: call a to-be-added path via + the real gateway with a non-admin token — gateway 403 = not registered, lambda-style + 404 = authorized. (Direct warden v1/v2 probes do not reflect gateway decisions for + non-admin users.) Quirks: statements POST works only **without** the trailing slash + (`/policies/{id}/statements`) and wants action **UUIDs**, not names; actions attach via + `PUT /resources/{id}/actions` (POST = 405). - **acs-cli deploy workflows have no `concurrency:` group**: concurrent merge-triggered runs race on the S3 `bundle-data/` upload (last writer wins with its own checkout's snapshot — the two 2026-09-04 dev runs collided; the complete checkout won by ~3 s). diff --git a/docs/MY_CLAS_API.md b/docs/MY_CLAS_API.md index 5db89ab2b..73fb202a0 100644 --- a/docs/MY_CLAS_API.md +++ b/docs/MY_CLAS_API.md @@ -145,11 +145,15 @@ trusting blindly. Each request logs `callerClientID` (`azp`), `callerSubject` (` | `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 records' `lf_username`. Useful for gerrit-era records tied to an older LDAP/LF username on the account. | +| `pageSize` | integer | no | Optional page size. When neither `pageSize` nor `offset` is provided the full list is returned exactly as before (no paging). | +| `offset` | integer | no | Optional 0-based row offset. Paged responses use a deterministic order (`signedOn` desc, then `signatureID`); `totalCount` always carries the pre-paging row count. | 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. +Serve client, the endpoint returns `401`. Paging is optional and off by default: without +`pageSize`/`offset` every row is returned (the upstream queries paginate internally). The +same optional `pageSize`/`offset` pair exists on `/my-clas/identities` (slices the sorted +identity list) and `/my-clas/{signatureID}/cla-managers` (paged windows are sorted by LF +username; the unpaged response keeps the stored ACL order). Example (through the gateway): diff --git a/utils/auth0.secret.example b/utils/auth0.secret.example new file mode 100644 index 000000000..4dcb43d9a --- /dev/null +++ b/utils/auth0.secret.example @@ -0,0 +1,13 @@ +# Copyright The Linux Foundation and each contributor to CommunityBridge. +# SPDX-License-Identifier: MIT +# +# Template for utils/auth0-dev.secret / utils/auth0-prod.secret (both gitignored via *.secret). +# Copy, fill in, chmod 600. Parsed as plain KEY=VALUE lines - never sourced. +AUTH0_USERNAME=your-lf-sso-username +AUTH0_PASSWORD=your-lf-sso-password +# Optional overrides (defaults are built into utils/get_auth0_token.sh per stage): +# AUTH0_DOMAIN= +# AUTH0_CLIENT_ID= +# AUTH0_AUDIENCE= +# AUTH0_TENANT= +# AUTH0_REDIRECT_URI= diff --git a/utils/get_auth0_token.sh b/utils/get_auth0_token.sh new file mode 100755 index 000000000..3e567db3a --- /dev/null +++ b/utils/get_auth0_token.sh @@ -0,0 +1,212 @@ +#!/bin/bash +# Copyright The Linux Foundation and each contributor to CommunityBridge. +# SPDX-License-Identifier: MIT +# +# Fully automatic Auth0 access token generator - no browser, no prompts. +# Performs the classic Auth0 cross-origin login flow (authorize -> login page +# -> /usernamepassword/login -> /login/callback -> /authorize/resume -> PKCE +# code exchange) using curl only, reading credentials from *.secret files. +# +# Credentials file (gitignored): utils/auth0-dev.secret / utils/auth0-prod.secret +# (template: utils/auth0.secret.example), parsed as simple KEY=VALUE lines - never sourced: +# AUTH0_USERNAME=someuser +# AUTH0_PASSWORD=somepassword +# Optional overrides in the same file: AUTH0_DOMAIN, AUTH0_CLIENT_ID, +# AUTH0_AUDIENCE, AUTH0_TENANT, AUTH0_REDIRECT_URI. +# +# Output: token written to utils/auth0-dev.token.secret (or auth0-prod.token.secret) +# and printed on stdout, so both work: +# TOKEN=$(./utils/get_auth0_token.sh dev) +# TOKEN=$(cat utils/auth0-dev.token.secret) +# +# Usage examples: +# ./utils/get_auth0_token.sh # dev token (default stage) +# ./utils/get_auth0_token.sh dev # dev token -> utils/auth0-dev.token.secret +# ./utils/get_auth0_token.sh prod # prod token -> utils/auth0-prod.token.secret +# TOKEN=$(./utils/get_auth0_token.sh dev) # capture directly, diagnostics go to stderr +# DEBUG=1 ./utils/get_auth0_token.sh dev # verbose step-by-step diagnostics +# ./utils/get_auth0_token.sh dev && curl -s -H "Authorization: Bearer $(cat utils/auth0-dev.token.secret)" \ +# "https://api-gw.dev.platform.linuxfoundation.org/cla-service/v4/my-clas" | jq . +# +# Token lifetimes (Auth0 client settings): dev ~1h, prod ~3h. + +set -euo pipefail + +STAGE="${1:-dev}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +case "$STAGE" in + dev) + AUTH0_DOMAIN="linuxfoundation-dev.auth0.com" + AUTH0_CLIENT_ID="G5CNCTp6X5Z1HizkotPHm6Ug11oGr2Eo" + AUTH0_AUDIENCE="https://api-gw.dev.platform.linuxfoundation.org/" + AUTH0_TENANT="linuxfoundation-dev" + ;; + prod) + AUTH0_DOMAIN="sso.linuxfoundation.org" + AUTH0_CLIENT_ID="DoMcTpihSo3is7hfGngHz7phw7kC6daw" + AUTH0_AUDIENCE="https://api-gw.platform.linuxfoundation.org/" + AUTH0_TENANT="linuxfoundation" + ;; + *) + echo "usage: $0 [dev|prod]" >&2 + exit 1 + ;; +esac + +AUTH0_REDIRECT_URI="http://localhost:55001/callback" +SECRET_FILE="$SCRIPT_DIR/auth0-$STAGE.secret" +TOKEN_FILE="$SCRIPT_DIR/auth0-$STAGE.token.secret" + +if [ ! -f "$SECRET_FILE" ]; then + { + echo "error: credentials file $SECRET_FILE not found" + echo "create it (gitignored, see utils/auth0.secret.example) with:" + echo " AUTH0_USERNAME=youruser" + echo " AUTH0_PASSWORD=yourpassword" + } >&2 + exit 1 +fi + +# parse KEY=VALUE lines instead of sourcing, so the secret file can never execute code +secret_get() { sed -n "s/^$1=//p" "$SECRET_FILE" | tail -1 | tr -d '\r'; } + +AUTH0_USERNAME="$(secret_get AUTH0_USERNAME)" +AUTH0_PASSWORD="$(secret_get AUTH0_PASSWORD)" +for key in AUTH0_DOMAIN AUTH0_CLIENT_ID AUTH0_AUDIENCE AUTH0_TENANT AUTH0_REDIRECT_URI; do + value="$(secret_get "$key")" + [ -n "$value" ] && eval "$key=\"\$value\"" +done + +if [ -z "${AUTH0_USERNAME:-}" ] || [ -z "${AUTH0_PASSWORD:-}" ]; then + echo "error: AUTH0_USERNAME/AUTH0_PASSWORD not set in $SECRET_FILE" >&2 + exit 1 +fi + +dbg() { [ -n "${DEBUG:-}" ] && echo "$@" >&2 || true; } + +umask 077 +JAR="$(mktemp)" +trap 'rm -f "$JAR"' EXIT + +b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; } +urlenc() { python3 -c 'import sys,urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$1"; } + +STATE="$(head -c 18 /dev/urandom | b64url)" +VERIFIER="$(head -c 48 /dev/urandom | b64url)" +CHALLENGE="$(printf %s "$VERIFIER" | openssl dgst -sha256 -binary | b64url)" + +AUTHORIZE_URL="https://$AUTH0_DOMAIN/authorize?client_id=$AUTH0_CLIENT_ID&response_type=code&redirect_uri=$(urlenc "$AUTH0_REDIRECT_URI")&scope=$(urlenc "openid profile email access:api")&audience=$(urlenc "$AUTH0_AUDIENCE")&state=$STATE&code_challenge=$CHALLENGE&code_challenge_method=S256" + +# step 1: /authorize -> 302 to the hosted login page carrying the interaction state +LOGIN_URL="$(curl -sS -c "$JAR" -o /dev/null -w '%{redirect_url}' "$AUTHORIZE_URL")" +dbg "step1 authorize -> $LOGIN_URL" +case "$LOGIN_URL" in + *state=*) ;; + *) echo "error: /authorize did not redirect to the login page (check client/audience settings)" >&2; exit 1 ;; +esac +LOGIN_STATE="$(printf %s "$LOGIN_URL" | sed -n 's/.*[?&]state=\([^&]*\).*/\1/p')" + +# step 2: fetch the login page to obtain the _csrf cookie +curl -sS -b "$JAR" -c "$JAR" -o /dev/null "$LOGIN_URL" +CSRF="$(awk '$6=="_csrf"{print $7}' "$JAR" | tail -1)" +dbg "step2 login page fetched, csrf present: $([ -n "$CSRF" ] && echo yes || echo no)" + +# step 3: same-origin credentials POST -> WS-Fed self-posting form +# credentials reach python via the environment and curl via stdin - never via argv +UPL_BODY="$(A0_CLIENT_ID="$AUTH0_CLIENT_ID" A0_REDIRECT_URI="$AUTH0_REDIRECT_URI" A0_TENANT="$AUTH0_TENANT" \ + A0_AUDIENCE="$AUTH0_AUDIENCE" A0_STATE="$LOGIN_STATE" A0_USERNAME="$AUTH0_USERNAME" \ + A0_PASSWORD="$AUTH0_PASSWORD" A0_CSRF="$CSRF" python3 << 'PYEOF' +import json +import os + +env = os.environ +print(json.dumps({ + "client_id": env["A0_CLIENT_ID"], + "redirect_uri": env["A0_REDIRECT_URI"], + "tenant": env["A0_TENANT"], + "response_type": "code", + "scope": "openid profile email access:api", + "audience": env["A0_AUDIENCE"], + "state": env["A0_STATE"], + "username": env["A0_USERNAME"], + "password": env["A0_PASSWORD"], + "connection": "Username-Password-Authentication", + "protocol": "oauth2", + "popup_options": {}, + "sso": True, + "_csrf": env["A0_CSRF"], + "_intstate": "deprecated", +})) +PYEOF +)" +UPL_HTML="$(printf %s "$UPL_BODY" | curl -sS -b "$JAR" -c "$JAR" -X POST "https://$AUTH0_DOMAIN/usernamepassword/login" \ + -H "Content-Type: application/json" -H "Origin: https://$AUTH0_DOMAIN" -H "Referer: $LOGIN_URL" \ + --data @-)" +if ! printf %s "$UPL_HTML" | grep -q 'name="wresult"'; then + echo "error: login failed: $(printf %s "$UPL_HTML" | head -c 300)" >&2 + exit 1 +fi +dbg "step3 credentials accepted" + +# step 4: post the WS-Fed form back to /login/callback -> 302 /authorize/resume +# the page HTML feeds python via stdin (program via -c, so stdin stays the pipe) +CALLBACK_FORM="$(printf %s "$UPL_HTML" | python3 -c ' +import html +import re +import sys +import urllib.parse + +page = sys.stdin.read() + +def field(name): + match = re.search("name=\"" + name + "\"\\s+value=\"([^\"]*)\"", page) + return html.unescape(match.group(1)) if match else "" + +if not field("wresult"): + sys.exit("error: no wresult field on the WS-Fed page - login flow changed?") +print(urllib.parse.urlencode({"wa": field("wa"), "wresult": field("wresult"), "wctx": field("wctx")})) +')" +RESUME_URL="$(printf %s "$CALLBACK_FORM" | curl -sS -b "$JAR" -c "$JAR" -o /dev/null -w '%{redirect_url}' -X POST "https://$AUTH0_DOMAIN/login/callback" \ + -H "Content-Type: application/x-www-form-urlencoded" -H "Origin: https://$AUTH0_DOMAIN" --data @-)" +dbg "step4 login callback -> $RESUME_URL" +case "$RESUME_URL" in + *"/authorize/resume"*) ;; + *) echo "error: unexpected /login/callback redirect: $RESUME_URL" >&2; exit 1 ;; +esac + +# step 5: resume -> 302 to redirect_uri with ?code= (no local listener needed) +FINAL_URL="$(curl -sS -b "$JAR" -c "$JAR" -o /dev/null -w '%{redirect_url}' "$RESUME_URL")" +CODE="$(printf %s "$FINAL_URL" | sed -n 's/.*[?&]code=\([^&]*\).*/\1/p')" +dbg "step5 resume -> code present: $([ -n "$CODE" ] && echo yes || echo no)" +if [ -z "$CODE" ]; then + echo "error: no authorization code returned: $FINAL_URL" >&2 + exit 1 +fi + +# step 6: PKCE code exchange (code and verifier via stdin, not argv) +TOKEN_JSON="$(A0_CLIENT_ID="$AUTH0_CLIENT_ID" A0_CODE="$CODE" A0_REDIRECT_URI="$AUTH0_REDIRECT_URI" A0_VERIFIER="$VERIFIER" python3 << 'PYEOF' | curl -sS -X POST "https://$AUTH0_DOMAIN/oauth/token" -H "Content-Type: application/json" --data @- +import json +import os + +env = os.environ +print(json.dumps({ + "grant_type": "authorization_code", + "client_id": env["A0_CLIENT_ID"], + "code": env["A0_CODE"], + "redirect_uri": env["A0_REDIRECT_URI"], + "code_verifier": env["A0_VERIFIER"], +})) +PYEOF +)" +ACCESS_TOKEN="$(printf %s "$TOKEN_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("access_token",""))')" +if [ -z "$ACCESS_TOKEN" ]; then + echo "error: token exchange failed: $(printf %s "$TOKEN_JSON" | head -c 300)" >&2 + exit 1 +fi + +printf '%s\n' "$ACCESS_TOKEN" > "$TOKEN_FILE" +chmod 600 "$TOKEN_FILE" +EXPIRES_IN="$(printf %s "$TOKEN_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("expires_in",""))')" +echo "token for stage '$STAGE' saved to $TOKEN_FILE (expires in ${EXPIRES_IN}s)" >&2 +printf '%s\n' "$ACCESS_TOKEN" From a4299d7ab3f83dea0cec1e10cc0e63ab4209d0ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Gryglicki?= Date: Thu, 10 Sep 2026 13:35:24 +0200 Subject: [PATCH 2/3] Address AI feedback - 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Łukasz Gryglicki Assisted by [OpenAI](https://platform.openai.com/) Assisted by [GitHub Copilot](https://github.com/features/copilot) Assisted by [Claude](https://claude.ai) --- cla-backend-go/swagger/cla.v2.yaml | 3 +- .../common/cla-manager-request-list.yaml | 8 +- cla-backend-go/v2/cla_manager/requests.go | 18 ++- .../v2/cla_manager/requests_test.go | 14 +- cla-backend-go/v2/my_clas/handlers_test.go | 50 +++++- cla-backend-go/v2/my_clas/service_test.go | 7 +- .../company_corporate_contributors_test.go | 153 +++++++++++++++++- cla-backend-go/v2/signatures/handlers.go | 37 ++++- docs/M3_ORG_LENS_API.md | 26 ++- docs/MY_CLAS_API.md | 10 +- utils/get_auth0_token.sh | 40 ++++- 11 files changed, 322 insertions(+), 44 deletions(-) diff --git a/cla-backend-go/swagger/cla.v2.yaml b/cla-backend-go/swagger/cla.v2.yaml index 6ef9fcacc..1d33b6d47 100644 --- a/cla-backend-go/swagger/cla.v2.yaml +++ b/cla-backend-go/swagger/cla.v2.yaml @@ -4163,7 +4163,7 @@ paths: /company/external/{companySFID}/cla-group/{claGroupID}/corporate-contributors: get: summary: List corporate contributors by company SFID - description: Returns the corporate contributors (employee acknowledgements) of the CLA Group for the company identified by its SFID - the same data as /cla-group/{claGroupID}/corporate-contributors on an organization-scoped path, so organization-scoped (CLA manager) tokens are authorized at the API gateway + description: Returns the corporate contributors (employee acknowledgements) of the CLA Group for the company identified by its SFID - the same data as /cla-group/{claGroupID}/corporate-contributors on an organization-scoped path, so organization-scoped (CLA manager) tokens are authorized at the API gateway. The optional companyID query parameter selects one signing-entity record of the company - when omitted the parent record is used operationId: listCompanyClaGroupCorporateContributors parameters: - $ref: "#/parameters/x-request-id" @@ -4172,6 +4172,7 @@ paths: - $ref: "#/parameters/x-email" - $ref: '#/parameters/path-companySFID' - $ref: "#/parameters/path-claGroupID" + - $ref: '#/parameters/companyID' - $ref: '#/parameters/searchTerm' - $ref: '#/parameters/pageSize' - $ref: '#/parameters/nextKey' diff --git a/cla-backend-go/swagger/common/cla-manager-request-list.yaml b/cla-backend-go/swagger/common/cla-manager-request-list.yaml index 83525bd3d..85fe3b901 100644 --- a/cla-backend-go/swagger/common/cla-manager-request-list.yaml +++ b/cla-backend-go/swagger/common/cla-manager-request-list.yaml @@ -12,7 +12,13 @@ properties: x-omitempty: false items: $ref: '#/definitions/cla-manager-request' + resultCount: + type: integer + format: int64 + x-omitempty: false + description: Number of requests returned after optional pageSize/offset paging was applied totalCount: type: integer format: int64 - description: Total number of requests before optional pageSize/offset paging was applied - only set by the v2 API + x-omitempty: false + description: Total number of requests before optional pageSize/offset paging was applied diff --git a/cla-backend-go/v2/cla_manager/requests.go b/cla-backend-go/v2/cla_manager/requests.go index a6cb17f39..1a9e77d0a 100644 --- a/cla-backend-go/v2/cla_manager/requests.go +++ b/cla-backend-go/v2/cla_manager/requests.go @@ -31,16 +31,20 @@ func (s *service) GetCLAManagerRequests(ctx context.Context, companyModel *v1Mod for i := range result.Requests { result.Requests[i].CompanyExternalID = companyModel.CompanyExternalID } - // deterministic order so the optional pageSize/offset windows are stable - sort.Slice(result.Requests, func(i, j int) bool { - if result.Requests[i].Created != result.Requests[j].Created { - return result.Requests[i].Created < result.Requests[j].Created - } - return result.Requests[i].RequestID < result.Requests[j].RequestID - }) + if pageSize != nil || offset != nil { + // deterministic order so the optional pageSize/offset windows are stable - the + // unpaged response keeps the stored (GSI) order + sort.Slice(result.Requests, func(i, j int) bool { + if result.Requests[i].Created != result.Requests[j].Created { + return result.Requests[i].Created < result.Requests[j].Created + } + return result.Requests[i].RequestID < result.Requests[j].RequestID + }) + } result.TotalCount = int64(len(result.Requests)) start, end := utils.PageBounds(len(result.Requests), pageSize, offset) result.Requests = result.Requests[start:end] + result.ResultCount = int64(len(result.Requests)) return result, nil } diff --git a/cla-backend-go/v2/cla_manager/requests_test.go b/cla-backend-go/v2/cla_manager/requests_test.go index 435a715f8..014ef0863 100644 --- a/cla-backend-go/v2/cla_manager/requests_test.go +++ b/cla-backend-go/v2/cla_manager/requests_test.go @@ -213,7 +213,7 @@ func TestGetCLAManagerRequests(t *testing.T) { assert.Nil(t, err) body, marshalErr := json.Marshal(result) assert.Nil(t, marshalErr) - assert.JSONEq(t, `{"requests":[]}`, string(body)) + assert.JSONEq(t, `{"requests":[],"resultCount":0,"totalCount":0}`, string(body)) }) t.Run("propagates the v1 service error", func(t *testing.T) { @@ -243,21 +243,25 @@ func TestGetCLAManagerRequests(t *testing.T) { full, err := s.GetCLAManagerRequests(context.Background(), acmeCompany(), "cla-group-1", nil, nil) assert.Nil(t, err) assert.Equal(t, int64(3), full.TotalCount) + assert.Equal(t, int64(3), full.ResultCount) if assert.Len(t, full.Requests, 3) { - assert.Equal(t, []string{"req-a", "req-b", "req-c"}, - []string{full.Requests[0].RequestID, full.Requests[1].RequestID, full.Requests[2].RequestID}, "sorted by created date") + assert.Equal(t, []string{"req-c", "req-a", "req-b"}, + []string{full.Requests[0].RequestID, full.Requests[1].RequestID, full.Requests[2].RequestID}, + "the unpaged response keeps the stored order - no re-sorting without paging params") } page, err := s.GetCLAManagerRequests(context.Background(), acmeCompany(), "cla-group-1", aws.Int64(1), aws.Int64(1)) assert.Nil(t, err) assert.Equal(t, int64(3), page.TotalCount) + assert.Equal(t, int64(1), page.ResultCount) if assert.Len(t, page.Requests, 1) { - assert.Equal(t, "req-b", page.Requests[0].RequestID) + assert.Equal(t, "req-b", page.Requests[0].RequestID, "paged windows use the deterministic created-date order") } beyond, err := s.GetCLAManagerRequests(context.Background(), acmeCompany(), "cla-group-1", aws.Int64(5), aws.Int64(10)) assert.Nil(t, err) assert.Equal(t, int64(3), beyond.TotalCount) + assert.Equal(t, int64(0), beyond.ResultCount) assert.Len(t, beyond.Requests, 0) }) } @@ -474,5 +478,5 @@ func TestClaManagerRequestJSONContract(t *testing.T) { listBody, err := json.Marshal(v2ClaManagerRequestList(nil)) assert.Nil(t, err) - assert.JSONEq(t, `{"requests":[]}`, string(listBody)) + assert.JSONEq(t, `{"requests":[],"resultCount":0,"totalCount":0}`, string(listBody)) } diff --git a/cla-backend-go/v2/my_clas/handlers_test.go b/cla-backend-go/v2/my_clas/handlers_test.go index ecb7ab959..c121067b4 100644 --- a/cla-backend-go/v2/my_clas/handlers_test.go +++ b/cla-backend-go/v2/my_clas/handlers_test.go @@ -22,14 +22,18 @@ import ( type fakeService struct { callers []*Caller + pageSizes []*int64 + offsets []*int64 err error nilPdf bool nilManagers bool invalidRecipients bool } -func (f *fakeService) GetMyClaManagers(_ context.Context, caller *Caller, _ *Identity, _ string, _, _ *int64) (*models.MyClaManagerList, error) { +func (f *fakeService) GetMyClaManagers(_ context.Context, caller *Caller, _ *Identity, _ string, pageSize, offset *int64) (*models.MyClaManagerList, error) { f.callers = append(f.callers, caller) + f.pageSizes = append(f.pageSizes, pageSize) + f.offsets = append(f.offsets, offset) if f.err != nil { return nil, f.err } @@ -53,8 +57,10 @@ func (f *fakeService) CreateMyClaManagerRequest(_ context.Context, caller *Calle return &models.MyClaManagerRequestResult{}, nil } -func (f *fakeService) GetMyClas(_ context.Context, caller *Caller, _ *Identity, _, _ *int64) (*models.MyClaList, error) { +func (f *fakeService) GetMyClas(_ context.Context, caller *Caller, _ *Identity, pageSize, offset *int64) (*models.MyClaList, error) { f.callers = append(f.callers, caller) + f.pageSizes = append(f.pageSizes, pageSize) + f.offsets = append(f.offsets, offset) if f.err != nil { return nil, f.err } @@ -72,8 +78,10 @@ func (f *fakeService) GetMyClaPdfURL(_ context.Context, caller *Caller, _ *Ident return &models.MyClaPdf{}, nil } -func (f *fakeService) GetMyIdentities(_ context.Context, currentUsername string, _, _ *int64) (*models.MyIdentityList, error) { +func (f *fakeService) GetMyIdentities(_ context.Context, currentUsername string, pageSize, offset *int64) (*models.MyIdentityList, error) { f.callers = append(f.callers, &Caller{Username: currentUsername}) + f.pageSizes = append(f.pageSizes, pageSize) + f.offsets = append(f.offsets, offset) if f.err != nil { return nil, f.err } @@ -190,6 +198,42 @@ func TestClaManagerHandlers(t *testing.T) { assert.Equal(t, http.StatusInternalServerError, statusOf(t, api.MyClasCreateMyClaManagerRequestHandler.Handle(myClasOps.CreateMyClaManagerRequestParams{HTTPRequest: req, SignatureID: "sig-1", Body: body}, authUser))) } +// the optional paging query parameters must reach the service untouched - and stay +// nil when the caller omits them, which is what keeps the legacy response shape +func TestListHandlersForwardPagingParams(t *testing.T) { + api, service := configuredAPI(t, nil) + authUser := &auth.User{UserName: "someone"} + req := request(t, "") + pageSize, offset := int64(25), int64(50) + + assert.Equal(t, http.StatusOK, statusOf(t, api.MyClasGetMyClasHandler.Handle( + myClasOps.GetMyClasParams{HTTPRequest: req, PageSize: &pageSize, Offset: &offset}, authUser))) + assert.Equal(t, http.StatusOK, statusOf(t, api.MyClasGetMyClaManagersHandler.Handle( + myClasOps.GetMyClaManagersParams{HTTPRequest: req, SignatureID: "sig-1", PageSize: &pageSize, Offset: &offset}, authUser))) + assert.Equal(t, http.StatusOK, statusOf(t, api.MyClasGetMyIdentitiesHandler.Handle( + myClasOps.GetMyIdentitiesParams{HTTPRequest: req, PageSize: &pageSize, Offset: &offset}, authUser))) + + require.Len(t, service.pageSizes, 3) + require.Len(t, service.offsets, 3) + for i := range service.pageSizes { + require.NotNil(t, service.pageSizes[i], "call %d must forward pageSize", i) + require.NotNil(t, service.offsets[i], "call %d must forward offset", i) + assert.Equal(t, pageSize, *service.pageSizes[i]) + assert.Equal(t, offset, *service.offsets[i]) + } + + assert.Equal(t, http.StatusOK, statusOf(t, api.MyClasGetMyClasHandler.Handle(myClasOps.GetMyClasParams{HTTPRequest: req}, authUser))) + 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.MyClasGetMyIdentitiesHandler.Handle(myClasOps.GetMyIdentitiesParams{HTTPRequest: req}, authUser))) + + require.Len(t, service.pageSizes, 6) + require.Len(t, service.offsets, 6) + for i := 3; i < 6; i++ { + assert.Nil(t, service.pageSizes[i], "call %d must forward a nil pageSize when omitted", i) + assert.Nil(t, service.offsets[i], "call %d must forward a nil offset when omitted", i) + } +} + func TestHandlersTrustAllowListedCallers(t *testing.T) { verifier := &fakeVerifier{enabled: true, callers: map[string]*claAuth.TrustedCaller{ "Bearer trusted": {ClientID: "ss-client", Subject: "ss-client@clients", Trusted: true}, diff --git a/cla-backend-go/v2/my_clas/service_test.go b/cla-backend-go/v2/my_clas/service_test.go index 597a67d5c..8dd451722 100644 --- a/cla-backend-go/v2/my_clas/service_test.go +++ b/cla-backend-go/v2/my_clas/service_test.go @@ -1755,11 +1755,12 @@ func TestGetMyClasPaging(t *testing.T) { repo := &fakeRepo{ byUserID: map[string][]*signatures.ItemSignature{ "user-a": { - icla("sig-c", "user-a", "cla-group-1", "2024-03-01T00:00:00Z", true), + // SignedOn tie between sig-c and sig-d - listed d-before-c so only + // the signature-ID tiebreak can produce the expected order + icla("sig-d", "user-a", "cla-group-1", "2024-03-01T00:00:00Z", true), icla("sig-e", "user-a", "cla-group-1", "2024-01-01T00:00:00Z", true), icla("sig-b", "user-a", "cla-group-1", "2024-04-01T00:00:00Z", true), - // SignedOn tie with sig-c - the paged order falls back to the signature ID - icla("sig-d", "user-a", "cla-group-1", "2024-03-01T00:00:00Z", true), + icla("sig-c", "user-a", "cla-group-1", "2024-03-01T00:00:00Z", true), icla("sig-a", "user-a", "cla-group-1", "2024-05-01T00:00:00Z", true), }, }, diff --git a/cla-backend-go/v2/signatures/company_corporate_contributors_test.go b/cla-backend-go/v2/signatures/company_corporate_contributors_test.go index ea41dce80..10a7efe87 100644 --- a/cla-backend-go/v2/signatures/company_corporate_contributors_test.go +++ b/cla-backend-go/v2/signatures/company_corporate_contributors_test.go @@ -19,8 +19,10 @@ import ( "github.com/linuxfoundation/easycla/cla-backend-go/gen/v2/restapi/operations" sigOps "github.com/linuxfoundation/easycla/cla-backend-go/gen/v2/restapi/operations/signatures" mock_project_repo "github.com/linuxfoundation/easycla/cla-backend-go/project/mocks" + "github.com/linuxfoundation/easycla/cla-backend-go/project/repository" "github.com/linuxfoundation/easycla/cla-backend-go/projects_cla_groups" mock_projects_cla_groups "github.com/linuxfoundation/easycla/cla-backend-go/projects_cla_groups/mocks" + "github.com/linuxfoundation/easycla/cla-backend-go/utils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -79,7 +81,7 @@ func TestListCompanyClaGroupCorporateContributors(t *testing.T) { mockProjectRepo.EXPECT().GetCLAGroupByID(gomock.Any(), claGroupID, false).Return(&v1Models.ClaGroup{ProjectID: claGroupID, ProjectCCLAEnabled: true}, nil) mockCompanyService := mock_company.NewMockIService(ctrl) - mockCompanyService.EXPECT().GetCompanyByExternalID(gomock.Any(), companySFID).Return(&v1Models.Company{CompanyID: companyID, CompanyExternalID: companySFID}, nil) + mockCompanyService.EXPECT().GetCompaniesByExternalID(gomock.Any(), companySFID, false).Return([]*v1Models.Company{{CompanyID: companyID, CompanyExternalID: companySFID}}, nil) mockPcgRepo := mock_projects_cla_groups.NewMockRepository(ctrl) mockPcgRepo.EXPECT().GetProjectsIdsForClaGroup(gomock.Any(), claGroupID).Return([]*projects_cla_groups.ProjectClaGroup{{ClaGroupID: claGroupID, FoundationSFID: foundation}}, nil) @@ -117,7 +119,7 @@ func TestListCompanyClaGroupCorporateContributors(t *testing.T) { mockProjectRepo.EXPECT().GetCLAGroupByID(gomock.Any(), claGroupID, false).Return(&v1Models.ClaGroup{ProjectID: claGroupID, ProjectCCLAEnabled: true}, nil) mockCompanyService := mock_company.NewMockIService(ctrl) - mockCompanyService.EXPECT().GetCompanyByExternalID(gomock.Any(), companySFID).Return(&v1Models.Company{CompanyID: companyID, CompanyExternalID: companySFID}, nil) + mockCompanyService.EXPECT().GetCompaniesByExternalID(gomock.Any(), companySFID, false).Return([]*v1Models.Company{{CompanyID: companyID, CompanyExternalID: companySFID}}, nil) mockPcgRepo := mock_projects_cla_groups.NewMockRepository(ctrl) mockPcgRepo.EXPECT().GetProjectsIdsForClaGroup(gomock.Any(), claGroupID).Return([]*projects_cla_groups.ProjectClaGroup{{ClaGroupID: claGroupID, FoundationSFID: foundation}}, nil) @@ -147,7 +149,8 @@ func TestListCompanyClaGroupCorporateContributors(t *testing.T) { mockProjectRepo.EXPECT().GetCLAGroupByID(gomock.Any(), claGroupID, false).Return(&v1Models.ClaGroup{ProjectID: claGroupID, ProjectCCLAEnabled: true}, nil) mockCompanyService := mock_company.NewMockIService(ctrl) - mockCompanyService.EXPECT().GetCompanyByExternalID(gomock.Any(), companySFID).Return(nil, errors.New("company not found")) + mockCompanyService.EXPECT().GetCompaniesByExternalID(gomock.Any(), companySFID, false).Return(nil, + &utils.CompanyNotFound{Message: "no company records found with matching external SFID", CompanySFID: companySFID}) v2Service := &fakeCorporateContributorsService{} api := operations.NewEasyclaAPI(nil) @@ -159,6 +162,148 @@ func TestListCompanyClaGroupCorporateContributors(t *testing.T) { assert.Equal(t, 0, v2Service.calls) }) + t.Run("company lookup operational error is an internal server error", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockProjectRepo := mock_project_repo.NewMockProjectRepository(ctrl) + mockProjectRepo.EXPECT().GetCLAGroupByID(gomock.Any(), claGroupID, false).Return(&v1Models.ClaGroup{ProjectID: claGroupID, ProjectCCLAEnabled: true}, nil) + + mockCompanyService := mock_company.NewMockIService(ctrl) + mockCompanyService.EXPECT().GetCompaniesByExternalID(gomock.Any(), companySFID, false).Return(nil, errors.New("dynamodb unavailable")) + + v2Service := &fakeCorporateContributorsService{} + api := operations.NewEasyclaAPI(nil) + Configure(api, nil, mockProjectRepo, mockCompanyService, nil, nil, nil, v2Service, mock_projects_cla_groups.NewMockRepository(ctrl)) + + recorder := serve(t, api, orgScopedUser(), sigOps.ListCompanyClaGroupCorporateContributorsParams{ClaGroupID: claGroupID, CompanySFID: companySFID}) + + assert.Equal(t, http.StatusInternalServerError, recorder.Code, recorder.Body.String()) + assert.Equal(t, 0, v2Service.calls, "a failed lookup must never reach the service") + }) + + t.Run("companyID query parameter selects the signing-entity record", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + const childCompanyID = "company-cc-child" + + mockProjectRepo := mock_project_repo.NewMockProjectRepository(ctrl) + mockProjectRepo.EXPECT().GetCLAGroupByID(gomock.Any(), claGroupID, false).Return(&v1Models.ClaGroup{ProjectID: claGroupID, ProjectCCLAEnabled: true}, nil) + + mockCompanyService := mock_company.NewMockIService(ctrl) + mockCompanyService.EXPECT().GetCompaniesByExternalID(gomock.Any(), companySFID, true).Return([]*v1Models.Company{ + {CompanyID: companyID, CompanyName: "Acme", CompanyExternalID: companySFID}, + {CompanyID: childCompanyID, CompanyName: "Acme", SigningEntityName: "Acme Sub", CompanyExternalID: companySFID}, + }, nil) + + mockPcgRepo := mock_projects_cla_groups.NewMockRepository(ctrl) + mockPcgRepo.EXPECT().GetProjectsIdsForClaGroup(gomock.Any(), claGroupID).Return([]*projects_cla_groups.ProjectClaGroup{{ClaGroupID: claGroupID, FoundationSFID: foundation}}, nil) + + requestedCompanyID := childCompanyID + v2Service := &fakeCorporateContributorsService{result: &models.CorporateContributorList{List: []*models.CorporateContributor{{LinuxFoundationID: "child-lfid"}}}} + + api := operations.NewEasyclaAPI(nil) + Configure(api, nil, mockProjectRepo, mockCompanyService, nil, nil, nil, v2Service, mockPcgRepo) + + recorder := serve(t, api, orgScopedUser(), sigOps.ListCompanyClaGroupCorporateContributorsParams{ + ClaGroupID: claGroupID, CompanySFID: companySFID, CompanyID: &requestedCompanyID, + }) + + assert.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + require.Equal(t, 1, v2Service.calls) + require.NotNil(t, v2Service.gotParams.CompanyID) + assert.Equal(t, childCompanyID, *v2Service.gotParams.CompanyID, "the selected signing-entity record is delegated, not the parent") + }) + + t.Run("companyID query parameter without a matching record is not found", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockProjectRepo := mock_project_repo.NewMockProjectRepository(ctrl) + mockProjectRepo.EXPECT().GetCLAGroupByID(gomock.Any(), claGroupID, false).Return(&v1Models.ClaGroup{ProjectID: claGroupID, ProjectCCLAEnabled: true}, nil) + + mockCompanyService := mock_company.NewMockIService(ctrl) + mockCompanyService.EXPECT().GetCompaniesByExternalID(gomock.Any(), companySFID, true).Return([]*v1Models.Company{ + {CompanyID: companyID, CompanyExternalID: companySFID}, + }, nil) + + requestedCompanyID := "company-of-some-other-sfid" + v2Service := &fakeCorporateContributorsService{} + api := operations.NewEasyclaAPI(nil) + Configure(api, nil, mockProjectRepo, mockCompanyService, nil, nil, nil, v2Service, mock_projects_cla_groups.NewMockRepository(ctrl)) + + recorder := serve(t, api, orgScopedUser(), sigOps.ListCompanyClaGroupCorporateContributorsParams{ + ClaGroupID: claGroupID, CompanySFID: companySFID, CompanyID: &requestedCompanyID, + }) + + assert.Equal(t, http.StatusNotFound, recorder.Code, recorder.Body.String()) + assert.Equal(t, 0, v2Service.calls, "a company ID outside the SFID's records must never be delegated") + }) + + t.Run("unknown CLA group is not found", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockProjectRepo := mock_project_repo.NewMockProjectRepository(ctrl) + mockProjectRepo.EXPECT().GetCLAGroupByID(gomock.Any(), claGroupID, false).Return(nil, repository.ErrProjectDoesNotExist) + + v2Service := &fakeCorporateContributorsService{} + api := operations.NewEasyclaAPI(nil) + Configure(api, nil, mockProjectRepo, mock_company.NewMockIService(ctrl), nil, nil, nil, v2Service, mock_projects_cla_groups.NewMockRepository(ctrl)) + + recorder := serve(t, api, orgScopedUser(), sigOps.ListCompanyClaGroupCorporateContributorsParams{ClaGroupID: claGroupID, CompanySFID: companySFID}) + + assert.Equal(t, http.StatusNotFound, recorder.Code, recorder.Body.String()) + assert.Equal(t, 0, v2Service.calls) + }) + + t.Run("missing project CLA group mappings is a bad request", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockProjectRepo := mock_project_repo.NewMockProjectRepository(ctrl) + mockProjectRepo.EXPECT().GetCLAGroupByID(gomock.Any(), claGroupID, false).Return(&v1Models.ClaGroup{ProjectID: claGroupID, ProjectCCLAEnabled: true}, nil) + + mockCompanyService := mock_company.NewMockIService(ctrl) + mockCompanyService.EXPECT().GetCompaniesByExternalID(gomock.Any(), companySFID, false).Return([]*v1Models.Company{{CompanyID: companyID, CompanyExternalID: companySFID}}, nil) + + mockPcgRepo := mock_projects_cla_groups.NewMockRepository(ctrl) + mockPcgRepo.EXPECT().GetProjectsIdsForClaGroup(gomock.Any(), claGroupID).Return([]*projects_cla_groups.ProjectClaGroup{}, nil) + + v2Service := &fakeCorporateContributorsService{} + api := operations.NewEasyclaAPI(nil) + Configure(api, nil, mockProjectRepo, mockCompanyService, nil, nil, nil, v2Service, mockPcgRepo) + + recorder := serve(t, api, orgScopedUser(), sigOps.ListCompanyClaGroupCorporateContributorsParams{ClaGroupID: claGroupID, CompanySFID: companySFID}) + + assert.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String()) + assert.Equal(t, 0, v2Service.calls) + }) + + t.Run("delegate service error is an internal server error", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockProjectRepo := mock_project_repo.NewMockProjectRepository(ctrl) + mockProjectRepo.EXPECT().GetCLAGroupByID(gomock.Any(), claGroupID, false).Return(&v1Models.ClaGroup{ProjectID: claGroupID, ProjectCCLAEnabled: true}, nil) + + mockCompanyService := mock_company.NewMockIService(ctrl) + mockCompanyService.EXPECT().GetCompaniesByExternalID(gomock.Any(), companySFID, false).Return([]*v1Models.Company{{CompanyID: companyID, CompanyExternalID: companySFID}}, nil) + + mockPcgRepo := mock_projects_cla_groups.NewMockRepository(ctrl) + mockPcgRepo.EXPECT().GetProjectsIdsForClaGroup(gomock.Any(), claGroupID).Return([]*projects_cla_groups.ProjectClaGroup{{ClaGroupID: claGroupID, FoundationSFID: foundation}}, nil) + + v2Service := &fakeCorporateContributorsService{err: errors.New("signature query blew up")} + api := operations.NewEasyclaAPI(nil) + Configure(api, nil, mockProjectRepo, mockCompanyService, nil, nil, nil, v2Service, mockPcgRepo) + + recorder := serve(t, api, orgScopedUser(), sigOps.ListCompanyClaGroupCorporateContributorsParams{ClaGroupID: claGroupID, CompanySFID: companySFID}) + + assert.Equal(t, http.StatusInternalServerError, recorder.Code, recorder.Body.String()) + assert.Equal(t, 1, v2Service.calls) + }) + t.Run("CLA group without CCLA support is a bad request", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -167,7 +312,7 @@ func TestListCompanyClaGroupCorporateContributors(t *testing.T) { mockProjectRepo.EXPECT().GetCLAGroupByID(gomock.Any(), claGroupID, false).Return(&v1Models.ClaGroup{ProjectID: claGroupID, ProjectCCLAEnabled: false}, nil) mockCompanyService := mock_company.NewMockIService(ctrl) - mockCompanyService.EXPECT().GetCompanyByExternalID(gomock.Any(), companySFID).Return(&v1Models.Company{CompanyID: companyID, CompanyExternalID: companySFID}, nil) + mockCompanyService.EXPECT().GetCompaniesByExternalID(gomock.Any(), companySFID, false).Return([]*v1Models.Company{{CompanyID: companyID, CompanyExternalID: companySFID}}, nil) v2Service := &fakeCorporateContributorsService{} api := operations.NewEasyclaAPI(nil) diff --git a/cla-backend-go/v2/signatures/handlers.go b/cla-backend-go/v2/signatures/handlers.go index d89a852b9..afc8101af 100644 --- a/cla-backend-go/v2/signatures/handlers.go +++ b/cla-backend-go/v2/signatures/handlers.go @@ -993,12 +993,43 @@ func Configure(api *operations.EasyclaAPI, claGroupService service.Service, proj utils.ErrorResponseBadRequest(reqID, problemLoadingCLAGroupByID)) } - companyModel, err := companyService.GetCompanyByExternalID(ctx, params.CompanySFID) - if err != nil || companyModel == nil { + // non-mutating lookup - the singular GetCompanyByExternalID auto-creates a company + // record on miss, which a GET must never do; one SFID maps to one company record per + // signing entity, so the optional companyID query parameter selects a specific record + // (default: the parent record, matching the original endpoint's resolution) + selectByCompanyID := params.CompanyID != nil && *params.CompanyID != "" + companyRecords, err := companyService.GetCompaniesByExternalID(ctx, params.CompanySFID, selectByCompanyID) + if err != nil { msg := fmt.Sprintf("company lookup by SFID: %s failed", params.CompanySFID) log.WithFields(f).WithError(err).Warn(msg) + if _, notFound := err.(*utils.CompanyNotFound); notFound { + return signatures.NewListCompanyClaGroupCorporateContributorsNotFound().WithXRequestID(reqID).WithPayload( + utils.ErrorResponseNotFoundWithError(reqID, msg, err)) + } + return signatures.NewListCompanyClaGroupCorporateContributorsInternalServerError().WithXRequestID(reqID).WithPayload( + utils.ErrorResponseInternalServerErrorWithError(reqID, msg, err)) + } + if len(companyRecords) == 0 || companyRecords[0] == nil { + msg := fmt.Sprintf("no company records found for SFID: %s", params.CompanySFID) + log.WithFields(f).Warn(msg) return signatures.NewListCompanyClaGroupCorporateContributorsNotFound().WithXRequestID(reqID).WithPayload( - utils.ErrorResponseNotFoundWithError(reqID, msg, err)) + utils.ErrorResponseNotFound(reqID, msg)) + } + companyModel := companyRecords[0] + if selectByCompanyID { + companyModel = nil + for _, companyRecord := range companyRecords { + if companyRecord != nil && companyRecord.CompanyID == *params.CompanyID { + companyModel = companyRecord + break + } + } + if companyModel == nil { + msg := fmt.Sprintf("company with ID: %s was not found under SFID: %s", *params.CompanyID, params.CompanySFID) + log.WithFields(f).Warn(msg) + return signatures.NewListCompanyClaGroupCorporateContributorsNotFound().WithXRequestID(reqID).WithPayload( + utils.ErrorResponseNotFound(reqID, msg)) + } } if !claGroupModel.ProjectCCLAEnabled { diff --git a/docs/M3_ORG_LENS_API.md b/docs/M3_ORG_LENS_API.md index 3d19e2a55..a6b1efe49 100644 --- a/docs/M3_ORG_LENS_API.md +++ b/docs/M3_ORG_LENS_API.md @@ -51,15 +51,28 @@ matches (ACS resource `company_cla_groups`, action `view_all`). Probe: Organization-scoped alias of the pre-existing `GET /v4/cla-group/{claGroupID}/corporate-contributors` (whose path has no SFID segment, so `organization`/`project|organization` scopes can never match at the gateway — effectively admin-only; note the EasyCLA `azp` trusted-caller -allow-list is my-clas-only and not enabled). Resolves the company by SFID, enforces the -same in-handler project/organization access check as the original, then delegates to the -same service; `searchTerm`, `pageSize` and `nextKey` pass through unchanged. The new path +allow-list is my-clas-only and not enabled). Resolves the company by SFID with a +read-only lookup — no record auto-creation; an unknown SFID is a 404 — selecting the +parent signing-entity record by default, or a specific one via the optional `companyID` +query parameter (an ID outside that SFID's records is a 404). Enforces the same +in-handler project/organization access check as the original, then delegates to the same +service; `searchTerm`, `pageSize` and `nextKey` pass through unchanged. The new path needs the same ACS resource registration (organization object on `{companySFID}`) as the other org-lens paths before gateway scope-matching works. Done on dev: resource `company_cla_group_corporate_contributors` (type-2 + type-1 twin, `view_all`) bound into `ViewCompanyClaGroups`, plus the OPA bundle-data refresh described below — verified: a non-admin cla-manager token now passes the gateway (pre-deploy proof = lambda 404 instead -of gateway 403). Declared in acs-cli `services/11-cla-service.yaml` for prod. +of gateway 403). Declared in acs-cli `services/11-cla-service.yaml` for prod. Probe +(read-only, non-admin cla-manager token): + +```bash +BASE="https://api-gw.dev.platform.linuxfoundation.org/cla-service" +curl -s -H "Authorization: Bearer $TOKEN" \ + "$BASE/v4/company/external/$COMPANY_SFID/cla-group/$CLA_GROUP_ID/corporate-contributors" +curl -s -H "Authorization: Bearer $TOKEN" \ + "$BASE/v4/company/external/$COMPANY_SFID/cla-group/$CLA_GROUP_ID/corporate-contributors?companyID=$COMPANY_ID" +# gateway-shaped 403 = ACS resource not registered; JSON list or lambda 404 = authorized +``` ## `POST /v4/self-serve/request-corporate-signature` ([lfx-self-serve#2150](https://github.com/linuxfoundation/lfx-self-serve/issues/2150)) @@ -149,8 +162,9 @@ that matter again at **prod rollout**: hand-fixed via `PUT /resources/{id}` (honors the field; body needs `name`, `path`, `object_type_id`, `any_role`): `cla_manager_request_approve`/`_deny`/`_admin`, `ecla_invalidate`, `self_serve_request_corporate_signature` → type 1 (project); - `company_cla_groups` → type 2 (organization) plus a type-1 twin resource wired into - `ViewCompanyClaGroups` (`view_all`) so `project|organization` pair holders pass too, + `company_cla_groups` and `company_cla_group_corporate_contributors` → type 2 + (organization), each plus a type-1 twin resource wired into `ViewCompanyClaGroups` + (`view_all`) so `project|organization` pair holders pass too, and an extra `CLAManagerRequestAdmin` statement binding the type-1 `cla_manager_request` row. **DB edits alone do not propagate**: the live gateway authorizer is OPA reading a bundle from S3 (`s3://lf-opa-bundle-{stage}`), not the diff --git a/docs/MY_CLAS_API.md b/docs/MY_CLAS_API.md index 73fb202a0..62d9bb0ab 100644 --- a/docs/MY_CLAS_API.md +++ b/docs/MY_CLAS_API.md @@ -440,6 +440,7 @@ date), and the listing never clears a flag. The first persist of a new sanction "skippedIdentities": [], "sssMode": "optional", "resultCount": 2, + "totalCount": 2, "clas": [ { "signatureID": "3c1e5d7a-...", @@ -516,7 +517,8 @@ List-level fields: `lfUsername` (the effective username the list was resolved fo `userIds` (matched EasyCLA user record IDs), `skippedIdentities` (identity parameters dropped by the ownership enforcement, `":"` strings, always present — `[]` when nothing was skipped), `sssMode` (the sanctions screening mode in effect, always -present), `resultCount`. +present), `resultCount` (rows in this response), `totalCount` (pre-paging row count — +equals `resultCount` whenever paging is not requested). Errors: `401` (token carries no username — also returned by the gateway for a missing/invalid token before the request reaches EasyCLA), `400` (an admin or trusted @@ -586,8 +588,9 @@ 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). This +Returns the deduplicated identities the **authenticated user** owns — no identity query +parameters (`pageSize`/`offset` are the only accepted inputs), 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 @@ -613,6 +616,7 @@ Each entry is `":"`, deduplicated and sorted; types are `lf-usernam { "lfUsername": "lukaszgryglicki", "resultCount": 3, + "totalCount": 3, "identities": [ "email:lgryglicki@cncf.io", "github-id:26589865", diff --git a/utils/get_auth0_token.sh b/utils/get_auth0_token.sh index 3e567db3a..a070aa85e 100755 --- a/utils/get_auth0_token.sh +++ b/utils/get_auth0_token.sh @@ -71,6 +71,13 @@ fi # parse KEY=VALUE lines instead of sourcing, so the secret file can never execute code secret_get() { sed -n "s/^$1=//p" "$SECRET_FILE" | tail -1 | tr -d '\r'; } +# warn when the credentials file is readable by anyone but the owner (GNU stat, then BSD stat) +SECRET_MODE="$(stat -c '%a' "$SECRET_FILE" 2>/dev/null || stat -f '%Lp' "$SECRET_FILE" 2>/dev/null || echo '')" +case "$SECRET_MODE" in + "" | *00) ;; + *) echo "warning: $SECRET_FILE is group/other-accessible (mode $SECRET_MODE) - run: chmod 600 $SECRET_FILE" >&2 ;; +esac + AUTH0_USERNAME="$(secret_get AUTH0_USERNAME)" AUTH0_PASSWORD="$(secret_get AUTH0_PASSWORD)" for key in AUTH0_DOMAIN AUTH0_CLIENT_ID AUTH0_AUDIENCE AUTH0_TENANT AUTH0_REDIRECT_URI; do @@ -85,6 +92,23 @@ fi dbg() { [ -n "${DEBUG:-}" ] && echo "$@" >&2 || true; } +# every curl call is bounded so a wedged Auth0 endpoint cannot hang the script +CURL=(curl -sS --connect-timeout 15 --max-time 60) + +# tolerant JSON field reader: empty output (never a traceback) on a non-JSON body, +# so the callers' own error paths report the raw response instead +json_field() { python3 -c ' +import json +import sys + +try: + doc = json.load(sys.stdin) +except ValueError: + sys.exit(0) +if isinstance(doc, dict): + print(doc.get(sys.argv[1], "")) +' "$1"; } + umask 077 JAR="$(mktemp)" trap 'rm -f "$JAR"' EXIT @@ -99,7 +123,7 @@ CHALLENGE="$(printf %s "$VERIFIER" | openssl dgst -sha256 -binary | b64url)" AUTHORIZE_URL="https://$AUTH0_DOMAIN/authorize?client_id=$AUTH0_CLIENT_ID&response_type=code&redirect_uri=$(urlenc "$AUTH0_REDIRECT_URI")&scope=$(urlenc "openid profile email access:api")&audience=$(urlenc "$AUTH0_AUDIENCE")&state=$STATE&code_challenge=$CHALLENGE&code_challenge_method=S256" # step 1: /authorize -> 302 to the hosted login page carrying the interaction state -LOGIN_URL="$(curl -sS -c "$JAR" -o /dev/null -w '%{redirect_url}' "$AUTHORIZE_URL")" +LOGIN_URL="$("${CURL[@]}" -c "$JAR" -o /dev/null -w '%{redirect_url}' "$AUTHORIZE_URL")" dbg "step1 authorize -> $LOGIN_URL" case "$LOGIN_URL" in *state=*) ;; @@ -108,7 +132,7 @@ esac LOGIN_STATE="$(printf %s "$LOGIN_URL" | sed -n 's/.*[?&]state=\([^&]*\).*/\1/p')" # step 2: fetch the login page to obtain the _csrf cookie -curl -sS -b "$JAR" -c "$JAR" -o /dev/null "$LOGIN_URL" +"${CURL[@]}" -b "$JAR" -c "$JAR" -o /dev/null "$LOGIN_URL" CSRF="$(awk '$6=="_csrf"{print $7}' "$JAR" | tail -1)" dbg "step2 login page fetched, csrf present: $([ -n "$CSRF" ] && echo yes || echo no)" @@ -140,7 +164,7 @@ print(json.dumps({ })) PYEOF )" -UPL_HTML="$(printf %s "$UPL_BODY" | curl -sS -b "$JAR" -c "$JAR" -X POST "https://$AUTH0_DOMAIN/usernamepassword/login" \ +UPL_HTML="$(printf %s "$UPL_BODY" | "${CURL[@]}" -b "$JAR" -c "$JAR" -X POST "https://$AUTH0_DOMAIN/usernamepassword/login" \ -H "Content-Type: application/json" -H "Origin: https://$AUTH0_DOMAIN" -H "Referer: $LOGIN_URL" \ --data @-)" if ! printf %s "$UPL_HTML" | grep -q 'name="wresult"'; then @@ -167,7 +191,7 @@ if not field("wresult"): sys.exit("error: no wresult field on the WS-Fed page - login flow changed?") print(urllib.parse.urlencode({"wa": field("wa"), "wresult": field("wresult"), "wctx": field("wctx")})) ')" -RESUME_URL="$(printf %s "$CALLBACK_FORM" | curl -sS -b "$JAR" -c "$JAR" -o /dev/null -w '%{redirect_url}' -X POST "https://$AUTH0_DOMAIN/login/callback" \ +RESUME_URL="$(printf %s "$CALLBACK_FORM" | "${CURL[@]}" -b "$JAR" -c "$JAR" -o /dev/null -w '%{redirect_url}' -X POST "https://$AUTH0_DOMAIN/login/callback" \ -H "Content-Type: application/x-www-form-urlencoded" -H "Origin: https://$AUTH0_DOMAIN" --data @-)" dbg "step4 login callback -> $RESUME_URL" case "$RESUME_URL" in @@ -176,7 +200,7 @@ case "$RESUME_URL" in esac # step 5: resume -> 302 to redirect_uri with ?code= (no local listener needed) -FINAL_URL="$(curl -sS -b "$JAR" -c "$JAR" -o /dev/null -w '%{redirect_url}' "$RESUME_URL")" +FINAL_URL="$("${CURL[@]}" -b "$JAR" -c "$JAR" -o /dev/null -w '%{redirect_url}' "$RESUME_URL")" CODE="$(printf %s "$FINAL_URL" | sed -n 's/.*[?&]code=\([^&]*\).*/\1/p')" dbg "step5 resume -> code present: $([ -n "$CODE" ] && echo yes || echo no)" if [ -z "$CODE" ]; then @@ -185,7 +209,7 @@ if [ -z "$CODE" ]; then fi # step 6: PKCE code exchange (code and verifier via stdin, not argv) -TOKEN_JSON="$(A0_CLIENT_ID="$AUTH0_CLIENT_ID" A0_CODE="$CODE" A0_REDIRECT_URI="$AUTH0_REDIRECT_URI" A0_VERIFIER="$VERIFIER" python3 << 'PYEOF' | curl -sS -X POST "https://$AUTH0_DOMAIN/oauth/token" -H "Content-Type: application/json" --data @- +TOKEN_JSON="$(A0_CLIENT_ID="$AUTH0_CLIENT_ID" A0_CODE="$CODE" A0_REDIRECT_URI="$AUTH0_REDIRECT_URI" A0_VERIFIER="$VERIFIER" python3 << 'PYEOF' | "${CURL[@]}" -X POST "https://$AUTH0_DOMAIN/oauth/token" -H "Content-Type: application/json" --data @- import json import os @@ -199,7 +223,7 @@ print(json.dumps({ })) PYEOF )" -ACCESS_TOKEN="$(printf %s "$TOKEN_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("access_token",""))')" +ACCESS_TOKEN="$(printf %s "$TOKEN_JSON" | json_field access_token)" if [ -z "$ACCESS_TOKEN" ]; then echo "error: token exchange failed: $(printf %s "$TOKEN_JSON" | head -c 300)" >&2 exit 1 @@ -207,6 +231,6 @@ fi printf '%s\n' "$ACCESS_TOKEN" > "$TOKEN_FILE" chmod 600 "$TOKEN_FILE" -EXPIRES_IN="$(printf %s "$TOKEN_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("expires_in",""))')" +EXPIRES_IN="$(printf %s "$TOKEN_JSON" | json_field expires_in)" echo "token for stage '$STAGE' saved to $TOKEN_FILE (expires in ${EXPIRES_IN}s)" >&2 printf '%s\n' "$ACCESS_TOKEN" From 371378bc40687a0f787108c307e6ae5605110c3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Gryglicki?= Date: Thu, 10 Sep 2026 14:03:56 +0200 Subject: [PATCH 3/3] Address AI feedback - 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Łukasz Gryglicki Assisted by [OpenAI](https://platform.openai.com/) Assisted by [GitHub Copilot](https://github.com/features/copilot) Assisted by [Claude](https://claude.ai) --- .../v2/signatures/company_corporate_contributors_test.go | 3 +-- cla-backend-go/v2/signatures/handlers.go | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cla-backend-go/v2/signatures/company_corporate_contributors_test.go b/cla-backend-go/v2/signatures/company_corporate_contributors_test.go index 10a7efe87..f17d3f807 100644 --- a/cla-backend-go/v2/signatures/company_corporate_contributors_test.go +++ b/cla-backend-go/v2/signatures/company_corporate_contributors_test.go @@ -19,7 +19,6 @@ import ( "github.com/linuxfoundation/easycla/cla-backend-go/gen/v2/restapi/operations" sigOps "github.com/linuxfoundation/easycla/cla-backend-go/gen/v2/restapi/operations/signatures" mock_project_repo "github.com/linuxfoundation/easycla/cla-backend-go/project/mocks" - "github.com/linuxfoundation/easycla/cla-backend-go/project/repository" "github.com/linuxfoundation/easycla/cla-backend-go/projects_cla_groups" mock_projects_cla_groups "github.com/linuxfoundation/easycla/cla-backend-go/projects_cla_groups/mocks" "github.com/linuxfoundation/easycla/cla-backend-go/utils" @@ -246,7 +245,7 @@ func TestListCompanyClaGroupCorporateContributors(t *testing.T) { defer ctrl.Finish() mockProjectRepo := mock_project_repo.NewMockProjectRepository(ctrl) - mockProjectRepo.EXPECT().GetCLAGroupByID(gomock.Any(), claGroupID, false).Return(nil, repository.ErrProjectDoesNotExist) + mockProjectRepo.EXPECT().GetCLAGroupByID(gomock.Any(), claGroupID, false).Return(nil, &utils.CLAGroupNotFound{CLAGroupID: claGroupID}) v2Service := &fakeCorporateContributorsService{} api := operations.NewEasyclaAPI(nil) diff --git a/cla-backend-go/v2/signatures/handlers.go b/cla-backend-go/v2/signatures/handlers.go index afc8101af..780295de0 100644 --- a/cla-backend-go/v2/signatures/handlers.go +++ b/cla-backend-go/v2/signatures/handlers.go @@ -985,7 +985,8 @@ func Configure(api *operations.EasyclaAPI, claGroupService service.Service, proj claGroupModel, err := projectRepo.GetCLAGroupByID(ctx, params.ClaGroupID, repository.DontLoadRepoDetails) if err != nil { log.WithFields(f).WithError(err).Warn(problemLoadingCLAGroupByID) - if err == repository.ErrProjectDoesNotExist { + var claGroupNotFound *utils.CLAGroupNotFound + if errors.As(err, &claGroupNotFound) || errors.Is(err, repository.ErrProjectDoesNotExist) { return signatures.NewListCompanyClaGroupCorporateContributorsNotFound().WithXRequestID(reqID).WithPayload( utils.ErrorResponseNotFoundWithError(reqID, problemLoadingCLAGroupByID, err)) }