diff --git a/cla-backend-go/cmd/s3_upload/main.go b/cla-backend-go/cmd/s3_upload/main.go index 5b5bd247a..93c498a24 100644 --- a/cla-backend-go/cmd/s3_upload/main.go +++ b/cla-backend-go/cmd/s3_upload/main.go @@ -57,7 +57,7 @@ func init() { if err != nil { log.Fatal(err) } - signService = sign.NewService("", "", companyRepo, nil, nil, nil, nil, configFile.DocuSignPrivateKey, nil, nil, nil, nil, githubOrgService, nil, "", "", nil, nil, nil, nil, nil, nil, false) + signService = sign.NewService("", "", companyRepo, nil, nil, nil, nil, configFile.DocuSignPrivateKey, nil, nil, nil, nil, githubOrgService, nil, "", "", nil, nil, nil, nil, nil, nil, false, 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 ec9368abc..04186d089 100644 --- a/cla-backend-go/cmd/server.go +++ b/cla-backend-go/cmd/server.go @@ -453,20 +453,21 @@ func server(localMode bool) http.Handler { // Initialize SSS (Sanctions Screening Service) client if configured. // The sssRequired flag is controlled by the cla-sss-required-{stage} SSM parameter. sssRequired := configFile.SSS.Required + sssEnabled := configFile.SSS.Enabled var sssClient *sss.Client sssClient, err = sss.NewClientFromPlatformCredentials(configFile.SSS.BaseURL, configFile.SSS.Audience, configFile.Auth0Platform.URL, configFile.Auth0Platform.ClientID, configFile.Auth0Platform.ClientSecret) if err != nil { - if sssRequired { + if sssEnabled && sssRequired { log.WithFields(f).WithError(err).Fatal("failed to initialize required SSS client") } log.WithFields(f).WithError(err).Warn("failed to initialize optional SSS client, screening will be unavailable") sssClient = nil } - if sssRequired && sssClient == nil { + if sssEnabled && sssRequired && sssClient == nil { log.WithFields(f).Fatal("SSS is required but not configured") } - v2SignService := sign.NewService(configFile.ClaAPIV4Base, configFile.ClaV1ApiURL, v1CompanyRepo, v1CLAGroupRepo, v1ProjectClaGroupRepo, v1CompanyService, v2ClaGroupService, configFile.DocuSignPrivateKey, usersService, v1SignaturesService, storeRepository, v1RepositoriesService, githubOrganizationsService, gitlabOrganizationsService, configFile.CLALandingPage, configFile.CLALogoURL, emailService, eventsService, gitlabActivityService, gitlabApp, gerritService, sssClient, sssRequired) + v2SignService := sign.NewService(configFile.ClaAPIV4Base, configFile.ClaV1ApiURL, v1CompanyRepo, v1CLAGroupRepo, v1ProjectClaGroupRepo, v1CompanyService, v2ClaGroupService, configFile.DocuSignPrivateKey, usersService, v1SignaturesService, storeRepository, v1RepositoriesService, githubOrganizationsService, gitlabOrganizationsService, configFile.CLALandingPage, configFile.CLALogoURL, emailService, eventsService, gitlabActivityService, gitlabApp, gerritService, sssClient, sssRequired, sssEnabled) sessionStore, err := dynastore.New(dynastore.Path("/"), dynastore.HTTPOnly(), dynastore.TableName(configFile.SessionStoreTableName), dynastore.DynamoDB(dynamodb.New(awsSession))) if err != nil { diff --git a/cla-backend-go/config/config.go b/cla-backend-go/config/config.go index 1874000dc..9d1b9eb1e 100644 --- a/cla-backend-go/config/config.go +++ b/cla-backend-go/config/config.go @@ -143,6 +143,9 @@ type SSS struct { // 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"` + // Enabled is the SSS kill switch (cla-sss-enabled-{stage}, default true): when false, + // checkCompanyCompliance skips the live SSS check; persisted is_sanctioned still blocks elsewhere. + Enabled bool `json:"enabled"` } // Docraptor model diff --git a/cla-backend-go/config/ssm.go b/cla-backend-go/config/ssm.go index 66e5b953c..fa78dd2c1 100644 --- a/cla-backend-go/config/ssm.go +++ b/cla-backend-go/config/ssm.go @@ -287,6 +287,7 @@ func loadOptionalSSSConfig(ssmClient *ssm.SSM, stage string, config *Config, f l 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) + config.SSS.Enabled = getOptionalSSMBoolDefault(ssmClient, fmt.Sprintf("cla-sss-enabled-%s", stage), true, f) } // getOptionalSSMString fetches a parameter that may legitimately be absent while @@ -311,28 +312,33 @@ 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. +// getOptionalSSMBool fetches an optional boolean parameter, returning false (the default) +// when the value is unreadable, absent, or malformed. func getOptionalSSMBool(ssmClient *ssm.SSM, key string, f logrus.Fields) bool { + return getOptionalSSMBoolDefault(ssmClient, key, false, f) +} + +// getOptionalSSMBoolDefault is getOptionalSSMBool with a caller-supplied default for a +// missing/unreadable parameter - used for cla-sss-enabled, which must default to true so a +// not-yet-provisioned key never silently disables screening. +func getOptionalSSMBoolDefault(ssmClient *ssm.SSM, key string, def bool, 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) + log.WithFields(f).Debugf("optional SSM key %s not provisioned - using default value %t", key, def) } else { - log.WithFields(f).WithError(err).Warnf("unable to read optional SSM key %s - using default value false", key) + log.WithFields(f).WithError(err).Warnf("unable to read optional SSM key %s - using default value %t", key, def) } - return false + return def } 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 + log.WithFields(f).WithError(err).Warnf("unable to parse optional SSM key %s as boolean - using default value %t", key, def) + return def } return boolVal diff --git a/cla-backend-go/v2/sign/service.go b/cla-backend-go/v2/sign/service.go index aba2e11d9..4f016f1e4 100644 --- a/cla-backend-go/v2/sign/service.go +++ b/cla-backend-go/v2/sign/service.go @@ -125,6 +125,7 @@ type service struct { gerritService gerrits.Service sssClient *sss.Client sssRequired bool + sssEnabled bool complianceCache map[string]complianceCacheEntry complianceCacheMu *sync.Mutex } @@ -137,7 +138,7 @@ type complianceCacheEntry struct { // NewService returns an instance of v2 project service func NewService(apiURL, v1API string, compRepo company.IRepository, projectRepo ProjectRepo, pcgRepo projects_cla_groups.Repository, compService company.IService, claGroupService cla_groups.Service, docsignPrivateKey string, userService users.Service, signatureService signatures.SignatureService, storeRepository store.Repository, repositoryService repositories.Service, githubOrgService github_organizations.Service, gitlabOrgService gitlab_organizations.ServiceInterface, claLandingPage string, claLogoURL string, emailTemplateService emails.EmailTemplateService, eventsService events.Service, gitlabActivityService gitlab_activity.Service, gitlabApp *gitlab_api.App, - gerritService gerrits.Service, sssClient *sss.Client, sssRequired bool) Service { + gerritService gerrits.Service, sssClient *sss.Client, sssRequired bool, sssEnabled bool) Service { return &service{ ClaV4ApiURL: apiURL, ClaV1ApiURL: v1API, @@ -162,6 +163,7 @@ func NewService(apiURL, v1API string, compRepo company.IRepository, projectRepo eventsService: eventsService, sssClient: sssClient, sssRequired: sssRequired, + sssEnabled: sssEnabled, complianceCache: make(map[string]complianceCacheEntry), complianceCacheMu: &sync.Mutex{}, } @@ -3003,6 +3005,11 @@ func (s *service) checkCompanyCompliance(ctx context.Context, company *v1Models. return true, nil } + if !s.sssEnabled { + log.WithFields(f).Warn("sanctions screening disabled (cla-sss-enabled=false); skipping SSS check") + return false, 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) diff --git a/cla-backend-go/v2/sign/service_sss_test.go b/cla-backend-go/v2/sign/service_sss_test.go index a465f9dc8..df9d8210b 100644 --- a/cla-backend-go/v2/sign/service_sss_test.go +++ b/cla-backend-go/v2/sign/service_sss_test.go @@ -42,7 +42,7 @@ func TestResolveDomainFallsBackToParsedLink(t *testing.T) { } func TestCheckCompanyComplianceRequiredBlocksMissingClient(t *testing.T) { - svc := &service{sssRequired: true} + svc := &service{sssRequired: true, sssEnabled: true} _, err := svc.checkCompanyCompliance(context.Background(), &models.Company{ CompanyID: "company-id", @@ -54,7 +54,7 @@ func TestCheckCompanyComplianceRequiredBlocksMissingClient(t *testing.T) { } func TestCheckCompanyComplianceOptionalAllowsMissingClient(t *testing.T) { - svc := &service{sssRequired: false} + svc := &service{sssRequired: false, sssEnabled: true} blocked, err := svc.checkCompanyCompliance(context.Background(), &models.Company{ CompanyID: "company-id", @@ -141,7 +141,7 @@ func TestSSSStatusActionable(t *testing.T) { } func TestCheckCompanyComplianceRequiredBlocksMissingExternalID(t *testing.T) { - svc := &service{sssRequired: true, sssClient: newTestSSSClient(t)} + svc := &service{sssRequired: true, sssEnabled: true, sssClient: newTestSSSClient(t)} _, err := svc.checkCompanyCompliance(context.Background(), &models.Company{ CompanyID: "company-id", @@ -154,7 +154,7 @@ func TestCheckCompanyComplianceRequiredBlocksMissingExternalID(t *testing.T) { } func TestCheckCompanyComplianceOptionalAllowsMissingExternalID(t *testing.T) { - svc := &service{sssRequired: false, sssClient: newTestSSSClient(t)} + svc := &service{sssRequired: false, sssEnabled: true, sssClient: newTestSSSClient(t)} blocked, err := svc.checkCompanyCompliance(context.Background(), &models.Company{ CompanyID: "company-id", @@ -170,7 +170,7 @@ func TestCheckCompanyComplianceOptionalAllowsMissingExternalID(t *testing.T) { } func TestCheckCompanyComplianceOptionalBlocksPersistedSanctionMissingExternalID(t *testing.T) { - svc := &service{sssRequired: false, sssClient: newTestSSSClient(t)} + svc := &service{sssRequired: false, sssEnabled: true, sssClient: newTestSSSClient(t)} // origin=sss bypasses the manual-block short-circuit so the missing-external-ID early // exit is the path under test: in optional mode it must honor the persisted sanction. @@ -266,6 +266,7 @@ func TestCheckCompanyComplianceCacheHitMutatesModel(t *testing.T) { // A cached "clean" result must clear the stale loaded model so downstream gates // (e.g. ProcessEmployeeSignature) in the same request stay consistent. svc := &service{ + sssEnabled: true, complianceCache: map[string]complianceCacheEntry{ "external-id": {sanctioned: false, expiresAt: time.Now().Add(time.Minute)}, }, @@ -294,7 +295,7 @@ func TestCheckCompanyComplianceCacheHitMutatesModel(t *testing.T) { func TestCheckCompanyComplianceOptionalHonorsPersistedSSSFlag(t *testing.T) { // Optional mode with no SSS client: an already-persisted SSS-origin block must keep // blocking until a live clean result can clear it (do not fail open on the flag). - svc := &service{sssRequired: false} + svc := &service{sssRequired: false, sssEnabled: true} blocked, err := svc.checkCompanyCompliance(context.Background(), &models.Company{ CompanyID: "company-id", @@ -313,7 +314,7 @@ func TestCheckCompanyComplianceOptionalHonorsPersistedSSSFlag(t *testing.T) { func TestCheckCompanyComplianceAdminBlockAlwaysBlocks(t *testing.T) { // A manual/admin block (is_sanctioned=true, no/!=sss origin) must short-circuit and // block regardless of mode or SSS availability. - svc := &service{sssRequired: false} + svc := &service{sssRequired: false, sssEnabled: true} blocked, err := svc.checkCompanyCompliance(context.Background(), &models.Company{ CompanyID: "company-id", @@ -326,3 +327,22 @@ func TestCheckCompanyComplianceAdminBlockAlwaysBlocks(t *testing.T) { t.Fatal("expected a manual/admin sanction (no origin) to always block") } } + +func TestCheckCompanyComplianceDisabledSkipsSSS(t *testing.T) { + // Kill switch off (sssEnabled=false): the live SSS check is skipped and the company is + // not blocked, even in required mode with a persisted sss-origin flag. + svc := &service{sssRequired: true, sssEnabled: false, sssClient: newTestSSSClient(t)} + + blocked, err := svc.checkCompanyCompliance(context.Background(), &models.Company{ + CompanyID: "company-id", + CompanyName: "Company", + IsSanctioned: true, + SanctionOrigin: "sss", + }) + if err != nil { + t.Fatalf("expected disabled SSS to skip without error, got %v", err) + } + if blocked { + t.Fatal("expected disabled SSS not to block") + } +} diff --git a/cla-backend-legacy/internal/api/handlers.go b/cla-backend-legacy/internal/api/handlers.go index a0c38e1fd..042c13768 100644 --- a/cla-backend-legacy/internal/api/handlers.go +++ b/cla-backend-legacy/internal/api/handlers.go @@ -80,6 +80,7 @@ type Handlers struct { userService *userservicelegacy.Client sssClient *sss.Client sssRequired bool + sssEnabled bool } func NewHandlers() *Handlers { @@ -215,16 +216,18 @@ func NewHandlers() *Handlers { 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) + sssEnabled := getOptionalSSMBoolDefault(ctx, ssmClient, fmt.Sprintf("cla-sss-enabled-%s", stage), true, 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 + h.sssEnabled = sssEnabled if sssBaseURL != "" && sssAudience != "" { sssClient, err := sss.NewClientFromPlatformCredentials(sssBaseURL, sssAudience, auth0URL, auth0ClientID, auth0ClientSecret) if err != nil { - if sssRequired { + if sssEnabled && 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) @@ -234,7 +237,7 @@ func NewHandlers() *Handlers { } } - if sssRequired && h.sssClient == nil { + if sssEnabled && sssRequired && h.sssClient == nil { logging.Fatalf("SSS is required but not configured (base URL or audience missing in SSM)") } @@ -266,13 +269,26 @@ func getOptionalSSMString(ctx context.Context, ssmClient *ssm.Client, key string } // getOptionalSSMBool retrieves a boolean parameter from SSM. -// It returns false (the safe default) when the value is unreadable or the parameter is absent. +// It returns false (the safe default) when the value is unreadable, absent, or malformed. func getOptionalSSMBool(ctx context.Context, ssmClient *ssm.Client, key string, f logrus.Fields) bool { - val := getOptionalSSMString(ctx, ssmClient, key, f) + return getOptionalSSMBoolDefault(ctx, ssmClient, key, false, f) +} + +// getOptionalSSMBoolDefault is getOptionalSSMBool with a caller-supplied default for a +// missing parameter - used for cla-sss-enabled, which must default to true so a +// not-yet-provisioned key never silently disables screening. A malformed value also +// falls back to the default rather than being treated as false. +func getOptionalSSMBoolDefault(ctx context.Context, ssmClient *ssm.Client, key string, def bool, f logrus.Fields) bool { + val := strings.TrimSpace(getOptionalSSMString(ctx, ssmClient, key, f)) if val == "" { - return false + return def + } + boolVal, err := strconv.ParseBool(val) + if err != nil { + logging.Warnf("unable to parse optional SSM key %s=%q as boolean - using default value %t", key, val, def) + return def } - return strings.ToLower(val) == "true" + return boolVal } // formatPynamoDateTimeUTC formats timestamps the way Python's pynamodb @@ -8918,6 +8934,11 @@ func (h *Handlers) checkCompanyCompliance(ctx context.Context, company map[strin return true, nil } + if !h.sssEnabled { + logging.Warnf("sanctions screening disabled (cla-sss-enabled=false); skipping SSS check for company %s", companyID) + return false, nil + } + // In the fallbacks below (no live SSS check possible) we honor the persisted state: // a manual/admin block was already handled by the short-circuit above, and a stale // sss-origin block keeps blocking until a live "clean" can clear it. The mode only