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/cmd/s3_upload/main.go b/cla-backend-go/cmd/s3_upload/main.go index ca47cded8..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) + 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 678035e70..ec9368abc 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,24 @@ 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) + + // Initialize SSS (Sanctions Screening Service) client if configured. + // The sssRequired flag is controlled by the cla-sss-required-{stage} SSM parameter. + sssRequired := configFile.SSS.Required + var sssClient *sss.Client + sssClient, err = sss.NewClientFromPlatformCredentials(configFile.SSS.BaseURL, configFile.SSS.Audience, configFile.Auth0Platform.URL, configFile.Auth0Platform.ClientID, configFile.Auth0Platform.ClientSecret) + if err != nil { + if 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, 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/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..60aca59d0 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"` } @@ -87,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 } @@ -148,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/company/repository.go b/cla-backend-go/company/repository.go index 0d848bc71..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,6 +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, origin string) error + ClearCompanySanctionStatusIfSSS(ctx context.Context, companyID string) error IsCCLAEnabledForCompany(ctx context.Context, companyID string) (bool, error) } @@ -1276,7 +1279,93 @@ func (repo repository) UpdateCompanyAccessList(ctx context.Context, companyID st return nil } -// CreateCompany creates a new company record +// 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, + } + + _, 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" + + if origin != "" { + names["#O"] = aws.String("sanction_origin") + values[":o"] = &dynamodb.AttributeValue{S: aws.String(origin)} + updateExpr += ", #O = :o" + } + + 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), + } + + if _, err := repo.dynamoDBClient.UpdateItem(input); err != nil { + log.WithFields(f).Warnf("error updating company sanction status, error: %v", err) + return err + } + return nil +} + +// 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{ + ":false": {BOOL: aws.Bool(false)}, + ":m": {S: aws.String(now)}, + ":sss": {S: aws.String("sss")}, + }, + } + + 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 +} + 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..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 +} diff --git a/cla-backend-go/go.mod b/cla-backend-go/go.mod index 9ecb474f0..ed17ab70a 100644 --- a/cla-backend-go/go.mod +++ b/cla-backend-go/go.mod @@ -4,10 +4,12 @@ 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 +replace github.com/linuxfoundation/easycla/cla-sss-base => ../cla-sss-base + require ( github.com/LF-Engineering/aws-lambda-go-api-proxy v0.3.2 github.com/LF-Engineering/lfx-kit v0.1.39 @@ -40,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/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..50d3c1666 100644 --- a/cla-backend-go/sss/client.go +++ b/cla-backend-go/sss/client.go @@ -1,329 +1,14 @@ // 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 - } - - 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) - } +import sssbase "github.com/linuxfoundation/easycla/cla-sss-base" - 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) +// Re-export types and functions for backward compatibility +type Client = sssbase.Client - 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 -} +var ( + NewClient = sssbase.NewClient +) 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/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' 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..86d673631 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" @@ -28,6 +29,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" @@ -41,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" @@ -56,9 +59,11 @@ import ( // constants const ( - DontLoadRepoDetails = false - DocSignFalse = "false" - DocusignCompleted = "Completed" + DontLoadRepoDetails = false + DocSignFalse = "false" + DocusignCompleted = "Completed" + complianceCacheTTL = 5 * time.Minute + maxComplianceCacheSize = 1000 ) // errors @@ -117,12 +122,21 @@ type service struct { gitlabActivityService gitlab_activity.Service gitlabApp *gitlab_api.App gerritService gerrits.Service + sssClient *sss.Client + sssRequired bool + complianceCache map[string]complianceCacheEntry + complianceCacheMu sync.Mutex +} + +type complianceCacheEntry struct { + sanctioned bool + 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) Service { + gerritService gerrits.Service, sssClient *sss.Client, sssRequired bool) Service { return &service{ ClaV4ApiURL: apiURL, ClaV1ApiURL: v1API, @@ -145,6 +159,9 @@ func NewService(apiURL, v1API string, compRepo company.IRepository, projectRepo gitlabApp: gitlabApp, gerritService: gerritService, eventsService: eventsService, + sssClient: sssClient, + sssRequired: sssRequired, + complianceCache: make(map[string]complianceCacheEntry), } } @@ -243,7 +260,19 @@ func (s *service) RequestCorporateSignature(ctx context.Context, lfUsername stri } // 1.5 Check if company is sanctioned - if comp != nil && comp.IsSanctioned { + 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 { @@ -2936,3 +2965,273 @@ 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). +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, + } + + // 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, nil + } + + if s.sssClient == 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 + } + + // 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") + return false, nil + } + return false, resultErr + } + org, err := orgClient.GetOrganization(ctx, company.CompanyExternalID) + if err != nil { + 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") + return false, nil + } + return false, resultErr + } + if org == nil { + 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") + return false, nil + } + return false, resultErr + } + + // Resolve domain: prefer Domains field, fallback to Link field. + domain := s.resolveDomain(f, org) + if domain == "" { + 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 + } + + log.WithFields(f).Debugf("resolved domain: %s for SSS check", 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 s.handleSSSError(f, company.CompanyID, err) + } + + sanctioned := result.Status == sss.StatusFlagged + + // 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 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) + return false, fmt.Errorf("failed to persist sanction status for company %s: %w", company.CompanyID, persistErr) + } + } else { + // 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) + } + } + + s.setComplianceCache(cacheKey, sanctioned) + return sanctioned, 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() + + 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) { + if key == "" { + return + } + s.complianceCacheMu.Lock() + defer s.complianceCacheMu.Unlock() + 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, + expiresAt: time.Now().Add(complianceCacheTTL), + } +} + +// 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 "" + } + 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 + } + } + if org.Link != "" { + if d := s.parseDomain(org.Link); d != "" { + return d + } + } + 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) + return allowWhenOptional("SSS organization not found") + + case errors.As(err, &badReqErr): + log.WithFields(f).WithError(err).Warnf("SSS bad request for company %s", companyID) + return allowWhenOptional("SSS bad request") + + 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..310c6a989 --- /dev/null +++ b/cla-backend-go/v2/sign/service_sss_test.go @@ -0,0 +1,139 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package sign + +import ( + "context" + "testing" + "time" + + "github.com/linuxfoundation/easycla/cla-backend-go/gen/v1/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" +) + +func TestResolveDomainPrefersDomains(t *testing.T) { + svc := &service{} + + got := svc.resolveDomain(logrus.Fields{}, &orgModels.Organization{ + Domains: "www.example.com,fallback.example.org", + 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{}, &orgModels.Organization{ + Link: "www.example.org/path?query=1", + }) + + if got != "example.org" { + t.Fatalf("expected parsed Link hostname, got %q", got) + } +} + +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} + + _, err := svc.handleSSSError(logrus.Fields{}, "company-id", &sss.RetryableError{Message: "unavailable"}) + if err == nil { + t.Fatal("expected required SSS retryable error to block") + } +} + +func TestHandleSSSErrorOptionalAllowsAuthErrors(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 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{} + + 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, + expiresAt: time.Now().Add(-time.Second), + }, + }, + } + + if _, ok := svc.getComplianceCache("company-id"); ok { + t.Fatal("expected expired cache entry to be ignored") + } +} + +func TestComplianceCacheSkipsErrors(t *testing.T) { + svc := &service{} + + // 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 cache entry to be stored") + } +} diff --git a/cla-backend-legacy/go.mod b/cla-backend-legacy/go.mod index 6442ba44d..88d159ab8 100644 --- a/cla-backend-legacy/go.mod +++ b/cla-backend-legacy/go.mod @@ -2,7 +2,9 @@ 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 require ( github.com/aws/aws-lambda-go v1.53.0 @@ -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..d4b274e2a 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) + + 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 != "" { + 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,203 @@ 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 + } + + req := sss.OrganizationStatusRequest{ + Domain: domain, + OrgName: companyName, + } + + if strings.HasPrefix(companyExternalID, "001") { + req.SFDCID = companyExternalID + } + + result, err := h.sssClient.GetOrganizationStatus(ctx, req) + 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 "" + } + domains := strings.Split(org.Domains, ",") + + for _, d := range domains { + d = strings.TrimPrefix(strings.TrimSpace(d), "www.") + if 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) @@ -8858,21 +9131,6 @@ func (h *Handlers) RequestEmployeeSignatureV2(w http.ResponseWriter, r *http.Req return } - 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..9b20a99a8 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/orgs/%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-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-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-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 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"` +}