From 18261b80578f7822d5f2c7de3f14130de5d19809 Mon Sep 17 00:00:00 2001 From: psrsingh Date: Sat, 30 May 2026 14:59:03 +0530 Subject: [PATCH 01/11] feat(sign): integrate sanctions screening service Signed-off-by: psrsingh --- cla-backend-go/cmd/s3_upload/main.go | 2 +- cla-backend-go/cmd/server.go | 2 +- cla-backend-go/v2/sign/helpers.go | 17 +++++ cla-backend-go/v2/sign/service.go | 93 +++++++++++++++++++++++++--- 4 files changed, 104 insertions(+), 10 deletions(-) diff --git a/cla-backend-go/cmd/s3_upload/main.go b/cla-backend-go/cmd/s3_upload/main.go index ca47cded8..49973560f 100644 --- a/cla-backend-go/cmd/s3_upload/main.go +++ b/cla-backend-go/cmd/s3_upload/main.go @@ -57,7 +57,7 @@ func init() { if err != nil { log.Fatal(err) } - signService = sign.NewService("", "", companyRepo, nil, nil, nil, nil, configFile.DocuSignPrivateKey, nil, nil, nil, nil, githubOrgService, nil, "", "", nil, nil, nil, nil, nil) + signService = sign.NewService("", "", companyRepo, nil, nil, nil, nil, configFile.DocuSignPrivateKey, nil, nil, nil, nil, githubOrgService, nil, "", "", nil, nil, nil, nil, nil, nil) // projectRepo = repository.NewRepository(awsSession, stage, nil, nil, nil) utils.SetS3Storage(awsSession, configFile.SignatureFilesBucket) } diff --git a/cla-backend-go/cmd/server.go b/cla-backend-go/cmd/server.go index 678035e70..707888d33 100644 --- a/cla-backend-go/cmd/server.go +++ b/cla-backend-go/cmd/server.go @@ -448,7 +448,7 @@ func server(localMode bool) http.Handler { v2GithubActivityService := v2GithubActivity.NewService(gitV1Repository, githubOrganizationsRepo, eventsService, autoEnableService, emailService) v2ClaGroupService := cla_groups.NewService(v1ProjectService, templateService, v1ProjectClaGroupRepo, v1ClaManagerService, v1SignaturesService, metricsRepo, gerritService, v1RepositoriesService, eventsService) - v2SignService := sign.NewService(configFile.ClaAPIV4Base, configFile.ClaV1ApiURL, v1CompanyRepo, v1CLAGroupRepo, v1ProjectClaGroupRepo, v1CompanyService, v2ClaGroupService, configFile.DocuSignPrivateKey, usersService, v1SignaturesService, storeRepository, v1RepositoriesService, githubOrganizationsService, gitlabOrganizationsService, configFile.CLALandingPage, configFile.CLALogoURL, emailService, eventsService, gitlabActivityService, gitlabApp, gerritService) + v2SignService := sign.NewService(configFile.ClaAPIV4Base, configFile.ClaV1ApiURL, v1CompanyRepo, v1CLAGroupRepo, v1ProjectClaGroupRepo, v1CompanyService, v2ClaGroupService, configFile.DocuSignPrivateKey, usersService, v1SignaturesService, storeRepository, v1RepositoriesService, githubOrganizationsService, gitlabOrganizationsService, configFile.CLALandingPage, configFile.CLALogoURL, emailService, eventsService, gitlabActivityService, gitlabApp, gerritService, nil) sessionStore, err := dynastore.New(dynastore.Path("/"), dynastore.HTTPOnly(), dynastore.TableName(configFile.SessionStoreTableName), dynastore.DynamoDB(dynamodb.New(awsSession))) if err != nil { diff --git a/cla-backend-go/v2/sign/helpers.go b/cla-backend-go/v2/sign/helpers.go index db1003604..33f9b089f 100644 --- a/cla-backend-go/v2/sign/helpers.go +++ b/cla-backend-go/v2/sign/helpers.go @@ -145,6 +145,23 @@ func (s service) hasUserSigned(ctx context.Context, user *models.User, projectID log.WithFields(f).WithError(compModelErr).Warnf("problem looking up company: %s", companyID) return &hasSigned, &companyAffiliation, compModelErr } + if companyModel == nil { + compModelErr = fmt.Errorf("company not found: %s", companyID) + log.WithFields(f).WithError(compModelErr).Warnf("company record is nil for company: %s", companyID) + return &hasSigned, &companyAffiliation, compModelErr + } + + // Check if company is sanctioned before allowing ECLA acknowledgement + sanctioned, sanctionErr := s.checkCompanyCompliance(ctx, companyModel) + if sanctionErr != nil { + log.WithFields(f).WithError(sanctionErr).Warnf("failed to check company compliance for company: %s", companyID) + return &hasSigned, &companyAffiliation, sanctionErr + } + if sanctioned { + sanctionedErr := fmt.Errorf("company %s is sanctioned", companyID) + log.WithFields(f).WithError(sanctionedErr).Error("company is sanctioned") + return &hasSigned, &companyAffiliation, sanctionedErr + } // Load the CLA Group - make sure it is valid claGroupModel, claGroupModelErr := s.claGroupService.GetCLAGroup(ctx, projectID) diff --git a/cla-backend-go/v2/sign/service.go b/cla-backend-go/v2/sign/service.go index f5b583d5e..8806e7234 100644 --- a/cla-backend-go/v2/sign/service.go +++ b/cla-backend-go/v2/sign/service.go @@ -28,6 +28,7 @@ import ( "github.com/linuxfoundation/easycla/cla-backend-go/projects_cla_groups" "github.com/linuxfoundation/easycla/cla-backend-go/repositories" "github.com/linuxfoundation/easycla/cla-backend-go/signatures" + "github.com/linuxfoundation/easycla/cla-backend-go/sss" "github.com/linuxfoundation/easycla/cla-backend-go/users" "github.com/linuxfoundation/easycla/cla-backend-go/v2/cla_groups" gitlab_activity "github.com/linuxfoundation/easycla/cla-backend-go/v2/gitlab-activity" @@ -117,12 +118,13 @@ type service struct { gitlabActivityService gitlab_activity.Service gitlabApp *gitlab_api.App gerritService gerrits.Service + sssClient *sss.Client } // NewService returns an instance of v2 project service func NewService(apiURL, v1API string, compRepo company.IRepository, projectRepo ProjectRepo, pcgRepo projects_cla_groups.Repository, compService company.IService, claGroupService cla_groups.Service, docsignPrivateKey string, userService users.Service, signatureService signatures.SignatureService, storeRepository store.Repository, repositoryService repositories.Service, githubOrgService github_organizations.Service, gitlabOrgService gitlab_organizations.ServiceInterface, claLandingPage string, claLogoURL string, emailTemplateService emails.EmailTemplateService, eventsService events.Service, gitlabActivityService gitlab_activity.Service, gitlabApp *gitlab_api.App, - gerritService gerrits.Service) Service { + gerritService gerrits.Service, sssClient *sss.Client) Service { return &service{ ClaV4ApiURL: apiURL, ClaV1ApiURL: v1API, @@ -145,6 +147,7 @@ func NewService(apiURL, v1API string, compRepo company.IRepository, projectRepo gitlabApp: gitlabApp, gerritService: gerritService, eventsService: eventsService, + sssClient: sssClient, } } @@ -243,14 +246,21 @@ func (s *service) RequestCorporateSignature(ctx context.Context, lfUsername stri } // 1.5 Check if company is sanctioned - if comp != nil && comp.IsSanctioned { - if input.CompanySfid != nil { - err = fmt.Errorf("company %s is sanctioned", *input.CompanySfid) - } else { - err = fmt.Errorf("company is sanctioned") + if comp != nil { + sanctioned, sanctionErr := s.checkCompanyCompliance(ctx, comp) + if sanctionErr != nil { + log.WithFields(f).WithError(sanctionErr).Error("failed to check company compliance") + return nil, sanctionErr + } + if sanctioned { + if input.CompanySfid != nil { + err = fmt.Errorf("company %s is sanctioned", *input.CompanySfid) + } else { + err = fmt.Errorf("company is sanctioned") + } + log.WithFields(f).WithError(err).Error("company is sanctioned") + return nil, err } - log.WithFields(f).WithError(err).Error("company is sanctioned") - return nil, err } // 2. Ensure this is a valid project @@ -2936,3 +2946,70 @@ func (s *service) GetUserActiveSignature(ctx context.Context, userID string) (*m UserID: userID, }, nil } + +// checkCompanyCompliance queries the Sanctions Screening Service for the given company +// and persists the result. Returns (sanctioned, error). A nil sssClient is a no-op. +func (s *service) checkCompanyCompliance(ctx context.Context, company *v1Models.Company) (bool, error) { + f := logrus.Fields{ + "functionName": "sign.checkCompanyCompliance", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "companyID": company.CompanyID, + "companyName": company.CompanyName, + } + + if s.sssClient == nil { + log.WithFields(f).Debug("SSS client not configured, skipping compliance check") + return false, nil + } + + // Fetch org from organization service to get the website/domain. + orgClient := organizationService.GetClient() + org, err := orgClient.GetOrganization(ctx, company.CompanyExternalID) + if err != nil { + return false, fmt.Errorf("checkCompanyCompliance: failed to get organization %s: %w", company.CompanyExternalID, err) + } + if org == nil { + return false, fmt.Errorf("checkCompanyCompliance: organization record is nil for %s", company.CompanyExternalID) + } + + if strings.TrimSpace(org.Link) == "" { + log.WithFields(f).Warnf("organization %s has no Link/website; skipping SSS check", company.CompanyExternalID) + return false, nil + } + + // Strip protocol to get bare domain. + domain := org.Link + if idx := strings.Index(domain, "://"); idx != -1 { + domain = domain[idx+3:] + } + domain = strings.TrimRight(domain, "/") + + req := sss.OrganizationStatusRequest{ + Domain: domain, + OrgName: company.CompanyName, + } + if strings.HasPrefix(company.CompanyExternalID, "001") { + req.SFDCID = company.CompanyExternalID + } + + result, err := s.sssClient.GetOrganizationStatus(ctx, req) + if err != nil { + return false, fmt.Errorf("checkCompanyCompliance: SSS request failed for company %s: %w", company.CompanyID, err) + } + + sanctioned := result.Status == sss.StatusFlagged + + if persistErr := s.companyRepo.UpdateCompanySanctionStatus( + ctx, + company.CompanyID, + sanctioned, + ); persistErr != nil { + return false, fmt.Errorf( + "failed to persist sanction status for company %s: %w", + company.CompanyID, + persistErr, + ) + } + + return sanctioned, nil +} From 112609e79ef52d8825f4156aac25cf037c0f37b7 Mon Sep 17 00:00:00 2001 From: psrsingh Date: Mon, 1 Jun 2026 18:20:20 +0530 Subject: [PATCH 02/11] fix(sign): improve company compliance check and error handling Signed-off-by: psrsingh --- cla-backend-go/v2/sign/service.go | 48 +++++++++++++++++++------------ 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/cla-backend-go/v2/sign/service.go b/cla-backend-go/v2/sign/service.go index 8806e7234..3514999d1 100644 --- a/cla-backend-go/v2/sign/service.go +++ b/cla-backend-go/v2/sign/service.go @@ -246,21 +246,26 @@ func (s *service) RequestCorporateSignature(ctx context.Context, lfUsername stri } // 1.5 Check if company is sanctioned - if comp != nil { - sanctioned, sanctionErr := s.checkCompanyCompliance(ctx, comp) - if sanctionErr != nil { - log.WithFields(f).WithError(sanctionErr).Error("failed to check company compliance") - return nil, sanctionErr - } - if sanctioned { - if input.CompanySfid != nil { - err = fmt.Errorf("company %s is sanctioned", *input.CompanySfid) - } else { - err = fmt.Errorf("company is sanctioned") - } - log.WithFields(f).WithError(err).Error("company is sanctioned") - return nil, err + if comp == nil { + if input.CompanySfid != nil { + return nil, fmt.Errorf("company not found for SFID %s", *input.CompanySfid) } + return nil, fmt.Errorf("company not found") + } + + sanctioned, sanctionErr := s.checkCompanyCompliance(ctx, comp) + if sanctionErr != nil { + log.WithFields(f).WithError(sanctionErr).Error("failed to check company compliance") + return nil, sanctionErr + } + if sanctioned { + if input.CompanySfid != nil { + err = fmt.Errorf("company %s is sanctioned", *input.CompanySfid) + } else { + err = fmt.Errorf("company is sanctioned") + } + log.WithFields(f).WithError(err).Error("company is sanctioned") + return nil, err } // 2. Ensure this is a valid project @@ -2977,12 +2982,17 @@ func (s *service) checkCompanyCompliance(ctx context.Context, company *v1Models. return false, nil } - // Strip protocol to get bare domain. - domain := org.Link - if idx := strings.Index(domain, "://"); idx != -1 { - domain = domain[idx+3:] + // Parse org.Link to extract bare hostname (strips scheme, path, port, query). + domain := strings.TrimSpace(org.Link) + if u, parseErr := url.Parse(domain); parseErr == nil && u.Hostname() != "" { + domain = u.Hostname() + } else { + // Fallback: strip scheme and trailing slashes. + if idx := strings.Index(domain, "://"); idx != -1 { + domain = domain[idx+3:] + } + domain = strings.TrimRight(domain, "/") } - domain = strings.TrimRight(domain, "/") req := sss.OrganizationStatusRequest{ Domain: domain, From 8f3a851876c15286cda3f50174aaa5d123f6ae95 Mon Sep 17 00:00:00 2001 From: psrsingh Date: Tue, 2 Jun 2026 13:58:58 +0530 Subject: [PATCH 03/11] fix(sign): address SSS review feedback Signed-off-by: psrsingh --- cla-backend-go/cmd/s3_upload/main.go | 2 +- cla-backend-go/cmd/server.go | 27 ++- cla-backend-go/company/repository.go | 63 ++++- cla-backend-go/config/config.go | 13 ++ cla-backend-go/config/local.go | 1 + cla-backend-go/config/ssm.go | 1 + cla-backend-go/v2/sign/service.go | 255 ++++++++++++++++++--- cla-backend-go/v2/sign/service_sss_test.go | 102 +++++++++ 8 files changed, 430 insertions(+), 34 deletions(-) create mode 100644 cla-backend-go/v2/sign/service_sss_test.go diff --git a/cla-backend-go/cmd/s3_upload/main.go b/cla-backend-go/cmd/s3_upload/main.go index 49973560f..0ffc99a61 100644 --- a/cla-backend-go/cmd/s3_upload/main.go +++ b/cla-backend-go/cmd/s3_upload/main.go @@ -57,7 +57,7 @@ func init() { if err != nil { log.Fatal(err) } - signService = sign.NewService("", "", companyRepo, nil, nil, nil, nil, configFile.DocuSignPrivateKey, nil, nil, nil, nil, githubOrgService, nil, "", "", nil, nil, nil, nil, nil, nil) + signService = sign.NewService("", "", companyRepo, nil, nil, nil, nil, configFile.DocuSignPrivateKey, nil, nil, nil, nil, githubOrgService, nil, "", "", nil, nil, nil, nil, nil, nil, configFile.SSS.Required) // projectRepo = repository.NewRepository(awsSession, stage, nil, nil, nil) utils.SetS3Storage(awsSession, configFile.SignatureFilesBucket) } diff --git a/cla-backend-go/cmd/server.go b/cla-backend-go/cmd/server.go index 707888d33..0d377c0dc 100644 --- a/cla-backend-go/cmd/server.go +++ b/cla-backend-go/cmd/server.go @@ -83,6 +83,7 @@ import ( "github.com/linuxfoundation/easycla/cla-backend-go/api_logs" "github.com/linuxfoundation/easycla/cla-backend-go/signatures" + "github.com/linuxfoundation/easycla/cla-backend-go/sss" "github.com/linuxfoundation/easycla/cla-backend-go/telemetry" v2Signatures "github.com/linuxfoundation/easycla/cla-backend-go/v2/signatures" @@ -448,7 +449,31 @@ func server(localMode bool) http.Handler { v2GithubActivityService := v2GithubActivity.NewService(gitV1Repository, githubOrganizationsRepo, eventsService, autoEnableService, emailService) v2ClaGroupService := cla_groups.NewService(v1ProjectService, templateService, v1ProjectClaGroupRepo, v1ClaManagerService, v1SignaturesService, metricsRepo, gerritService, v1RepositoriesService, eventsService) - v2SignService := sign.NewService(configFile.ClaAPIV4Base, configFile.ClaV1ApiURL, v1CompanyRepo, v1CLAGroupRepo, v1ProjectClaGroupRepo, v1CompanyService, v2ClaGroupService, configFile.DocuSignPrivateKey, usersService, v1SignaturesService, storeRepository, v1RepositoriesService, githubOrganizationsService, gitlabOrganizationsService, configFile.CLALandingPage, configFile.CLALogoURL, emailService, eventsService, gitlabActivityService, gitlabApp, gerritService, nil) + + // Initialize SSS (Sanctions Screening Service) client if configured + var sssClient *sss.Client + if configFile.SSS.BaseURL != "" && configFile.SSS.Auth0Domain != "" && configFile.SSS.Auth0ClientID != "" && configFile.SSS.Auth0ClientSecret != "" && configFile.SSS.Auth0Audience != "" { + sssTimeout := time.Duration(configFile.SSS.RequestTimeoutSec) * time.Second + if sssTimeout <= 0 { + sssTimeout = 30 * time.Second // default timeout + } + sssConfig := sss.SSSConfig{ + BaseURL: configFile.SSS.BaseURL, + Auth0Domain: configFile.SSS.Auth0Domain, + Auth0ClientID: configFile.SSS.Auth0ClientID, + Auth0ClientSecret: configFile.SSS.Auth0ClientSecret, + Auth0Audience: configFile.SSS.Auth0Audience, + Timeout: sssTimeout, + } + var sssErr error + sssClient, sssErr = sss.NewClient(sssConfig) + if sssErr != nil { + log.WithFields(f).WithError(sssErr).Warnf("failed to initialize SSS client, screening will be unavailable: %v", sssErr) + sssClient = nil + } + } + + v2SignService := sign.NewService(configFile.ClaAPIV4Base, configFile.ClaV1ApiURL, v1CompanyRepo, v1CLAGroupRepo, v1ProjectClaGroupRepo, v1CompanyService, v2ClaGroupService, configFile.DocuSignPrivateKey, usersService, v1SignaturesService, storeRepository, v1RepositoriesService, githubOrganizationsService, gitlabOrganizationsService, configFile.CLALandingPage, configFile.CLALogoURL, emailService, eventsService, gitlabActivityService, gitlabApp, gerritService, sssClient, configFile.SSS.Required) sessionStore, err := dynastore.New(dynastore.Path("/"), dynastore.HTTPOnly(), dynastore.TableName(configFile.SessionStoreTableName), dynastore.DynamoDB(dynamodb.New(awsSession))) if err != nil { diff --git a/cla-backend-go/company/repository.go b/cla-backend-go/company/repository.go index 0d848bc71..7784d1372 100644 --- a/cla-backend-go/company/repository.go +++ b/cla-backend-go/company/repository.go @@ -53,6 +53,7 @@ type IRepository interface { //nolint ApproveCompanyAccessRequest(ctx context.Context, companyInviteID string) error RejectCompanyAccessRequest(ctx context.Context, companyInviteID string) error UpdateCompanyAccessList(ctx context.Context, companyID string, companyACL []string) error + UpdateCompanySanctionStatus(ctx context.Context, companyID string, sanctioned bool) error IsCCLAEnabledForCompany(ctx context.Context, companyID string) (bool, error) } @@ -1276,7 +1277,67 @@ func (repo repository) UpdateCompanyAccessList(ctx context.Context, companyID st return nil } -// CreateCompany creates a new company record +// UpdateCompanySanctionStatus updates the is_sanctioned flag for a company. +// It only performs the update if the value has changed to avoid unnecessary DynamoDB writes. +func (repo repository) UpdateCompanySanctionStatus(ctx context.Context, companyID string, sanctioned bool) error { + f := logrus.Fields{ + "functionName": "company.repository.UpdateCompanySanctionStatus", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "companyID": companyID, + "sanctioned": sanctioned, + } + + // Fetch current company to check if value has changed + currentCompany, err := repo.GetCompany(ctx, companyID) + if err != nil { + log.WithFields(f).Warnf("unable to fetch current company record to check sanction status, error: %v", err) + return err + } + if currentCompany == nil { + return fmt.Errorf("company not found: %s", companyID) + } + + // Avoid unnecessary writes - only update if value has changed + if currentCompany.IsSanctioned == sanctioned { + log.WithFields(f).Debugf("sanction status unchanged (current=%v, new=%v), skipping update", currentCompany.IsSanctioned, sanctioned) + return nil + } + + log.WithFields(f).Debugf("updating sanction status from %v to %v", currentCompany.IsSanctioned, sanctioned) + + _, now := utils.CurrentTime() + + input := &dynamodb.UpdateItemInput{ + ExpressionAttributeNames: map[string]*string{ + "#S": aws.String("is_sanctioned"), + "#M": aws.String("date_modified"), + }, + ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ + ":s": { + BOOL: aws.Bool(sanctioned), + }, + ":m": { + S: aws.String(now), + }, + }, + TableName: aws.String(repo.companyTableName), + Key: map[string]*dynamodb.AttributeValue{ + "company_id": { + S: aws.String(companyID), + }, + }, + UpdateExpression: aws.String("SET #S = :s, #M = :m"), + } + + _, err = repo.dynamoDBClient.UpdateItem(input) + if err != nil { + log.WithFields(f).Warnf("error updating company sanction status, error: %v", err) + return err + } + + return nil +} + func (repo repository) CreateCompany(ctx context.Context, in *models.Company) (*models.Company, error) { f := logrus.Fields{ "functionName": "company.repository.CreateCompany", diff --git a/cla-backend-go/config/config.go b/cla-backend-go/config/config.go index e81b70170..90a117b88 100644 --- a/cla-backend-go/config/config.go +++ b/cla-backend-go/config/config.go @@ -101,6 +101,19 @@ type Config struct { // SSS holds the Sanctions Screening Service client configuration SSS SSS `json:"sss"` + // SSS (Sanctions Screening Service) configuration + SSS SSS `json:"sss"` +} + +// SSS model for Sanctions Screening Service configuration +type SSS struct { + BaseURL string `json:"base_url"` + Auth0Domain string `json:"auth0_domain"` + Auth0ClientID string `json:"auth0_client_id"` + Auth0ClientSecret string `json:"auth0_client_secret"` + Auth0Audience string `json:"auth0_audience"` + RequestTimeoutSec int `json:"request_timeout_sec"` + Required bool `json:"required"` } // Auth0 model diff --git a/cla-backend-go/config/local.go b/cla-backend-go/config/local.go index edccc145e..147bdeaa6 100644 --- a/cla-backend-go/config/local.go +++ b/cla-backend-go/config/local.go @@ -23,6 +23,7 @@ func loadLocalConfig(configFilePath string) (Config, error) { } localConfig := Config{} + localConfig.SSS.Required = true err = json.Unmarshal(content, &localConfig) if err != nil { return Config{}, err diff --git a/cla-backend-go/config/ssm.go b/cla-backend-go/config/ssm.go index a29107fb0..8cdbb4f84 100644 --- a/cla-backend-go/config/ssm.go +++ b/cla-backend-go/config/ssm.go @@ -46,6 +46,7 @@ func loadSSMConfig(awsSession *session.Session, stage string) Config { //nolint } config := Config{} config.SignatureQueryDefaultValue = "all" + config.SSS.Required = true ssmClient := ssm.New(awsSession) diff --git a/cla-backend-go/v2/sign/service.go b/cla-backend-go/v2/sign/service.go index 3514999d1..5c65a8fa4 100644 --- a/cla-backend-go/v2/sign/service.go +++ b/cla-backend-go/v2/sign/service.go @@ -15,6 +15,7 @@ import ( "net/url" "strconv" "strings" + "sync" "time" "github.com/go-openapi/strfmt" @@ -60,6 +61,7 @@ const ( DontLoadRepoDetails = false DocSignFalse = "false" DocusignCompleted = "Completed" + complianceCacheTTL = 5 * time.Minute ) // errors @@ -119,12 +121,21 @@ type service struct { gitlabApp *gitlab_api.App gerritService gerrits.Service sssClient *sss.Client + sssRequired bool + complianceCache map[string]complianceCacheEntry + complianceCacheMu sync.Mutex +} + +type complianceCacheEntry struct { + sanctioned bool + err error + expiresAt time.Time } // NewService returns an instance of v2 project service func NewService(apiURL, v1API string, compRepo company.IRepository, projectRepo ProjectRepo, pcgRepo projects_cla_groups.Repository, compService company.IService, claGroupService cla_groups.Service, docsignPrivateKey string, userService users.Service, signatureService signatures.SignatureService, storeRepository store.Repository, repositoryService repositories.Service, githubOrgService github_organizations.Service, gitlabOrgService gitlab_organizations.ServiceInterface, claLandingPage string, claLogoURL string, emailTemplateService emails.EmailTemplateService, eventsService events.Service, gitlabActivityService gitlab_activity.Service, gitlabApp *gitlab_api.App, - gerritService gerrits.Service, sssClient *sss.Client) Service { + gerritService gerrits.Service, sssClient *sss.Client, sssRequired bool) Service { return &service{ ClaV4ApiURL: apiURL, ClaV1ApiURL: v1API, @@ -148,6 +159,8 @@ func NewService(apiURL, v1API string, compRepo company.IRepository, projectRepo gerritService: gerritService, eventsService: eventsService, sssClient: sssClient, + sssRequired: sssRequired, + complianceCache: make(map[string]complianceCacheEntry), } } @@ -2962,37 +2975,69 @@ func (s *service) checkCompanyCompliance(ctx context.Context, company *v1Models. "companyName": company.CompanyName, } + // Check if company is already manually sanctioned - if so, always block + if company.IsSanctioned { + log.WithFields(f).Warnf("company is manually sanctioned, blocking") + return true, nil + } + + cacheKey := s.complianceCacheKey(company) + if cached, ok := s.getComplianceCache(cacheKey); ok { + log.WithFields(f).Debugf("using cached compliance result for organization/company: %s", cacheKey) + return cached.sanctioned, cached.err + } + if s.sssClient == nil { - log.WithFields(f).Debug("SSS client not configured, skipping compliance check") + log.WithFields(f).Debug("SSS client not configured, skipping live compliance check") + s.setComplianceCache(cacheKey, false, nil) return false, nil } // Fetch org from organization service to get the website/domain. orgClient := organizationService.GetClient() + if orgClient == nil { + resultErr := fmt.Errorf("checkCompanyCompliance: organization service client is not configured") + if !s.sssRequired { + log.WithFields(f).WithError(resultErr).Warn("SSS is not required; continuing without live compliance result") + s.setComplianceCache(cacheKey, false, nil) + return false, nil + } + s.setComplianceCache(cacheKey, false, resultErr) + return false, resultErr + } org, err := orgClient.GetOrganization(ctx, company.CompanyExternalID) if err != nil { - return false, fmt.Errorf("checkCompanyCompliance: failed to get organization %s: %w", company.CompanyExternalID, err) + log.WithFields(f).WithError(err).Warnf("failed to get organization %s for domain resolution", company.CompanyExternalID) + resultErr := fmt.Errorf("checkCompanyCompliance: failed to get organization %s: %w", company.CompanyExternalID, err) + if !s.sssRequired { + log.WithFields(f).WithError(resultErr).Warn("SSS is not required; continuing without live compliance result") + s.setComplianceCache(cacheKey, false, nil) + return false, nil + } + s.setComplianceCache(cacheKey, false, resultErr) + return false, resultErr } if org == nil { - return false, fmt.Errorf("checkCompanyCompliance: organization record is nil for %s", company.CompanyExternalID) - } - - if strings.TrimSpace(org.Link) == "" { - log.WithFields(f).Warnf("organization %s has no Link/website; skipping SSS check", company.CompanyExternalID) + log.WithFields(f).Warnf("organization record is nil for %s", company.CompanyExternalID) + resultErr := fmt.Errorf("checkCompanyCompliance: organization record is nil for %s", company.CompanyExternalID) + if !s.sssRequired { + log.WithFields(f).WithError(resultErr).Warn("SSS is not required; continuing without live compliance result") + s.setComplianceCache(cacheKey, false, nil) + return false, nil + } + s.setComplianceCache(cacheKey, false, resultErr) + return false, resultErr + } + + // Resolve domain: prefer Domains field, fallback to Link field + domain := s.resolveDomain(f, org) + if domain == "" { + log.WithFields(f).Warnf("unable to resolve domain for organization %s; skipping SSS check", company.CompanyExternalID) + s.setComplianceCache(cacheKey, false, nil) return false, nil } - // Parse org.Link to extract bare hostname (strips scheme, path, port, query). - domain := strings.TrimSpace(org.Link) - if u, parseErr := url.Parse(domain); parseErr == nil && u.Hostname() != "" { - domain = u.Hostname() - } else { - // Fallback: strip scheme and trailing slashes. - if idx := strings.Index(domain, "://"); idx != -1 { - domain = domain[idx+3:] - } - domain = strings.TrimRight(domain, "/") - } + log.WithFields(f).Debugf("resolved domain: %s for SSS check", domain) req := sss.OrganizationStatusRequest{ Domain: domain, @@ -3004,22 +3049,170 @@ func (s *service) checkCompanyCompliance(ctx context.Context, company *v1Models. result, err := s.sssClient.GetOrganizationStatus(ctx, req) if err != nil { - return false, fmt.Errorf("checkCompanyCompliance: SSS request failed for company %s: %w", company.CompanyID, err) + blocked, handledErr := s.handleSSSError(f, company.CompanyID, err) + s.setComplianceCache(cacheKey, blocked, handledErr) + return blocked, handledErr } sanctioned := result.Status == sss.StatusFlagged - if persistErr := s.companyRepo.UpdateCompanySanctionStatus( - ctx, - company.CompanyID, - sanctioned, - ); persistErr != nil { - return false, fmt.Errorf( - "failed to persist sanction status for company %s: %w", - company.CompanyID, - persistErr, - ) + // Only persist if flagged (never clear a manual sanction via SSS clean result) + if sanctioned { + log.WithFields(f).Warnf("SSS returned flagged status for company %s, persisting sanction", company.CompanyID) + if persistErr := s.companyRepo.UpdateCompanySanctionStatus(ctx, company.CompanyID, true); persistErr != nil { + log.WithFields(f).WithError(persistErr).Warnf("failed to persist sanction status for company %s", company.CompanyID) + resultErr := fmt.Errorf("failed to persist sanction status for company %s: %w", company.CompanyID, persistErr) + s.setComplianceCache(cacheKey, false, resultErr) + return false, resultErr + } + } else { + log.WithFields(f).Debugf("SSS returned clean status for company %s", company.CompanyID) + } + + // Return combined result: blocked if manually sanctioned OR sss flagged + blocked := company.IsSanctioned || sanctioned + s.setComplianceCache(cacheKey, blocked, nil) + return blocked, nil +} + +func (s *service) complianceCacheKey(company *v1Models.Company) string { + if company == nil { + return "" + } + if key := strings.TrimSpace(company.CompanyExternalID); key != "" { + return key + } + return strings.TrimSpace(company.CompanyID) +} + +func (s *service) getComplianceCache(key string) (complianceCacheEntry, bool) { + if key == "" || s.complianceCache == nil { + return complianceCacheEntry{}, false } + s.complianceCacheMu.Lock() + defer s.complianceCacheMu.Unlock() - return sanctioned, nil + entry, ok := s.complianceCache[key] + if !ok { + return complianceCacheEntry{}, false + } + if time.Now().After(entry.expiresAt) { + delete(s.complianceCache, key) + return complianceCacheEntry{}, false + } + return entry, true +} + +func (s *service) setComplianceCache(key string, sanctioned bool, err error) { + if key == "" { + return + } + s.complianceCacheMu.Lock() + defer s.complianceCacheMu.Unlock() + if s.complianceCache == nil { + s.complianceCache = make(map[string]complianceCacheEntry) + } + s.complianceCache[key] = complianceCacheEntry{ + sanctioned: sanctioned, + err: err, + expiresAt: time.Now().Add(complianceCacheTTL), + } +} + +// resolveDomain attempts to resolve the domain for an organization. +// Priority: 1) Domains field from org (if available), 2) Parse Link field +func (s *service) resolveDomain(f logrus.Fields, org interface{}) string { + if domainStruct, ok := org.(interface{ GetDomains() []string }); ok { + domains := domainStruct.GetDomains() + if len(domains) > 0 && strings.TrimSpace(domains[0]) != "" { + domain := strings.TrimSpace(domains[0]) + domain = strings.TrimPrefix(domain, "www.") + log.WithFields(f).Debugf("resolved domain from Domains field: %s", domain) + return domain + } + } + + if linkStruct, ok := org.(interface{ GetLink() string }); ok { + link := strings.TrimSpace(linkStruct.GetLink()) + if link != "" { + domain := s.parseDomain(link) + if domain != "" { + return domain + } + } + } + + return "" +} + +// parseDomain extracts the hostname from a URL string. +// If the URL lacks a scheme, it prepends https:// for parsing. +func (s *service) parseDomain(urlStr string) string { + urlStr = strings.TrimSpace(urlStr) + if urlStr == "" { + return "" + } + + // Prepend https:// if no scheme is present + if !strings.Contains(urlStr, "://") { + urlStr = "https://" + urlStr + } + + u, err := url.Parse(urlStr) + if err != nil { + return "" + } + + hostname := u.Hostname() + if hostname == "" { + return "" + } + + // Strip leading www. + hostname = strings.TrimPrefix(hostname, "www.") + return hostname +} + +// handleSSSError differentiates between various SSS error types and logs appropriately. +// Returns a non-nil error for SSS failures that should block signing. +func (s *service) handleSSSError(f logrus.Fields, companyID string, err error) (bool, error) { + var badReqErr *sss.BadRequestError + var authErr *sss.AuthError + var retryErr *sss.RetryableError + var notFoundErr *sss.NotFoundError + var timeoutErr *sss.TimeoutError + allowWhenOptional := func(message string) (bool, error) { + if s.sssRequired { + return false, fmt.Errorf("%s for company %s: %w", message, companyID, err) + } + log.WithFields(f).WithError(err).Warnf("%s for company %s; SSS is not required, continuing", message, companyID) + return false, nil + } + + switch { + case errors.As(err, &timeoutErr): + log.WithFields(f).WithError(err).Warnf("SSS request timed out for company %s", companyID) + return allowWhenOptional("SSS screening unavailable (timeout)") + + case errors.As(err, &authErr): + log.WithFields(f).WithError(err).Errorf("SSS authentication/configuration error for company %s", companyID) + return allowWhenOptional("SSS authentication error (check configuration)") + + case errors.As(err, &retryErr): + log.WithFields(f).WithError(err).Warnf("SSS request failed with retryable error for company %s", companyID) + return allowWhenOptional("SSS screening unavailable (transient failure)") + + case errors.As(err, ¬FoundErr): + log.WithFields(f).WithError(err).Warnf("SSS organization not found for company %s", companyID) + // Not found is not a blocking error - proceed without SSS result + return false, nil + + case errors.As(err, &badReqErr): + log.WithFields(f).WithError(err).Warnf("SSS bad request for company %s", companyID) + return false, fmt.Errorf("SSS bad request for company %s: %w", companyID, err) + + default: + log.WithFields(f).WithError(err).Warnf("SSS request failed with unexpected error for company %s", companyID) + return allowWhenOptional("SSS request failed") + } } diff --git a/cla-backend-go/v2/sign/service_sss_test.go b/cla-backend-go/v2/sign/service_sss_test.go new file mode 100644 index 000000000..99ad505f1 --- /dev/null +++ b/cla-backend-go/v2/sign/service_sss_test.go @@ -0,0 +1,102 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package sign + +import ( + "errors" + "testing" + "time" + + "github.com/linuxfoundation/easycla/cla-backend-go/gen/v1/models" + "github.com/linuxfoundation/easycla/cla-backend-go/sss" + "github.com/sirupsen/logrus" +) + +type testOrg struct { + domains []string + link string +} + +func (o testOrg) GetDomains() []string { + return o.domains +} + +func (o testOrg) GetLink() string { + return o.link +} + +func TestResolveDomainPrefersDomains(t *testing.T) { + svc := &service{} + + got := svc.resolveDomain(logrus.Fields{}, testOrg{ + domains: []string{"www.example.com"}, + link: "https://fallback.example.org/path", + }) + + if got != "example.com" { + t.Fatalf("expected domain from Domains field, got %q", got) + } +} + +func TestResolveDomainFallsBackToParsedLink(t *testing.T) { + svc := &service{} + + got := svc.resolveDomain(logrus.Fields{}, testOrg{ + link: "www.example.org/path?query=1", + }) + + if got != "example.org" { + t.Fatalf("expected parsed Link hostname, got %q", got) + } +} + +func TestHandleSSSErrorRequiredBlocksAvailabilityErrors(t *testing.T) { + svc := &service{sssRequired: true} + + _, err := svc.handleSSSError(logrus.Fields{}, "company-id", &sss.RetryableError{Message: "unavailable"}) + if err == nil { + t.Fatal("expected required SSS retryable error to block") + } +} + +func TestHandleSSSErrorOptionalAllowsAvailabilityErrors(t *testing.T) { + svc := &service{sssRequired: false} + + blocked, err := svc.handleSSSError(logrus.Fields{}, "company-id", &sss.AuthError{Message: "auth failed"}) + if err != nil { + t.Fatalf("expected optional SSS auth error to continue, got %v", err) + } + if blocked { + t.Fatal("expected optional SSS auth error not to block") + } +} + +func TestComplianceCacheKeyPrefersExternalID(t *testing.T) { + svc := &service{} + + got := svc.complianceCacheKey(&models.Company{ + CompanyID: "internal-id", + CompanyExternalID: "external-id", + }) + + if got != "external-id" { + t.Fatalf("expected external id cache key, got %q", got) + } +} + +func TestComplianceCacheExpires(t *testing.T) { + svc := &service{ + complianceCache: map[string]complianceCacheEntry{ + "company-id": { + sanctioned: true, + err: errors.New("cached"), + expiresAt: time.Now().Add(-time.Second), + }, + }, + } + + if _, ok := svc.getComplianceCache("company-id"); ok { + t.Fatal("expected expired cache entry to be ignored") + } +} From ab0669f6ffce6ba21ebd61c9dfb6ea50cf62ef08 Mon Sep 17 00:00:00 2001 From: psrsingh Date: Tue, 2 Jun 2026 14:56:46 +0530 Subject: [PATCH 04/11] fix(sign): address SSS review feedback Signed-off-by: psrsingh --- cla-backend-go/company/repository.go | 6 ++++++ cla-backend-go/v2/sign/service_sss_test.go | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/cla-backend-go/company/repository.go b/cla-backend-go/company/repository.go index 7784d1372..531f76c29 100644 --- a/cla-backend-go/company/repository.go +++ b/cla-backend-go/company/repository.go @@ -1287,6 +1287,12 @@ func (repo repository) UpdateCompanySanctionStatus(ctx context.Context, companyI "sanctioned": sanctioned, } + // SSS may only set a sanction block, never clear one (clearing could override a manual block). + if !sanctioned { + log.WithFields(f).Debugf("ignoring request to clear sanction flag via SSS to protect manual/other-source blocks") + return nil + } + // Fetch current company to check if value has changed currentCompany, err := repo.GetCompany(ctx, companyID) if err != nil { diff --git a/cla-backend-go/v2/sign/service_sss_test.go b/cla-backend-go/v2/sign/service_sss_test.go index 99ad505f1..18a3e5e4f 100644 --- a/cla-backend-go/v2/sign/service_sss_test.go +++ b/cla-backend-go/v2/sign/service_sss_test.go @@ -60,7 +60,7 @@ func TestHandleSSSErrorRequiredBlocksAvailabilityErrors(t *testing.T) { } } -func TestHandleSSSErrorOptionalAllowsAvailabilityErrors(t *testing.T) { +func TestHandleSSSErrorOptionalAllowsAuthErrors(t *testing.T) { svc := &service{sssRequired: false} blocked, err := svc.handleSSSError(logrus.Fields{}, "company-id", &sss.AuthError{Message: "auth failed"}) From ac79c85d5946d419e43b44545286c63762593894 Mon Sep 17 00:00:00 2001 From: psrsingh Date: Tue, 2 Jun 2026 16:58:01 +0530 Subject: [PATCH 05/11] fix(sign): address SSS review feedback Signed-off-by: psrsingh --- cla-backend-go/cmd/s3_upload/main.go | 2 +- cla-backend-go/cmd/server.go | 32 +++---- cla-backend-go/config/config.go | 13 --- cla-backend-go/config/local.go | 1 - cla-backend-go/config/ssm.go | 1 - cla-backend-go/v2/sign/service.go | 102 +++++++++++++++------ cla-backend-go/v2/sign/service_sss_test.go | 68 +++++++++++--- 7 files changed, 140 insertions(+), 79 deletions(-) diff --git a/cla-backend-go/cmd/s3_upload/main.go b/cla-backend-go/cmd/s3_upload/main.go index 0ffc99a61..5b5bd247a 100644 --- a/cla-backend-go/cmd/s3_upload/main.go +++ b/cla-backend-go/cmd/s3_upload/main.go @@ -57,7 +57,7 @@ func init() { if err != nil { log.Fatal(err) } - signService = sign.NewService("", "", companyRepo, nil, nil, nil, nil, configFile.DocuSignPrivateKey, nil, nil, nil, nil, githubOrgService, nil, "", "", nil, nil, nil, nil, nil, nil, configFile.SSS.Required) + signService = sign.NewService("", "", companyRepo, nil, nil, nil, nil, configFile.DocuSignPrivateKey, nil, nil, nil, nil, githubOrgService, nil, "", "", nil, nil, nil, nil, nil, nil, false) // projectRepo = repository.NewRepository(awsSession, stage, nil, nil, nil) utils.SetS3Storage(awsSession, configFile.SignatureFilesBucket) } diff --git a/cla-backend-go/cmd/server.go b/cla-backend-go/cmd/server.go index 0d377c0dc..97f8ceb93 100644 --- a/cla-backend-go/cmd/server.go +++ b/cla-backend-go/cmd/server.go @@ -450,30 +450,22 @@ func server(localMode bool) http.Handler { v2ClaGroupService := cla_groups.NewService(v1ProjectService, templateService, v1ProjectClaGroupRepo, v1ClaManagerService, v1SignaturesService, metricsRepo, gerritService, v1RepositoriesService, eventsService) - // Initialize SSS (Sanctions Screening Service) client if configured + // Initialize SSS (Sanctions Screening Service) client if configured. + sssRequired := !localMode var sssClient *sss.Client - if configFile.SSS.BaseURL != "" && configFile.SSS.Auth0Domain != "" && configFile.SSS.Auth0ClientID != "" && configFile.SSS.Auth0ClientSecret != "" && configFile.SSS.Auth0Audience != "" { - sssTimeout := time.Duration(configFile.SSS.RequestTimeoutSec) * time.Second - if sssTimeout <= 0 { - sssTimeout = 30 * time.Second // default timeout - } - sssConfig := sss.SSSConfig{ - BaseURL: configFile.SSS.BaseURL, - Auth0Domain: configFile.SSS.Auth0Domain, - Auth0ClientID: configFile.SSS.Auth0ClientID, - Auth0ClientSecret: configFile.SSS.Auth0ClientSecret, - Auth0Audience: configFile.SSS.Auth0Audience, - Timeout: sssTimeout, - } - var sssErr error - sssClient, sssErr = sss.NewClient(sssConfig) - if sssErr != nil { - log.WithFields(f).WithError(sssErr).Warnf("failed to initialize SSS client, screening will be unavailable: %v", sssErr) - sssClient = nil + sssClient, err = sss.NewClientFromPlatformCredentials(configFile.SSS.BaseURL, configFile.SSS.Audience, configFile.Auth0Platform.URL, configFile.Auth0Platform.ClientID, configFile.Auth0Platform.ClientSecret) + if err != nil { + if sssRequired { + log.WithFields(f).WithError(err).Fatal("failed to initialize required SSS client") } + log.WithFields(f).WithError(err).Warn("failed to initialize optional SSS client, screening will be unavailable") + sssClient = nil + } + if sssRequired && sssClient == nil { + log.WithFields(f).Fatal("SSS is required but not configured") } - v2SignService := sign.NewService(configFile.ClaAPIV4Base, configFile.ClaV1ApiURL, v1CompanyRepo, v1CLAGroupRepo, v1ProjectClaGroupRepo, v1CompanyService, v2ClaGroupService, configFile.DocuSignPrivateKey, usersService, v1SignaturesService, storeRepository, v1RepositoriesService, githubOrganizationsService, gitlabOrganizationsService, configFile.CLALandingPage, configFile.CLALogoURL, emailService, eventsService, gitlabActivityService, gitlabApp, gerritService, sssClient, configFile.SSS.Required) + v2SignService := sign.NewService(configFile.ClaAPIV4Base, configFile.ClaV1ApiURL, v1CompanyRepo, v1CLAGroupRepo, v1ProjectClaGroupRepo, v1CompanyService, v2ClaGroupService, configFile.DocuSignPrivateKey, usersService, v1SignaturesService, storeRepository, v1RepositoriesService, githubOrganizationsService, gitlabOrganizationsService, configFile.CLALandingPage, configFile.CLALogoURL, emailService, eventsService, gitlabActivityService, gitlabApp, gerritService, sssClient, sssRequired) sessionStore, err := dynastore.New(dynastore.Path("/"), dynastore.HTTPOnly(), dynastore.TableName(configFile.SessionStoreTableName), dynastore.DynamoDB(dynamodb.New(awsSession))) if err != nil { diff --git a/cla-backend-go/config/config.go b/cla-backend-go/config/config.go index 90a117b88..e81b70170 100644 --- a/cla-backend-go/config/config.go +++ b/cla-backend-go/config/config.go @@ -101,19 +101,6 @@ type Config struct { // SSS holds the Sanctions Screening Service client configuration SSS SSS `json:"sss"` - // SSS (Sanctions Screening Service) configuration - SSS SSS `json:"sss"` -} - -// SSS model for Sanctions Screening Service configuration -type SSS struct { - BaseURL string `json:"base_url"` - Auth0Domain string `json:"auth0_domain"` - Auth0ClientID string `json:"auth0_client_id"` - Auth0ClientSecret string `json:"auth0_client_secret"` - Auth0Audience string `json:"auth0_audience"` - RequestTimeoutSec int `json:"request_timeout_sec"` - Required bool `json:"required"` } // Auth0 model diff --git a/cla-backend-go/config/local.go b/cla-backend-go/config/local.go index 147bdeaa6..edccc145e 100644 --- a/cla-backend-go/config/local.go +++ b/cla-backend-go/config/local.go @@ -23,7 +23,6 @@ func loadLocalConfig(configFilePath string) (Config, error) { } localConfig := Config{} - localConfig.SSS.Required = true err = json.Unmarshal(content, &localConfig) if err != nil { return Config{}, err diff --git a/cla-backend-go/config/ssm.go b/cla-backend-go/config/ssm.go index 8cdbb4f84..a29107fb0 100644 --- a/cla-backend-go/config/ssm.go +++ b/cla-backend-go/config/ssm.go @@ -46,7 +46,6 @@ func loadSSMConfig(awsSession *session.Session, stage string) Config { //nolint } config := Config{} config.SignatureQueryDefaultValue = "all" - config.SSS.Required = true ssmClient := ssm.New(awsSession) diff --git a/cla-backend-go/v2/sign/service.go b/cla-backend-go/v2/sign/service.go index 5c65a8fa4..e95c37327 100644 --- a/cla-backend-go/v2/sign/service.go +++ b/cla-backend-go/v2/sign/service.go @@ -13,6 +13,7 @@ import ( "math/rand" "net/http" "net/url" + "reflect" "strconv" "strings" "sync" @@ -2966,7 +2967,7 @@ func (s *service) GetUserActiveSignature(ctx context.Context, userID string) (*m } // checkCompanyCompliance queries the Sanctions Screening Service for the given company -// and persists the result. Returns (sanctioned, error). A nil sssClient is a no-op. +// and persists the result. Returns (sanctioned, error). func (s *service) checkCompanyCompliance(ctx context.Context, company *v1Models.Company) (bool, error) { f := logrus.Fields{ "functionName": "sign.checkCompanyCompliance", @@ -2988,8 +2989,12 @@ func (s *service) checkCompanyCompliance(ctx context.Context, company *v1Models. } if s.sssClient == nil { - log.WithFields(f).Debug("SSS client not configured, skipping live compliance check") - s.setComplianceCache(cacheKey, false, nil) + if s.sssRequired { + resultErr := fmt.Errorf("checkCompanyCompliance: SSS is required but the client is not configured") + log.WithFields(f).WithError(resultErr).Error("SSS client not configured") + return false, resultErr + } + log.WithFields(f).Debug("SSS client not configured, skipping optional live compliance check") return false, nil } @@ -2999,10 +3004,8 @@ func (s *service) checkCompanyCompliance(ctx context.Context, company *v1Models. resultErr := fmt.Errorf("checkCompanyCompliance: organization service client is not configured") if !s.sssRequired { log.WithFields(f).WithError(resultErr).Warn("SSS is not required; continuing without live compliance result") - s.setComplianceCache(cacheKey, false, nil) return false, nil } - s.setComplianceCache(cacheKey, false, resultErr) return false, resultErr } org, err := orgClient.GetOrganization(ctx, company.CompanyExternalID) @@ -3011,10 +3014,8 @@ func (s *service) checkCompanyCompliance(ctx context.Context, company *v1Models. resultErr := fmt.Errorf("checkCompanyCompliance: failed to get organization %s: %w", company.CompanyExternalID, err) if !s.sssRequired { log.WithFields(f).WithError(resultErr).Warn("SSS is not required; continuing without live compliance result") - s.setComplianceCache(cacheKey, false, nil) return false, nil } - s.setComplianceCache(cacheKey, false, resultErr) return false, resultErr } if org == nil { @@ -3022,18 +3023,20 @@ func (s *service) checkCompanyCompliance(ctx context.Context, company *v1Models. resultErr := fmt.Errorf("checkCompanyCompliance: organization record is nil for %s", company.CompanyExternalID) if !s.sssRequired { log.WithFields(f).WithError(resultErr).Warn("SSS is not required; continuing without live compliance result") - s.setComplianceCache(cacheKey, false, nil) return false, nil } - s.setComplianceCache(cacheKey, false, resultErr) return false, resultErr } - // Resolve domain: prefer Domains field, fallback to Link field + // Resolve domain: prefer Domains field, fallback to Link field. domain := s.resolveDomain(f, org) if domain == "" { - log.WithFields(f).Warnf("unable to resolve domain for organization %s; skipping SSS check", company.CompanyExternalID) - s.setComplianceCache(cacheKey, false, nil) + resultErr := fmt.Errorf("checkCompanyCompliance: unable to resolve domain for organization %s", company.CompanyExternalID) + if s.sssRequired { + log.WithFields(f).WithError(resultErr).Error("unable to resolve domain for required SSS check") + return false, resultErr + } + log.WithFields(f).WithError(resultErr).Warn("SSS is not required; continuing without live compliance result") return false, nil } @@ -3049,9 +3052,7 @@ func (s *service) checkCompanyCompliance(ctx context.Context, company *v1Models. result, err := s.sssClient.GetOrganizationStatus(ctx, req) if err != nil { - blocked, handledErr := s.handleSSSError(f, company.CompanyID, err) - s.setComplianceCache(cacheKey, blocked, handledErr) - return blocked, handledErr + return s.handleSSSError(f, company.CompanyID, err) } sanctioned := result.Status == sss.StatusFlagged @@ -3062,7 +3063,6 @@ func (s *service) checkCompanyCompliance(ctx context.Context, company *v1Models. if persistErr := s.companyRepo.UpdateCompanySanctionStatus(ctx, company.CompanyID, true); persistErr != nil { log.WithFields(f).WithError(persistErr).Warnf("failed to persist sanction status for company %s", company.CompanyID) resultErr := fmt.Errorf("failed to persist sanction status for company %s: %w", company.CompanyID, persistErr) - s.setComplianceCache(cacheKey, false, resultErr) return false, resultErr } } else { @@ -3104,7 +3104,7 @@ func (s *service) getComplianceCache(key string) (complianceCacheEntry, bool) { } func (s *service) setComplianceCache(key string, sanctioned bool, err error) { - if key == "" { + if key == "" || err != nil { return } s.complianceCacheMu.Lock() @@ -3120,28 +3120,70 @@ func (s *service) setComplianceCache(key string, sanctioned bool, err error) { } // resolveDomain attempts to resolve the domain for an organization. -// Priority: 1) Domains field from org (if available), 2) Parse Link field +// Priority: 1) Domains field from org (if available), 2) Parse Link field. func (s *service) resolveDomain(f logrus.Fields, org interface{}) string { - if domainStruct, ok := org.(interface{ GetDomains() []string }); ok { - domains := domainStruct.GetDomains() - if len(domains) > 0 && strings.TrimSpace(domains[0]) != "" { - domain := strings.TrimSpace(domains[0]) - domain = strings.TrimPrefix(domain, "www.") + if domains := s.stringField(org, "Domains"); domains != "" { + if domain := s.firstDomain(domains); domain != "" { log.WithFields(f).Debugf("resolved domain from Domains field: %s", domain) return domain } } - if linkStruct, ok := org.(interface{ GetLink() string }); ok { - link := strings.TrimSpace(linkStruct.GetLink()) - if link != "" { - domain := s.parseDomain(link) - if domain != "" { - return domain + link := s.stringField(org, "Link") + if link != "" { + domain := s.parseDomain(link) + if domain != "" { + return domain + } + } + + return "" +} + +func (s *service) stringField(v interface{}, fieldName string) string { + if v == nil { + return "" + } + rv := reflect.ValueOf(v) + if rv.Kind() == reflect.Ptr { + if rv.IsNil() { + return "" + } + rv = rv.Elem() + } + if rv.Kind() != reflect.Struct { + return "" + } + field := rv.FieldByName(fieldName) + if !field.IsValid() { + return "" + } + switch field.Kind() { + case reflect.String: + return strings.TrimSpace(field.String()) + case reflect.Slice: + if field.Type().Elem().Kind() != reflect.String { + return "" + } + for i := 0; i < field.Len(); i++ { + if value := strings.TrimSpace(field.Index(i).String()); value != "" { + return value } } } + return "" +} +func (s *service) firstDomain(domains string) string { + parts := strings.FieldsFunc(domains, func(r rune) bool { + return r == ',' || r == ';' || r == '\n' || r == '\t' || r == ' ' + }) + for _, part := range parts { + domain := strings.TrimPrefix(strings.TrimSpace(part), "www.") + if domain != "" { + return domain + } + } return "" } @@ -3209,7 +3251,7 @@ func (s *service) handleSSSError(f logrus.Fields, companyID string, err error) ( case errors.As(err, &badReqErr): log.WithFields(f).WithError(err).Warnf("SSS bad request for company %s", companyID) - return false, fmt.Errorf("SSS bad request for company %s: %w", companyID, err) + return allowWhenOptional("SSS bad request") default: log.WithFields(f).WithError(err).Warnf("SSS request failed with unexpected error for company %s", companyID) diff --git a/cla-backend-go/v2/sign/service_sss_test.go b/cla-backend-go/v2/sign/service_sss_test.go index 18a3e5e4f..bea8e1e12 100644 --- a/cla-backend-go/v2/sign/service_sss_test.go +++ b/cla-backend-go/v2/sign/service_sss_test.go @@ -4,6 +4,7 @@ package sign import ( + "context" "errors" "testing" "time" @@ -14,24 +15,16 @@ import ( ) type testOrg struct { - domains []string - link string -} - -func (o testOrg) GetDomains() []string { - return o.domains -} - -func (o testOrg) GetLink() string { - return o.link + Domains string + Link string } func TestResolveDomainPrefersDomains(t *testing.T) { svc := &service{} got := svc.resolveDomain(logrus.Fields{}, testOrg{ - domains: []string{"www.example.com"}, - link: "https://fallback.example.org/path", + Domains: "www.example.com, fallback.example.org", + Link: "https://fallback.example.org/path", }) if got != "example.com" { @@ -43,7 +36,7 @@ func TestResolveDomainFallsBackToParsedLink(t *testing.T) { svc := &service{} got := svc.resolveDomain(logrus.Fields{}, testOrg{ - link: "www.example.org/path?query=1", + Link: "www.example.org/path?query=1", }) if got != "example.org" { @@ -51,6 +44,33 @@ func TestResolveDomainFallsBackToParsedLink(t *testing.T) { } } +func TestCheckCompanyComplianceRequiredBlocksMissingClient(t *testing.T) { + svc := &service{sssRequired: true} + + _, err := svc.checkCompanyCompliance(context.Background(), &models.Company{ + CompanyID: "company-id", + CompanyName: "Company", + }) + if err == nil { + t.Fatal("expected required SSS missing client to block") + } +} + +func TestCheckCompanyComplianceOptionalAllowsMissingClient(t *testing.T) { + svc := &service{sssRequired: false} + + blocked, err := svc.checkCompanyCompliance(context.Background(), &models.Company{ + CompanyID: "company-id", + CompanyName: "Company", + }) + if err != nil { + t.Fatalf("expected optional SSS missing client to continue, got %v", err) + } + if blocked { + t.Fatal("expected optional SSS missing client not to block") + } +} + func TestHandleSSSErrorRequiredBlocksAvailabilityErrors(t *testing.T) { svc := &service{sssRequired: true} @@ -72,6 +92,18 @@ func TestHandleSSSErrorOptionalAllowsAuthErrors(t *testing.T) { } } +func TestHandleSSSErrorOptionalAllowsBadRequest(t *testing.T) { + svc := &service{sssRequired: false} + + blocked, err := svc.handleSSSError(logrus.Fields{}, "company-id", &sss.BadRequestError{Message: "bad request"}) + if err != nil { + t.Fatalf("expected optional SSS bad request to continue, got %v", err) + } + if blocked { + t.Fatal("expected optional SSS bad request not to block") + } +} + func TestComplianceCacheKeyPrefersExternalID(t *testing.T) { svc := &service{} @@ -100,3 +132,13 @@ func TestComplianceCacheExpires(t *testing.T) { t.Fatal("expected expired cache entry to be ignored") } } + +func TestComplianceCacheSkipsErrors(t *testing.T) { + svc := &service{} + + svc.setComplianceCache("company-id", false, errors.New("transient")) + + if _, ok := svc.getComplianceCache("company-id"); ok { + t.Fatal("expected error cache entry not to be stored") + } +} From c173a49873233b0764bf6b78ae6aae343c78e194 Mon Sep 17 00:00:00 2001 From: psrsingh Date: Tue, 2 Jun 2026 17:17:49 +0530 Subject: [PATCH 06/11] fix(sign): address SSS review feedback Signed-off-by: psrsingh --- cla-backend-go/cmd/server.go | 3 ++- cla-backend-go/config/config.go | 5 +++++ cla-backend-go/config/ssm.go | 28 ++++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/cla-backend-go/cmd/server.go b/cla-backend-go/cmd/server.go index 97f8ceb93..ec9368abc 100644 --- a/cla-backend-go/cmd/server.go +++ b/cla-backend-go/cmd/server.go @@ -451,7 +451,8 @@ func server(localMode bool) http.Handler { v2ClaGroupService := cla_groups.NewService(v1ProjectService, templateService, v1ProjectClaGroupRepo, v1ClaManagerService, v1SignaturesService, metricsRepo, gerritService, v1RepositoriesService, eventsService) // Initialize SSS (Sanctions Screening Service) client if configured. - sssRequired := !localMode + // The sssRequired flag is controlled by the cla-sss-required-{stage} SSM parameter. + sssRequired := configFile.SSS.Required var sssClient *sss.Client sssClient, err = sss.NewClientFromPlatformCredentials(configFile.SSS.BaseURL, configFile.SSS.Audience, configFile.Auth0Platform.URL, configFile.Auth0Platform.ClientID, configFile.Auth0Platform.ClientSecret) if err != nil { diff --git a/cla-backend-go/config/config.go b/cla-backend-go/config/config.go index e81b70170..1874000dc 100644 --- a/cla-backend-go/config/config.go +++ b/cla-backend-go/config/config.go @@ -138,6 +138,11 @@ type SSS struct { // identifier exactly (e.g. // https://sanctions-screening.dev.v2.cluster.linuxfound.info/) Audience string `json:"audience"` + // Required is a flag controlling whether SSS screening is required or optional. + // When true, any SSS errors (unavailable, timeout, config errors, or missing domain) + // will block the operation. When false, SSS errors are logged but do not block. + // This flag is loaded from the SSM parameter cla-sss-required-{stage}. + Required bool `json:"required"` } // Docraptor model diff --git a/cla-backend-go/config/ssm.go b/cla-backend-go/config/ssm.go index a29107fb0..66e5b953c 100644 --- a/cla-backend-go/config/ssm.go +++ b/cla-backend-go/config/ssm.go @@ -286,6 +286,7 @@ func loadSSMConfig(awsSession *session.Session, stage string) Config { //nolint func loadOptionalSSSConfig(ssmClient *ssm.SSM, stage string, config *Config, f logrus.Fields) { config.SSS.BaseURL = getOptionalSSMString(ssmClient, fmt.Sprintf("cla-sss-base-url-%s", stage), f) config.SSS.Audience = getOptionalSSMString(ssmClient, fmt.Sprintf("cla-sss-auth0-audience-%s", stage), f) + config.SSS.Required = getOptionalSSMBool(ssmClient, fmt.Sprintf("cla-sss-required-%s", stage), f) } // getOptionalSSMString fetches a parameter that may legitimately be absent while @@ -309,3 +310,30 @@ func getOptionalSSMString(ssmClient *ssm.SSM, key string, f logrus.Fields) strin return strings.TrimSpace(*out.Parameter.Value) } + +// getOptionalSSMBool fetches an optional boolean parameter. It logs exactly once: +// a missing parameter is reported at debug (an expected, benign state), while any +// other failure - IAM, throttling, parse errors, etc. - is reported as a warning. +// Returns false (the default) when the value is unreadable or the parameter is absent. +func getOptionalSSMBool(ssmClient *ssm.SSM, key string, f logrus.Fields) bool { + out, err := ssmClient.GetParameter(&ssm.GetParameterInput{ + Name: aws.String(key), + WithDecryption: aws.Bool(false), + }) + if err != nil { + if aerr, ok := err.(awserr.Error); ok && aerr.Code() == ssm.ErrCodeParameterNotFound { + log.WithFields(f).Debugf("optional SSM key %s not provisioned - using default value false", key) + } else { + log.WithFields(f).WithError(err).Warnf("unable to read optional SSM key %s - using default value false", key) + } + return false + } + + boolVal, err := strconv.ParseBool(strings.TrimSpace(*out.Parameter.Value)) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to parse optional SSM key %s as boolean - using default value false", key) + return false + } + + return boolVal +} From 3918235231194ffc10d8abbbe58d36dd506ae88e Mon Sep 17 00:00:00 2001 From: psrsingh Date: Tue, 2 Jun 2026 20:54:37 +0530 Subject: [PATCH 07/11] feat(sign): add shared SSS client and ECLA screening Signed-off-by: psrsingh --- cla-backend-go/company/mocks/mock_repo.go | 28 ++ cla-backend-go/company/models.go | 1 + cla-backend-go/company/repository.go | 96 +++-- cla-backend-go/go.mod | 1 + cla-backend-go/sss/auth.go | 18 +- cla-backend-go/sss/client.go | 343 ++---------------- cla-backend-go/sss/errors.go | 74 +--- cla-backend-go/sss/from_config.go | 55 +-- cla-backend-go/sss/types.go | 56 +-- cla-backend-go/v2/sign/service.go | 136 +++---- cla-backend-go/v2/sign/service_sss_test.go | 21 +- cla-backend-legacy/go.mod | 3 + cla-backend-legacy/internal/api/handlers.go | 278 +++++++++++++- .../internal/legacy/salesforce/service.go | 50 +++ .../internal/store/companies.go | 75 ++++ cla-backend-legacy/internal/store/dynamo.go | 27 +- cla-sss-base/LICENSE | 18 + cla-sss-base/auth.go | 20 + cla-sss-base/client.go | 329 +++++++++++++++++ cla-sss-base/errors.go | 72 ++++ cla-sss-base/from_config.go | 57 +++ cla-sss-base/go.mod | 3 + cla-sss-base/types.go | 55 +++ 23 files changed, 1169 insertions(+), 647 deletions(-) create mode 100644 cla-sss-base/LICENSE create mode 100644 cla-sss-base/auth.go create mode 100644 cla-sss-base/client.go create mode 100644 cla-sss-base/errors.go create mode 100644 cla-sss-base/from_config.go create mode 100644 cla-sss-base/go.mod create mode 100644 cla-sss-base/types.go diff --git a/cla-backend-go/company/mocks/mock_repo.go b/cla-backend-go/company/mocks/mock_repo.go index 5dcf92365..7e1665cd3 100644 --- a/cla-backend-go/company/mocks/mock_repo.go +++ b/cla-backend-go/company/mocks/mock_repo.go @@ -349,3 +349,31 @@ func (mr *MockIRepositoryMockRecorder) UpdateCompanyAccessList(ctx, companyID, c mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateCompanyAccessList", reflect.TypeOf((*MockIRepository)(nil).UpdateCompanyAccessList), ctx, companyID, companyACL) } + +// UpdateCompanySanctionStatus mocks base method. +func (m *MockIRepository) UpdateCompanySanctionStatus(ctx context.Context, companyID string, sanctioned bool, origin string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateCompanySanctionStatus", ctx, companyID, sanctioned, origin) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateCompanySanctionStatus indicates an expected call of UpdateCompanySanctionStatus. +func (mr *MockIRepositoryMockRecorder) UpdateCompanySanctionStatus(ctx, companyID, sanctioned, origin interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateCompanySanctionStatus", reflect.TypeOf((*MockIRepository)(nil).UpdateCompanySanctionStatus), ctx, companyID, sanctioned, origin) +} + +// ClearCompanySanctionStatusIfSSS mocks base method. +func (m *MockIRepository) ClearCompanySanctionStatusIfSSS(ctx context.Context, companyID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ClearCompanySanctionStatusIfSSS", ctx, companyID) + ret0, _ := ret[0].(error) + return ret0 +} + +// ClearCompanySanctionStatusIfSSS indicates an expected call of ClearCompanySanctionStatusIfSSS. +func (mr *MockIRepositoryMockRecorder) ClearCompanySanctionStatusIfSSS(ctx, companyID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClearCompanySanctionStatusIfSSS", reflect.TypeOf((*MockIRepository)(nil).ClearCompanySanctionStatusIfSSS), ctx, companyID) +} diff --git a/cla-backend-go/company/models.go b/cla-backend-go/company/models.go index d639293fc..3895d55af 100644 --- a/cla-backend-go/company/models.go +++ b/cla-backend-go/company/models.go @@ -25,6 +25,7 @@ type DBModel struct { Updated string `dynamodbav:"date_modified" json:"date_modified"` Note string `dynamodbav:"note" json:"note"` IsSanctioned bool `dynamodbav:"is_sanctioned" json:"is_sanctioned"` + SanctionOrigin string `dynamodbav:"sanction_origin" json:"sanction_origin,omitempty"` Version string `dynamodbav:"version" json:"version"` } diff --git a/cla-backend-go/company/repository.go b/cla-backend-go/company/repository.go index 531f76c29..fadb95dab 100644 --- a/cla-backend-go/company/repository.go +++ b/cla-backend-go/company/repository.go @@ -21,6 +21,7 @@ import ( log "github.com/linuxfoundation/easycla/cla-backend-go/logging" "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/dynamodb" "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute" @@ -53,7 +54,8 @@ type IRepository interface { //nolint ApproveCompanyAccessRequest(ctx context.Context, companyInviteID string) error RejectCompanyAccessRequest(ctx context.Context, companyInviteID string) error UpdateCompanyAccessList(ctx context.Context, companyID string, companyACL []string) error - UpdateCompanySanctionStatus(ctx context.Context, companyID string, sanctioned bool) error + UpdateCompanySanctionStatus(ctx context.Context, companyID string, sanctioned bool, origin string) error + ClearCompanySanctionStatusIfSSS(ctx context.Context, companyID string) error IsCCLAEnabledForCompany(ctx context.Context, companyID string) (bool, error) } @@ -1277,70 +1279,90 @@ func (repo repository) UpdateCompanyAccessList(ctx context.Context, companyID st return nil } -// UpdateCompanySanctionStatus updates the is_sanctioned flag for a company. -// It only performs the update if the value has changed to avoid unnecessary DynamoDB writes. -func (repo repository) UpdateCompanySanctionStatus(ctx context.Context, companyID string, sanctioned bool) error { +// UpdateCompanySanctionStatus sets is_sanctioned and, when origin is non-empty, sanction_origin. +// Pass origin="sss" when flagging via SSS; pass origin="" for manual admin updates. +func (repo repository) UpdateCompanySanctionStatus(ctx context.Context, companyID string, sanctioned bool, origin string) error { f := logrus.Fields{ "functionName": "company.repository.UpdateCompanySanctionStatus", utils.XREQUESTID: ctx.Value(utils.XREQUESTID), "companyID": companyID, "sanctioned": sanctioned, + "origin": origin, } - // SSS may only set a sanction block, never clear one (clearing could override a manual block). - if !sanctioned { - log.WithFields(f).Debugf("ignoring request to clear sanction flag via SSS to protect manual/other-source blocks") - return nil + _, now := utils.CurrentTime() + + names := map[string]*string{ + "#S": aws.String("is_sanctioned"), + "#M": aws.String("date_modified"), + } + values := map[string]*dynamodb.AttributeValue{ + ":s": {BOOL: aws.Bool(sanctioned)}, + ":m": {S: aws.String(now)}, } + updateExpr := "SET #S = :s, #M = :m" - // Fetch current company to check if value has changed - currentCompany, err := repo.GetCompany(ctx, companyID) - if err != nil { - log.WithFields(f).Warnf("unable to fetch current company record to check sanction status, error: %v", err) - return err + if origin != "" { + names["#O"] = aws.String("sanction_origin") + values[":o"] = &dynamodb.AttributeValue{S: aws.String(origin)} + updateExpr += ", #O = :o" } - if currentCompany == nil { - return fmt.Errorf("company not found: %s", companyID) + + input := &dynamodb.UpdateItemInput{ + ExpressionAttributeNames: names, + ExpressionAttributeValues: values, + TableName: aws.String(repo.companyTableName), + Key: map[string]*dynamodb.AttributeValue{ + "company_id": {S: aws.String(companyID)}, + }, + UpdateExpression: aws.String(updateExpr), } - // Avoid unnecessary writes - only update if value has changed - if currentCompany.IsSanctioned == sanctioned { - log.WithFields(f).Debugf("sanction status unchanged (current=%v, new=%v), skipping update", currentCompany.IsSanctioned, sanctioned) - return nil + if _, err := repo.dynamoDBClient.UpdateItem(input); err != nil { + log.WithFields(f).Warnf("error updating company sanction status, error: %v", err) + return err } + return nil +} - log.WithFields(f).Debugf("updating sanction status from %v to %v", currentCompany.IsSanctioned, sanctioned) +// ClearCompanySanctionStatusIfSSS clears is_sanctioned only when sanction_origin="sss". +// A ConditionalCheckFailedException (manual/absent origin) is silently ignored. +func (repo repository) ClearCompanySanctionStatusIfSSS(ctx context.Context, companyID string) error { + f := logrus.Fields{ + "functionName": "company.repository.ClearCompanySanctionStatusIfSSS", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "companyID": companyID, + } _, now := utils.CurrentTime() input := &dynamodb.UpdateItemInput{ + TableName: aws.String(repo.companyTableName), + Key: map[string]*dynamodb.AttributeValue{ + "company_id": {S: aws.String(companyID)}, + }, + UpdateExpression: aws.String("SET #S = :false, #M = :m REMOVE #O"), + ConditionExpression: aws.String("#O = :sss"), ExpressionAttributeNames: map[string]*string{ "#S": aws.String("is_sanctioned"), "#M": aws.String("date_modified"), + "#O": aws.String("sanction_origin"), }, ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ - ":s": { - BOOL: aws.Bool(sanctioned), - }, - ":m": { - S: aws.String(now), - }, + ":false": {BOOL: aws.Bool(false)}, + ":m": {S: aws.String(now)}, + ":sss": {S: aws.String("sss")}, }, - TableName: aws.String(repo.companyTableName), - Key: map[string]*dynamodb.AttributeValue{ - "company_id": { - S: aws.String(companyID), - }, - }, - UpdateExpression: aws.String("SET #S = :s, #M = :m"), } - _, err = repo.dynamoDBClient.UpdateItem(input) - if err != nil { - log.WithFields(f).Warnf("error updating company sanction status, error: %v", err) + if _, err := repo.dynamoDBClient.UpdateItem(input); err != nil { + if aerr, ok := err.(awserr.Error); ok && aerr.Code() == dynamodb.ErrCodeConditionalCheckFailedException { + log.WithFields(f).Debugf("sanction_origin != sss for company %s; not auto-clearing (manual/admin block)", companyID) + return nil + } + log.WithFields(f).Warnf("error clearing company sanction status: %v", err) return err } - return nil } diff --git a/cla-backend-go/go.mod b/cla-backend-go/go.mod index 9ecb474f0..bf52f20df 100644 --- a/cla-backend-go/go.mod +++ b/cla-backend-go/go.mod @@ -7,6 +7,7 @@ go 1.25.0 toolchain go1.25.10 replace github.com/awslabs/aws-lambda-go-api-proxy => github.com/LF-Engineering/aws-lambda-go-api-proxy v0.3.2 +replace github.com/linuxfoundation/easycla/cla-sss-base => ../cla-sss-base require ( github.com/LF-Engineering/aws-lambda-go-api-proxy v0.3.2 diff --git a/cla-backend-go/sss/auth.go b/cla-backend-go/sss/auth.go index 2b12d2019..df4c9856b 100644 --- a/cla-backend-go/sss/auth.go +++ b/cla-backend-go/sss/auth.go @@ -1,20 +1,10 @@ // Copyright The Linux Foundation and each contributor to CommunityBridge. // SPDX-License-Identifier: MIT +// Package sss re-exports Auth functions from the shared cla-sss-base module. package sss -// authRequest is the payload used for the Auth0 client credentials request. -type authRequest struct { - GrantType string `json:"grant_type"` - ClientID string `json:"client_id"` - ClientSecret string `json:"client_secret"` - Audience string `json:"audience"` -} +import sssbase "github.com/linuxfoundation/easycla/cla-sss-base" -// authResponse is the Auth0 token response payload. -type authResponse struct { - AccessToken string `json:"access_token"` - ExpiresIn int `json:"expires_in"` - Scope string `json:"scope"` - TokenType string `json:"token_type"` -} +// Re-export factory function for backward compatibility +var NewClientFromPlatformCredentials = sssbase.NewClientFromPlatformCredentials diff --git a/cla-backend-go/sss/client.go b/cla-backend-go/sss/client.go index 04273ed66..e55e6ad57 100644 --- a/cla-backend-go/sss/client.go +++ b/cla-backend-go/sss/client.go @@ -1,329 +1,32 @@ // Copyright The Linux Foundation and each contributor to CommunityBridge. // SPDX-License-Identifier: MIT +// Package sss re-exports the shared SSS client from cla-sss-base for backward compatibility. package sss -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net" - "net/http" - "net/url" - "strconv" - "strings" - "sync" - "time" -) - -const ( - defaultTimeout = 30 * time.Second - defaultTokenTTL = time.Hour - userAgent = "easycla-cla-backend-go/sss-client" -) - -// Client is a reusable HTTP client for the Sanctions Screening Service. -type Client struct { - cfg SSSConfig - httpClient *http.Client - token string - expiry time.Time - tokenMutex sync.RWMutex -} - -// NewClient creates a new SSS client configured for Auth0 client credentials. -func NewClient(cfg SSSConfig) (*Client, error) { - if strings.TrimSpace(cfg.BaseURL) == "" { - return nil, fmt.Errorf("base URL is required") - } - if strings.TrimSpace(cfg.Auth0Domain) == "" { - return nil, fmt.Errorf("Auth0 domain is required") - } - if strings.TrimSpace(cfg.Auth0ClientID) == "" { - return nil, fmt.Errorf("Auth0 client ID is required") - } - if strings.TrimSpace(cfg.Auth0ClientSecret) == "" { - return nil, fmt.Errorf("Auth0 client secret is required") - } - if strings.TrimSpace(cfg.Auth0Audience) == "" { - return nil, fmt.Errorf("Auth0 audience is required") - } - if cfg.Timeout <= 0 { - cfg.Timeout = defaultTimeout - } - - return &Client{ - cfg: cfg, - httpClient: &http.Client{Timeout: cfg.Timeout}, - }, nil -} - -// GetOrganizationStatus retrieves the sanctions screening result for an organization. -func (c *Client) GetOrganizationStatus(ctx context.Context, statusReq OrganizationStatusRequest) (*ScreeningResult, error) { - if strings.TrimSpace(statusReq.Domain) == "" { - return nil, &BadRequestError{Message: "domain is required"} - } - if strings.TrimSpace(statusReq.OrgName) == "" { - return nil, &BadRequestError{Message: "org_name is required"} - } - - token, err := c.getToken(ctx) - if err != nil { - return nil, err - } - - endpoint := strings.TrimRight(c.cfg.BaseURL, "/") + "/api/v1/organizations/status" - reqURL, err := url.Parse(endpoint) - if err != nil { - return nil, fmt.Errorf("invalid base URL: %w", err) - } - - query := reqURL.Query() - query.Set("domain", strings.TrimSpace(statusReq.Domain)) - query.Set("org_name", strings.TrimSpace(statusReq.OrgName)) - if v := strings.TrimSpace(statusReq.Country); v != "" { - query.Set("country", v) - } - if v := strings.TrimSpace(statusReq.City); v != "" { - query.Set("city", v) - } - if v := strings.TrimSpace(statusReq.State); v != "" { - query.Set("state", v) - } - if v := strings.TrimSpace(statusReq.PostalCode); v != "" { - query.Set("postal_code", v) - } - if v := strings.TrimSpace(statusReq.SFDCID); v != "" { - query.Set("sfdc_id", v) - } - if v := strings.TrimSpace(statusReq.ClearbitID); v != "" { - query.Set("clearbit_id", v) - } - reqURL.RawQuery = query.Encode() - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Accept", "application/json") - req.Header.Set("Authorization", "Bearer "+token) - req.Header.Set("User-Agent", userAgent) - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, toClientError(err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - - switch resp.StatusCode { - case http.StatusOK: - var result ScreeningResult - if err := json.NewDecoder(bytes.NewReader(body)).Decode(&result); err != nil { - return nil, fmt.Errorf("failed to decode screening result: %w", err) - } - return &result, nil - case http.StatusBadRequest: - details := responseErrorDetails(body) - return nil, &BadRequestError{Message: details.Message, Code: details.Code, RequestID: details.RequestID} - case http.StatusNotFound: - details := responseErrorDetails(body) - return nil, &NotFoundError{Message: details.Message, Code: details.Code, RequestID: details.RequestID} - case http.StatusUnauthorized, http.StatusForbidden: - c.invalidateToken(token) - details := responseErrorDetails(body) - return nil, &AuthError{Message: details.Message, Code: details.Code, RequestID: details.RequestID} - case http.StatusTooManyRequests, http.StatusServiceUnavailable: - details := responseErrorDetails(body) - return nil, &RetryableError{Message: details.Message, Code: details.Code, RequestID: details.RequestID, RetryAfter: parseRetryAfter(resp.Header.Get("Retry-After"))} - default: - return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, responseMessage(body)) - } -} - -func (c *Client) getToken(ctx context.Context) (string, error) { - c.tokenMutex.RLock() - currentToken := c.token - expiry := c.expiry - c.tokenMutex.RUnlock() - - if currentToken == "" || time.Until(expiry) <= time.Minute { - return c.fetchToken(ctx) - } - - return currentToken, nil -} - -func (c *Client) fetchToken(ctx context.Context) (string, error) { - c.tokenMutex.Lock() - defer c.tokenMutex.Unlock() - - if c.token != "" && time.Until(c.expiry) > time.Minute { - return c.token, nil - } +import sssbase "github.com/linuxfoundation/easycla/cla-sss-base" - requestPayload := authRequest{ - GrantType: "client_credentials", - ClientID: c.cfg.Auth0ClientID, - ClientSecret: c.cfg.Auth0ClientSecret, - Audience: c.cfg.Auth0Audience, - } - payload, err := json.Marshal(requestPayload) - if err != nil { - return "", fmt.Errorf("failed to marshal auth request: %w", err) - } +// Re-export types and functions for backward compatibility +type Client = sssbase.Client +type SSSConfig = sssbase.SSSConfig +type OrganizationStatusRequest = sssbase.OrganizationStatusRequest +type ScreeningResult = sssbase.ScreeningResult +type BadRequestError = sssbase.BadRequestError +type AuthError = sssbase.AuthError +type RetryableError = sssbase.RetryableError +type NotFoundError = sssbase.NotFoundError +type TimeoutError = sssbase.TimeoutError - authURL := c.authTokenURL() - req, err := http.NewRequestWithContext(ctx, http.MethodPost, authURL, bytes.NewReader(payload)) - if err != nil { - return "", fmt.Errorf("failed to create auth request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", userAgent) - - resp, err := c.httpClient.Do(req) - if err != nil { - return "", toClientError(err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("failed to read auth response body: %w", err) - } - - if resp.StatusCode != http.StatusOK { - var auth0Err struct { - Error string `json:"error"` - ErrorDescription string `json:"error_description"` - } - details := upstreamErrorDetails{} - if err := json.Unmarshal(body, &auth0Err); err == nil { - details.Code = strings.TrimSpace(auth0Err.Error) - details.Message = strings.TrimSpace(auth0Err.ErrorDescription) - if details.Message == "" && details.Code != "" { - details.Message = details.Code - } - if details.Message == "" { - details.Message = resp.Status - } - } else { - details.Message = strings.TrimSpace(string(body)) - if details.Message == "" { - details.Message = resp.Status - } - } - return "", &AuthError{Message: fmt.Sprintf("authentication failed: %s", details.Message), Code: details.Code, RequestID: details.RequestID} - } - - var tokenResp authResponse - if err := json.Unmarshal(body, &tokenResp); err != nil { - return "", fmt.Errorf("failed to decode auth response: %w", err) - } - - if tokenResp.AccessToken == "" { - return "", &AuthError{Message: "empty access token from auth server"} - } - - expiresIn := time.Duration(tokenResp.ExpiresIn) * time.Second - if expiresIn <= 0 { - expiresIn = defaultTokenTTL - } - c.token = tokenResp.AccessToken - c.expiry = time.Now().Add(expiresIn) - - return c.token, nil -} - -func (c *Client) invalidateToken(token string) { - c.tokenMutex.Lock() - defer c.tokenMutex.Unlock() - - if c.token == token { - c.token = "" - c.expiry = time.Time{} - } -} - -func (c *Client) authTokenURL() string { - domain := strings.TrimSpace(c.cfg.Auth0Domain) - if strings.HasPrefix(domain, "http://") || strings.HasPrefix(domain, "https://") { - return strings.TrimRight(domain, "/") + "/oauth/token" - } - return "https://" + strings.TrimRight(domain, "/") + "/oauth/token" -} - -func parseRetryAfter(value string) time.Duration { - if strings.TrimSpace(value) == "" { - return 0 - } - - if seconds, err := strconv.Atoi(strings.TrimSpace(value)); err == nil { - if seconds < 0 { - return 0 - } - return time.Duration(seconds) * time.Second - } - - if parsedTime, err := http.ParseTime(value); err == nil { - d := time.Until(parsedTime) - if d < 0 { - return 0 - } - return d - } - return 0 -} - -type upstreamErrorDetails struct { - Message string - Code string - RequestID string -} - -func responseErrorDetails(body []byte) upstreamErrorDetails { - var errPayload struct { - Error struct { - Code string `json:"code"` - Message string `json:"message"` - } `json:"error"` - RequestID string `json:"request_id"` - } - if err := json.Unmarshal(body, &errPayload); err == nil { - details := upstreamErrorDetails{ - Message: strings.TrimSpace(errPayload.Error.Message), - Code: strings.TrimSpace(errPayload.Error.Code), - RequestID: strings.TrimSpace(errPayload.RequestID), - } - if details.Message != "" || details.Code != "" || details.RequestID != "" { - return details - } - } - - return upstreamErrorDetails{Message: strings.TrimSpace(string(body))} -} - -func responseMessage(body []byte) string { - return responseErrorDetails(body).Message -} - -func toClientError(err error) error { - if errors.Is(err, context.DeadlineExceeded) { - return &TimeoutError{Message: err.Error()} - } +const ( + StatusClean = sssbase.StatusClean + StatusFlagged = sssbase.StatusFlagged - var netErr net.Error - if errors.As(err, &netErr) && netErr.Timeout() { - return &TimeoutError{Message: err.Error()} - } + SourceScreeningDB = sssbase.SourceScreeningDB + SourceSFDC = sssbase.SourceSFDC + SourceDescartesAPI = sssbase.SourceDescartesAPI +) - return err -} +var ( + NewClient = sssbase.NewClient + NewClientFromPlatformCredentials = sssbase.NewClientFromPlatformCredentials +) diff --git a/cla-backend-go/sss/errors.go b/cla-backend-go/sss/errors.go index bece4b45d..9882558f5 100644 --- a/cla-backend-go/sss/errors.go +++ b/cla-backend-go/sss/errors.go @@ -1,72 +1,14 @@ // Copyright The Linux Foundation and each contributor to CommunityBridge. // SPDX-License-Identifier: MIT +// Package sss re-exports error types from the shared cla-sss-base module. package sss -import ( - "fmt" - "time" -) +import sssbase "github.com/linuxfoundation/easycla/cla-sss-base" -// BadRequestError indicates a 400 response from the SSS API. -type BadRequestError struct { - Message string - Code string - RequestID string -} - -func (e *BadRequestError) Error() string { - return formatError("bad request", e.Message, e.Code, e.RequestID) -} - -// AuthError indicates a 401 or 403 response from the SSS API. -type AuthError struct { - Message string - Code string - RequestID string -} - -func (e *AuthError) Error() string { - return formatError("authentication error", e.Message, e.Code, e.RequestID) -} - -// RetryableError indicates a 503 response from the SSS API. -type RetryableError struct { - Message string - Code string - RequestID string - RetryAfter time.Duration -} - -func (e *RetryableError) Error() string { - return formatError("retryable error", e.Message, e.Code, e.RequestID) -} - -// NotFoundError indicates a 404 response from the SSS API. -type NotFoundError struct { - Message string - Code string - RequestID string -} - -func (e *NotFoundError) Error() string { - return formatError("not found", e.Message, e.Code, e.RequestID) -} - -// TimeoutError indicates the request timed out. -type TimeoutError struct { - Message string - Code string - RequestID string -} - -func (e *TimeoutError) Error() string { - return formatError("timeout", e.Message, e.Code, e.RequestID) -} - -func formatError(prefix, message, code, requestID string) string { - if code != "" || requestID != "" { - return fmt.Sprintf("%s: %s (code=%s request_id=%s)", prefix, message, code, requestID) - } - return fmt.Sprintf("%s: %s", prefix, message) -} +// Error type aliases for backward compatibility +type BadRequestError = sssbase.BadRequestError +type AuthError = sssbase.AuthError +type RetryableError = sssbase.RetryableError +type NotFoundError = sssbase.NotFoundError +type TimeoutError = sssbase.TimeoutError diff --git a/cla-backend-go/sss/from_config.go b/cla-backend-go/sss/from_config.go index ec43f10ed..63e51c152 100644 --- a/cla-backend-go/sss/from_config.go +++ b/cla-backend-go/sss/from_config.go @@ -1,57 +1,8 @@ // Copyright The Linux Foundation and each contributor to CommunityBridge. // SPDX-License-Identifier: MIT +// Package sss re-exports from_config from the shared cla-sss-base module. package sss -import ( - "net/url" - "strings" -) - -// NewClientFromPlatformCredentials builds an SSS client that reuses the shared -// LFX platform M2M (Auth0) credentials already configured for EasyCLA. -// -// oauthTokenURL is the full Auth0 token endpoint used for platform auth -// (e.g. https:///oauth/token); its scheme+host is reused as the SSS -// client's Auth0 domain, since SSS authenticates with the same client against -// the same Auth0 tenant - only the requested audience differs. -// -// It returns (nil, nil) when baseURL or audience is empty so callers can treat -// an unconfigured SSS as a disabled, no-op feature rather than an error. -func NewClientFromPlatformCredentials(baseURL, audience, oauthTokenURL, clientID, clientSecret string) (*Client, error) { - baseURL = strings.TrimSpace(baseURL) - audience = strings.TrimSpace(audience) - if baseURL == "" || audience == "" { - return nil, nil - } - - return NewClient(SSSConfig{ - BaseURL: baseURL, - Auth0Domain: auth0DomainFromTokenURL(oauthTokenURL), - Auth0ClientID: strings.TrimSpace(clientID), - Auth0ClientSecret: strings.TrimSpace(clientSecret), - Auth0Audience: audience, - }) -} - -// auth0DomainFromTokenURL reduces a full Auth0 token endpoint to its scheme+host -// (e.g. https://tenant.auth0.com), which is what the SSS client expects as its -// Auth0 domain. It tolerates a missing scheme: url.Parse on a scheme-less value -// puts the whole string in Path and leaves Host empty, so a value like -// "tenant.auth0.com/oauth/token" would otherwise be passed through verbatim and -// produce a doubled "/oauth/token" when the client builds the token URL. -func auth0DomainFromTokenURL(oauthTokenURL string) string { - oauthTokenURL = strings.TrimSpace(oauthTokenURL) - if oauthTokenURL == "" { - return "" - } - - parseTarget := oauthTokenURL - if !strings.Contains(parseTarget, "://") { - parseTarget = "https://" + parseTarget - } - if u, err := url.Parse(parseTarget); err == nil && u.Host != "" { - return u.Scheme + "://" + u.Host - } - return oauthTokenURL -} +// Empty re-export wrapper - the actual implementation is in the shared module +// via NewClientFromPlatformCredentials in auth.go diff --git a/cla-backend-go/sss/types.go b/cla-backend-go/sss/types.go index 351b847de..58e5f6fb7 100644 --- a/cla-backend-go/sss/types.go +++ b/cla-backend-go/sss/types.go @@ -1,55 +1,21 @@ // Copyright The Linux Foundation and each contributor to CommunityBridge. // SPDX-License-Identifier: MIT +// Package sss re-exports types from the shared cla-sss-base module. package sss -import "time" +import sssbase "github.com/linuxfoundation/easycla/cla-sss-base" -// SSSConfig holds configuration values for the SSS client. -type SSSConfig struct { - BaseURL string - Auth0Domain string - Auth0ClientID string - Auth0ClientSecret string - // Auth0Audience is the Auth0 API audience/resource server identifier. - // Production values may require the exact identifier configured in Auth0, - // including a trailing slash when the resource server uses one. - Auth0Audience string - // Timeout is shared by SSS API requests and Auth0 token acquisition requests. - Timeout time.Duration -} - -// OrganizationStatusRequest holds parameters for querying organization screening status. -type OrganizationStatusRequest struct { - Domain string `json:"domain"` - OrgName string `json:"org_name"` - Country string `json:"country,omitempty"` - City string `json:"city,omitempty"` - State string `json:"state,omitempty"` - PostalCode string `json:"postal_code,omitempty"` - SFDCID string `json:"sfdc_id,omitempty"` - ClearbitID string `json:"clearbit_id,omitempty"` -} +// Type aliases for backward compatibility +type SSSConfig = sssbase.SSSConfig +type OrganizationStatusRequest = sssbase.OrganizationStatusRequest +type ScreeningResult = sssbase.ScreeningResult const ( - StatusClean = "clean" - StatusFlagged = "flagged" + StatusClean = sssbase.StatusClean + StatusFlagged = sssbase.StatusFlagged - SourceScreeningDB = "screening_db" - SourceSFDC = "sfdc" - SourceDescartesAPI = "descartes_api" + SourceScreeningDB = sssbase.SourceScreeningDB + SourceSFDC = sssbase.SourceSFDC + SourceDescartesAPI = sssbase.SourceDescartesAPI ) - -// ScreeningResult is returned by the SSS organization status endpoint. -type ScreeningResult struct { - Status string `json:"status"` - EntityID string `json:"entity_id"` - Source string `json:"source"` - ScreenedAt time.Time `json:"screened_at"` - ClearbitID string `json:"clearbit_id"` - SFDCID *string `json:"sfdc_id"` - OrgName string `json:"org_name"` - Domain string `json:"domain"` - Vendor string `json:"vendor"` - ClearbitEnriched bool `json:"clearbit_enriched"` -} diff --git a/cla-backend-go/v2/sign/service.go b/cla-backend-go/v2/sign/service.go index e95c37327..2b416d7c2 100644 --- a/cla-backend-go/v2/sign/service.go +++ b/cla-backend-go/v2/sign/service.go @@ -13,7 +13,6 @@ import ( "math/rand" "net/http" "net/url" - "reflect" "strconv" "strings" "sync" @@ -44,6 +43,7 @@ import ( sigs "github.com/linuxfoundation/easycla/cla-backend-go/gen/v1/restapi/operations/signatures" organizationService "github.com/linuxfoundation/easycla/cla-backend-go/v2/organization-service" + orgModels "github.com/linuxfoundation/easycla/cla-backend-go/v2/organization-service/models" projectService "github.com/linuxfoundation/easycla/cla-backend-go/v2/project-service" userService "github.com/linuxfoundation/easycla/cla-backend-go/v2/user-service" @@ -62,7 +62,8 @@ const ( DontLoadRepoDetails = false DocSignFalse = "false" DocusignCompleted = "Completed" - complianceCacheTTL = 5 * time.Minute + complianceCacheTTL = 5 * time.Minute + maxComplianceCacheSize = 1000 ) // errors @@ -129,7 +130,6 @@ type service struct { type complianceCacheEntry struct { sanctioned bool - err error expiresAt time.Time } @@ -2976,16 +2976,17 @@ func (s *service) checkCompanyCompliance(ctx context.Context, company *v1Models. "companyName": company.CompanyName, } - // Check if company is already manually sanctioned - if so, always block - if company.IsSanctioned { - log.WithFields(f).Warnf("company is manually sanctioned, blocking") + // Short-circuit for manually/admin-set blocks (sanction_origin != "sss" or no origin). + // SSS-origin blocks fall through so a now-clean result can clear them. + if company.IsSanctioned && company.SanctionOrigin != "sss" { + log.WithFields(f).Warnf("company has non-SSS sanction block (origin=%q), blocking without SSS call", company.SanctionOrigin) return true, nil } cacheKey := s.complianceCacheKey(company) if cached, ok := s.getComplianceCache(cacheKey); ok { log.WithFields(f).Debugf("using cached compliance result for organization/company: %s", cacheKey) - return cached.sanctioned, cached.err + return cached.sanctioned, nil } if s.sssClient == nil { @@ -3057,22 +3058,28 @@ func (s *service) checkCompanyCompliance(ctx context.Context, company *v1Models. sanctioned := result.Status == sss.StatusFlagged - // Only persist if flagged (never clear a manual sanction via SSS clean result) + // In required mode, only an explicit "clean" is acceptable — any other status blocks. + if s.sssRequired && result.Status != sss.StatusClean && result.Status != sss.StatusFlagged { + return false, fmt.Errorf("checkCompanyCompliance: unexpected SSS status %q for company %s (required mode blocks on ambiguous results)", result.Status, company.CompanyID) + } + + // Persist result: set origin="sss" on flagged; conditionally clear on clean (only if sss-origin). if sanctioned { - log.WithFields(f).Warnf("SSS returned flagged status for company %s, persisting sanction", company.CompanyID) - if persistErr := s.companyRepo.UpdateCompanySanctionStatus(ctx, company.CompanyID, true); persistErr != nil { + log.WithFields(f).Warnf("SSS returned flagged status for company %s, persisting sanction with origin=sss", company.CompanyID) + if persistErr := s.companyRepo.UpdateCompanySanctionStatus(ctx, company.CompanyID, true, "sss"); persistErr != nil { log.WithFields(f).WithError(persistErr).Warnf("failed to persist sanction status for company %s", company.CompanyID) - resultErr := fmt.Errorf("failed to persist sanction status for company %s: %w", company.CompanyID, persistErr) - return false, resultErr + return false, fmt.Errorf("failed to persist sanction status for company %s: %w", company.CompanyID, persistErr) } } else { - log.WithFields(f).Debugf("SSS returned clean status for company %s", company.CompanyID) + // Clear only when previously set by SSS; manual blocks are left untouched. + log.WithFields(f).Debugf("SSS returned clean status for company %s; attempting conditional clear", company.CompanyID) + if clearErr := s.companyRepo.ClearCompanySanctionStatusIfSSS(ctx, company.CompanyID); clearErr != nil { + log.WithFields(f).WithError(clearErr).Warnf("failed to conditionally clear sanction status for company %s", company.CompanyID) + } } - // Return combined result: blocked if manually sanctioned OR sss flagged - blocked := company.IsSanctioned || sanctioned - s.setComplianceCache(cacheKey, blocked, nil) - return blocked, nil + s.setComplianceCache(cacheKey, sanctioned) + return sanctioned, nil } func (s *service) complianceCacheKey(company *v1Models.Company) string { @@ -3103,8 +3110,8 @@ func (s *service) getComplianceCache(key string) (complianceCacheEntry, bool) { return entry, true } -func (s *service) setComplianceCache(key string, sanctioned bool, err error) { - if key == "" || err != nil { +func (s *service) setComplianceCache(key string, sanctioned bool) { + if key == "" { return } s.complianceCacheMu.Lock() @@ -3112,76 +3119,44 @@ func (s *service) setComplianceCache(key string, sanctioned bool, err error) { if s.complianceCache == nil { s.complianceCache = make(map[string]complianceCacheEntry) } + if len(s.complianceCache) >= maxComplianceCacheSize { + // Evict one expired entry or, if none, the first entry found. + evicted := false + now := time.Now() + for k, v := range s.complianceCache { + if now.After(v.expiresAt) { + delete(s.complianceCache, k) + evicted = true + break + } + } + if !evicted { + for k := range s.complianceCache { + delete(s.complianceCache, k) + break + } + } + } s.complianceCache[key] = complianceCacheEntry{ sanctioned: sanctioned, - err: err, expiresAt: time.Now().Add(complianceCacheTTL), } } -// resolveDomain attempts to resolve the domain for an organization. -// Priority: 1) Domains field from org (if available), 2) Parse Link field. -func (s *service) resolveDomain(f logrus.Fields, org interface{}) string { - if domains := s.stringField(org, "Domains"); domains != "" { - if domain := s.firstDomain(domains); domain != "" { - log.WithFields(f).Debugf("resolved domain from Domains field: %s", domain) - return domain - } - } - - link := s.stringField(org, "Link") - if link != "" { - domain := s.parseDomain(link) - if domain != "" { - return domain - } - } - - return "" -} - -func (s *service) stringField(v interface{}, fieldName string) string { - if v == nil { - return "" - } - rv := reflect.ValueOf(v) - if rv.Kind() == reflect.Ptr { - if rv.IsNil() { - return "" - } - rv = rv.Elem() - } - if rv.Kind() != reflect.Struct { - return "" - } - field := rv.FieldByName(fieldName) - if !field.IsValid() { +// resolveDomain returns the best domain for an org: first entry from Domains, else host from Link. +func (s *service) resolveDomain(f logrus.Fields, org *orgModels.Organization) string { + if org == nil { return "" } - switch field.Kind() { - case reflect.String: - return strings.TrimSpace(field.String()) - case reflect.Slice: - if field.Type().Elem().Kind() != reflect.String { - return "" - } - for i := 0; i < field.Len(); i++ { - if value := strings.TrimSpace(field.Index(i).String()); value != "" { - return value - } + for _, d := range org.Domains { + if d = strings.TrimPrefix(strings.TrimSpace(d), "www."); d != "" { + log.WithFields(f).Debugf("resolved domain from Domains field: %s", d) + return d } } - return "" -} - -func (s *service) firstDomain(domains string) string { - parts := strings.FieldsFunc(domains, func(r rune) bool { - return r == ',' || r == ';' || r == '\n' || r == '\t' || r == ' ' - }) - for _, part := range parts { - domain := strings.TrimPrefix(strings.TrimSpace(part), "www.") - if domain != "" { - return domain + if org.Link != "" { + if d := s.parseDomain(org.Link); d != "" { + return d } } return "" @@ -3246,8 +3221,7 @@ func (s *service) handleSSSError(f logrus.Fields, companyID string, err error) ( case errors.As(err, ¬FoundErr): log.WithFields(f).WithError(err).Warnf("SSS organization not found for company %s", companyID) - // Not found is not a blocking error - proceed without SSS result - return false, nil + return allowWhenOptional("SSS organization not found") case errors.As(err, &badReqErr): log.WithFields(f).WithError(err).Warnf("SSS bad request for company %s", companyID) diff --git a/cla-backend-go/v2/sign/service_sss_test.go b/cla-backend-go/v2/sign/service_sss_test.go index bea8e1e12..1beeb7e9c 100644 --- a/cla-backend-go/v2/sign/service_sss_test.go +++ b/cla-backend-go/v2/sign/service_sss_test.go @@ -5,25 +5,20 @@ package sign import ( "context" - "errors" "testing" "time" "github.com/linuxfoundation/easycla/cla-backend-go/gen/v1/models" + orgModels "github.com/linuxfoundation/easycla/cla-backend-go/v2/organization-service/models" "github.com/linuxfoundation/easycla/cla-backend-go/sss" "github.com/sirupsen/logrus" ) -type testOrg struct { - Domains string - Link string -} - func TestResolveDomainPrefersDomains(t *testing.T) { svc := &service{} - got := svc.resolveDomain(logrus.Fields{}, testOrg{ - Domains: "www.example.com, fallback.example.org", + got := svc.resolveDomain(logrus.Fields{}, &orgModels.Organization{ + Domains: []string{"www.example.com", "fallback.example.org"}, Link: "https://fallback.example.org/path", }) @@ -35,7 +30,7 @@ func TestResolveDomainPrefersDomains(t *testing.T) { func TestResolveDomainFallsBackToParsedLink(t *testing.T) { svc := &service{} - got := svc.resolveDomain(logrus.Fields{}, testOrg{ + got := svc.resolveDomain(logrus.Fields{}, &orgModels.Organization{ Link: "www.example.org/path?query=1", }) @@ -122,7 +117,6 @@ func TestComplianceCacheExpires(t *testing.T) { complianceCache: map[string]complianceCacheEntry{ "company-id": { sanctioned: true, - err: errors.New("cached"), expiresAt: time.Now().Add(-time.Second), }, }, @@ -136,9 +130,10 @@ func TestComplianceCacheExpires(t *testing.T) { func TestComplianceCacheSkipsErrors(t *testing.T) { svc := &service{} - svc.setComplianceCache("company-id", false, errors.New("transient")) + // setComplianceCache no longer takes an err param; just verify it stores the entry + svc.setComplianceCache("company-id", false) - if _, ok := svc.getComplianceCache("company-id"); ok { - t.Fatal("expected error cache entry not to be stored") + if _, ok := svc.getComplianceCache("company-id"); !ok { + t.Fatal("expected cache entry to be stored") } } diff --git a/cla-backend-legacy/go.mod b/cla-backend-legacy/go.mod index 6442ba44d..a2b8f9ea0 100644 --- a/cla-backend-legacy/go.mod +++ b/cla-backend-legacy/go.mod @@ -4,6 +4,8 @@ go 1.25.0 toolchain go1.25.10 +replace github.com/linuxfoundation/easycla/cla-sss-base => ../cla-sss-base + require ( github.com/aws/aws-lambda-go v1.53.0 github.com/aws/aws-sdk-go-v2 v1.41.6 @@ -18,6 +20,7 @@ require ( github.com/go-chi/chi/v5 v5.0.12 github.com/golang-jwt/jwt/v4 v4.5.2 github.com/google/uuid v1.6.0 + github.com/linuxfoundation/easycla/cla-sss-base v0.0.0 github.com/sirupsen/logrus v1.9.3 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 go.opentelemetry.io/otel v1.43.0 diff --git a/cla-backend-legacy/internal/api/handlers.go b/cla-backend-legacy/internal/api/handlers.go index 41f3ac5dc..e2d430bda 100644 --- a/cla-backend-legacy/internal/api/handlers.go +++ b/cla-backend-legacy/internal/api/handlers.go @@ -27,8 +27,11 @@ import ( "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/ssm" + ssmTypes "github.com/aws/aws-sdk-go-v2/service/ssm/types" "github.com/go-chi/chi/v5" "github.com/google/uuid" + "github.com/sirupsen/logrus" "github.com/linuxfoundation/easycla/cla-backend-legacy/internal/auth" "github.com/linuxfoundation/easycla/cla-backend-legacy/internal/contracts" @@ -44,6 +47,7 @@ import ( "github.com/linuxfoundation/easycla/cla-backend-legacy/internal/respond" "github.com/linuxfoundation/easycla/cla-backend-legacy/internal/store" "github.com/linuxfoundation/easycla/cla-backend-legacy/internal/telemetry" + sss "github.com/linuxfoundation/easycla/cla-sss-base" ) // Handlers implements the legacy (v1/v2) API surface in Go. @@ -74,6 +78,8 @@ type Handlers struct { github *githublegacy.Service lfGroup *lfgroup.Client userService *userservicelegacy.Client + sssClient *sss.Client + sssRequired bool } func NewHandlers() *Handlers { @@ -193,9 +199,82 @@ func NewHandlers() *Handlers { h.cclaAllowlistReqs = cars } + // Initialize SSS (Sanctions Screening Service) client if configured. + // We load configuration from SSM to match the Go backend's behavior. + ssmClient, err := store.NewSSMClientFromEnv(ctx) + if err != nil { + logging.Fatalf("failed to create SSM client: %v", err) + } + + stage := store.StageFromEnv() + f := logrus.Fields{ + "stage": stage, + } + + // SSS parameters are named according to the Go backend convention: cla-sss-- + sssBaseURL := getOptionalSSMString(ctx, ssmClient, fmt.Sprintf("cla-sss-base-url-%s", stage), f) + sssAudience := getOptionalSSMString(ctx, ssmClient, fmt.Sprintf("cla-sss-auth0-audience-%s", stage), f) + sssRequired := getOptionalSSMBool(ctx, ssmClient, fmt.Sprintf("cla-sss-required-%s", stage), f) + + auth0URL := strings.TrimSpace(os.Getenv("AUTH0_URL")) + auth0ClientID := strings.TrimSpace(os.Getenv("AUTH0_PLATFORM_CLIENT_ID")) + auth0ClientSecret := strings.TrimSpace(os.Getenv("AUTH0_PLATFORM_CLIENT_SECRET")) + + h.sssRequired = sssRequired + if sssBaseURL != "" && sssAudience != "" { + sssClient, err := sss.NewClientFromPlatformCredentials(sssBaseURL, sssAudience, auth0URL, auth0ClientID, auth0ClientSecret) + if err != nil { + if sssRequired { + logging.Fatalf("failed to initialize required SSS client: %v", err) + } + logging.Warnf("failed to initialize optional SSS client, screening will be unavailable: %v", err) + h.sssClient = nil + } else if sssClient != nil { + h.sssClient = sssClient + } + } + + if sssRequired && h.sssClient == nil { + logging.Fatalf("SSS is required but not configured (base URL or audience missing in SSM)") + } + return h } +// getOptionalSSMString retrieves a string parameter from SSM. +// It returns an empty string if the parameter is missing or unreadable. +func getOptionalSSMString(ctx context.Context, ssmClient *ssm.Client, key string, f logrus.Fields) string { + out, err := ssmClient.GetParameter(ctx, &ssm.GetParameterInput{ + Name: &key, + WithDecryption: nil, + }) + if err != nil { + var pnf *ssmTypes.ParameterNotFound + if errors.As(err, &pnf) { + logging.WithFields(f).Debugf("optional SSM key %s not provisioned - sanctions screening disabled until it is set", key) + } else { + logging.WithFields(f).WithError(err).Warnf("unable to read optional SSM key %s - sanctions screening disabled", key) + } + return "" + } + + if out.Parameter == nil || out.Parameter.Value == nil { + return "" + } + + return strings.TrimSpace(*out.Parameter.Value) +} + +// getOptionalSSMBool retrieves a boolean parameter from SSM. +// It returns false (the safe default) when the value is unreadable or the parameter is absent. +func getOptionalSSMBool(ctx context.Context, ssmClient *ssm.Client, key string, f logrus.Fields) bool { + val := getOptionalSSMString(ctx, ssmClient, key, f) + if val == "" { + return false + } + return strings.ToLower(val) == "true" +} + // formatPynamoDateTimeUTC formats timestamps the way Python's pynamodb // UTCDateTimeAttribute serializes them when written to DynamoDB. // @@ -8790,9 +8869,195 @@ func (h *Handlers) employeeSignaturePrecheck(ctx context.Context, projectID, com } } + // checkCompanyCompliance now handles SSS screening and sanction persistence. + blocked, sanctionErr := h.checkCompanyCompliance(ctx, company) + if sanctionErr != nil { + logging.Warnf("failed to check company compliance for company: %s, error: %v", companyID, sanctionErr) + return nil, nil, nil, nil, nil, sanctionErr + } + if blocked { + fn := "employeeSignaturePrecheck" + sanctioned := map[string]any{ + "sanctioned": fmt.Sprintf("%s - user %s, company %s is sanctioned", fn, userID, companyID), + "description": "We’re sorry, but you are currently unable to sign the Employee Contributor License Agreement (ECLA). If you believe this may be an error, please reach out to support", + "user_id": userID, + "company_id": companyID, + } + return project, company, user, cclaSig, map[string]any{"code": 403, "errors": sanctioned}, nil + } + return project, company, user, cclaSig, nil, nil } +// checkCompanyCompliance queries the Sanctions Screening Service for the given company. +// It returns true if the company is sanctioned and should be blocked. +func (h *Handlers) checkCompanyCompliance(ctx context.Context, company map[string]types.AttributeValue) (bool, error) { + companyID := getAttrString(company, "company_id") + companyName := getAttrString(company, "company_name") + companyExternalID := getAttrString(company, "company_external_id") + + // Short-circuit for manually/admin-set blocks (sanction_origin != "sss" or no origin). + // SSS-origin blocks fall through so a now-clean result can clear them. + isSanctioned := false + if av, ok := company["is_sanctioned"].(*types.AttributeValueMemberBOOL); ok { + isSanctioned = av.Value + } + sanctionOrigin := getAttrString(company, "sanction_origin") + + if isSanctioned && sanctionOrigin != "sss" { + logging.Warnf("company has non-SSS sanction block (origin=%q), blocking without SSS call", sanctionOrigin) + return true, nil + } + + if h.sssClient == nil { + if h.sssRequired { + return false, fmt.Errorf("checkCompanyCompliance: SSS is required but the client is not configured") + } + return isSanctioned, nil + } + + if companyExternalID == "" { + logging.Warnf("company %s has no external ID, skipping SSS call", companyID) + if h.sssRequired { + return false, fmt.Errorf("checkCompanyCompliance: company %s has no external ID (SFDC ID) required for screening", companyID) + } + return isSanctioned, nil + } + + // Fetch organization details from Salesforce to resolve the domain. + org, err := h.salesforce.GetOrganization(ctx, companyExternalID) + if err != nil { + logging.Warnf("failed to get organization %s: %v", companyExternalID, err) + if h.sssRequired { + return false, fmt.Errorf("checkCompanyCompliance: failed to get organization %s: %w", companyExternalID, err) + } + return isSanctioned, nil + } + if org == nil { + logging.Warnf("organization record is nil for %s", companyExternalID) + if h.sssRequired { + return false, fmt.Errorf("checkCompanyCompliance: organization record is nil for %s", companyExternalID) + } + return isSanctioned, nil + } + + domain := h.resolveDomain(org) + if domain == "" { + logging.Warnf("unable to resolve domain for organization %s, skipping SSS call", companyExternalID) + if h.sssRequired { + return false, fmt.Errorf("checkCompanyCompliance: unable to resolve domain for organization %s", companyExternalID) + } + return isSanctioned, nil + } + + result, err := h.sssClient.GetOrganizationStatus(ctx, sss.OrganizationStatusRequest{ + Domain: domain, + OrgName: companyName, + SFDCID: companyExternalID, + }) + if err != nil { + return h.handleSSSError(ctx, companyID, err) + } + + sanctioned := result.Status == sss.StatusFlagged + + // In required mode, only an explicit "clean" is acceptable — any other status blocks. + if h.sssRequired && result.Status != sss.StatusClean && result.Status != sss.StatusFlagged { + return false, fmt.Errorf("checkCompanyCompliance: unexpected SSS status %q for company %s (required mode blocks on ambiguous results)", result.Status, companyID) + } + + // Persist result: set origin="sss" on flagged; conditionally clear on clean (only if sss-origin). + if sanctioned { + logging.Warnf("SSS returned flagged status for company %s, persisting sanction with origin=sss", companyID) + if persistErr := h.companies.UpdateCompanySanctionStatus(ctx, companyID, true, "sss"); persistErr != nil { + logging.Warnf("failed to persist sanction status for company %s: %v", companyID, persistErr) + return false, fmt.Errorf("failed to persist sanction status for company %s: %w", companyID, persistErr) + } + } else { + // Clear only when previously set by SSS; manual blocks are left untouched. + logging.Debugf("SSS returned clean status for company %s; attempting conditional clear", companyID) + if clearErr := h.companies.ClearCompanySanctionStatusIfSSS(ctx, companyID); clearErr != nil { + logging.Warnf("failed to conditionally clear sanction status for company %s: %v", companyID, clearErr) + } + } + + return sanctioned, nil +} + +// handleSSSError differentiates between various SSS error types and logs appropriately. +func (h *Handlers) handleSSSError(ctx context.Context, companyID string, err error) (bool, error) { + var badReqErr *sss.BadRequestError + var authErr *sss.AuthError + var retryErr *sss.RetryableError + var notFoundErr *sss.NotFoundError + var timeoutErr *sss.TimeoutError + + allowWhenOptional := func(message string) (bool, error) { + if h.sssRequired { + return false, fmt.Errorf("%s for company %s: %w", message, companyID, err) + } + logging.Warnf("%s for company %s; SSS is not required, continuing", message, companyID) + return false, nil + } + + switch { + case errors.As(err, &timeoutErr): + logging.Warnf("SSS request timed out for company %s: %v", companyID, err) + return allowWhenOptional("SSS screening unavailable (timeout)") + + case errors.As(err, &authErr): + logging.Errorf("SSS authentication/configuration error for company %s: %v", companyID, err) + return allowWhenOptional("SSS authentication error (check configuration)") + + case errors.As(err, &retryErr): + logging.Warnf("SSS request failed with retryable error for company %s: %v", companyID, err) + return allowWhenOptional("SSS screening unavailable (transient failure)") + + case errors.As(err, ¬FoundErr): + logging.Warnf("SSS organization not found for company %s: %v", companyID, err) + return allowWhenOptional("SSS organization not found") + + case errors.As(err, &badReqErr): + logging.Warnf("SSS bad request for company %s: %v", companyID, err) + return allowWhenOptional("SSS bad request") + + default: + logging.Warnf("SSS request failed with unexpected error for company %s: %v", companyID, err) + return allowWhenOptional("SSS request failed") + } +} + +// resolveDomain returns the best domain for an org: first entry from Domains, else host from Link. +func (h *Handlers) resolveDomain(org *salesforce.Organization) string { + if org == nil { + return "" + } + for _, d := range org.Domains { + if d = strings.TrimPrefix(strings.TrimSpace(d), "www."); d != "" { + return d + } + } + if org.Link != "" { + return h.parseDomain(org.Link) + } + return "" +} + +func (h *Handlers) parseDomain(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + if !strings.Contains(s, "://") { + s = "https://" + s + } + u, err := url.Parse(s) + if err != nil { + return "" + } + return strings.TrimPrefix(u.Hostname(), "www.") +} + func (h *Handlers) RequestEmployeeSignatureV2(w http.ResponseWriter, r *http.Request) { ctx := r.Context() req := parseEmployeeSignatureRequestV2(r) @@ -8860,19 +9125,6 @@ func (h *Handlers) RequestEmployeeSignatureV2(w http.ResponseWriter, r *http.Req fn := "docusign_models.check_and_prepare_employee_signature" - // NOTE: Python does NOT do sanction checks in check_and_prepare_employee_signature(). - // It does it here (request_employee_signature / request_employee_signature_gerrit) after the precheck. - if av, ok := company["is_sanctioned"].(*types.AttributeValueMemberBOOL); ok && av.Value { - sanctioned := map[string]any{ - "sanctioned": fmt.Sprintf("%s - user %s, company %s is sanctioned", fn, req.UserID, req.CompanyID), - "description": "We’re sorry, but you are currently unable to sign the Employee Contributor License Agreement (ECLA). If you believe this may be an error, please reach out to support", - "user_id": req.UserID, - "company_id": req.CompanyID, - } - respond.JSON(w, http.StatusOK, map[string]any{"code": 403, "errors": sanctioned}) - return - } - // If the employee signature already exists, return it. existing, err := h.signatures.QueryByProjectAndReference(ctx, req.ProjectID, req.UserID) if err != nil { diff --git a/cla-backend-legacy/internal/legacy/salesforce/service.go b/cla-backend-legacy/internal/legacy/salesforce/service.go index d89e2651b..5723d0bf2 100644 --- a/cla-backend-legacy/internal/legacy/salesforce/service.go +++ b/cla-backend-legacy/internal/legacy/salesforce/service.go @@ -267,6 +267,56 @@ func (s *Service) projectsSearchURL(projectIDs []string) (string, error) { return endpoint, nil } +// Organization represents a minimal platform organization record. +type Organization struct { + ID string `json:"ID"` + Name string `json:"Name"` + Domains []string `json:"Domains"` + Link string `json:"Link"` +} + +// GetOrganization retrieves an organization by its Salesforce ID. +func (s *Service) GetOrganization(ctx context.Context, sfid string) (*Organization, error) { + if sfid == "" { + return nil, errors.New("salesforce id is required") + } + + tok, code, err := s.getAccessToken(ctx) + if err != nil { + return nil, fmt.Errorf("auth failure (status=%d): %w", code, err) + } + + base := strings.TrimRight(s.platformGatewayURL, "/") + if base == "" { + return nil, errors.New("PLATFORM_GATEWAY_URL is empty") + } + + endpoint := fmt.Sprintf("%s/organization-service/v1/organizations/%s", base, sfid) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+tok) + + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, &ProjectServiceError{Status: resp.StatusCode, Body: string(body), Cause: fmt.Errorf("failed to get organization %s", sfid)} + } + + var org Organization + if err := json.NewDecoder(resp.Body).Decode(&org); err != nil { + return nil, fmt.Errorf("decode organization: %w", err) + } + return &org, nil +} + // getAccessToken performs the platform Auth0 client_credentials flow. // // Python parity: cla/salesforce.py:get_access_token() uses x-www-form-urlencoded diff --git a/cla-backend-legacy/internal/store/companies.go b/cla-backend-legacy/internal/store/companies.go index 3ca2f30c0..d52f1f2c0 100644 --- a/cla-backend-legacy/internal/store/companies.go +++ b/cla-backend-legacy/internal/store/companies.go @@ -5,7 +5,9 @@ package store import ( "context" + "errors" "fmt" + "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/dynamodb" @@ -138,3 +140,76 @@ func (s *CompaniesStore) DeleteByID(ctx context.Context, companyID string) error }) return err } + +// UpdateCompanySanctionStatus sets is_sanctioned and, when origin is non-empty, sanction_origin. +// Pass origin="sss" when flagging via SSS; pass origin="" for manual admin updates. +func (s *CompaniesStore) UpdateCompanySanctionStatus(ctx context.Context, companyID string, sanctioned bool, origin string) error { + if s == nil || s.client == nil { + return nil + } + + now := time.Now().UTC().Format("2006-01-02T15:04:05.000000-0700") // Best effort for date_modified parity + + names := map[string]string{ + "#S": "is_sanctioned", + "#M": "date_modified", + } + values := map[string]types.AttributeValue{ + ":s": &types.AttributeValueMemberBOOL{Value: sanctioned}, + ":m": &types.AttributeValueMemberS{Value: now}, + } + updateExpr := "SET #S = :s, #M = :m" + + if origin != "" { + names["#O"] = "sanction_origin" + values[":o"] = &types.AttributeValueMemberS{Value: origin} + updateExpr += ", #O = :o" + } + + _, err := s.client.UpdateItem(ctx, &dynamodb.UpdateItemInput{ + TableName: aws.String(s.table), + Key: map[string]types.AttributeValue{ + "company_id": &types.AttributeValueMemberS{Value: companyID}, + }, + UpdateExpression: aws.String(updateExpr), + ExpressionAttributeNames: names, + ExpressionAttributeValues: values, + }) + return err +} + +// ClearCompanySanctionStatusIfSSS clears is_sanctioned only when sanction_origin="sss". +func (s *CompaniesStore) ClearCompanySanctionStatusIfSSS(ctx context.Context, companyID string) error { + if s == nil || s.client == nil { + return nil + } + + now := time.Now().UTC().Format("2006-01-02T15:04:05.000000-0700") + + _, err := s.client.UpdateItem(ctx, &dynamodb.UpdateItemInput{ + TableName: aws.String(s.table), + Key: map[string]types.AttributeValue{ + "company_id": &types.AttributeValueMemberS{Value: companyID}, + }, + UpdateExpression: aws.String("SET #S = :false, #M = :m REMOVE #O"), + ConditionExpression: aws.String("#O = :sss"), + ExpressionAttributeNames: map[string]string{ + "#S": "is_sanctioned", + "#M": "date_modified", + "#O": "sanction_origin", + }, + ExpressionAttributeValues: map[string]types.AttributeValue{ + ":false": &types.AttributeValueMemberBOOL{Value: false}, + ":m": &types.AttributeValueMemberS{Value: now}, + ":sss": &types.AttributeValueMemberS{Value: "sss"}, + }, + }) + if err != nil { + var condErr *types.ConditionalCheckFailedException + if errors.As(err, &condErr) { + return nil // Already manual/admin or not SSS-flagged + } + return err + } + return nil +} diff --git a/cla-backend-legacy/internal/store/dynamo.go b/cla-backend-legacy/internal/store/dynamo.go index f63233975..21cbe13f3 100644 --- a/cla-backend-legacy/internal/store/dynamo.go +++ b/cla-backend-legacy/internal/store/dynamo.go @@ -11,6 +11,7 @@ import ( "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/aws/aws-sdk-go-v2/service/ssm" ) // StageFromEnv returns the current deployment stage. @@ -42,6 +43,25 @@ func TableNameFromSuffix(suffix string) string { // For legacy parity we keep this intentionally minimal and rely on the Lambda execution // role + standard AWS_REGION/AWS_DEFAULT_REGION behavior. func NewDynamoDBClientFromEnv(ctx context.Context) (*dynamodb.Client, error) { + region := getAWSRegion() + cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(region)) + if err != nil { + return nil, err + } + return dynamodb.NewFromConfig(cfg), nil +} + +// NewSSMClientFromEnv creates an SSM client using the ambient AWS environment. +func NewSSMClientFromEnv(ctx context.Context) (*ssm.Client, error) { + region := getAWSRegion() + cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(region)) + if err != nil { + return nil, err + } + return ssm.NewFromConfig(cfg), nil +} + +func getAWSRegion() string { region := strings.TrimSpace(os.Getenv("DYNAMODB_AWS_REGION")) if region == "" { region = strings.TrimSpace(os.Getenv("AWS_REGION")) @@ -52,10 +72,5 @@ func NewDynamoDBClientFromEnv(ctx context.Context) (*dynamodb.Client, error) { if region == "" { region = "us-east-1" } - - cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(region)) - if err != nil { - return nil, err - } - return dynamodb.NewFromConfig(cfg), nil + return region } diff --git a/cla-sss-base/LICENSE b/cla-sss-base/LICENSE new file mode 100644 index 000000000..a935cb221 --- /dev/null +++ b/cla-sss-base/LICENSE @@ -0,0 +1,18 @@ +Copyright The Linux Foundation and each contributor to CommunityBridge. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/cla-sss-base/auth.go b/cla-sss-base/auth.go new file mode 100644 index 000000000..2b12d2019 --- /dev/null +++ b/cla-sss-base/auth.go @@ -0,0 +1,20 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package sss + +// authRequest is the payload used for the Auth0 client credentials request. +type authRequest struct { + GrantType string `json:"grant_type"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + Audience string `json:"audience"` +} + +// authResponse is the Auth0 token response payload. +type authResponse struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` + TokenType string `json:"token_type"` +} diff --git a/cla-sss-base/client.go b/cla-sss-base/client.go new file mode 100644 index 000000000..769a8e43d --- /dev/null +++ b/cla-sss-base/client.go @@ -0,0 +1,329 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package sss + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" +) + +const ( + defaultTimeout = 30 * time.Second + defaultTokenTTL = time.Hour + userAgent = "easycla-sss-base-client" +) + +// Client is a reusable HTTP client for the Sanctions Screening Service. +type Client struct { + cfg SSSConfig + httpClient *http.Client + token string + expiry time.Time + tokenMutex sync.RWMutex +} + +// NewClient creates a new SSS client configured for Auth0 client credentials. +func NewClient(cfg SSSConfig) (*Client, error) { + if strings.TrimSpace(cfg.BaseURL) == "" { + return nil, fmt.Errorf("base URL is required") + } + if strings.TrimSpace(cfg.Auth0Domain) == "" { + return nil, fmt.Errorf("Auth0 domain is required") + } + if strings.TrimSpace(cfg.Auth0ClientID) == "" { + return nil, fmt.Errorf("Auth0 client ID is required") + } + if strings.TrimSpace(cfg.Auth0ClientSecret) == "" { + return nil, fmt.Errorf("Auth0 client secret is required") + } + if strings.TrimSpace(cfg.Auth0Audience) == "" { + return nil, fmt.Errorf("Auth0 audience is required") + } + if cfg.Timeout <= 0 { + cfg.Timeout = defaultTimeout + } + + return &Client{ + cfg: cfg, + httpClient: &http.Client{Timeout: cfg.Timeout}, + }, nil +} + +// GetOrganizationStatus retrieves the sanctions screening result for an organization. +func (c *Client) GetOrganizationStatus(ctx context.Context, statusReq OrganizationStatusRequest) (*ScreeningResult, error) { + if strings.TrimSpace(statusReq.Domain) == "" { + return nil, &BadRequestError{Message: "domain is required"} + } + if strings.TrimSpace(statusReq.OrgName) == "" { + return nil, &BadRequestError{Message: "org_name is required"} + } + + token, err := c.getToken(ctx) + if err != nil { + return nil, err + } + + endpoint := strings.TrimRight(c.cfg.BaseURL, "/") + "/api/v1/organizations/status" + reqURL, err := url.Parse(endpoint) + if err != nil { + return nil, fmt.Errorf("invalid base URL: %w", err) + } + + query := reqURL.Query() + query.Set("domain", strings.TrimSpace(statusReq.Domain)) + query.Set("org_name", strings.TrimSpace(statusReq.OrgName)) + if v := strings.TrimSpace(statusReq.Country); v != "" { + query.Set("country", v) + } + if v := strings.TrimSpace(statusReq.City); v != "" { + query.Set("city", v) + } + if v := strings.TrimSpace(statusReq.State); v != "" { + query.Set("state", v) + } + if v := strings.TrimSpace(statusReq.PostalCode); v != "" { + query.Set("postal_code", v) + } + if v := strings.TrimSpace(statusReq.SFDCID); v != "" { + query.Set("sfdc_id", v) + } + if v := strings.TrimSpace(statusReq.ClearbitID); v != "" { + query.Set("clearbit_id", v) + } + reqURL.RawQuery = query.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("User-Agent", userAgent) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, toClientError(err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + switch resp.StatusCode { + case http.StatusOK: + var result ScreeningResult + if err := json.NewDecoder(bytes.NewReader(body)).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode screening result: %w", err) + } + return &result, nil + case http.StatusBadRequest: + details := responseErrorDetails(body) + return nil, &BadRequestError{Message: details.Message, Code: details.Code, RequestID: details.RequestID} + case http.StatusNotFound: + details := responseErrorDetails(body) + return nil, &NotFoundError{Message: details.Message, Code: details.Code, RequestID: details.RequestID} + case http.StatusUnauthorized, http.StatusForbidden: + c.invalidateToken(token) + details := responseErrorDetails(body) + return nil, &AuthError{Message: details.Message, Code: details.Code, RequestID: details.RequestID} + case http.StatusTooManyRequests, http.StatusServiceUnavailable: + details := responseErrorDetails(body) + return nil, &RetryableError{Message: details.Message, Code: details.Code, RequestID: details.RequestID, RetryAfter: parseRetryAfter(resp.Header.Get("Retry-After"))} + default: + return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, responseMessage(body)) + } +} + +func (c *Client) getToken(ctx context.Context) (string, error) { + c.tokenMutex.RLock() + currentToken := c.token + expiry := c.expiry + c.tokenMutex.RUnlock() + + if currentToken == "" || time.Until(expiry) <= time.Minute { + return c.fetchToken(ctx) + } + + return currentToken, nil +} + +func (c *Client) fetchToken(ctx context.Context) (string, error) { + c.tokenMutex.Lock() + defer c.tokenMutex.Unlock() + + if c.token != "" && time.Until(c.expiry) > time.Minute { + return c.token, nil + } + + requestPayload := authRequest{ + GrantType: "client_credentials", + ClientID: c.cfg.Auth0ClientID, + ClientSecret: c.cfg.Auth0ClientSecret, + Audience: c.cfg.Auth0Audience, + } + payload, err := json.Marshal(requestPayload) + if err != nil { + return "", fmt.Errorf("failed to marshal auth request: %w", err) + } + + authURL := c.authTokenURL() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, authURL, bytes.NewReader(payload)) + if err != nil { + return "", fmt.Errorf("failed to create auth request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", userAgent) + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", toClientError(err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read auth response body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + var auth0Err struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + details := upstreamErrorDetails{} + if err := json.Unmarshal(body, &auth0Err); err == nil { + details.Code = strings.TrimSpace(auth0Err.Error) + details.Message = strings.TrimSpace(auth0Err.ErrorDescription) + if details.Message == "" && details.Code != "" { + details.Message = details.Code + } + if details.Message == "" { + details.Message = resp.Status + } + } else { + details.Message = strings.TrimSpace(string(body)) + if details.Message == "" { + details.Message = resp.Status + } + } + return "", &AuthError{Message: fmt.Sprintf("authentication failed: %s", details.Message), Code: details.Code, RequestID: details.RequestID} + } + + var tokenResp authResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return "", fmt.Errorf("failed to decode auth response: %w", err) + } + + if tokenResp.AccessToken == "" { + return "", &AuthError{Message: "empty access token from auth server"} + } + + expiresIn := time.Duration(tokenResp.ExpiresIn) * time.Second + if expiresIn <= 0 { + expiresIn = defaultTokenTTL + } + c.token = tokenResp.AccessToken + c.expiry = time.Now().Add(expiresIn) + + return c.token, nil +} + +func (c *Client) invalidateToken(token string) { + c.tokenMutex.Lock() + defer c.tokenMutex.Unlock() + + if c.token == token { + c.token = "" + c.expiry = time.Time{} + } +} + +func (c *Client) authTokenURL() string { + domain := strings.TrimSpace(c.cfg.Auth0Domain) + if strings.HasPrefix(domain, "http://") || strings.HasPrefix(domain, "https://") { + return strings.TrimRight(domain, "/") + "/oauth/token" + } + return "https://" + strings.TrimRight(domain, "/") + "/oauth/token" +} + +func parseRetryAfter(value string) time.Duration { + if strings.TrimSpace(value) == "" { + return 0 + } + + if seconds, err := strconv.Atoi(strings.TrimSpace(value)); err == nil { + if seconds < 0 { + return 0 + } + return time.Duration(seconds) * time.Second + } + + if parsedTime, err := http.ParseTime(value); err == nil { + d := time.Until(parsedTime) + if d < 0 { + return 0 + } + return d + } + return 0 +} + +type upstreamErrorDetails struct { + Message string + Code string + RequestID string +} + +func responseErrorDetails(body []byte) upstreamErrorDetails { + var errPayload struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + RequestID string `json:"request_id"` + } + if err := json.Unmarshal(body, &errPayload); err == nil { + details := upstreamErrorDetails{ + Message: strings.TrimSpace(errPayload.Error.Message), + Code: strings.TrimSpace(errPayload.Error.Code), + RequestID: strings.TrimSpace(errPayload.RequestID), + } + if details.Message != "" || details.Code != "" || details.RequestID != "" { + return details + } + } + + return upstreamErrorDetails{Message: strings.TrimSpace(string(body))} +} + +func responseMessage(body []byte) string { + return responseErrorDetails(body).Message +} + +func toClientError(err error) error { + if errors.Is(err, context.DeadlineExceeded) { + return &TimeoutError{Message: err.Error()} + } + + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return &TimeoutError{Message: err.Error()} + } + + return err +} diff --git a/cla-sss-base/errors.go b/cla-sss-base/errors.go new file mode 100644 index 000000000..bece4b45d --- /dev/null +++ b/cla-sss-base/errors.go @@ -0,0 +1,72 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package sss + +import ( + "fmt" + "time" +) + +// BadRequestError indicates a 400 response from the SSS API. +type BadRequestError struct { + Message string + Code string + RequestID string +} + +func (e *BadRequestError) Error() string { + return formatError("bad request", e.Message, e.Code, e.RequestID) +} + +// AuthError indicates a 401 or 403 response from the SSS API. +type AuthError struct { + Message string + Code string + RequestID string +} + +func (e *AuthError) Error() string { + return formatError("authentication error", e.Message, e.Code, e.RequestID) +} + +// RetryableError indicates a 503 response from the SSS API. +type RetryableError struct { + Message string + Code string + RequestID string + RetryAfter time.Duration +} + +func (e *RetryableError) Error() string { + return formatError("retryable error", e.Message, e.Code, e.RequestID) +} + +// NotFoundError indicates a 404 response from the SSS API. +type NotFoundError struct { + Message string + Code string + RequestID string +} + +func (e *NotFoundError) Error() string { + return formatError("not found", e.Message, e.Code, e.RequestID) +} + +// TimeoutError indicates the request timed out. +type TimeoutError struct { + Message string + Code string + RequestID string +} + +func (e *TimeoutError) Error() string { + return formatError("timeout", e.Message, e.Code, e.RequestID) +} + +func formatError(prefix, message, code, requestID string) string { + if code != "" || requestID != "" { + return fmt.Sprintf("%s: %s (code=%s request_id=%s)", prefix, message, code, requestID) + } + return fmt.Sprintf("%s: %s", prefix, message) +} diff --git a/cla-sss-base/from_config.go b/cla-sss-base/from_config.go new file mode 100644 index 000000000..ec43f10ed --- /dev/null +++ b/cla-sss-base/from_config.go @@ -0,0 +1,57 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package sss + +import ( + "net/url" + "strings" +) + +// NewClientFromPlatformCredentials builds an SSS client that reuses the shared +// LFX platform M2M (Auth0) credentials already configured for EasyCLA. +// +// oauthTokenURL is the full Auth0 token endpoint used for platform auth +// (e.g. https:///oauth/token); its scheme+host is reused as the SSS +// client's Auth0 domain, since SSS authenticates with the same client against +// the same Auth0 tenant - only the requested audience differs. +// +// It returns (nil, nil) when baseURL or audience is empty so callers can treat +// an unconfigured SSS as a disabled, no-op feature rather than an error. +func NewClientFromPlatformCredentials(baseURL, audience, oauthTokenURL, clientID, clientSecret string) (*Client, error) { + baseURL = strings.TrimSpace(baseURL) + audience = strings.TrimSpace(audience) + if baseURL == "" || audience == "" { + return nil, nil + } + + return NewClient(SSSConfig{ + BaseURL: baseURL, + Auth0Domain: auth0DomainFromTokenURL(oauthTokenURL), + Auth0ClientID: strings.TrimSpace(clientID), + Auth0ClientSecret: strings.TrimSpace(clientSecret), + Auth0Audience: audience, + }) +} + +// auth0DomainFromTokenURL reduces a full Auth0 token endpoint to its scheme+host +// (e.g. https://tenant.auth0.com), which is what the SSS client expects as its +// Auth0 domain. It tolerates a missing scheme: url.Parse on a scheme-less value +// puts the whole string in Path and leaves Host empty, so a value like +// "tenant.auth0.com/oauth/token" would otherwise be passed through verbatim and +// produce a doubled "/oauth/token" when the client builds the token URL. +func auth0DomainFromTokenURL(oauthTokenURL string) string { + oauthTokenURL = strings.TrimSpace(oauthTokenURL) + if oauthTokenURL == "" { + return "" + } + + parseTarget := oauthTokenURL + if !strings.Contains(parseTarget, "://") { + parseTarget = "https://" + parseTarget + } + if u, err := url.Parse(parseTarget); err == nil && u.Host != "" { + return u.Scheme + "://" + u.Host + } + return oauthTokenURL +} diff --git a/cla-sss-base/go.mod b/cla-sss-base/go.mod new file mode 100644 index 000000000..8ede78af6 --- /dev/null +++ b/cla-sss-base/go.mod @@ -0,0 +1,3 @@ +module github.com/linuxfoundation/easycla/cla-sss-base + +go 1.25.0 diff --git a/cla-sss-base/types.go b/cla-sss-base/types.go new file mode 100644 index 000000000..351b847de --- /dev/null +++ b/cla-sss-base/types.go @@ -0,0 +1,55 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package sss + +import "time" + +// SSSConfig holds configuration values for the SSS client. +type SSSConfig struct { + BaseURL string + Auth0Domain string + Auth0ClientID string + Auth0ClientSecret string + // Auth0Audience is the Auth0 API audience/resource server identifier. + // Production values may require the exact identifier configured in Auth0, + // including a trailing slash when the resource server uses one. + Auth0Audience string + // Timeout is shared by SSS API requests and Auth0 token acquisition requests. + Timeout time.Duration +} + +// OrganizationStatusRequest holds parameters for querying organization screening status. +type OrganizationStatusRequest struct { + Domain string `json:"domain"` + OrgName string `json:"org_name"` + Country string `json:"country,omitempty"` + City string `json:"city,omitempty"` + State string `json:"state,omitempty"` + PostalCode string `json:"postal_code,omitempty"` + SFDCID string `json:"sfdc_id,omitempty"` + ClearbitID string `json:"clearbit_id,omitempty"` +} + +const ( + StatusClean = "clean" + StatusFlagged = "flagged" + + SourceScreeningDB = "screening_db" + SourceSFDC = "sfdc" + SourceDescartesAPI = "descartes_api" +) + +// ScreeningResult is returned by the SSS organization status endpoint. +type ScreeningResult struct { + Status string `json:"status"` + EntityID string `json:"entity_id"` + Source string `json:"source"` + ScreenedAt time.Time `json:"screened_at"` + ClearbitID string `json:"clearbit_id"` + SFDCID *string `json:"sfdc_id"` + OrgName string `json:"org_name"` + Domain string `json:"domain"` + Vendor string `json:"vendor"` + ClearbitEnriched bool `json:"clearbit_enriched"` +} From 760d660b45ab86136b29e9d0d98e57c387fb9d3d Mon Sep 17 00:00:00 2001 From: psrsingh Date: Tue, 2 Jun 2026 22:35:44 +0530 Subject: [PATCH 08/11] feat(sign): integrate sanctions screening service enforcement Signed-off-by: psrsingh --- cla-backend-legacy/internal/api/handlers.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cla-backend-legacy/internal/api/handlers.go b/cla-backend-legacy/internal/api/handlers.go index e2d430bda..57d0da241 100644 --- a/cla-backend-legacy/internal/api/handlers.go +++ b/cla-backend-legacy/internal/api/handlers.go @@ -9123,7 +9123,7 @@ func (h *Handlers) RequestEmployeeSignatureV2(w http.ResponseWriter, r *http.Req return } - fn := "docusign_models.check_and_prepare_employee_signature" + // If the employee signature already exists, return it. existing, err := h.signatures.QueryByProjectAndReference(ctx, req.ProjectID, req.UserID) From af53c56850bf9b4192a6b1fc5e589dcfe9e1a9d0 Mon Sep 17 00:00:00 2001 From: psrsingh Date: Wed, 3 Jun 2026 15:01:04 +0530 Subject: [PATCH 09/11] fix(sss): address review feedback and align with backend behavior Signed-off-by: psrsingh --- cla-backend-go/go.mod | 2 ++ cla-backend-go/sss/client.go | 20 +------------- cla-backend-go/v2/sign/service.go | 17 +++++++----- cla-backend-go/v2/sign/service_sss_test.go | 4 +-- cla-backend-legacy/internal/api/handlers.go | 26 ++++++++++++------- .../internal/legacy/salesforce/service.go | 10 +++---- .../sss => cla-sss-base}/client_test.go | 0 .../sss => cla-sss-base}/from_config_test.go | 0 8 files changed, 36 insertions(+), 43 deletions(-) rename {cla-backend-go/sss => cla-sss-base}/client_test.go (100%) rename {cla-backend-go/sss => cla-sss-base}/from_config_test.go (100%) diff --git a/cla-backend-go/go.mod b/cla-backend-go/go.mod index bf52f20df..a7848f51c 100644 --- a/cla-backend-go/go.mod +++ b/cla-backend-go/go.mod @@ -7,6 +7,7 @@ go 1.25.0 toolchain go1.25.10 replace github.com/awslabs/aws-lambda-go-api-proxy => github.com/LF-Engineering/aws-lambda-go-api-proxy v0.3.2 + replace github.com/linuxfoundation/easycla/cla-sss-base => ../cla-sss-base require ( @@ -41,6 +42,7 @@ require ( github.com/juju/zip v0.0.0-20160205105221-f6b1e93fa2e2 github.com/kr/pretty v0.3.1 // indirect github.com/leodido/go-urn v1.2.4 // indirect + github.com/linuxfoundation/easycla/cla-sss-base v0.0.0 github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/mapstructure v1.5.0 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect diff --git a/cla-backend-go/sss/client.go b/cla-backend-go/sss/client.go index e55e6ad57..50d3c1666 100644 --- a/cla-backend-go/sss/client.go +++ b/cla-backend-go/sss/client.go @@ -8,25 +8,7 @@ import sssbase "github.com/linuxfoundation/easycla/cla-sss-base" // Re-export types and functions for backward compatibility type Client = sssbase.Client -type SSSConfig = sssbase.SSSConfig -type OrganizationStatusRequest = sssbase.OrganizationStatusRequest -type ScreeningResult = sssbase.ScreeningResult -type BadRequestError = sssbase.BadRequestError -type AuthError = sssbase.AuthError -type RetryableError = sssbase.RetryableError -type NotFoundError = sssbase.NotFoundError -type TimeoutError = sssbase.TimeoutError - -const ( - StatusClean = sssbase.StatusClean - StatusFlagged = sssbase.StatusFlagged - - SourceScreeningDB = sssbase.SourceScreeningDB - SourceSFDC = sssbase.SourceSFDC - SourceDescartesAPI = sssbase.SourceDescartesAPI -) var ( - NewClient = sssbase.NewClient - NewClientFromPlatformCredentials = sssbase.NewClientFromPlatformCredentials + NewClient = sssbase.NewClient ) diff --git a/cla-backend-go/v2/sign/service.go b/cla-backend-go/v2/sign/service.go index 2b416d7c2..86d673631 100644 --- a/cla-backend-go/v2/sign/service.go +++ b/cla-backend-go/v2/sign/service.go @@ -59,11 +59,11 @@ import ( // constants const ( - DontLoadRepoDetails = false - DocSignFalse = "false" - DocusignCompleted = "Completed" - complianceCacheTTL = 5 * time.Minute - maxComplianceCacheSize = 1000 + DontLoadRepoDetails = false + DocSignFalse = "false" + DocusignCompleted = "Completed" + complianceCacheTTL = 5 * time.Minute + maxComplianceCacheSize = 1000 ) // errors @@ -3148,8 +3148,11 @@ func (s *service) resolveDomain(f logrus.Fields, org *orgModels.Organization) st if org == nil { return "" } - for _, d := range org.Domains { - if d = strings.TrimPrefix(strings.TrimSpace(d), "www."); d != "" { + domains := strings.Split(org.Domains, ",") + + for _, d := range domains { + d = strings.TrimPrefix(strings.TrimSpace(d), "www.") + if d != "" { log.WithFields(f).Debugf("resolved domain from Domains field: %s", d) return d } diff --git a/cla-backend-go/v2/sign/service_sss_test.go b/cla-backend-go/v2/sign/service_sss_test.go index 1beeb7e9c..310c6a989 100644 --- a/cla-backend-go/v2/sign/service_sss_test.go +++ b/cla-backend-go/v2/sign/service_sss_test.go @@ -9,8 +9,8 @@ import ( "time" "github.com/linuxfoundation/easycla/cla-backend-go/gen/v1/models" - orgModels "github.com/linuxfoundation/easycla/cla-backend-go/v2/organization-service/models" "github.com/linuxfoundation/easycla/cla-backend-go/sss" + orgModels "github.com/linuxfoundation/easycla/cla-backend-go/v2/organization-service/models" "github.com/sirupsen/logrus" ) @@ -18,7 +18,7 @@ func TestResolveDomainPrefersDomains(t *testing.T) { svc := &service{} got := svc.resolveDomain(logrus.Fields{}, &orgModels.Organization{ - Domains: []string{"www.example.com", "fallback.example.org"}, + Domains: "www.example.com,fallback.example.org", Link: "https://fallback.example.org/path", }) diff --git a/cla-backend-legacy/internal/api/handlers.go b/cla-backend-legacy/internal/api/handlers.go index 57d0da241..d4b274e2a 100644 --- a/cla-backend-legacy/internal/api/handlers.go +++ b/cla-backend-legacy/internal/api/handlers.go @@ -216,9 +216,9 @@ func NewHandlers() *Handlers { sssAudience := getOptionalSSMString(ctx, ssmClient, fmt.Sprintf("cla-sss-auth0-audience-%s", stage), f) sssRequired := getOptionalSSMBool(ctx, ssmClient, fmt.Sprintf("cla-sss-required-%s", stage), f) - auth0URL := strings.TrimSpace(os.Getenv("AUTH0_URL")) - auth0ClientID := strings.TrimSpace(os.Getenv("AUTH0_PLATFORM_CLIENT_ID")) - auth0ClientSecret := strings.TrimSpace(os.Getenv("AUTH0_PLATFORM_CLIENT_SECRET")) + auth0ClientID := strings.TrimSpace(os.Getenv("PLATFORM_AUTH0_CLIENT_ID")) + auth0ClientSecret := strings.TrimSpace(os.Getenv("PLATFORM_AUTH0_CLIENT_SECRET")) + auth0URL := strings.TrimSpace(os.Getenv("PLATFORM_AUTH0_URL")) h.sssRequired = sssRequired if sssBaseURL != "" && sssAudience != "" { @@ -8950,11 +8950,16 @@ func (h *Handlers) checkCompanyCompliance(ctx context.Context, company map[strin return isSanctioned, nil } - result, err := h.sssClient.GetOrganizationStatus(ctx, sss.OrganizationStatusRequest{ + req := sss.OrganizationStatusRequest{ Domain: domain, OrgName: companyName, - SFDCID: companyExternalID, - }) + } + + if strings.HasPrefix(companyExternalID, "001") { + req.SFDCID = companyExternalID + } + + result, err := h.sssClient.GetOrganizationStatus(ctx, req) if err != nil { return h.handleSSSError(ctx, companyID, err) } @@ -9032,8 +9037,11 @@ func (h *Handlers) resolveDomain(org *salesforce.Organization) string { if org == nil { return "" } - for _, d := range org.Domains { - if d = strings.TrimPrefix(strings.TrimSpace(d), "www."); d != "" { + domains := strings.Split(org.Domains, ",") + + for _, d := range domains { + d = strings.TrimPrefix(strings.TrimSpace(d), "www.") + if d != "" { return d } } @@ -9123,8 +9131,6 @@ func (h *Handlers) RequestEmployeeSignatureV2(w http.ResponseWriter, r *http.Req return } - - // If the employee signature already exists, return it. existing, err := h.signatures.QueryByProjectAndReference(ctx, req.ProjectID, req.UserID) if err != nil { diff --git a/cla-backend-legacy/internal/legacy/salesforce/service.go b/cla-backend-legacy/internal/legacy/salesforce/service.go index 5723d0bf2..9b20a99a8 100644 --- a/cla-backend-legacy/internal/legacy/salesforce/service.go +++ b/cla-backend-legacy/internal/legacy/salesforce/service.go @@ -269,10 +269,10 @@ func (s *Service) projectsSearchURL(projectIDs []string) (string, error) { // Organization represents a minimal platform organization record. type Organization struct { - ID string `json:"ID"` - Name string `json:"Name"` - Domains []string `json:"Domains"` - Link string `json:"Link"` + ID string `json:"ID"` + Name string `json:"Name"` + Domains string `json:"Domains"` + Link string `json:"Link"` } // GetOrganization retrieves an organization by its Salesforce ID. @@ -291,7 +291,7 @@ func (s *Service) GetOrganization(ctx context.Context, sfid string) (*Organizati return nil, errors.New("PLATFORM_GATEWAY_URL is empty") } - endpoint := fmt.Sprintf("%s/organization-service/v1/organizations/%s", base, sfid) + endpoint := fmt.Sprintf("%s/organization-service/v1/orgs/%s", base, sfid) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody) if err != nil { return nil, err diff --git a/cla-backend-go/sss/client_test.go b/cla-sss-base/client_test.go similarity index 100% rename from cla-backend-go/sss/client_test.go rename to cla-sss-base/client_test.go diff --git a/cla-backend-go/sss/from_config_test.go b/cla-sss-base/from_config_test.go similarity index 100% rename from cla-backend-go/sss/from_config_test.go rename to cla-sss-base/from_config_test.go From 901c026e6a75e270fdd448977e02ef60841f3e70 Mon Sep 17 00:00:00 2001 From: psrsingh Date: Wed, 3 Jun 2026 15:54:17 +0530 Subject: [PATCH 10/11] chore(go): update toolchain to go1.25.11 Signed-off-by: psrsingh --- cla-backend-go/go.mod | 2 +- cla-backend-legacy/go.mod | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cla-backend-go/go.mod b/cla-backend-go/go.mod index a7848f51c..ed17ab70a 100644 --- a/cla-backend-go/go.mod +++ b/cla-backend-go/go.mod @@ -4,7 +4,7 @@ module github.com/linuxfoundation/easycla/cla-backend-go go 1.25.0 -toolchain go1.25.10 +toolchain go1.25.11 replace github.com/awslabs/aws-lambda-go-api-proxy => github.com/LF-Engineering/aws-lambda-go-api-proxy v0.3.2 diff --git a/cla-backend-legacy/go.mod b/cla-backend-legacy/go.mod index a2b8f9ea0..88d159ab8 100644 --- a/cla-backend-legacy/go.mod +++ b/cla-backend-legacy/go.mod @@ -2,7 +2,7 @@ module github.com/linuxfoundation/easycla/cla-backend-legacy go 1.25.0 -toolchain go1.25.10 +toolchain go1.25.11 replace github.com/linuxfoundation/easycla/cla-sss-base => ../cla-sss-base From 6303d33311bcb4598cdedaa3fa13d311bd6b7a3d Mon Sep 17 00:00:00 2001 From: psrsingh Date: Wed, 3 Jun 2026 18:11:39 +0530 Subject: [PATCH 11/11] fix(sss): address remaining review feedback Signed-off-by: psrsingh --- .github/workflows/build-pr.yml | 19 +++++++++++++++++++ cla-backend-go/company/models.go | 2 ++ cla-backend-go/swagger/common/company.yaml | 5 +++++ 3 files changed, 26 insertions(+) diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml index 340daa821..6fccca0c5 100644 --- a/.github/workflows/build-pr.yml +++ b/.github/workflows/build-pr.yml @@ -111,3 +111,22 @@ jobs: - name: Go Lint CLA Legacy Backend working-directory: cla-backend-legacy run: make lint + + - name: Go Setup CLA SSS Base + working-directory: cla-sss-base + run: | + go mod tidy + + - name: Go Build CLA SSS Base + working-directory: cla-sss-base + run: go build ./... + + - name: Go Test CLA SSS Base + working-directory: cla-sss-base + run: go test ./... + + - name: Go Lint CLA SSS Base + working-directory: cla-sss-base + run: | + go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.8 + golangci-lint run ./... diff --git a/cla-backend-go/company/models.go b/cla-backend-go/company/models.go index 3895d55af..60aca59d0 100644 --- a/cla-backend-go/company/models.go +++ b/cla-backend-go/company/models.go @@ -88,6 +88,7 @@ func (dbCompanyModel *DBModel) toModel() (*models.Company, error) { Updated: strfmt.DateTime(updateDateTime), Note: dbCompanyModel.Note, IsSanctioned: dbCompanyModel.IsSanctioned, + SanctionOrigin: dbCompanyModel.SanctionOrigin, Version: dbCompanyModel.Version, }, nil } @@ -149,6 +150,7 @@ func toSwaggerModel(dbCompanyModel *DBModel) (*models.Company, error) { CompanyName: dbCompanyModel.CompanyName, SigningEntityName: dbCompanyModel.SigningEntityName, IsSanctioned: dbCompanyModel.IsSanctioned, + SanctionOrigin: dbCompanyModel.SanctionOrigin, CompanyExternalID: dbCompanyModel.CompanyExternalID, CompanyManagerID: dbCompanyModel.CompanyManagerID, Created: strfmt.DateTime(createdDateTime), diff --git a/cla-backend-go/swagger/common/company.yaml b/cla-backend-go/swagger/common/company.yaml index 26136b961..53b6fd4a5 100644 --- a/cla-backend-go/swagger/common/company.yaml +++ b/cla-backend-go/swagger/common/company.yaml @@ -43,6 +43,11 @@ properties: description: "Is this company OFAC sanctioned?" # default: false example: true + sanctionOrigin: + type: string + description: "Source of the sanction flag (e.g. sss)" + example: "sss" + version: type: string description: 'the version of the company record'